콘텐츠로 이동

얇은 클라이언트 + 멱등 동기화 — 진실은 한 곳, 재전송은 안전하게

정의

폰과 워치처럼 두 기기가 한 사용자의 데이터를 다룰 때, 진실은 폰 DB 하나에만 두고 워치는 두 가지만 가집니다.

워치가 가진 것 내용 없어져도 되나
스냅샷 캐시 폰이 보낸 오늘 종목·목표·누적 예, 재요청하면 됩니다
미전송 큐 아직 폰에 닿지 않은 기록 아니오, 닿을 때까지 보관합니다

워치에 DB를 두면 충돌 해결이 필요해지고, 그것은 기기 간 동기화 마일스톤의 일입니다. src-workout-history-launch는 그 일을 출시 후로 미루기 위해 워치를 얇게 유지했습니다.

메시지 흐름

flowchart LR
    W[워치: 탭] --> ID[UUIDv7 생성]
    ID --> S{즉시 메시지}
    S -- 성공 --> P[폰: 멱등 수신]
    S -- 실패 --> Q[미전송 큐]
    Q -- 연결 복구 --> P
    P --> DB[(폰 DB)]
    DB -- 2초 디바운스 --> Snap[스냅샷 푸시]
    Snap --> W

위 흐름의 요점은 세 가지입니다. 첫째, 기록의 ID는 워치에서 만듭니다. 그래야 같은 기록이 두 번 도착해도 폰이 알아봅니다. 둘째, 즉시 전송이 실패하면 큐에 넣고 연결이 복구될 때 다시 보냅니다. 셋째, 폰은 저장이 끝나면 스냅샷을 워치로 밀어 워치 캐시를 진실에 맞춥니다.

멱등 수신 — 재전송이 안전해지는 열쇠

// 폰 쪽 수신. 같은 ID가 두 번 오면 두 번째는 조용히 무시합니다.
Future<void> onWatchRecord(WatchRecord msg) async {
  if (await repo.exists(msg.id)) return;   // 멱등: 재전송·중복 도착 무해
  await repo.append(msg.toRecord());
}
// 워치 쪽 전송. 즉시 시도 + 실패 시 큐 폴백 + 낙관적 로컬 에코.
func send(_ record: WatchRecord) {
    localList.append(record)                     // 낙관적 에코: 목록에 즉시 반영
    if session.isReachable {
        session.sendMessage(record.json, replyHandler: nil) { _ in
            self.queue.enqueue(record)           // 실패하면 큐로
        }
    } else {
        queue.enqueue(record)
    }
}

UUIDv7을 쓰는 이유는 시간 순서가 ID에 들어 있어 큐를 비울 때 정렬이 공짜이기 때문입니다. 폰·워치 공통 JSON 메시지 스키마를 쓰면 Wear OS도 같은 스키마로 붙습니다.

폰→워치 푸시 — 트리거는 여럿, 전송은 하나

항목 규칙
트리거 기록 저장·목표 변경·종목 변경 등 5종
디바운스 2초. 연속 저장 다섯 번이 푸시 한 번으로 합쳐집니다
연결 복구 워치가 스냅샷을 재요청합니다

함정 · 증분 가져오기의 앵커

헬스 앱에서 운동을 증분으로 가져올 때, 앵커(마지막 가져온 시각)는 실제로 가져온 것이 있을 때만 전진시킵니다. 권한이 어중간해 전부 건너뛴 배치가 앵커를 밀면 이후 영영 0건이 됩니다.

Future<void> importIncremental() async {
  final batch = await health.fetchSince(anchor);
  final imported = await repo.appendAll(batch.records);
  if (imported > 0 || batch.complete) {
    anchor = batch.latestTimestamp;   // 가져온 게 있거나 완전 배치일 때만 전진
  }
  // 전부 건너뛴 배치는 앵커를 건드리지 않습니다.
}

전체 가져오기는 연 단위 청크로, 최신에서 과거 순으로 돌립니다. 한 번에 긁으면 스피너가 굳고 사용자가 가장 궁금한 최신 기록이 마지막에 옵니다. 청크 중간 실패는 부분 성공을 보존합니다. 불완전 플래그를 세우고 앵커 전진은 유지해야 전체 재스캔을 반복하지 않습니다.

삽질 · 시뮬레이터에서 붙지 않는 세션

폰↔워치 세션은 시뮬레이터에서 연결되지 않습니다. 스토어 스크린샷은 워치 앱에 시뮬레이터 전용 데모 스냅샷을 내장해 찍었습니다. 얇은 클라이언트라 "스냅샷 하나만 주입하면 화면이 선다"는 점이 여기서 이득이 됐습니다.

같은 인사이트 패턴 — "직접 참조 대신 신호로 협력한다"

영역 직접 결합 방식 신호 협력 방식 참조
폰↔워치 워치가 DB를 갖고 양방향 동기화 멱등 ID 메시지 + 큐 + 스냅샷 푸시 (이 페이지)
도메인 이벤트 한 트랜잭션에서 타 도메인 갱신 커밋 후 이벤트 발행 concept-domain-event-eventual-consistency
타이머 큐 엔진이 햅틱 API 직접 호출 큐 이벤트 발행 concept-wall-clock-state-machine
애그리거트 연결 객체 참조 ID만 보관 concept-id-reference-vs-object-reference

같은 인사이트 패턴 — "진실은 하나, 나머지는 파생"

영역 진실 파생 참조
폰↔워치 폰 DB 워치 스냅샷 캐시 (이 페이지)
기록 앱 append-only 기록 스트릭·통계 concept-local-first-append-only
타이머 인덱스 + 앵커 남은 시간 concept-wall-clock-state-machine

→ 공통 원리: 두 기기가 각자 진실을 가지면 충돌 해결이 제품 기능이 됩니다. 한쪽을 캐시로 격하하고 메시지를 멱등하게 만들면, 재전송·중복·순서 뒤바뀜이 전부 무해해집니다.

빠른 진단

  • "워치에서 두 번 탭했는데 기록이 두 개" → ID를 폰에서 만들고 있거나 수신이 멱등하지 않습니다.
  • "워치 화면이 폰과 다르다" → 푸시 트리거 누락 또는 연결 복구 시 재요청이 없습니다.
  • "헬스 가져오기가 어느 날부터 0건" → 빈 배치가 앵커를 전진시켰습니다.
  • "전체 가져오기 중 스피너가 굳는다" → 청크 없이 한 번에 긁고 있습니다.

원본 출처

  • raw: raw/workout-history/TECH_NOTES.md §3(증분 앵커)·§5 (2026-09-03 공개 요약)

관련 페이지