AppOrbit
← blog

육아앱 직접 만든 개발자의 회고 (4) — 가족 공유와 데이터 동기화

Supabase, RLS, 데이터 동기화, 오프라인 퍼스트, React Native

1. 들어가며: "마지막 수유 몇 시였어?"

육아를 하다 보면 가장 많이 하는 질문이 있습니다. "마지막에 언제 먹였어?" "기저귀 언제 갈았어?" 부부가 교대로 아기를 돌볼 때, 서로의 기록을 실시간으로 확인할 수 있다면 이 질문이 사라집니다.

하지만 가족 공유 기능은 단순히 "데이터를 같이 보는 것"이 아닙니다. 아빠와 엄마는 기록을 수정할 수 있어야 하고, 할머니는 보기만 가능해야 할 수 있고, 도우미는 기록 추가만 가능해야 할 수 있습니다. 이런 역할별 권한 제어실시간 데이터 동기화를 어떻게 구현했는지 이야기하겠습니다.

2. 데이터 모델 설계

가족 공유의 핵심 테이블은 세 개입니다.

데이터 모델 (수도코드):

babies 테이블:
  id, user_id(소유자), name, birthdate, gender

family_members 테이블:
  id, baby_id, user_id, permission
  → permission: "admin" | "editor" | "viewer"
  → UNIQUE 제약: (baby_id, user_id) 조합은 유일

family_invites 테이블:
  id, invite_code(UUID), baby_id, inviter_id
  status, permission, expires_at
  → status: "pending" | "used" | "expired"
  → 만료: 생성 후 7일

설계에서 가장 중요한 결정은 아기 단위의 공유였습니다. 사용자 계정 전체를 공유하는 것이 아니라, 특정 아기의 데이터에 대한 접근 권한을 부여하는 구조입니다. 이렇게 하면 쌍둥이를 키우는 경우, 첫째와 둘째에 대한 접근 권한을 다른 가족에게 다르게 설정할 수 있습니다.

가족 멤버 수는 아기당 최대 3명(소유자 포함)으로 제한했습니다. 사이드 프로젝트의 서버 비용을 고려한 현실적인 제약이기도 하고, 실제로 아기 한 명의 기록에 접근할 필요가 있는 사람은 3명을 넘기 어렵습니다.

3. Row-Level Security: 서버 로직 없는 접근 제어

DailyBaby는 별도의 백엔드 서버 없이 Supabase를 직접 사용합니다. 이 구조에서 데이터 보안의 핵심은 **Row-Level Security(RLS)**입니다.

RLS는 데이터베이스 레벨에서 "이 행에 대한 접근을 허용할 것인가?"를 결정하는 정책입니다. 클라이언트가 아무리 교묘한 쿼리를 보내도, RLS 정책을 통과하지 못하면 데이터에 접근할 수 없습니다.

기록 조회 정책

기록을 볼 수 있는 사람은 두 부류입니다. 기록을 직접 만든 사람, 또는 해당 아기의 가족 구성원.

records 조회 정책 (수도코드):

허용 조건 (OR):
  1) 요청자 = 기록의 created_by (생성자 본인)
  2) 요청자가 해당 baby_id의 family_members에 존재

→ 둘 중 하나라도 만족하면 조회 허용
→ 둘 다 만족하지 않으면 해당 행이 결과에서 완전히 제외

기록 수정/삭제 정책

수정과 삭제는 더 엄격합니다. viewer 권한의 가족 멤버는 기록을 수정하거나 삭제할 수 없어야 합니다.

records 수정 정책 (수도코드):

허용 조건 (OR):
  1) 요청자 = 기록의 created_by (본인 기록)
  2) 요청자가 해당 baby_id의 family_members에서
     permission이 "editor" 또는 "admin"

→ viewer는 조회만 가능, 수정 불가

RLS 무한 재귀 문제

여기서 실제로 부딪힌 가장 까다로운 문제가 있었습니다. records 테이블의 RLS 정책에서 "요청자가 가족 구성원인지" 확인하기 위해 family_members 테이블을 조회해야 합니다. 그런데 family_members 테이블에도 RLS가 걸려 있습니다. 그 RLS에서 또 다른 테이블을 참조하면... 무한 재귀가 발생합니다.

무한 재귀 시나리오:

records RLS: "family_members를 확인해야 해"
  → family_members RLS: "babies를 확인해야 해"
    → babies RLS: "소유자인지 확인해야 해"
      → 다시 family_members 확인...
      → 무한 루프!

이 문제의 해결책은 SECURITY DEFINER 함수입니다. 이 키워드로 생성된 함수는 RLS 정책을 우회해서 직접 테이블을 조회할 수 있습니다. 함수의 "정의자(definer)" 권한으로 실행되기 때문입니다.

해결 방식 (수도코드):

function is_baby_owner(baby_id) [SECURITY DEFINER]:
  → RLS를 무시하고 babies 테이블에서 직접 확인
  → baby의 user_id가 현재 인증 사용자와 같으면 true

function is_family_member(baby_id) [SECURITY DEFINER]:
  → RLS를 무시하고 family_members 테이블에서 직접 확인
  → 현재 사용자가 해당 baby의 멤버이면 true

records RLS 정책:
  → is_baby_owner(baby_id) OR is_family_member(baby_id)
  → 재귀 없이 안전하게 권한 확인

SECURITY DEFINER 함수는 강력하지만 위험할 수 있습니다. 이 함수 안에서 사용자 입력을 직접 사용하면 보안 취약점이 될 수 있기 때문에, 함수 내부의 로직을 최소한으로 유지하고 auth.uid()만을 사용해서 현재 인증된 사용자를 확인하도록 했습니다.

4. 초대 시스템 설계

가족 멤버를 추가하는 방식으로 여러 가지를 고민했습니다. 이메일로 초대하는 방식, 6자리 숫자 코드, QR 코드, 딥링크 등.

최종적으로 UUID 기반 초대 코드 방식을 선택했습니다.

초대 플로우 (수도코드):

[초대하는 사람]
1. 권한 선택 (editor 또는 viewer)
2. "초대 코드 생성" 버튼 탭
3. 시스템이 기존 pending 초대를 확인
   → 이미 있으면 기존 코드 재사용
   → 없으면 새 UUID 코드 생성 (유효기간 7일)
4. 코드를 복사하거나 공유 링크로 전달

[초대받는 사람]
1. 코드를 입력하거나 링크를 통해 접근
2. 시스템이 초대 정보 표시 (아기 이름, 초대자, 권한)
3. "수락" 버튼 탭
4. family_members에 레코드 생성
5. 초대 상태를 "used"로 변경

6자리 숫자 코드 대신 UUID를 선택한 이유는 보안입니다. 6자리 숫자는 브루트포스에 취약합니다. 100만 가지 조합밖에 안 되기 때문에, 자동화된 스크립트로 유효한 코드를 찾아낼 수 있습니다. UUID는 사실상 추측이 불가능합니다.

단, 코드가 길어지면 수동 입력이 불편해지므로 공유 링크 방식을 병행합니다. 코드를 직접 타이핑하는 것이 아니라 메신저로 링크를 보내면, 앱이 설치된 기기에서 바로 초대 화면으로 이동합니다.

초대 코드 재사용 로직

같은 아기에 대해 초대 코드를 여러 번 생성하면 서버에 불필요한 데이터가 쌓입니다. 이를 방지하기 위해 코드 생성 시 기존에 pending 상태이면서 유효기간이 남아 있는 초대가 있는지 먼저 확인합니다.

코드 생성 로직 (수도코드):

function createInviteCode(babyId, permission):
  기존초대 = 검색(baby_id = babyId, status = "pending", expires_at > 현재시간)

  IF 기존초대 존재:
    RETURN 기존초대.invite_code
  ELSE:
    새초대 = 생성(invite_code = UUID, expires_at = 현재 + 7일)
    RETURN 새초대.invite_code

이 방식의 부작용이 하나 있습니다. 이전에 editor 권한으로 초대 코드를 만들었는데, 이번에는 viewer 권한으로 만들고 싶을 때, 기존 코드가 재사용되면서 editor 권한이 유지됩니다. 이 경우는 기존 초대의 권한이 다르면 새로 생성하는 분기를 추가해서 해결했습니다.

5. 오프라인 퍼스트 아키텍처

육아앱은 네트워크 상태가 불안정한 환경에서 사용되는 경우가 많습니다. 병원 지하, 시골 할머니 댁, 비행기 안. 네트워크가 없어도 기록을 남기고 확인할 수 있어야 합니다.

DailyBaby는 Zustand + AsyncStorage를 기반으로 한 오프라인 퍼스트 아키텍처를 채택했습니다.

오프라인 퍼스트 데이터 흐름 (수도코드):

[기록 생성 시]
1. Zustand 스토어에 즉시 추가 (로컬 상태)
2. UI에 바로 반영 (사용자는 즉시 결과를 봄)
3. Supabase에 비동기로 저장 시도
   → 성공: 서버 ID로 로컬 데이터 업데이트
   → 실패: 로컬에는 남아있음, 다음 동기화 시 재시도

[앱 재시작 시]
1. AsyncStorage에서 캐시된 데이터 복원 (hydration)
2. 화면에 바로 표시 (네트워크 대기 없음)
3. 백그라운드에서 서버 동기화 시작

이 구조의 가장 큰 장점은 체감 속도입니다. 네트워크 응답을 기다리지 않고 즉시 UI에 반영되기 때문에, 사용자 입장에서는 앱이 매우 빠르게 느껴집니다.

6. 증분 동기화(Incremental Sync)

전체 데이터를 매번 가져오는 것은 비효율적입니다. 기록이 수백, 수천 개로 쌓이면 전체 동기화에 시간이 걸리고 모바일 데이터도 낭비됩니다.

DailyBaby는 updated_at 타임스탬프 기반의 증분 동기화를 사용합니다.

증분 동기화 (수도코드):

function fetchRecords():
  lastSyncedAt = 마지막 동기화 시간 가져오기

  변경된기록들 = Supabase 쿼리(
    WHERE baby_id = 현재아기
    AND updated_at > lastSyncedAt
    ORDER BY recorded_at DESC
  )

  로컬기록들과 병합:
    FOR EACH 서버기록 IN 변경된기록들:
      IF 로컬에 같은 ID 존재:
        → 서버 데이터로 덮어쓰기 (Last-Write-Wins)
      ELSE:
        → 새 기록으로 추가

  lastSyncedAt = 현재 시간으로 갱신

이 방식은 대부분의 경우 몇 개의 변경된 기록만 가져오면 되므로 매우 효율적입니다. 다만 충돌 해결은 Last-Write-Wins 전략을 사용합니다. 엄마와 아빠가 동시에 같은 기록을 수정하면, 마지막으로 저장한 쪽이 이기는 방식입니다. 육아 기록의 특성상 동시 수정이 극히 드물기 때문에 이 단순한 전략으로 충분했습니다.

풀 싱크 쿨다운

사용자가 홈 화면을 당겨서 새로고침(pull-to-refresh)할 때는 증분이 아닌 전체 동기화를 실행합니다. 하지만 빠르게 여러 번 당기면 서버에 부하가 걸립니다. 이를 방지하기 위해 30초 쿨다운을 적용했습니다.

풀 싱크 쿨다운 (수도코드):

FULL_SYNC_COOLDOWN = 30초

function forceFullSync():
  IF (현재시간 - lastFullSyncAt) < FULL_SYNC_COOLDOWN:
    → 토스트 메시지: "잠시 후 다시 시도해주세요"
    → 리턴 (동기화 실행 안 함)

  전체 기록을 서버에서 가져와서 로컬 교체
  lastFullSyncAt = 현재시간

한 가지 예외가 있습니다. 사용자가 아기 프로필을 전환할 때는 해당 아기의 데이터를 즉시 가져와야 합니다. 이 경우에는 쿨다운을 무시하되, lastFullSyncAt은 갱신하지 않습니다. 이렇게 하면 아기 전환 후 바로 풀 리프레시를 해도 쿨다운에 걸리지 않습니다.

아기 전환 동기화 (수도코드):

function syncForBabySwitch(newBabyId):
  → 쿨다운 무시
  → 전체 기록 가져오기
  → lastFullSyncAt은 갱신하지 않음 (다음 풀 싱크에 영향 없음)

7. 영속성의 선택적 설계

앞서 1편에서 Zustand의 선택적 persist를 언급했는데, 가족 공유 맥락에서 이 설계가 특히 중요합니다.

FamilyStoreUIStore는 아예 persist하지 않습니다. 가족 멤버 목록은 앱을 열 때마다 서버에서 최신 정보를 가져와야 합니다. 오프라인 캐시가 오래된 권한 정보를 보여주면 "viewer인데 수정 버튼이 보이고, 누르면 에러가 나는" 혼란스러운 경험을 만듭니다.

반면 RecordStore는 기록 데이터와 동기화 타임스탬프만 persist합니다.

영속성 전략 요약 (수도코드):

persist 하는 것:
  - records (기록 데이터) → 오프라인에서도 볼 수 있도록
  - lastSyncedAt → 증분 동기화 기준점
  - currentBabyId → 마지막으로 선택한 아기 기억

persist 하지 않는 것:
  - isLoading → 앱 재시작 시 로딩 상태가 남는 버그 방지
  - error → 이전 세션의 에러 메시지가 보이는 문제 방지
  - familyMembers → 항상 서버에서 최신 정보 로드
  - 모달/토스트 상태 → 휘발성 UI 상태

8. 실시간 반영은 어떻게?

"아빠가 기록하면 엄마 폰에 바로 보인다"를 구현하는 방식은 두 가지를 고려했습니다.

방법 1: Supabase Realtime (WebSocket)

  • 서버가 변경을 즉시 클라이언트에 푸시
  • 진정한 실시간이지만 연결 유지 비용 있음

방법 2: 앱 포그라운드 시 증분 동기화

  • 앱이 활성화될 때마다 변경된 데이터 가져오기
  • 몇 초 지연이 있지만 구현이 단순

DailyBaby는 방법 2를 선택했습니다. 육아 기록의 특성상 "초 단위 실시간"이 필요한 경우는 드물기 때문입니다. 아빠가 수유 기록을 남기고, 엄마가 1분 뒤에 앱을 열었을 때 반영되어 있으면 충분합니다. WebSocket 연결을 상시 유지하는 것은 배터리 소모와 서버 비용 측면에서 과잉 대응이었습니다.

실시간 반영 흐름 (수도코드):

[아빠 폰]
1. 수유 기록 저장 → Supabase에 즉시 전송

[엄마 폰]
1. 앱을 열거나 백그라운드에서 돌아옴
2. AppState 변경 감지
3. fetchRecords() 호출 → 증분 동기화
4. 아빠가 방금 저장한 기록이 내려옴
5. UI 자동 업데이트

9. 마치며

가족 공유 기능을 구현하면서 가장 어려웠던 것은 기술 자체가 아니라 경계 조건이었습니다.

  • RLS 무한 재귀는 문서에 나와 있지 않은 문제였고, 디버깅에 상당한 시간이 걸렸습니다
  • 초대 코드 재사용 시 권한이 달라지는 엣지 케이스는 실제 사용자 피드백으로 발견했습니다
  • 아기 전환 시 쿨다운을 무시해야 하는 케이스는 코드 리뷰가 아니라 직접 사용하면서 발견했습니다

서버리스 아키텍처(Supabase 직접 사용)는 1인 개발에서 큰 장점이지만, RLS로 모든 보안을 커버해야 하는 부담이 있습니다. 특히 가족 공유처럼 복잡한 접근 제어가 필요한 경우, RLS 정책을 꼼꼼하게 설계하고 테스트하는 것이 중요합니다.

마지막 편에서는 성능 최적화와 UX 디테일을 다룹니다. 위젯, 예방접종 관리, 일과표 공유, 그리고 앱 전반에 걸친 성능 최적화 이야기로 시리즈를 마무리하겠습니다.

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