AppOrbit
← blog

육아앱 직접 만든 개발자의 회고 (1) — 기획부터 기술 스택 선정까지

React Native, Expo, Zustand, Supabase, 사이드프로젝트

1. 들어가며: 왜 또 육아앱을 만들었나?

아기가 태어나고 가장 먼저 한 일은 육아앱을 깔아보는 것이었습니다. 베이비타임, 베이비위젯 등 이미 검증된 앱들이 많았고, 실제로 한동안 잘 사용했습니다. 그런데 새벽 3시에 수유하면서 기록을 남기려고 앱을 켤 때마다 몇 가지 불편함이 반복되었습니다.

화면이 너무 밝아서 겨우 재운 아기가 다시 깨는 경험, 수유를 시작했는데 지금 진행 중인 기록이 어디 있는지 바로 보이지 않는 경험, 아내와 기록을 공유하고 싶은데 권한 설정이 세밀하지 않은 경험. 이런 작은 불편함들이 쌓이면서 "내가 원하는 방식으로 직접 만들어보자"라는 생각이 들었습니다.

물론 기존 앱들이 나쁘다는 뜻은 아닙니다. 다만 개발자로서 "이 문제를 내가 풀 수 있겠다"는 확신이 들었고, 무엇보다 실제 사용자(저 자신)가 바로 옆에 있다는 점이 강력한 동기가 되었습니다.

2. 핵심 기능과 UX 설계 방향

기획 단계에서 가장 중요하게 생각한 원칙은 **"새벽에 졸린 상태에서도 쓸 수 있을 것"**이었습니다. 이 원칙에서 대부분의 UX 결정이 파생되었습니다.

원터치 기록 시스템을 최우선으로 설계했습니다. 홈 화면 하단에 수유, 기저귀, 수면 세 가지 퀵버튼을 고정 배치하고, 한 번 탭으로 기록을 시작하고 한 번 더 탭으로 종료할 수 있게 했습니다. 기록이 진행 중이면 버튼 자체가 펄스 애니메이션으로 상태를 알려주고, 경과 시간까지 실시간으로 표시합니다.

야간모드는 단순히 다크 테마를 넘어서, OLED 디스플레이에 최적화된 순수 블랙 배경에 따뜻한 색조를 적용했습니다. 시간대에 따라 자동 전환되도록 스케줄링 기능도 넣었습니다.

가족 공유는 단순 공유가 아니라 역할별 권한 분리를 적용했습니다. 부모는 기록 수정이 가능하고, 할머니나 도우미는 보기만 가능하게 설정할 수 있습니다. 아빠가 기록하면 엄마 폰에 실시간으로 반영되는 구조입니다.

기록 유형은 수유, 기저귀, 수면, 성장, 활동, 투약, 건강, 메모까지 총 8가지를 지원하되, 각 유형의 입력 폼을 최소한으로 설계해서 기록에 걸리는 시간을 줄이는 데 집중했습니다.

3. 기술 스택 선정 이유

React Native + Expo

크로스 플랫폼은 혼자 개발하는 사이드 프로젝트에서 사실상 필수 선택이었습니다. Flutter와 React Native 사이에서 고민했지만, 웹 프론트엔드 경험이 있었기 때문에 React Native를 선택했습니다.

Expo를 채택한 이유는 명확했습니다. 네이티브 빌드 설정 없이 빠르게 시작할 수 있고, EAS Build를 통해 CI/CD 파이프라인을 손쉽게 구축할 수 있었습니다. 특히 OTA 업데이트 기능은 스토어 심사 없이 핫픽스를 배포할 수 있어서 1인 개발에 큰 장점이었습니다.

라우팅은 Expo Router의 파일 기반 라우팅을 선택했습니다. Next.js의 App Router와 유사한 구조로, 파일 시스템이 곧 라우트가 됩니다.

app/
├── (auth)/login.tsx        // 미인증 사용자
├── (app)/(tabs)/
│   ├── index.tsx            // 홈 (기록 목록)
│   ├── calendar.tsx         // 캘린더 뷰
│   └── stats.tsx            // 통계/차트
├── (app)/family.tsx         // 가족 관리
├── (app)/vaccinations.tsx   // 예방접종
└── quick-record.tsx         // 위젯 딥링크 진입점

(auth)(app) 그룹으로 인증 상태에 따른 라우트를 깔끔하게 분리할 수 있었고, (tabs) 중첩 그룹으로 탭 네비게이션과 드로어 네비게이션을 자연스럽게 조합했습니다.

Zustand — 상태 관리

Redux, MobX, Jotai 등 여러 선택지가 있었지만, Zustand를 선택한 결정적 이유는 보일러플레이트의 최소화영속성(Persistence) 지원이었습니다.

육아앱의 상태는 크게 여덟 가지 도메인으로 나뉩니다. 인증, 앱 설정, 아기 프로필, 기록 데이터, 가족 멤버, 마일스톤, 예방접종, UI 상태. 이 도메인들을 각각 독립된 스토어로 분리하되, 필요할 때 다른 스토어의 상태를 직접 참조할 수 있는 유연한 구조가 필요했습니다.

// 스토어 간 의존 관계 (수도코드)

RecordStore.fetchRecords()
  → BabyStore.getState().currentBaby  // 현재 선택된 아기의 ID 참조
  → 해당 아기의 기록만 서버에서 가져오기

Zustand의 getState()를 활용하면 스토어 간 의존성을 구독 없이 즉시 해결할 수 있었습니다. Redux였다면 미들웨어나 thunk에서 처리해야 할 로직이 훨씬 간결해졌습니다.

영속성 설계에서 가장 중요한 결정은 선택적 persist였습니다. 모든 상태를 AsyncStorage에 저장하면 앱 재시작 시 이전의 로딩 상태나 에러 메시지가 남아 있는 문제가 생깁니다. 그래서 각 스토어별로 "어떤 필드를 저장하고 어떤 필드를 버릴 것인지"를 명확하게 정의했습니다.

// 선택적 영속성 (수도코드)

RecordStore persist 설정:
  저장하는 것: records, lastSyncedAt, currentBabyId
  저장하지 않는 것: isLoading, error, isSyncing

UIStore:
  저장하지 않음 (모달 열림 상태, 토스트 메시지 등은 휘발성)

이렇게 하면 앱을 껐다 켜도 기록 데이터는 즉시 보이면서, 로딩 스피너가 멈춘 채로 남아 있는 버그는 원천 차단됩니다.

Supabase — 백엔드

Firebase 대신 Supabase를 선택한 이유는 두 가지였습니다. 첫째, PostgreSQL 기반이라 복잡한 쿼리와 관계형 데이터 모델링이 자유롭습니다. 둘째, Row-Level Security(RLS) 정책으로 서버 로직 없이도 데이터 접근 제어가 가능합니다.

육아앱에서 데이터 보안은 특히 중요합니다. 아기의 건강 기록, 수유 패턴, 체온 데이터는 민감한 개인정보입니다. RLS를 활용하면 "이 기록은 가족 구성원만 볼 수 있다"는 규칙을 데이터베이스 레벨에서 강제할 수 있습니다.

// RLS 정책 설계 (수도코드)

records 테이블 조회 정책:
  IF 요청자가 기록의 생성자 → 허용
  IF 요청자가 해당 아기의 가족 멤버 → 허용
  ELSE → 차단

records 테이블 수정 정책:
  IF 요청자가 기록의 생성자 → 허용
  IF 요청자가 editor 또는 admin 권한의 가족 멤버 → 허용
  ELSE → 차단

다만 RLS에서 한 가지 까다로운 문제가 있었습니다. 가족 멤버 확인을 위해 family_members 테이블을 참조하는데, 이 테이블 자체에도 RLS가 걸려 있으면 무한 재귀가 발생합니다. 이 문제는 SECURITY DEFINER 함수로 해결했는데, 자세한 내용은 4편(가족 공유)에서 다루겠습니다.

인증은 Google OAuth를 메인으로 사용합니다. Expo AuthSession으로 OAuth 플로우를 처리하고, 받아온 ID Token을 Supabase의 signInWithIdToken()에 전달하는 구조입니다. 딥링크 스킴(dailybaby://redirect)을 설정해서 OAuth 콜백을 앱으로 다시 받아옵니다.

4. 아키텍처 설계에서 가장 고민했던 것들

레코드 타입의 유연한 설계

8가지 기록 유형은 각각 전혀 다른 데이터 구조를 가집니다. 수유는 양(ml)과 방식(모유/분유)이 필요하고, 수면은 시작/종료 시간이 필요하고, 성장은 키/체중/머리둘레가 필요합니다. 이걸 어떻게 하나의 테이블에 담을 것인가가 첫 번째 고민이었습니다.

선택한 방식은 PostgreSQL의 JSONB 필드입니다.

records 테이블:
  id, baby_id, type, recorded_at, created_by, ...
  details: JSONB  ← 기록 유형에 따라 다른 구조

type="feeding"일 때 details:
  { amount: 120, type: "bottle", start_time: "...", end_time: "..." }

type="growth"일 때 details:
  { height: 65.5, weight: 7.2, head: 42.1 }

유형별 테이블을 만드는 것도 고려했지만, 기록 목록을 시간순으로 한 번에 보여줘야 하는 UI 요구사항 때문에 단일 테이블이 더 적합했습니다. JSONB 필드의 유연성 덕분에 새로운 기록 유형(예: 투약)을 추가할 때도 테이블 마이그레이션 없이 확장할 수 있었습니다.

다만 TypeScript 측에서는 이 유연성이 트레이드오프를 만들었습니다. details 필드의 타입이 유니온 타입이기 때문에 특정 필드에 접근하려면 타입 내로잉이 필요합니다. 모달 컴포넌트에서는 실용적인 판단으로 any 타입을 허용하되, 외부 인터페이스에서는 타입 가드를 유지하는 방식으로 균형을 잡았습니다.

앱 초기화 시퀀스

앱 시작 시 "흰 화면"이 보이면 안 됩니다. 특히 새벽에 앱을 켜는 사용자에게 번쩍이는 화면은 치명적입니다. 초기화 시퀀스를 세밀하게 설계해야 했습니다.

앱 부팅 시퀀스:
1. 스플래시 화면 유지 (숨기지 않음)
2. AsyncStorage에서 저장된 상태 복원 (hydration)
3. 폰트 로드 완료 대기
4. 백그라운드에서 Supabase 세션 검증
5. 인증 상태에 따라 라우트 결정
   → 인증됨: 아기 목록 + 기록 데이터 fetch
   → 미인증: 로그인 화면
6. 모든 준비 완료 후 스플래시 해제

핵심은 2단계의 hydration입니다. Zustand의 onRehydrateStorage 콜백에서 _hasHydrated 플래그를 설정하고, 이 플래그가 true가 될 때까지 스플래시를 유지합니다. 이 덕분에 AsyncStorage에 캐시된 기록 데이터가 먼저 화면에 표시되고, 네트워크 응답은 뒤에서 조용히 병합됩니다.

날짜 변경 감지

육아앱은 "오늘"이라는 개념이 매우 중요합니다. 자정이 지나면 오늘의 기록 목록이 바뀌어야 하는데, 앱이 백그라운드에 있다가 다시 올라오면 날짜가 이미 바뀌어 있을 수 있습니다.

이 문제는 AppState 이벤트 리스너로 해결했습니다. 앱이 포그라운드로 돌아올 때마다 현재 날짜를 확인하고, 변경되었으면 dateKey를 갱신합니다. 이 dateKey를 의존성으로 사용하는 컴포넌트들이 자동으로 리렌더링되면서 오늘의 데이터로 화면이 갱신됩니다.

5. 마치며

이 글에서는 DailyBaby를 만들게 된 동기와 기술 스택 선정 과정을 다뤘습니다. 정리하면 다음과 같습니다.

  • Expo + React Native: 1인 개발에서 크로스 플랫폼과 빠른 배포가 핵심
  • Zustand: 선택적 영속성과 스토어 간 직접 참조의 유연함
  • Supabase: RLS 기반 보안과 JSONB의 유연한 데이터 모델링
  • 파일 기반 라우팅: 인증/비인증 라우트 분리의 깔끔함

다음 편에서는 DailyBaby의 핵심 UX인 실시간 기록 시스템을 다룹니다. 퀵액션 버튼의 펄스 애니메이션, 경과 시간 실시간 추적, 실행취소 스낵바 등 "새벽에 졸려도 쓸 수 있는 앱"을 만들기 위해 어떤 기술적 고민을 했는지 이야기하겠습니다.

👉 데일리베이비 - App Store에서 다운로드