Skip to content

완료 기록 쓰기 단일 파이프라인 — 완료 화면 [삭제] 1~5초 무반응에서 "대기열 먼저·즉시 반영·뒤에서 전송" 한 갈래까지 (2026-09-04)

  • 기간: 2026-09-03 ~ 2026-09-04 (세션 4개 — 원인 분석 3c2bb8c5, 조사 보고 3ccf55b2, 반박 검증·계획·이슈 개정 f1fa1426, Phase 0~6 코드·랜딩 5fb931c0. 오너 보고 원문 "완료 화면에서 기록 삭제를 누르면 반응이 없다가 몇 초 뒤에 닫힌다" → 오너 질문 "왕복 자체를 없앨 수 없나 / 삭제·저장·수정을 하나로 묶어야")
  • 랜딩: PR #1231 (Phase 0~6, squash 9f1e182c, 2026-09-04 KST) — 마이그레이션 0·엣지 함수 없음, Vercel 배포 O
  • 설계서: 없음(조사 보고·Phase 계획·"예상 효과·개선사항" = 이슈 #1199 본문·댓글 5·6, 오너 결정 표)
  • 정본: 계약 docs/contracts/completed-workout-write-pipeline.md(한 갈래 다섯 단계·행 상태 4종·동작별 기다림 정책표·합치기·실패 표시·여러 탭·삭제 조립) / 대기열 src/react/services/pendingWorkoutSaves.ts(행 상태·합치기·미러) · 전송기 pendingWorkoutMutationFlush.ts / 답장 처리 src/react/controllers/pendingWorkoutSavesStore.ts / 진입점 src/react/controllers/workoutWriteController.ts(resolveCompletedSessionDeleteBase·queueCompletedSessionEdit·queueCompletedWorkoutCreate·awaitCompletedWorkoutReceipt)
  • 도구: 로컬 Supabase 샌드박스(레포 밖 scratchpad/sbx, junction 방식) + 프리뷰 서버로 e2e 로컬 실행 · 앵커 테스트·e2e 재계산표(이슈 #1199 댓글, Explore 에이전트 2개)
  • 게이트: 단위 completedWorkoutWritePipelineContract(계약 grep) · pendingWorkoutOutboxStates(9) · completedWorkoutReceiptApply(4) · completedWorkoutDeleteCutover(5) · completedWorkoutEditCutover(4) · completedWorkoutSaveCutover(5) · e2e CASE-032(신설, 완료 화면 삭제가 서버 응답 전에 닫힘) · CASE-029(개정, 장애 주입 프로필) · CASE-015(개정, 자동 저장 실패 = 자동 대기열) · designContract 마커 finishSyncPending
  • 버그리포트: BUG-075(완료 화면 삭제 무반응)
  • 계약: 신설 completed-workout-write-pipeline.md · completed-session-write-source.md §5 삭제 조립 한 줄 · app-screen-rpc-contract.md outbox 절 개정 · 퇴역 docs/archive/workout-write-path.md(2026-07-30)

Phase 현황

Phase내용상태
Phase 0계약 문서 신설·07-30 문서 퇴역·앵커/e2e 재계산표✅ PR #1231 (3dbbb8c2)
Phase 1대기열 핵심 보강(상태 4종·즉시 전송·답장 핸들·보류·합치기·충돌 재전송·미러)✅ PR #1231 (47af3cd7)
Phase 2답장 처리 함수 완성(대기열 업로드 후처리가 온라인 경로의 일을 전부)✅ PR #1231 (5f8e1623)
Phase 3삭제 전환 — 재조회 없이 즉시 닫힘, 무음 잠금 제거(#1199 원 보고 해소)✅ PR #1231 (b7800898)
Phase 4수정 전환 — [메인으로]·연필·시간 수정 즉시 닫힘, 편집기 오프라인 게이트 제거✅ PR #1231 (b7800898)
Phase 5저장 전환 — 대기열 경유 답장 대기, 자동 저장 실패 = 자동 대기열 + "동기화 후 점수 반영"✅ PR #1231 (b7800898)
Phase 6잠금 정리·앵커 최종 재조준·e2e 신설/개정·작업 기록·버그리포트✅ PR #1231

1. 배경

유저 A가 운동을 끝내면 완료 화면이 뜨면서 기록이 자동 저장된다(#1017). A가 마음을 바꿔 [기록 삭제] → [삭제]를 누르면 팝업이 그대로 열린 채 1~5초 동안 아무 변화가 없다가 갑자기 메인으로 돌아갔다. 오너 보고(2026-09-03). Production 기록으로도 확인: 당일 오너 계정 삭제 5건 전부 자동 저장 뒤 4~30초 안(중앙값 13초)의 완료 화면 삭제였고, 최근 30일 실사용자 삭제 6건이 전부 이 경로였다.

원인 분석(세션 3c2bb8c5): 삭제 앞에 상세 재조회 1회(get_session_detail) + 삭제 답장 대기(delete_completed_session_v4)가 직렬로 놓여 있고, 화면에는 진행 표시가 없었다. 오너의 질문("왕복 자체를 없앨 수 없나 / 삭제·저장·수정을 하나로 묶어야")으로 조사가 "삭제 카드 로딩 표시"에서 완료 기록 쓰기 경로 전체의 구조로 넓어졌다(세션 3ccf55b2, 워크플로 33에이전트 + Production 실측; 반박 검증 10건 f1fa1426).

2. 문제 제기

2-1. 완료 기록 쓰기가 두 갈래였다

저장·수정·삭제 진입점 9곳이 "온라인이면 서버 직접 호출 → 답장 대기 / 오프라인이면 기기 대기열"로 갈라져 있었고, 답장 뒤 후처리(피드 새로고침·통계 재검증·알림 문구·충돌 처리·화면 사본 갱신)가 갈래·진입점마다 달랐다(차이 9곳, 이슈 댓글 5 §3). 온라인 갈래는 언제나 답장을 기다리므로 사용자가 서버를 기다리는 동작이 6개였다.

2-2. 수정·삭제 앞에 무조건 재조회했다

#1143 쓰기 원천 계약("개정번호·줄 번호표는 서버 응답 하나의 사본에서")을 적용하면서 수정·삭제 전 상세 강제 조회가 규칙이 됐다. 완료 화면의 기록은 이미 영수증 사본(writeSource: "receipt", 개정번호 동봉)으로 plans에 들어 있었는데도 다시 읽었다. 삭제는 줄 번호표가 필요 없어 어떤 사본으로도 조립할 수 있었다.

2-3. 대기열에 빈틈이 있었다

적재 직후 전송이 없고(부팅·online·visibilitychange뿐), 행 상태가 격리 하나뿐이라 전송 중 생성 행에 수정을 합치면 옛 id·새 id 두 요청이 같은 source_ref로 도착해 두 번째가 격리됐다. 로그인 만료(401/PGRST301)는 영구 실패로 분류돼 격리됐고 재로그인해도 자동 재개되지 않았다. 삭제→삭제는 행이 둘 남고, 삭제 뒤 수정 차단이 없었다. 수정·삭제 행에는 격리 표시·복구 수단이 없었다(생성 행만).

2-4. 무음 잠금

쓰기 진행 중 두 번째 탭은 workoutWriteInFlightRef가 아무 말 없이 무시했다(삭제 false 반환·수정 undefined·저장 조기 반환).

3. 해결 방안

원칙 (오너 결정, 2026-09-04)

결정내용
D1 방향③ 단일 쓰기 파이프라인만 진행. ④ 로컬 우선 데이터베이스는 고려하지 않음(통계는 서버 원칙 유지 시 거시적으로도 이득 없음). 관문 사본은 서버 응답으로만(#1143 일치)
D2 랜딩Phase 0~6을 한 PR·CI 1회(§16)
D3 문서2026-07-30 결정 문서 2개 퇴역 승인("수정·삭제는 대기열에 넣지 않는다"·"만들지 않을 것 목록")
D4 이슈#1199 자체를 트랙 이슈로 개정
1-a 저장 버튼·지난 기록(나) 답장 대기 — 대기열은 거치되 "저장 중…" 뒤 닫힘
1-b 자동 저장 실패(가) 자동 대기열 잔류 + "동기화 후 점수 반영", 배너·저장 버튼 제거
3 영구 거부 표시(가) 일지의 해당 기록에 배지 + 원인 한 줄 + [다시 시도]/[서버 최신본 보고 다시 저장], 안내 1회(#924 규격), 수정·삭제 행에도
4 삭제 되돌리기(가) 없음(확인 팝업만)

오너가 강조한 것: "실패 메시지 우수수 뜨면 앱 누가 쓰나" — 누르는 순간에는 어떤 경우에도 에러 없음, 늦거나 끊긴 것은 조용히 재시도, 영구 거부만 배지 1개.

접근

판단
(a) 삭제 카드 로딩 표시 + 재조회 생략(왕복 1회)기각 — 증상만 줄이고 두 갈래는 그대로
(b) ③ 단일 쓰기 파이프라인 — 모든 쓰기를 대기열에 먼저 적고 즉시 반영·즉시 전송, 답장을 기다릴지는 정책값채택 — 서버 변경 0, 두 갈래 유지비 제거, 부품(합치기·D1·격리·복구)이 이미 있어 확장만
(c) ④ 로컬 우선 DB(기기 DB = 읽기 정본)기각 — 서버 신규 공사(자식 표 updated_at·tombstone·변경 피드·파생값 12계열) 없이는 착수 불가, 개월 단위, ③의 부품 절반 폐기
(d) ⑤ 관문 사본 즉시 갱신 / 합계 근사 표시기각 — ownDataReplica 규칙·#1143이 기각한 부분 병합의 재현 / D2 재결정 필요

4. 적용한 내용

Phase 0 — 계약 문서 (3dbbb8c2)

docs/contracts/completed-workout-write-pipeline.md 신설(§1 유저 이야기 · §2 한 갈래 다섯 단계 · §3 행 상태 4종 · §4 정책표 · §5 합치기 · §6 전송기 실패 판정 · §7 실패 표시 · §8 여러 탭·인증 · §9 삭제 조립 · §10 유지 규칙 · §11 퇴역 규칙). docs/data/workout-write-path.mddocs/archive/(배너), app-screen-rpc-contract.md outbox 절 개정, 링크 3곳·사이드바·README 재등록, errorCaseExpectedFaultPolicy 앵커 재조준, 계약 grep 테스트. 앵커 테스트 재계산표(테스트 25파일·게이트 스크립트 3개)와 e2e 재계산표(9케이스 + 인접 5케이스) — 이슈 댓글.

Phase 1 — 대기열 핵심 (47af3cd7, 9ad90d0d)

  • pendingWorkoutSaves.ts: state(queued/sending/held/blocked)·sentAt·heldAt·deferredAt·announced, 전송 중 10초 만료 후 재전송, 전송 중 생성 행 합치기 금지(wait-for-create), 삭제→삭제 멱등, 삭제 뒤 수정 거부(PendingWorkoutTargetDeletedError), 격리 삭제 행 교체, releaseHeldPendingWorkoutSaves, retryPendingWorkoutSaveshold·deferred, localStorage 미러(IndexedDB가 비면 되살림), 저장 전 undefined 상태 필드 제거.
  • directWorkoutWrite.ts: holdPendingWorkoutSaveFailure(401·403·PGRST301은 보류, 42501은 격리).
  • pendingWorkoutMutationFlush.ts: 40001 상세의 actual_revision으로 상세 재조회 없이 재전송.
  • pendingWorkoutSavesStore.ts: 보류 해제, 답장 핸들 awaitReceipt(uploaded/isolated/held/deferred/unknown), queueCreateForUser·queueMutationForUser(적재→프로젝션→즉시 전송), 전송 중 적재 rerun.
  • 관문의 iOS 일시 fetch 재시도(250ms 1회)는 전송 계층 동작으로 유지(첫 시도에서 뗐다가 repository.test 실패로 원복).

Phase 2 — 답장 처리 한 곳 (5f8e1623)

수정 답장 → 영수증 줄 번호표 채택 확정 사본(writeSource receipt·새 개정번호)을 plans·열린 상세로 교체(뒤이은 삭제가 재조회 없이 이 개정번호로 조립된다 — CASE-029가 검증), 생성 답장 → 피드 강제 새로고침·프로필 통계 재조회, 안내는 announced 아니거나 deferredAt 있는 행만. completedWorkoutCommands.prepareSave/prepareDelete(서버 호출 없이 요청 조립).

Phase 3~5 — 삭제·수정·저장 전환 (b7800898)

  • 삭제: 열린 상세 → 달력 상세 사본 → 목록 카드 순으로 개정번호를 아는 사본으로 조립(resolveCompletedSessionDeleteBase), 대기열 적재, 즉시 닫힘. 옛 달(사본 없음)만 상세 1회. 대기 중 새 기록 삭제는 생성 행과 함께 버림, 전송 중이면 답장 뒤 서버 기록 삭제. 잠금 제거.
  • 수정: 쓰기 가능 사본(receipt/detail)이 있으면 재조회 없이 queueCompletedSessionEdit, 없으면 강제 조회 1회 — 온라인·오프라인 갈래 없음. pending: 대상은 생성 행에 합침, 전송 중이면 답장 뒤 서버 id로. 편집기 온라인 전용 게이트(requireOnlineCompletedWorkoutMutation) 제거.
  • 저장: prepareSave → 대기열 적재(announced) → 초안 정리 → 답장 대기. uploaded = "저장하였습니다" + 세트 스코어, deferred/held/unknown = 기기에 남음(완료 화면은 "동기화 후 점수 반영", 저장 버튼은 보관 문구 한 줄), isolated = 닫음(배지·안내는 답장 처리). 완료 화면 autoSave.status = "queued", 늦은 답장(awaitQueuedSave)으로 세트 스코어 재적용, [메인으로]·[기록 삭제]는 pending id로 동작(생성 행 합치기·버리기).

Phase 6 — 정리·e2e

같은 요청의 중복 저장은 무음 잠금이 아니라 같은 비행에 합류(saveFlights), 삭제 catch의 충돌 재조회 제거(전송기가 재전송). e2e CASE-032 신설(삭제 RPC 응답을 붙들어 둔 채 완료 화면이 닫히는지 — "즉시"를 시간이 아니라 순서로 고정, 상세 재조회 0회, 삭제 요청 정확히 1회), CASE-029 개정(즉시 전송 실패가 연결 상태를 강등하므로 fault-injection-full-stack으로 전환, 503 2건 + 연결 상태 이벤트 4건 선언, 재수화 붙들기), CASE-015 개정(자동 저장 실패 = 자동 대기열, 시도 4→3), CASE-023·024·028 주석·근거 갱신.

주요 결정과 그 근거

  • 답장 성공은 무음, 못 보냈던 행만 "동기화했어요" — 계약 §4에 오너 원칙("실패 메시지 우수수")과 CASE-029의 기존 사용자 경험(지하에서 고친 게 올라갔나)을 함께 담은 절충. 행 필드 announced·deferredAt으로 구분.
  • 연결 상태 강등은 유지 — 첫 전송 실패가 연결 상태를 강등해 재수화·재전송으로 이어지는 것은 CASE-015가 지키던 계약이라 그대로 두고, CASE-029를 그 프로필로 옮겼다.
  • 삭제 조립은 어느 사본으로든 — 계약 §9(쓰기 원천 계약 §5 보충). 낡은 개정번호는 40001→D1 재전송이 흡수.
  • 큐 행 저장 시 undefined 키 제거 — IndexedDB 구조 복제가 undefined를 키로 남겨 CASE-015의 행 모양 검사(Object.keys)가 흔들렸다.

작업 중 드러난 것

  • 앵커 테스트가 파일 슬라이스 문자열(const saveUiWorkoutSession = async·if (confirmedSession) 등)에 의존해 sourceSliceAnchors 래칫이 재조준을 강제한다 — Phase마다 재조준하고 pending-changes.json에 등재했다. 저장 함수를 비동기 아닌 래퍼로 바꿨다가 시그니처 앵커 4파일이 깨져 async 래퍼로 되돌렸다.
  • Bash 툴의 heredoc이 따옴표를 망가뜨려(메모리 bash-heredoc-escape-mangling) 여러 줄 치환은 전부 Write로 파이썬 스크립트를 만든 뒤 실행했다.
  • 팝업 루트 포털(#1214) 때문에 확인 팝업이 완료 화면 컨테이너 안에 없다 — CASE-032 첫 로컬 실행에서 잡아 page 범위 로케이터로 고쳤다(사전 검증으로 잡은 것, CI 왕복 0).
  • 로컬 게이트를 편집 중에 돌리면 결과가 섞인다 — Phase 3 게이트는 Phase 4 편집과 겹쳐 3~5를 한 커밋으로 묶었다.
  • npm run ci:local --full(§20, 첫 실행)에서 CASE-029가 첫 시도 실패·재시도 통과(플레이키)로 잡혔다. 원인은 앱이 아니라 테스트 순서 — 삭제 답장 뒤 앱이 하루 요약(get_calendar_day_summary)을 뒤에서 다시 읽는데, 테스트가 그 요청이 끝나기 전에 page.reload()를 해서 요청이 끊겼고(net::ERR_ABORTED), 장애 주입 프로필은 선언하지 않은 네트워크 실패에 예외를 두지 않는다. waitForLoadState("networkidle")는 이미 도달한 상태면 즉시 지나간다는 Playwright 동작이 함정. CASE-015와 같은 observeRpcDrain.waitForQuiet(RPC 완료 후 1.5초 조용)로 고쳐 로컬 4회 연속 통과. 트레이스(trace.zip의 네트워크 타임라인)로 끊긴 시각이 새로고침 직전임을 확인해 잡았다 — 사전 검증으로 잡은 것(CI 왕복 0).

5. 적용 결과

항목전 → 후
완료 화면 [삭제] 반응상세 재조회 1회 + 삭제 답장 대기 뒤 닫힘(1~5초 무반응) → 즉시 닫힘·"삭제하였습니다"(CASE-032: 삭제 응답이 붙들린 동안 닫힘, 상세 재조회 0회, 삭제 요청 1회)
사용자가 서버 답장을 기다리는 동작6(자동 저장·메인으로·저장 버튼·연필·시간·삭제) → 3(자동 저장·저장 버튼·지난 기록 — 오너 결정 1-a)
수정·삭제의 체감 왕복2~3회 → 0회(뒤에서 1회)
온라인·오프라인 코드 갈래2갈래 × 진입점 9 → 파이프라인 1(정책표)
오프라인 편집기열리지 않음 → 열림
자동 저장 실패 시배너 + 저장 버튼을 눌러야 대기열 → 자동 보관 + "동기화 후 점수 반영", 연결되면 점수 재적용
답이 늦을 때 실패 메시지그 자리에서 실패 표시 → 없음(조용히 재시도; 기기에 남았던 행이 올라가면 "동기화했어요" 1회)
대기열 막힘 경로전송 중 합치기 경쟁·인증 만료 격리·삭제 중복·삭제 뒤 수정 무방비 → 상태 4종·보류·멱등·거부로 차단
미전송 기록 사본IndexedDB 1부 → 미러 포함 2부
단위 테스트2,508 → 2,554건(신설 32건, 앵커 재조준 12파일)
로컬 e2e(샌드박스)npm run ci:local --full 통과 — pgTAP 102파일/1,629 assert · e2e 브라우저 31/31(CASE-032 신설·029·015 개정 포함) · local 11 · empty 7 · cardio 6 · viewport 13 (9분 2초)
CI1회(full-ci) — 통과 — Repository checks run 33801088284(full-ci): scope 14초 · verify 4분 54초 · migration-smoke(pgTAP·e2e) 11분 50초, 재시도 0회
미검증실기기 체감·토스트 시각 품질(사람 눈), 다중 탭 동시 저장(단위만), Production 실측(릴리스 뒤 get_session_detail 대비 삭제 호출 비율)

6. 이번 개선으로 향상된 것

삭제·수정을 누르면 그 자리에서 끝난다

유저가 기다리는 것은 서버가 아니라 기기 저장(수 ms)이다. 지하 헬스장에서도 같은 동작이다.

실패가 줄고 조용해졌다

늦거나 끊긴 답은 재시도, 충돌은 자동 재전송, 로그인 만료는 보류 뒤 자동 재개 — 사용자에게 보이는 것은 영구 거부의 배지 하나뿐이다.

구조적으로 남는 것

계약 문서 1벌(정책표는 값이라 코드 갈래 없이 바꿀 수 있다), 대기열 행 상태 기계, 답장 처리 함수 1개, 계약 grep 테스트, 순서를 고정한 e2e(CASE-032), 장애 주입 프로필로 옮긴 CASE-029.

남은 것

  • 새 계획(planned) 생성은 서버 발급 id가 필요해 파이프라인 범위 밖(계획 수정·삭제 행은 종전대로 대기열).
  • 편집기로 대기 중(pending) 카드 자체를 여는 것은 여전히 막혀 있다(상세 시트의 빠른 수정·삭제는 된다).
  • 다른 기기 변경의 즉시 반영·오프라인 통계는 ④ 미고려 결정에 따라 범위 밖.
  • Production 실측(릴리스 뒤): 삭제 호출 대비 상세 재조회 비율, 대기열 격리 행 발생률.