완료 기록 쓰기 단일 파이프라인 — 완료 화면 [삭제] 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) · e2eCASE-032(신설, 완료 화면 삭제가 서버 응답 전에 닫힘) ·CASE-029(개정, 장애 주입 프로필) ·CASE-015(개정, 자동 저장 실패 = 자동 대기열) ·designContract마커finishSyncPending - 버그리포트: BUG-075(완료 화면 삭제 무반응)
- 계약: 신설
completed-workout-write-pipeline.md·completed-session-write-source.md§5 삭제 조립 한 줄 ·app-screen-rpc-contract.mdoutbox 절 개정 · 퇴역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.md → docs/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,retryPendingWorkoutSaves에hold·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초) |
| CI | 1회(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 실측(릴리스 뒤): 삭제 호출 대비 상세 재조회 비율, 대기열 격리 행 발생률.