완료 기록 쓰기 파이프라인 계약 (completed-workout write pipeline)
규칙 한 문장 — 완료 기록의 저장·수정·삭제는 온라인이든 오프라인이든 한 갈래로 간다: 요청을 조립해 기기 대기열(아웃박스)에 먼저 적고, 화면은 그 즉시 바뀌며, 서버 전송은 뒤에서 곧바로 시작된다. 서버 답장을 화면이 기다릴지는 동작별 정책값 하나로 정하고, 코드 갈래(온라인/오프라인 try/catch)로 정하지 않는다.
- 이슈: #1199 (오너 결정 2026-09-04 — ③ 단일 쓰기 파이프라인만 진행, ④ 로컬 우선 DB는 고려하지 않음)
- 대체하는 문서:
docs/data/workout-write-path.md(2026-07-30, →docs/archive/로 퇴역) ·docs/data/app-screen-rpc-contract.md의 "create-only outbox" 절(개정) - 유지되는 계약:
completed-session-write-source.md(#1143 쓰기 원천 — 이 문서 §9가 삭제 조립 한 줄을 보탠다) - 구현:
src/react/services/pendingWorkoutSaves.ts(대기열 행·합치기·전송 루프) ·pendingWorkoutMutationFlush.ts(단일 sender — 종류별 전송·충돌 재전송) ·src/react/persistence/dispatch/**(dispatcher·영수증 대조·실패 분류, v0.18.0 S05) ·src/react/features/completed-workout/pendingSavesFeatureBinding.ts(답장 처리의 화면 반응) ·src/react/controllers/pendingWorkoutSavesStore.ts(프로젝션·답장 핸들·비행) ·src/react/controllers/workoutWriteController.ts(진입점) - 게이트:
tests/react/completedWorkoutWritePipelineContract.test.mjs(이 문서의 정책표·상태 4종·퇴역 문장 부재) - 서버 변경: 없음. 쓰기 RPC 한 쌍(
save_session_v5생성·수정 /delete_session_v5삭제, #1215 이후; 이 문서 작성 당시는 v4 3종)은 멱등 id·요청 해시·개정번호 검사·영수증을 이미 갖추고 있다.
1. 왜 바꾸나 (유저 이야기)
유저 A가 폰으로 헬스장에서 운동을 끝내면 완료 화면이 뜨면서 기록이 자동 저장된다. A가 마음을 바꿔 [기록 삭제] → [삭제]를 누르면, 지금은 앱이 ① 방금 저장한 기록의 상세를 서버에서 다시 읽고 ② 삭제 요청을 보내고 ③ 둘 다 끝나야 화면을 닫는다. 그동안 팝업은 아무 반응이 없다(1~5초). 메모를 고치고 [메인으로]를 눌러도 같은 2왕복을 기다린다. 지하 헬스장에서는 편집기가 아예 열리지 않는다.
같은 저장·수정·삭제가 오프라인이면 다른 코드(대기열)를 타고, 답장 뒤 후처리도 9개 진입점마다 손으로 다르게 적혀 있다(피드 새로고침·통계 재검증·알림 문구·충돌 처리가 제각각).
이 계약은 두 갈래를 하나로 합친다. A에게 달라지는 것: [삭제]가 즉시 닫힌다. [메인으로]가 즉시 닫힌다. 편집기는 오프라인에서도 열린다. 오프라인에서 운동을 끝내도 버튼을 누를 필요 없이 자동 보관되고 연결되면 점수가 붙는다. 저장·수정·삭제를 누르는 순간에는 어떤 경우에도 에러가 뜨지 않는다.
2. 한 갈래 — 다섯 단계
모든 완료 기록 쓰기(생성·수정·삭제)는 아래 다섯 단계를 같은 순서로 지난다. 온라인과 오프라인의 차이는 ④에서 전송이 성공하느냐뿐이다.
| 단계 | 하는 일 | 규칙 |
|---|---|---|
| ① 요청 조립 | 서버에 보낼 요청(멱등 id clientMutationId·요청 해시 requestHash·개정번호 expectedRevision·줄 번호표) 완성 | 쓰기 원천은 #1143 계약 그대로(수정은 detail/receipt 사본 하나에서만). 삭제는 §9 |
| ② 대기열 적재 | 요청을 기기 대기열(IndexedDB pendingSaves) 행 하나로 저장. 상태 queued | 같은 기록의 앞선 행과 §5 규칙으로 합친다. 적재 실패(기기 저장소 오류)만이 이 시점에 사용자에게 보이는 유일한 오류다 |
| ③ 기기 반영 | 화면 사본(plans·selectedSession·달력 덮어그리기)을 요청 내용으로 즉시 바꾸고, 정책이 immediate면 화면을 닫고 확정 문구를 띄운다 | 기기 반영 함수 한 곳(applyLocalWrite)이 종류별로 그린다. 사본은 writeSource: "display"(쓰기 조립 금지) + syncPending |
| ④ 즉시 전송 | 적재 직후 전송기가 그 사용자의 대기열을 앞에서부터 보낸다(부팅·online·visibilitychange에서도 같은 전송기) | 행 상태 queued → sending. 타임아웃·재시도·격리·보류 판정은 전송기 한 곳(§3·§6) |
| ⑤ 답장 처리 | 영수증을 받으면 확정 사본으로 교체(줄 번호표 children 채택, writeSource: "receipt"), 세트 스코어 적용, 통계 재검증(stats_requested_version 세대 폴링), 상세·목록 무효화, 배경 재조회 | 답장 처리 함수 한 곳(applyReceipt)이 종류별로 처리. 정책이 wait인 동작은 이 단계가 끝나야 화면이 닫힌다 |
⑤에서 하는 배경 재조회(reloadRemoteData·refreshCalendarDates·상세 force)는 사용자가 기다리지 않는 작업이다. 관문 사본(ownDataReplica)은 서버 응답으로만 채운다 — 앱이 요청 본문과 영수증으로 상세 응답을 합성해 사본을 덮어쓰지 않는다(#1143이 기각한 부분 병합의 재현이므로).
3. 대기열 행의 상태 4종
행 하나는 아래 넷 중 하나다. 종전 행(상태 필드 없음)은 queued로 읽는다.
| 상태 | 뜻 | 들어오는 조건 | 나가는 조건 |
|---|---|---|---|
queued (대기) | 보낼 차례를 기다린다 | 적재 직후 / sending 만료 / held 해제 / 일시 실패 뒤 | 전송기가 집어 가면 sending |
sending (전송 중) | 이 탭이 서버에 보내는 중이다. 합치기 금지(§5) | 전송기가 집어 갈 때 sentAt 기록 | 성공 → 행 삭제. 일시 실패(네트워크·5xx·타임아웃) → queued. 인증 실패 → held. 영구 거부 → blocked. sentAt에서 10초가 지나도 결론이 없으면 queued로 되돌린다(탭이 죽었거나 멈춘 경우) |
held (보류) | 로그인이 만료됐다. 재시도도 격리도 아니다 | 401/403/PGRST301 | 그 사용자의 remoteStatus가 ready로 돌아오면 queued. 다른 계정이 로그인하면 그대로 남는다(행은 사용자별) |
blocked (격리, #924 sync_blocked) | 서버가 영구 거부했다. 자동 재시도 없음 | 영구 실패 코드(§6 표) | 사용자 복구([다시 시도]·[새 기록으로 다시 올리기]·[서버 최신본 보고 다시 저장]) 또는 삭제 |
- 행의 상태 필드:
state(없으면queued),sentAt(전송 시작),heldAt(보류),blockedAt/blockedCode(격리, #924),deferredAt(한 번이라도 못 보낸 시각),announced(적재 때 확정 문구를 띄웠다). 값이 없는 상태 필드는 키째 저장하지 않는다. - 전송기는
queued행만 집어 간다.sending·held·blocked는 건너뛰고 다음 행으로 간다. - 한 탭에서 한 사용자의 전송은 한 번에 하나(
retryFlight). 여러 탭이 같은 행을 보내도 서버 영수증 멱등성이 중복 반영을 막는다 — 탭 간 리더 선출은 두지 않는다. 행 단위 claim(보내는 탭 표시·만료)·fence(만료 뒤 늦은 답장 무시)는 v0.18.0 계약(ADR §3-1, S04)이며, S01의 구형 탭 공존 실험(2026-09-07, 이슈 #1285 — 실제 v0.17.0 번들 탭과 새 번들 탭이 같은 대기열을 함께 쓰는 CASE-039)을 통과했으므로 활성화 조건은pending-save-codec.md§5 가 정한다 — S04(2026-09-08, 이슈 #1334)가 그 조건대로 켰다: 정본outbox-claim-successor.md(행 단위 claim·fence·ACK 적용·후속 행·의도 행). 전송 중 행은 더 이상 지워지지 않고 후속 행이 선행 확정 뒤에 나간다. - 행을 읽고 다시 쓰는 규칙은
pending-save-codec.md(S01)가 정본이다: 행의 요청 묶음(clientMutationId·requestHash·sourceRef·payload·userId)은 한 글자도 바꾸지 않고,update는 저장소의 현재 행과 모르는 필드를 보존하는 병합으로 쓰며, 모르는 버전·깨진 행은 지우거나 덮어쓰지 않고 원문째 따로 둔다. - 되돌아간
queued행을 다시 보내는 것은 서버 멱등 재생이라 안전하다(같은clientMutationId·requestHash).
4. 동작별 "서버 답장을 기다리는가" 정책표
정책값은 코드 갈래가 아니라 표의 값이다. 어느 동작을 wait로 되돌리는 것은 값 한 줄이며 코드 갈래를 복원하지 않는다. 초깃값은 오너 결정(2026-09-04)이다.
| 동작 (진입점) | 종류 | 정책 | 화면이 닫히는 시점 | 확정 문구 |
|---|---|---|---|---|
| 완료 화면 자동 저장 (#1017) | 생성 | wait | 답장 뒤 (화면은 열린 채 "저장이 완료되었습니다" → 완료 화면, 세트 스코어 표시) | — (#1164, 점수를 같이 보여 주기 위해) |
| 저장 버튼 (진행 중 운동을 완료 화면 없이 바로 저장) | 생성 | wait | 답장 뒤 ("저장 중…") | "저장하였습니다" |
| 지난 기록 작성 (과거 날짜 직접 입력) | 생성 | wait | 답장 뒤 ("저장 중…") | "저장하였습니다" |
| 완료 화면 [메인으로] (메모·시간이 자동 저장값과 달라졌을 때의 수정) | 수정 | immediate | 적재 직후 | "수정하였습니다" |
| 세션 상세 연필 편집 저장 | 수정 | immediate | 적재 직후 | "수정하였습니다" |
| 세션 상세 시간 수정 | 수정 | immediate | 적재 직후 | "수정하였습니다" |
| 완료 화면 [기록 삭제] → [삭제] | 삭제 | immediate | 적재 직후 | "삭제하였습니다" |
| 세션 상세 삭제 | 삭제 | immediate | 적재 직후 | "삭제하였습니다" |
| 피드 카드 삭제 | 삭제 | immediate | 적재 직후 | "삭제하였습니다" |
wait 동작의 규칙:
- 답장이 늦거나 끊기면(일시 실패) 행은
queued로 남고 화면은 오류 없이 닫힌다 — 완료 화면 자동 저장은 화면을 유지한 채 "동기화 후 점수 반영" 표시로 바꾼다(결정 1-b). 저장 버튼·지난 기록은 "기기에 보관했어요. 연결되면 자동으로 올라가요." - 늦게 온 답장의 세트 스코어는 완료 화면이 아직 열려 있으면 화면에 재적용한다.
- 완료 화면 자동 저장 실패 시의 안내 배너·수동 저장 버튼 경로는 퇴역한다(자동 대기열 잔류로 대체).
immediate 동작의 규칙:
- 적재 직후 확정 문구를 띄운다. 답장이 성공하면 무음(추가 알림 없음). 서버가 영구 거부하면 §7.
- 개정번호 충돌(40001/
LG409)은 "나중 저장 우선"(#1173 D1) — 전송기가 최신 개정번호로 다시 보낸다(메시지 없음).
공통 규칙 — "동기화했어요" 안내: 적재 때 확정 문구를 띄운 행(announced)은 답장 성공을 알리지 않는다. 다만 한 번이라도 보내지 못해 기기에 남았던 행(deferredAt)이 나중에 올라가면 "대기 중이던 기록 변경을 동기화했어요."(생성이면 "… 운동 기록을 동기화했어요.")를 1회 띄운다 — 사용자가 "지하에서 고친 게 올라갔나"를 알 수 있게.
5. 합치기 규칙 (같은 기록의 행은 마지막 상태 하나)
#1173 Phase 4 규칙을 유지하고 두 줄을 보탠다.
| 대기열에 이미 있는 행 | 새로 들어오는 것 | 결과 |
|---|---|---|
생성(queued) | 수정 | 수정 내용으로 생성 하나(새 식별자·새 해시) |
생성(queued) | 삭제 | 둘 다 버림(서버에 아무것도 안 감) |
생성(sending) | 수정 | 합치지 않는다. 답장을 기다렸다가 서버 id로 수정 행을 새로 적는다(합치면 같은 source_ref에 다른 해시 두 요청이 도착해 두 번째가 LG001로 격리된다) |
생성(sending) | 삭제 | 합치지 않는다. 답장 뒤 서버 id로 삭제 행을 적는다 |
| 수정 | 수정 | 마지막 수정 하나 |
| 수정 | 삭제 | 삭제 하나 |
| 삭제 | 삭제 | 삭제 하나(멱등) — 두 번째 행을 만들지 않는다 |
| 삭제 | 수정 | 거부 — 지운 기록을 고칠 수 없다. 호출부는 "이미 삭제된 기록"으로 처리 |
blocked 행 | 같은 기록의 수정·삭제 | 격리 행을 지우고 새 행을 적는다(사용자의 새 시도가 복구다) |
| 계획 저장·삭제 | (동일 규칙) | 종전대로 |
6. 전송기 한 곳의 실패 판정
타임아웃(10초)·재시도·실패 분류는 전송기(단일 sender sendDurableMutation + dispatcher persistence/dispatch/**, v0.18.0 S05 — 계약) 한 곳에만 있다. 판정의 정본 구현은 persistence/dispatch/failure.ts 하나다. 진입점이나 명령 어댑터에 종류별 재시도를 두지 않는다. (관문 barbelicRepository가 iOS의 일시 fetch 오류 "Load failed"에 대해 같은 요청을 250ms 뒤 1회 다시 부르는 것은 전송 계층의 기존 동작이며 종류별 정책이 아니다.)
| 서버·전송 결과 | 판정 | 행 상태 | 화면 |
|---|---|---|---|
| 영수증 성공 | 완료 | 삭제 | §2 ⑤ |
네트워크 오류·타임아웃·408·429·5xx·57014 | 일시 실패 | queued (다음 기회에 재시도, 전송 중단) | 없음(무음) |
401 / 403 / PGRST301 | 인증 만료 | held | 없음(재로그인 시 자동 재개) |
40001 / LG409 (개정번호 불일치) | 충돌 | 40001 상세의 actual_revision이 있으면 그 번호로 즉시 재전송(재조회 없음), 없으면 상세 1회 읽어 재전송(#1173 D1) | 없음 |
P0002 / LG001 (삭제 대상이 이미 없음) | 이미 처리됨 | 삭제(성공과 동일, 통계 재검증은 생략 — 서버 변경이 없었다) | 없음 |
LG001(수정 대상 없음 / source_ref 불일치)·42501·22xxx·23xxx·42xxx·P****(P0002 제외)·PGRST1xx/2xx·LG_WORKOUT_CONTRACT·LG_WORKOUT_*_LIMIT | 영구 거부 | blocked | §7 |
영구 거부의 대부분(한도 초과·형식 오류)은 적재 전 앱 검증(validateCompletedWorkoutPayload, #975·#938)이 막는다 — 적재 전 검증 실패는 대기열에 넣지 않고 입력 화면에 그 자리에서 원인 문구를 보인다(이것은 "쓰기 실패"가 아니라 입력 오류다).
7. 실패 표시 규칙 (오너 원칙: "저장 플로우에 절대 에러 안 뜸")
- 저장·수정·삭제를 누르는 순간에는 어떤 경우에도 에러를 띄우지 않는다(예외: §2 ② 기기 저장소 적재 실패 — "서버와 기기 모두에 저장하지 못했어요").
- 답이 늦거나 끊긴 것(일시 실패·인증 만료)은 조용히 대기열에 남긴다. 메시지 0.
- 충돌은 자동으로 재전송한다. 메시지 0.
- 서버가 영구 거부한 경우만: 일지의 해당 기록에 "동기화 실패" 배지 + 원인 한 줄 + 복구 버튼([다시 시도] / 수정이면 [서버 최신본 보고 다시 저장] / 생성이면 [새 기록으로 다시 올리기], #924 규격). 안내 문구는 1회("동기화하지 못한 운동 기록이 n건 있어요. 일지에서 확인해 주세요.").
- 같은 기록이 여러 번 실패해도 배지는 하나다. 수정·삭제 행에도 이 배지·복구가 있다(종전에는 생성 행만).
- 완료 화면 삭제의 되돌리기(취소 토스트)는 두지 않는다(결정 4 — 확인 팝업 한 번).
8. 여러 탭·인증
- 답장 핸들(
wait동작이 기다리는 약속)은 탭 메모리다. 다른 탭이 먼저 올려 대기열에서 행이 사라지면 핸들은 "결과 미상"으로 풀리고, 화면은 상세 강제 조회로 수렴한다(세션 id는source_ref에서 결정되므로 조회 가능). - 대기열 행은 사용자별(
userId)이다. 다른 계정으로 로그인하면 그 계정의 행만 보이고 보내진다. 계정 삭제는 그 사용자의 행을 지운다(#928). 로그아웃은 지우지 않는다(재로그인 후 이어 올린다). - 생성 직접 경로에만 있던 "401/403 뒤 세션 소유자 재확인 → 같은 소유자면 큐" 판정은 퇴역한다. 행은 적재 시점에 이미 사용자별로 저장돼 있으므로, 인증 실패는 언제나
held이고 그 사용자가 다시ready가 될 때 재개된다.
9. 삭제 요청의 조립 (쓰기 원천 계약 §5 보충)
삭제에는 줄 번호표가 필요 없다. 따라서 어느 사본의 serverRevision·sourceRef로든 삭제를 조립할 수 있다 (영수증 사본 → 선택된 세션 → 달력 상세 사본 fullById → 세션 목록 plans 순). 삭제 전에 상세를 다시 읽지 않는다. 개정번호가 낡았으면 서버가 40001로 거부하고 §6 충돌 규칙이 흡수한다. 개정번호를 어느 사본에서도 모르는 기록(옛 달, 부팅 범위 밖)만 전송기가 보내기 전에 상세를 1회 읽는다 — 화면은 이미 닫혀 있으므로 사용자는 기다리지 않는다.
buildLocalWorkoutSaveInput(수정 조립)의 금지 목록(달력 캐시·세션 목록·선택 세션 읽기 금지)은 그대로다.
10. 유지되는 규칙 (2026-07-30 문서에서 그대로 가져온 것)
- 쓰기 RPC는
save_session_v5·delete_session_v5한 쌍뿐이다(#1215 이후). 다른 버전·raw write로 폴백하지 않는다. 대기열 재전송도 같은 v5 요청을 재생한다. 오류·텔레메트리의operation라벨도 이 실명을 쓴다(#1246). - 멱등: 새 기록의
clientMutationId는 초안의operationIdUUID, 재시도는 같은 id·같은 해시.workout_mutation_receipts고유 제약이 중복 반영을 막는다. - 개정번호: 수정·삭제는 양의
expectedRevision을 보낸다. 서버는 직렬화 충돌 40001을LG409로 번역한다(PostgREST 자동 재생 방지). - SQLSTATE
42501은 HTTP 5xx·timeout 문구와 함께 와도 항상 영구 거부가 우선한다. degradedEmpty(정본 카탈로그 없이 만든 자유 입력)는 대기열로 승격하지 않고 초안으로만 보존한다.- 쓰기 경로 IndexedDB
barbelic-workout-local-cache에는 사용자별 초안 하나와 대기열 행만 둔다. 초안은 250ms debounce·last-writer-wins. 대기열 행은clientMutationId키, 같은 키에 다른 내용 덮어쓰기는 거부(fail-closed). - 열람 스냅샷은 별도 IndexedDB
barbelic-read-snapshots에 저장한다. 명시적 로그아웃은 owner-scoped 읽기 snapshot만 별도 purge channel로 삭제하며, draft와 pending 행의 삭제 정책을 추가하지 않는다. 단순 세션 만료 이벤트는 삭제 근거로 사용하지 않는다. - 대기 중인 생성 행은 일지에 "동기화 대기" 카드로만 합성하며 통계에는 포함하지 않는다(#1173 D2 — 오프라인 저장분의 통계는 "동기화 후 반영" 표시만, 앱에서 근사 계산 안 함). 온라인에서도 하루·주·월 합계가 답장 뒤 1초 안팎 늦게 갱신되는 것은 이 원칙의 결과이며 수용한다.
- 새 계획 생성은 서버 발급 id가 필요하므로 이 파이프라인 범위 밖이다. 계획 수정·삭제는 v0.18.0 A09(#1342, 2026-09-08)부터 완료 기록과 같은 한 갈래다 — 온라인이든 아니든 S03 조립 → 대기열 → dispatcher(
features/plan/commands/planWriteCommand.ts), 확정 문구는 적재 때(immediate규칙), 정책표 자체는 불변. 첫 수직 통합 계약 §4.
11. 퇴역한 규칙 (2026-07-30 "만들지 않을 것 목록"의 정리)
| 2026-07-30 문장 | 지금 | 이유 |
|---|---|---|
| "수정·삭제는 항상 live-only이며 로컬 queue로 전환하지 않는다" / "오프라인 수정·삭제는 큐에 넣지 않는다" | 퇴역 — 수정·삭제도 대기열 한 갈래 | #1173 Phase 4에서 이미 벗어났고, 이 계약이 정본 |
| "create-only outbox" | 퇴역 — 생성·수정·삭제·계획 수정·계획 삭제 | 같음 |
| "parked/quarantine/후속 행 skip은 도입하지 않는다" | 퇴역 — blocked(격리, #924)·held(보류)와 건너뛰기가 있다 | 머리 포이즌(#924)·인증 만료 처리 |
| "수정은 막고 업로드 취소만 기기에서 처리한다" | 퇴역 — 대기 중 생성 기록의 수정은 생성 행에 합친다(§5) | #1173 Phase 4 |
| "HTTP 401/403은 세션 소유자를 재확인해 같은 소유자일 때만 create를 보관" | 퇴역 — 인증 실패는 held(§8) | 행이 적재 시점에 사용자별로 저장됨 |
| "이중 클릭은 controller의 단일 in-flight 경계에서 한 번만 전송" | 퇴역 — 잠금 3곳 제거. 순서는 대기열이, 중복은 멱등 id가 보장 | 잠금이 무음 실패를 만들었다(#1199) |
| "Web Lock, CAS, lease, lineage, quarantine, BroadcastChannel은 사용하지 않는다" | 부분 유지 · v0.18.0 개정 — Web Lock·BroadcastChannel(큐 전파용)·탭 간 리더 선출은 여전히 없다. 행 단위 CAS claim·만료·fence는 v0.18.0에서 도입했다(ADR §3-1, S04 — 2026-09-08 활성, 계약). claim 의 lease 는 10초이고 claim 이 없는 행(옛 번들이 쓴 행)은 종전대로 "10초 뒤 대기로 복귀" 한 줄로 판정한다 | 이슈 #1279 G01 |
| "remote live-ready이면서 online일 때만 … 저장 순서대로 전송" | 개정 — 적재 직후에도 전송한다. 오프라인이면 전송기가 즉시 일시 실패로 돌아온다 | 즉시 전송 방아쇠 |
| "retryable/offline flush 실패는 connectivity를 degraded/offline으로 내리고 다음 probe 성공 전까지 자동 flush하지 않는다" | 유지 |
12. D14 서버 자식 저장 (2026-09-10)
변경 행 저장은 서버의 물리 자식 쓰기만 줄인다. DTO·요청 해시·mutation 재생·기대 revision·원본 ID·영수증 규칙은 유지한다. 동일 내용의 새 완료 저장도 revision과 통계 요청 세대를 올리며, 같은 mutation 재전송만 기존 영수증을 재사용한다. 클라이언트 대기열·답장 처리 정책은 바뀌지 않는다.