Skip to content

완료 운동 수정 저장 거부에서 쓰기 원천 계약까지 — 성근 회원 22023 4회 거부, 사본 하나·정직한 영수증·저장 5경로 e2e (2026-09-03)

  • 기간: 2026-09-02 ~ 2026-09-03 (세션 1개 — 오너 보고 "오늘 오후에 운동하다가, '성근' 회원이 완료한 운동을 저장하려했으나 저장이 안되는 상황 발생" → Production 실측·구조 평가(설계자 3·심사 2·누락 점검 1) → 오너 go "phase 중간에 멈추지 말고 1-7까지 진행하고, CI도 그냥 전체 작업 끝나고 한번만")
  • 랜딩: PR #1154(Phase 1~7, 71feedea) — 마이그레이션 20260903235900_workout_receipt_children_v1.sql 1건, 엣지 0건. Production 적용 = 릴리스 PR 대기
  • 설계서: 없음(분석·Phase 계획·구조 평가는 이슈 #1143 본문·댓글 2건, "예상 효과·개선사항" 절 포함)
  • 정본: 쓰기 원천 계약 · workoutWriteController.ts resolveCompletedSessionWriteBase · completedSessionReceiptIdentity.ts · types/workout.ts CompletedSessionWriteSource · 영수증 children = app-screen-rpc-contract.md 영수증 절
  • 도구: Production 실측 = Management API 쿼리 러너(read_only) + logs.all 서버 오류 로그(6시간 창) · 구조 평가 = 워크플로 11 에이전트(코드 판독 5·설계자 3·심사 2·누락 점검 1)
  • 게이트: tests/react/completedSessionWriteSource.test.mjs(계약·소스 경계) · completedSessionRecoveryScenarios.test.mjs(#404·#327·충돌 수렴 재증명) · completedSessionReceiptIdentity.test.mjs · pgTAP workout_receipt_children_v1.test.sql(14) · 브라우저 e2e CASE-023~027 + e2e/support 오류 표면 감시 · crudRoundtrip 연속 수정
  • 버그리포트: bug-report/bug-064-20260902.md
  • 계약: completed-session-write-source.md 신설 · app-screen-rpc-contract.md 영수증 children

Phase 현황

Phase내용상태
Phase 1완료 화면 자동 저장 뒤 후속 수정 = 저장 직전 상세 강제 조회 1회로 요청 조립(영수증에 번호표 없을 때의 예비 경로로 존속)✅ PR #1154
Phase 2사본 역할 꼬리표 writeSource('detail'/'receipt'/'display') · 표시용 사본 UUID→줄 번호표 승격 금지 · 강제 조회 null은 카드로 격하 금지✅ PR #1154
Phase 3개정번호·종목 도우미 2개 → 단일 쓰기 원천 도우미 · 달력 캐시·세션 목록 쓰기 입력 제거 · 편집기 열기·시간 수정 사본 섞기 제거 · 데스크톱 개정번호 전달✅ PR #1154
Phase 4영수증 children(줄 번호표) 마이그레이션 + 앱 채택(저장·수정·오프라인 대기열) → 확정 사본 'receipt'✅ PR #1154 (20260903235900)
Phase 5쓰기 원천 계약 문서 + 계약 테스트·소스 경계 게이트✅ PR #1154
Phase 6저장 5경로 브라우저 e2e CASE-023~027 + 오류 표면 감시 헬퍼 + API 왕복 연속 수정✅ PR #1154
Phase 7랜딩·버그리포트 BUG-063·작업 기록

1. 배경

2026-09-01 완료 화면 즉시 저장(#1017)으로 라이브 운동은 완료 화면에 들어오는 순간 서버에 저장되고, 메모·시간을 고치고 '메인으로'를 누르면 같은 기록을 한 번 더 수정 저장한다. 즉 "저장 직후 몇 초 안에 같은 기록을 다시 저장한다"는 흐름이 처음으로 흔해졌다. 9/2 오후 '성근' 회원이 그룹 보드 운동을 마치고 기록을 고치다 이 흐름에서 4회 연속 거부를 겪었다.

2. 문제 제기

수정 저장 요청의 개정번호와 종목 줄 번호표가 서로 다른 사본에서 조립됐다

개정번호는 선택된 세션 → 세션 목록 → 화면값 순, 종목 목록은 화면이 넘긴 이전 세션 → 선택된 세션 → 달력 캐시 → 목록 중 종목 배열이 있는 첫 후보 순으로 골랐다(구 도우미 2개). 저장 직후 일부 사본만 갱신되는 구간에 "최신 개정번호 + 옛 줄 번호표"가 만들어졌다. 서버 로그: 17:23:18~:39 "Existing exercise does not belong to completed session"(22023) 4건.

저장 영수증이 줄 번호표를 돌려주지 않아 확정 사본이 가짜 번호표를 가졌다

영수증은 세션 번호·개정번호만 돌려줬다. 앱은 "보낸 내용"으로 저장 직후 사본을 만들면서 lgCompletedExercises가 UUID 모양 id를 전부 기존 줄 번호표로 승격했다 — 초안의 id는 앱이 임시로 붙인 무작위 값이다. 달력 요약(번호표 없음)이 먼저 잡히면 반대로 줄 전부 삭제·재생성(개정 4 무늬, BUG-053 경로).

거부를 입력값 오류로 취급해 자가 복구가 없었다

분류기는 40001/LG409만 충돌로 보고 재조회한다. 22023은 일반 문구만 띄웠다.

CI가 저장 경로를 "첫 저장 한 번"으로만 검증했다

API 왕복은 고정 payload·번호표 없음·연속 수정 없음. 브라우저 e2e는 완료 화면에서 메모를 바꾸지 않아 후속 수정이 발생하지 않았다. 완료 기록 수정·연속 수정·그룹 운동 완료 저장 케이스 없음. 오류 표면 감시 없음.

3. 해결 방안

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

  • D1 "사본이 여러 개인 구조가 근본적으로 맞나" → 읽기 사본 다수는 정상(조회 모델별 캐시), 빠진 것은 쓰기 규칙. 오너 지시로 서버 영수증에 줄 번호표 싣기사본 역할 꼬리표를 같은 트랙에 포함 — 채택.
  • 서버 관대화(없는 번호표를 새 줄로) — 심사 2명 반대(줄 churn·원인 은폐)로 기각.
  • 실행 방식: "phase 중간에 멈추지 말고 1-7까지, CI는 전체 끝나고 한 번만, 중간에 묻지 말 것".

접근

대안판단
사본을 하나로 합치기서버가 화면마다 다른 모양(월 요약=종목 없음, 일 요약=16/12 절단, 상세=전체)을 주므로 합치면 부분 데이터 병합 오류가 생긴다 — 기각
서버 관대화기각(위)
쓰기 원천 하나 + 정직한 영수증 + e2e 5경로채택

4. 적용한 내용

Phase 1 — 완료 화면 후속 수정의 원천 확보 (#1154)

buildLocalWorkoutSaveInputasync가 되어, 수정 저장이면 resolveCompletedSessionWriteBase가 쓰기 가능한 사본을 고르고 없으면 ensureSessionDetail({force:true}) 1회로 원천을 확보한다. 수정 저장의 온라인 요구를 강제 조회보다 앞으로 옮겼다.

Phase 2 — 사본 역할 꼬리표 (#1154)

CompletedSessionWriteSource 타입. 상세 매퍼 2곳 'detail', 달력 요약 'display', 확정 사본 'display'(채택 시 'receipt'). lgSessionToFull은 꼬리표를 통과시키고, lgCompletedExercises'display'·'receipt'에서 UUID 승격을 하지 않는다. ensureSessionDetail은 force 조회가 null이면 null.

Phase 3 — 단일 조립 도우미 (#1154)

resolveCompletedSessionExpectedRevision·resolveCompletedSessionPreviousSession·resolveCompletedSessionExercisesForSave 삭제 → resolveCompletedSessionWriteBase(개정번호·출처·날짜·체중·종목을 한 사본에서). 종목을 고친 초안은 초안 자신의 개정번호+줄(낡으면 40001, 22023 불가). WorkoutWriteEnv.calendarFullById 제거. openCompletedWorkoutEditor·updateUiSession{...selectedFull, ...source} 섞기 제거. 데스크톱 편집 초안에 expectedRevision 전달.

Phase 4 — 영수증 children (#1154, 20260903235900)

get_workout_mutation_receipt_v1children: [{id, position, entry_kind, composite_meta{group_key, movement_position}|null, sets:[{id, position}]}] 추가 키(응답 시점 현재 행, (position, created_at, id) 정렬, 재실행=현재 행, 삭제=[], 값 미포함). 시그니처·권한·contract_version 불변. 클라이언트: 검증기 선택 필드, 저장·수정 응답에 childIdentity(보낸 행 position 맞대기, receiptChildIdentityFromRows), 확정 사본 채택(adoptReceiptChildIdentity) — 저장·수정·오프라인 대기열.

Phase 5 — 계약·게이트 (#1154)

completed-session-write-source.md + completedSessionWriteSource.test.mjs.

Phase 6 — e2e (#1154)

CASE-023(사고 재현: 자동 저장 → 메모 수정 → 메인으로 → 상세 수정 → 곧바로 재수정) · 024(완료 수정 두 갈래) · 025(계획 → 운동 → 저장) · 026(그룹 보드 → 이 운동으로 시작 → 저장 → 후속 수정) · 027(그룹 계획 작성 → 저장 → 수정). 공통 오류 표면 감시 헬퍼. crudRoundtrip에 영수증 children의 번호표로 연속 수정.

주요 결정과 그 근거

  • 종목을 고친 초안은 초안 자신의 개정번호를 쓴다(새 개정번호를 빌리지 않는다) — 낡았으면 서버가 40001로 거부해 재조회 경로를 타게 하려고. 22023은 구조적으로 불가.
  • 영수증 children은 저장하지 않고 응답 시점에 계산한다 — 영수증 테이블·재실행 의미를 바꾸지 않기 위해.
  • 채택은 행 수·position·세트 수가 하나라도 어긋나면 하지 않는다 — 조용히 잘못 붙이는 것보다 예비 경로(강제 조회)가 안전.

작업 중 드러난 것

  • 워크트리의 TS 파일은 CRLF — 다중 행 앵커 치환 스크립트는 정규화 후 되돌려 써야 한다(Bash 히어독 이스케이프 망가짐 → Write 툴 스크립트).
  • workoutRepositoryV4.test가 영수증 13키를 deepEqual로 잠근다 — children은 있을 때만 키를 넣어 기존 픽스처를 깨지 않았다.
  • 감사 명부(tests/audit/manifest.json)에 등재된 테스트 수정은 pending-changes.json에 사유를 적어야 하며 같은 파일 중복 등재는 게이트가 거부한다(기존 항목에 절을 덧붙인다).
  • 로컬 Docker 미가동 — pgTAP·브라우저 e2e는 CI 1회에서만 실측.

5. 적용 결과

항목결과
서버 로그 "does not belong"(22023)9/2 4건 → CI 통과 후 재발 0 (Production은 릴리스 뒤 관찰)
확정 사본의 가짜 줄 번호표있음 → 없음(display) / 영수증 채택 시 서버 번호표(receipt) — 단위 테스트
요청 조립 사본도우미 2개·후보 4종 → 도우미 1개·원천 1개 — 소스 경계 테스트
영수증 children없음 → 저장·수정·재실행·삭제 계약 — pgTAP 14단언(CI)
브라우저 e2e 저장 경로2케이스(후속 수정 없음) → 7케이스 + 오류 표면 0 단언(CI)
미검증Production 실사용 재발 여부(릴리스 뒤 서버 로그·텔레메트리로 확인), 옛 번들×새 DB 창은 검증기가 미지 키를 버리므로 무영향(설계)

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

수정 저장이 사본 시차에 흔들리지 않는다

저장 직후 몇 초 안에 같은 기록을 다시 저장해도 요청은 서버 응답 하나의 사본에서만 만들어진다.

저장 직후 화면 사본이 서버와 같은 번호표를 가진다

영수증이 줄 번호표를 돌려주므로 후속 수정에 추가 조회가 거의 필요 없다(없을 때만 예비 경로).

구조적으로 남는 것

writeSource 꼬리표 · "한 요청 = 한 원천" 계약과 소스 경계 게이트 · 영수증 children 규약 · 오류 표면 감시 헬퍼(모든 새 브라우저 케이스에 장착) · 저장 5경로 e2e.

남은 것

  • 릴리스 PR 머지 → Production 적용 → 이슈 #1143 [반영완료].
  • 누락 점검이 지목한 후보 2건(종목 0개 사본 → 저장 전 검증 오류 / 복합 종목 기록으로 '이 운동으로 시작' 시 새 저장에 기존 번호표) — e2e 실측 결과에 따라 후속.
  • 충돌(40001) 뒤 열린 초안을 새 상세로 자동 리베이스하는 것은 범위 밖(현재는 재조회 + 안내 문구, 사용자가 다시 열어 저장).