v0.18.0 S06 — 충돌 재전송 요청과 복구 근거의 원자적 영속화: 개정번호 충돌 뒤 재조립한 요청을 보내기 전에 기기 후속 행으로 적고, 답장이 유실돼도 같은 id·지문으로 재생 (2026-09-08)
- 기간: 2026-09-08 (세션 1개
ae5d574f-72ed-4af2-91e0-e8d2a9994ed9, 오너 지시 "1341 진행해줘" → 분석·계획 게시 → "go, Phase 0 부터 진행해줘") - 랜딩: PR #1385 → main
1ebfda64(Phase 0~4 한 PR, 랜딩 큐 run 34183888729 squash 머지 · staging Deploy run 34183907122 성공) — 마이그레이션·엣지 함수·서버 변경 없음.package.json한 줄(test:persistence-browser목록에 브라우저 검사 1파일 추가)이 공용 통합 자원이라 랜딩 큐(npm run landing:request)로 머지. 앱 코드 변경은src/react/persistence/conflict/{types,evidence,successorPlan,resolver,ledger,index}.ts(신규) +src/react/services/pendingWorkoutMutationFlush.ts(충돌 분기 4종 → 후속 행 영속화 포트) +src/react/persistence/dispatch/dispatcher.ts(resolver 배선·superseded·장부)·failure.ts(기기 저장 일시 실패 2코드) +src/react/services/pendingWorkoutSaves.ts(전송 루프 3줄·타입·export 1) +src/react/contracts/persistence/pendingSaveRow.ts(추가 전용 필드conflict) +src/react/features/completed-workout/pendingSavesDispatchPorts.ts(개정번호 재조회가 상세를 함께 반환). 총괄 S06 카드 · 계획 ID S06 · Phase 2 스텝 2-4 - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1341 착수 댓글
- 정본:
docs/contracts/outbox-conflict-successor.md(유저 이야기·영속화 절차·근거 필드·요약 장부·증명 시나리오·인계) · 도메인 계약 §14·§19 · ADR §3-1 표·§3-3 · S04 계약 §4·§8 · S05 계약 §7 - 도구: 시나리오 픽스처
tests/support/conflictScenarios.mjs(멱등 장부·개정번호 상승·답장 유실 주입·옛 응답·연속 충돌 가짜 서버, R02 재사용) - 게이트: 단위 —
tests/react/conflictSuccessor.test.mjs2(Phase 0 결함 재현 → 통과) ·conflictSuccessorPlan.test.mjs4(근거·순수 계획기·resolver·장부) ·conflictSuccessorScenarios.test.mjs5(증명 시나리오) ·pendingWorkoutMutationFlush.test.mjs7(후속 행 설계로 다시 씀) · 브라우저 —outboxConflict.browser.mjs1(실제 Chromium IndexedDB 재시작,npm run test:persistence-browser등록) · 기존 대기열·dispatch·codec·복구 스위트 12파일 76/76(dispatchScenarios ④수정 없이 통과). G05 규칙 2줄(테스트conflict·계약 문서) + 장부 재생성. e2e·pgTAP 변경 없음.npm run ci:local -- --full: ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 119파일/2059 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 12/12 · e2e-empty 통과 8/8 · e2e-cardio 통과 6/6 · e2e-persistence 통과 38/38 · e2e-browser 통과 57/57 · e2e-viewport 통과 14/14 · e2e-evidence 통과 · 18분 24초 - 버그리포트: 없음(구조 트랙, 감사 보고서 F07·F07b 의 수리). Phase 0 의 결함은 사용자에게 보이는 결함이었다(두 기기에서 같은 기록을 고친 뒤 답장이 유실되면 같은 수정이 두 번 반영) — v0.17.x 부터 있던 구조이나 재현 조건(충돌 + 답장 유실)이 드물어 보고된 사례는 없다
- 계약:
outbox-conflict-successor.md신설 · 도메인 계약 §14(S06 구현 문단)·§19(conflict필드·장부 키) · ADR §3-1 표 durable successor 행·§3-3 계약 문장 · S04 계약 §4(재전송 문장)·§8 S06 행 완료 · S05 계약 §7 S06 행 완료
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 결함 재현 테스트 2건 먼저 실패(충돌 재전송 뒤 답장 유실 → 재시작하면 원 요청이 또 나가 다른 id 로 한 번 더 반영 · 재조립 저장 실패 시 미저장 요청 전송) | ✅ b97c68ec |
| Phase 1 | persistence/conflict/** — 근거 조립·순수 계획기(claim 표 검사)·resolver(stale/storage/storm)·요약 장부 · 행 계약 추가 전용 필드 conflict | ✅ b2ea3569 |
| Phase 2 | sender 충돌 분기 4종 → successor 포트·superseded · dispatcher 배선·장부 · 전송 루프 3줄 · 분류기 2코드 · 재조회 포트 상세 반환 · Phase 0 초록 | ✅ eb928981 |
| Phase 3 | 증명 시나리오 3벌(재시작 재생·모든 중단 지점·사본 가용성/LWW 유지) · 실제 Chromium IndexedDB 재시작 · R02 픽스처 | ✅ 8825e96a |
| Phase 4 | 정본 계약 신설·갱신 5곳 · G05 규칙·장부 · 이 기록 + 등록 2곳 · ci:local --full · PR · CI 1회 · 랜딩 큐 | ✅ PR #1385 → 1ebfda64 (CI 2회: 1회차 CASE-028 flaky, 2회차 전부 통과) |
1. 배경
유저 A 가 폰과 태블릿을 같이 쓴다. 태블릿에서 어제 기록을 먼저 고쳐 서버는 개정번호 2 가 됐고, 폰에는 같은 기록의 수정이 개정번호 1 기준으로 기기 대기열에 남아 있다. 연결이 돌아오면 폰의 요청은 "개정번호가 다르다" 고 거부되고, 앱은 오너 결정 D1(나중에 저장한 쪽이 이김, #1173)대로 서버가 알려 준 최신 개정번호로 새 요청을 만들어 다시 보낸다. v0.18.0 총괄(ADR §3-3)은 이 재전송을 후속 행(durable successor)으로 먼저 기기에 적고 보내기로 정했고(S06, 감사 F07 "충돌 사본·명시 복구"·F07b "재전송 ID 의 비영속성"), 선행 S04(후속 행·claim·원자 교체)·S05(단일 sender·dispatcher·영수증 대조)가 끝난 뒤 이 트랙이 그것을 실제로 했다.
2. 문제 제기
재조립한 요청이 메모리에만 있었다
pendingWorkoutMutationFlush.ts 의 충돌 분기가 새 요청(새 멱등 키·새 지문)을 만들어 바로 보냈다. 기기 대기열에는 여전히 원 요청이 적혀 있었다. 서버가 새 요청을 저장했는데 답장이 유실되면(터널·앱 종료) 다음 전송에서 원 요청이 또 나가고 → 또 거부(개정번호는 더 올랐다) → 또 다른 새 요청 이 같은 내용을 한 번 더 저장했다. 새 지문에 기대 개정번호가 들어가므로 서버 개정번호가 오를 때마다 다른 id 가 되어 서버 멱등 장부가 막지 못했다. 삭제·계획 저장·계획 삭제도 같은 구조였다.
충돌의 근거가 어디에도 없었다
충돌이 났다는 사실, 원 요청이 무엇이었는지, 서버가 알려 준 개정번호가 숫자만이었는지 상세를 읽었는지, 덮이는 서버 사본을 어디서 얻을 수 있는지 — 행에도 다른 저장소에도 자리가 없었다. G03 의 Conflict·ConflictCopy 타입은 쓰는 곳이 없었다. 복구 화면(S09)이 보여 줄 근거가 없었다.
어떤 구조라서 가능했나(§22): 대기열의 약속은 "행에 적힌 정체성이 정본이고, 재시작 뒤 같은 id·지문이 다시 나간다"(codec 계약 §3)인데, 충돌 분기만 그 약속 밖에서 메모리 요청을 보냈다. S05 가 "dispatcher 는 durable 행만 보낸다" 고 정했지만 sender 안의 이 한 분기가 예외였다.
3. 해결 방안
원칙
- 오너 결정 없음 — 계획의 전제로 진행: ① SQL·서버 변경 없음(덮이는 서버 사본은
user_fact_history참조로만 기록, 조회 함수는 S09/S10) ② 확정 뒤 근거의 장기 보관은 localStorage 요약 장부 100건/owner(정책이 아니라 기술 선택, S09 가 바꿀 수 있음) ③ 전송 루프(pendingWorkoutSaves.ts, S07 소유) 변경은 typed 신호 처리 3줄로 한정. - §22(근본 구조): 재전송 id 를 원 id 에서 파생하거나(개정번호가 또 오르면 소용없고 근거도 안 남음) 답장 유실 뒤 상세를 읽어 "내 내용과 같으면 성공으로 간주"(성공 발명, S05 §4 금지)하는 땜질 대신, 충돌 재전송도 다른 모든 재전송과 같은 길("기기 행에 적힌 것만 보낸다")을 지나게 했다.
접근
| 대안 | 판정 |
|---|---|
A. 충돌 재전송을 후속 행으로 먼저 영속화 — 원 행 제거 + 후속 행 쓰기 + 근거를 S04 원자 교체 하나로, 전송 루프가 종전 규칙대로 집어 보냄. 근거는 행 필드 conflict(확정 전) + 요약 장부(확정 뒤) | 채택 |
| B. 재전송 id 를 원 요청 id 에서 결정적으로 파생 | 기각 — 서버 개정번호가 다시 오르면 또 다른 id, 답장 유실 자체를 다루지 않음, 근거 없음 |
| C. 답장 유실 시 상세를 읽어 같으면 성공으로 간주 | 기각 — 성공 발명. 같은 내용이 다른 기기에서 왔을 수도 있다 |
| D. 후속 행을 sender 안에서 claim 까지 잡고 바로 보내기 | 기각 — 전송 루프의 claim·ACK 절차를 sender 가 한 벌 더 갖게 된다. 대신 루프에 superseded 신호 처리 3줄을 두어 후속 행이 종전 절차로 나간다 |
| E. 근거를 새 IndexedDB object store 에 | 기각 — IDB 버전 6 고정(S07, 올리면 옛 탭 VersionError). 행 필드(원자) + localStorage 요약(확정 뒤) 으로 |
4. 적용한 내용
Phase 0 — 결함 재현 (b97c68ec)
tests/react/conflictSuccessor.test.mjs 2건: ① 충돌 → 재전송 저장 → 답장 유실 → 재시작에서 기기에는 원 요청만 남아 있음(재시작 시 다른 id 로 한 번 더 전송의 원인) ② 재조립 저장 실패 시에도 서버가 요청 2건 수신. 현재 코드로 둘 다 실패 확인.
Phase 1 — persistence/conflict/** (b2ea3569)
evidence.ts: 원 행·서버 근거·새 요청 →conflict. 숫자만 받으면 remoterevision_only(내용 발명 없음), 상세를 읽었으면snapshot, base 는 없음, 완료 기록만user_fact_history참조(server_only), 연속 충돌은 원 요청 보존 +lineage.successorPlan.ts(순수): 원 행의 claim 이 지금도 내 탭·내 fence(S04verifyOutboxTicket)이고 관찰이 그대로일 때만 "후속 행 쓰기 + 원 행 삭제" 커밋. 후속 행은 owner·kind·targetId·dates·savedAt·announced 를 잇고successorOf= 원 행,state: queued, 시도 횟수 이어 셈.resolver.ts: 저장소 읽기(S07 index·readRows) → 계획 → 원자 교체, stale 재읽기 3회, storage 예외, 같은 기록 한 전송에 3개 상한(storm). 계획 후속 행의 id 발급.ledger.ts: localStoragebarbelic:conflict-ledger:v1:<owner>, 후속 id 멱등, 최신 앞, 100건.- 행 계약
pendingSaveRow.ts에 추가 전용 필드conflict(+PENDING_SAVE_ROW_FIELDS).
Phase 2 — 배선 (eb928981)
- sender: 충돌 분기 4종이 재조립한 요청을
successor.persist로 적고superseded반환(결과 unionsent | superseded). 못 적으면PendingWorkoutSuccessorPersistError(LG_SUCCESSOR_PERSIST_FAILED/LG_CONFLICT_STORM).resolvedConflict= 행에conflict있음. - dispatcher: 전송마다 resolver 하나(저장소는 여기서만),
superseded→OutboxSendSuperseded, 확정 전 장부 기록(성공 ACK 전),already_gone도 기록. - 전송 루프:
superseded면 ACK 없이continue(3줄).defaultPendingWorkoutSaveStoreexport. - 분류기: 두 코드를 기기 저장 일시 실패로(요청 재대기·전송 중단,
transientEvidence아님 → 연결 상태 불변). - 포트 배선:
loadCompletedSessionRevision이 읽은 상세session도 반환. - sender 단위 테스트 7건 다시 씀. 기존 12파일 76/76.
Phase 3 — 증명 (8825e96a)
계약 §5 표 9행. 실제 Chromium IndexedDB 재시작 1건을 test:persistence-browser 에 등록(package.json 한 줄).
작업 중 드러난 것
- 답장 유실로 기기에 남았던 후속 행이 나중에 동기화되면 "동기화했어요" 1회 — 종전 정책(쓰기 파이프라인 §4
deferredAt) 그대로. 충돌 처리 자체는 무음. 재현 테스트 기대값을 그 정책에 맞췄다. - 테스트 도우미 결함 1건: 같은 요청 id 를 두 탭이 보낼 때 답장 문을 덮어써 첫 탭의 약속이 영영 안 풀려 스위트가 300초를 넘겼다 → 보낸 순서 큐로 수정. 제품 코드 결함 아님.
- 리베이스(main
1d8b9aaf) 뒤 G05 장부 도구(check-coverage-inventory)가 main 의 미등록 파일 8개(#1361 의e2e/required-coverage.json·e2e 신뢰 도우미 테스트 4·userFacingErrorMonitor.browser, #1382 의heroNumberFit·verticalScrollContainersContract)로 "미분류 8" 을 냈다(CI 게이트는 아니고 장부 규약, main 의 남의 누락). 이 PR 이 규칙 4줄로 등록하고 장부를 재생성했다. - CI 1회차(run 34182547014, head
82fb34df) 빨간불 1건: browser-journeys 샤드 2/4 의 CASE-028(완료 화면 복귀 뒤 재저장)이 첫 시도 실패 → 재시도 통과(failOnFlakyTests로 잡 실패, e2e-evidence 도 "CASE-028 exactly once" 위반). 첫 시도의 실패 원인 = 영수증 뒤 상세 재조회(refresh_completed_session_after_receipt, main #1361 이pendingSavesFeatureBinding에 넣은 코드)가resource_load_failure오류 보고 이벤트를 남긴 것 — 저장·영수증·DB 읽기(개정 2)는 전부 정상이었고, 재조회 요청이 테스트의page.reload()와 겹쳐 실패한 경쟁으로 보인다. 이 PR 의 변경 밖(충돌이 없는 수정 저장 경로)이며 로컬ci:local --full2회에서 CASE-028 은 통과 → 재현 불가(main 의 남의 회귀). 문서 커밋을 더한 head 로 CI 를 다시 돌렸다(아래 §5). - 사전 검증 누락: 없음. CI 2회차(head
8fa5db4f) 전부 통과 → 랜딩 큐 squash 머지1ebfda64→ staging Deploy 성공.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 충돌 재전송 뒤 답장 유실 → 재시작: 서버가 받은 서로 다른 요청 id | 3 (원·재전송·또 다른 재전송) | 2 (원·후속) — 재시작 뒤 전송은 같은 id·같은 지문(§5 #1·#3·#8) |
| 같은 시나리오의 서버 개정번호 증가 | 2회(같은 수정 두 번 반영) | 1회(재생 영수증) |
| 재조립 저장 실패 때 미저장 요청 전송 | 1회 | 0회, 원 행 보존·대기·연결 상태 불변(#2) |
| 충돌 근거(원 요청 원문·서버 근거·사본 가용성) 기록 | 0 | 행 conflict 1 + 확정 뒤 장부 1(#3·#7·#8) |
| "dispatcher 는 durable 행만 보낸다" 의 예외 | 1(sender 충돌 분기) | 0 |
| 연속 충돌의 tight loop | 무한(개정번호가 계속 오르면 계속 새 요청) | 전송당 3개, 4번째는 멈추고 다음 기회(#6) |
| 서버·저장 형식 | — | 무변경(형식 v1·추가 필드 conflict), IDB 버전 6 그대로 |
| 게이트 | — | 단위 18건 + 브라우저 1건 신설, 기존 76/76, ci:local --full ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 119파일/2059 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 12/12 · e2e-empty 통과 8/8 · e2e-cardio 통과 6/6 · e2e-persistence 통과 38/38 · e2e-browser 통과 57/57 · e2e-viewport 통과 14/14 · e2e-evidence 통과 · 18분 24초, CI 1회 |
미검증(자동 검증 밖): 실제 두 기기의 Production 충돌 — 재현 조건(충돌 + 답장 유실)이 드물어 실측 사례 없음. 서버 쪽 LG409 상세의 actual_revision 은 e2e CASE-040(S04)이 v0.17.1 번들과 함께 실측한 것을 그대로 믿는다.
6. 이번 개선으로 향상된 것
- 두 기기를 쓰는 사용자의 같은 수정이 답장 유실 뒤 두 번 반영되지 않는다(이력·통계 세대 중복 없음).
- 충돌 재전송도 "기기 행에 적힌 것만 보낸다" 는 하나의 규칙을 따른다 — 재시작·탭 인계·늦은 답장·다른 owner 의 모든 경우가 S04·S05 의 같은 검사(claim 표·영수증 대조)로 처리된다.
- 복구 화면(S09)이 쓸 근거가 생겼다: 무엇이(원 요청 원문) 무엇에(개정번호 n) 덮였고 서버 사본은 어디에 있는지(이력 참조, 서버 함수 필요).
- 연속 충돌이 무한 루프가 되지 않는다.
남은 것
- S09:
conflict·장부 →RecoveryDescriptor, 서버 사본 조회 함수(user_fact_history는 앱 계정이 읽을 수 없다), 장부 보관 정책. S10: 원 요청 원문·lineage 로 복원 명령. - 후속 행이 대기 중일 때 같은 기록을 또 고치면 후속 행이 새 행으로 교체되며 확정 전
conflict근거는 장부에 남지 않는다(계약 §6) — 이어 갈지는 S09 가 정한다. - 총괄 S06 카드의 상태 문구는 HQ 갱신분(다른 트랙과 같이 카드 본문은 그대로 두었다).