v0.18.0 S02 — 기기 대기열 교체·합치기를 "지우고 나서 쓰는 두 번의 저장" 에서 IndexedDB 트랜잭션 하나의 원자 교체로 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (세션 1개
e33fdf53, 오너 지시 "#1325 진행해줘" → 분석·Phase 계획 게시 → "ㄱ") - 랜딩: PR #1349 → main
2801239a(2026-09-07, Phase 0~5 한 PR, HQ §4-2 자동 랜딩 큐가 squash 머지 + staging 확인: 랜딩 run 34124635308 · deploy.yml run 34124661853 success) — 마이그레이션·엣지 함수·서버 변경 없음. 앱 코드 변경은src/react/services/pendingWorkoutSaves.ts(facade) + 신설src/react/persistence/outbox/**+package.json스크립트 한 줄. 저장 형식·쓰는 필드 집합은 종전과 같다. 총괄 S02 카드 · 계획 ID S02 · Phase 2 스텝 2-1 - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1325 댓글
- 정본:
docs/contracts/outbox-atomic-replace.md(조건표·원자 교체·밖/안 경계·미러·화면 위치·실제 Chromium 실측·인계) ·src/react/persistence/outbox/{replacePlan,idbOutboxStore,memoryOutboxStore,index}.ts· ADR §3-1 원자 교체 행 · 대기열 행 codec §6 인계 표 · 도메인 계약 §17 OutboxPort - 도구:
npm run test:persistence-browser에tests/react/outboxAtomicity.browser.mjs추가(esbuild 번들 + 실제 Chromium, 서버·인증·DB 불필요, CDP quota 주입) - 게이트: 단위 —
tests/react/outboxAtomicReplace.test.mjs6(유실 재현 → 통과) ·tests/react/outboxReplacePlan.test.mjs9(조건표·순수성·stale·all-or-nothing) · 기존 대기열 테스트(pendingWorkoutMutationQueue·pendingWorkoutOutboxStates·pendingSaveStorePreservation·pendingWorkoutSaves·pendingQueueHeadPoison) 기대값 무변경. 실제 Chromium —outboxAtomicity.browser.mjs8 시나리오(3회 연속 8/8). G05 규칙 2줄 + 장부 재생성 - 버그리포트: 없음(구조 트랙, 감사 보고서 F01 의 수리)
- 계약:
outbox-atomic-replace.md신설 · ADR §3-1 표 S02 행에 구현 링크 · codec 계약 §6 S02 행 갱신 · 도메인 계약 §17 OutboxPort 행에 구현 위치
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 교체·취소 조건표(계약 §1) + 지금 구조의 유실을 먼저 실패하는 테스트 6건으로 고정(5건 실패 확인) | ✅ bf4735a2 |
| Phase 1 | persistence/outbox/ — 순수 계획기 · IDB 한 트랜잭션 커밋(재검사·보존 병합) · 같은 계약의 메모리 adapter + 테스트 9건 | ✅ 3d589326 |
| Phase 2 | facade 연결 — PendingWorkoutSaveStore.replace, 읽기→계획→해시→커밋→stale 재준비(최대 3회), 생성→삭제 상쇄 범위 축소, 미러·화면 위치 명시(계약 §2~§4) | ✅ 9cd5af11 |
| Phase 3 | 실제 Chromium IndexedDB 검증 8 시나리오 + test:persistence-browser 연결(계약 §5) | ✅ 089d7227 |
| Phase 4 | 계약 §6 인계 · ADR·codec·도메인 계약 갱신 · G05 규칙·장부 · 이 기록 · 등록 2곳 | ✅ (같은 PR) |
| Phase 5 | npm run ci:local(full) · PR · CI · 랜딩 큐 · 이슈 [v0.18.0 스테이징] | ✅ (세션 6c9b7e6d, ci:local full 17분 11초 → PR #1349 → 리베이스 5회 · CI full 초록 3회(마지막 run 34124006113) → 랜딩 큐 요청 d98b95e8 → 머지 2801239a · staging 확인) |
1. 배경
유저 A 가 지하철에서 어제 기록의 무게를 60 → 80 으로 고쳤다. 연결이 없어 앱은 그 수정을 기기 대기열(IndexedDB pendingSaves)에 행 하나로 남긴다(#1199 단일 쓰기 파이프라인, #1173 합치기 규칙). 몇 분 뒤 A 가 같은 기록의 메모까지 고치면 앱은 "같은 기록의 행은 마지막 하나" 규칙으로 옛 행을 새 행으로 바꾼다. v0.18.0 ADR §3-1은 이 교체를 "IndexedDB readwrite 트랜잭션 하나로, 커밋된 뒤에만 화면에 반영" 으로 정했고(S02), 그 위에 행 단위 claim·fence(S04)·index·mirror 개선(S07)을 쌓기로 했다. S01(#1285)이 행 형식 계약과 codec, 실제 옛 번들 공존 실측을 끝내 놓았다.
2. 문제 제기
교체가 "지우고 나서 쓰는" 두 번의 저장이었다
queuePendingWorkoutMutation 은 대기열을 읽어 규칙을 정한 뒤 행마다 store.delete() 를 따로 부르고 마지막에 store.put() 을 불렀다. 두 번째 저장이 실패하면(기기 저장 공간이 꽉 참, 브라우저의 트랜잭션 중단, 그 순간 탭 닫힘) 옛 행은 이미 지워졌고 새 행은 없다 — A 의 80kg 수정이 기기에서 사라진다(감사 보고서 F01). 격리 행 복구(recoverBlockedWorkoutSave)는 반대로 쓰고 나서 지워, 중간에 끊기면 같은 기록이 두 행으로 남아 두 번 전송됐다.
읽은 뒤 쓰기 전에 바뀐 것을 몰랐다
대기열을 읽은 시점과 쓰는 시점 사이에 다른 탭이 같은 기록의 행을 바꿔도(보내기 시작·격리·새 수정) 그 사실을 모른 채 덮어썼다.
답을 못 받은 생성 기록을 삭제로 지워 버렸다
생성 기록을 삭제하면 생성 행을 무조건 버렸는데, 그 생성이 이미 한 번 보내졌다가 답을 못 받은 상태(전송 만료·일시 실패 뒤 대기)면 서버에는 기록이 있고 기기에는 아무 흔적도 남지 않았다.
어떤 구조라서 가능했나: 스토어 인터페이스가 getAll / put / update / delete 네 개의 독립 연산뿐이라 "이전 행 삭제와 새 행 쓰기를 한 묶음으로" 표현할 자리가 없었다. 메모리 스토어(테스트용)도 같은 인터페이스라 이 유실을 테스트로 드러낼 수도 없었다.
3. 해결 방안
원칙
- 오너 결정 없음 — 계획의 전제로 진행: ① 생성→삭제 상쇄는 한 번도 보내지 않은 queued(
sentAt·deferredAt없음)와 영구 거부 blocked(사용자 폐기)만, 그 밖(sending 만료 포함·deferred·held)은wait-for-create로 보존 ② 수정→생성 합치기는 종전 규칙 유지(응답 미상이어도 서버가 같은 출처를 LG001 로 거부해 격리·복구되므로 유실이 아님) ③ 재검사 실패 시 재준비 최대 3회 ④ 저장 형식 v1·새 필드 없음(S01 rollback 조건 유지) ⑤contracts/core/**·의존성·서버는 만지지 않는다. - §22(근본 구조): 삭제 순서를 바꾸거나 "대체됨" 표시로 모면하지 않고, 스토어에 원자 연산 하나를 두고 그 연산이 실제 브라우저에서 원자적임을 실측했다.
접근
| 대안 | 판정 |
|---|---|
A. 스토어에 원자 연산 replace 하나. 규칙은 순수 계획기로, 해시는 트랜잭션 밖에서, 트랜잭션 안에서 같은 owner·target 행을 다시 읽어 재검사한 뒤 새 행 쓰기와 이전 행 삭제를 같이. 달라졌으면 아무것도 쓰지 않고 다시 준비. 메모리 adapter 도 같은 all-or-nothing 계약 | 채택 |
| B. 순서만 put → delete 로(두 트랜잭션) | 기각 — 총괄 S02 카드가 명시로 금지. 중간에 끊기면 두 행이 남아 두 번 전송 |
| C. 삭제를 미루고 "대체됨" 표시 뒤 나중 청소 | 기각 — 옛 번들 탭이 표시를 몰라 두 행을 다 보낸다(S01 실측: 옛 탭은 claim 을 무시) |
D. 기존 getAll/put/delete 를 순서대로 부르는 "원자 어댑터" | 기각 — 이슈가 금지한 가짜 원자화. 각 호출이 자기 트랜잭션을 열어 원자성이 없다 |
4. 적용한 내용
Phase 0 — 조건표·유실 재현 (bf4735a2)
들어오는 것(수정·삭제·계획 저장·계획 삭제·격리 복구) × 이전 행 상태(queued 미시도 / deferred / sending / held / blocked) × owner 열쇠의 조건표를 계약 §1 로 적었다. 메모리 스토어에 실패 주입(withStoreFaults, G02)을 걸어 지금 구조의 유실을 테스트 6건으로 고정했고, 현재 코드에서 5건이 예상대로 실패했다(수정 위 수정 실패 시 이전 행 유실 · 생성 합치기 실패 시 생성 행 유실 · 격리 복구 중단 시 두 행 잔존 · 답 못 받은 생성의 삭제 폐기 · 화면 프로젝션과 저장소 불일치).
Phase 1 — outbox 모듈 (3d589326)
src/react/persistence/outbox/:
replacePlan.ts— 순수 계획기. 행 목록 + 입력 →{write, remove, expected(관찰한 행의 id+원문 JSON), targetKeys}. 시계·난수·해시를 만들지 않고 입력으로 받는다. 생성 합치기는needs-identity를 돌려주고 facade 가 밖에서 만든 뒤 같은 입력으로 다시 계획한다.idbOutboxStore.ts— readwrite 트랜잭션 하나:getAll→ 같은 owner·target 행 재검사(다르면 abort →stale) → 같은 id 기존 행과 보존 병합(불변 묶음 다르면identity_conflict) →put→delete→complete가 성공. 요청 하나가 실패하면 브라우저가 전체를 되돌린다.memoryOutboxStore.ts— 같은 순서를 임시 사본에 적용하고 마지막에 교체. 단계 도중 예외면 원본 불변.- 주요 결정: 재검사 단위는 행 원문 전체(
JSON.stringify) — 같은 열쇠 행의 추가·삭제·상태 변화 어느 것이든 stale. 다른 열쇠(다른 target·다른 owner)는 무시. 쓰는 id 와 같은 id 는 지우지 않는다(쓰고 나서 지우면 새 행이 사라지는 순서 문제를 테스트에서 잡음).
Phase 2 — facade 연결 (9cd5af11)
PendingWorkoutSaveStore 에 replace(commit) 추가, IDB·메모리 스토어 둘 다 outbox 모듈로 구현. queuePendingWorkoutMutation 과 recoverBlockedWorkoutSave 는 "읽기 → 계획 → (해시, 밖) → 커밋(안에서 재검사) → stale 이면 처음부터(최대 3회, 넘기면 PendingWorkoutOutboxContentionError — 저장 안 됨)". 결과는 커밋 뒤에만 돌아온다 — 호출부(pendingWorkoutSavesStore.queueMutationForUser 등)는 그 뒤에만 프로젝션을 갱신하므로 화면이 저장보다 앞서가지 않는다. 미러(localStorage 사본)는 커밋 이후 syncMirror() 로 위치를 고정했다(S07 인계 지점). 생성→삭제 상쇄는 전제 ①대로 좁혔다. npm run check 통과(단위 2,896).
Phase 3 — 실제 Chromium 검증 (089d7227)
tests/react/outboxAtomicity.browser.mjs — esbuild 로 스토어 코드를 묶어 격리된 Chromium 컨텍스트(https origin, 요청 전부 로컬 응답)에서 돌리고, 중단 지점마다 페이지를 닫고 다시 열어 저장소를 읽는다. 8 시나리오(계약 §5 표): put 직전·직후·delete 뒤 abort, 저장 공간 초과(CDP quota 8 KB → QuotaExceededError), commit 직전 탭 종료, 같은 owner·target 동시 교체(직접 커밋 둘 = committed 1·stale 1, facade 동시 호출 둘 = 둘 다 성공·행 1개), 다른 owner 같은 target 불변, 삭제 대기 중 수정·identity 재사용 거부 뒤 저장소 불변. 3회 연속 8/8.
작업 중 드러난 것
npm run test:persistence-browser가 Phase 1 종료 보완(#1322)으로 이미 있고 CI browser-journeys 마지막 샤드·ci:local에 배선돼 있었다 → 계획의 "viewport-matrix 잡에 단계 추가(워크플로 변경·랜딩 큐)" 전제는 필요 없어졌고 스크립트 한 줄로 끝났다.- Chromium 은 http origin 에는 저장 공간 제한을 걸지 않는다 — quota 재현은 https origin 에서만 됐다.
- CDP
Storage.overrideQuotaForOrigin은 그 origin 이 IndexedDB 를 처음 쓰기 전 에, 그 페이지에 붙인 CDP 세션으로 걸어야 효력이 있다(저장 버킷을 만들 때 quota 를 읽음). 첫 쓰기 뒤에 걸면 10 GB 기본값이 그대로였고, 64 KB 로는 512 KB 쓰기가 통과했으며 8 KB 에서만 거부됐다. 계약 §5 에 재현 조건으로 고정했다. - 커밋 직전 탭 종료는 실측에서 항상 "이전 행 전부" 였다(트랜잭션이 커밋되기 전). 단언은 계약대로 "이전 행 전부 또는 새 행 하나" 둘 중 하나를 허용한다.
Phase 5 — 검증·PR·랜딩 (세션 6c9b7e6d)
ci:local --full 17분 11초 전부 통과 → PR #1349. main 이 다섯 번 앞서가(B01 #1346·A03 #1351·A02 #1354·S08 #1350·문서) 리베이스 5회 — 충돌은 package.json 의 브라우저 검사 스크립트 한 줄(세 파일 모두 유지)·커버리지 장부(규칙 JSON 에서 재생성)·문서 등록 2곳(양쪽 항목 보존). CI full 초록 3회, 마지막 run 34124006113(browser-journeys 4샤드·viewport-matrix·migration-smoke·unit·verify). npm run landing:request -- --pr 1349 → 랜딩 워크플로가 squash 머지(2801239a)와 staging 배포(deploy.yml run 34124661853)를 확인.
작업 중 드러난 것
- main 회귀(이 PR 아님): 2차 리베이스 뒤 CI 브라우저 레인 5잡이 전멸 — A03 #1351 합류 뒤 main 자체의 회귀. Node 22 에서 Playwright·tsx 로더가
src/**/*.ts를 CommonJS 로 변환하는데src/react/contracts/package.json {"type":"module"}때문에 그 하위만 ESM 섬이었고, 로더가 만든 CJS 모듈이 ESM 을 require 하는 첫 런타임 경로(catalogCodec → contracts/core/ids → ./result)에서module not been linked. 로컬 Node 24 는 재현되지 않아npx node@22로 재현했다. 앞수리 PR #1357(그 package.json 삭제)을 별도 머지(main0eb60bce, full 런 34121964500 초록). 막다른 길(Playwrightbuild.external→ tsx 도 CJS 판정해 이름 내보내기 소실 ·src/package.jsonESM 선언 → 루트app-config.js이름 내보내기 소실 · 루트"type":"module"→api/**/*.js의미 변경)은 이슈 #1330 댓글에 기록. 앱 소스 전체 ESM 선언은 B02(#1332) 범위로 제안. - 랜딩 큐의 merge-base 조건: 큐는 PR 의 merge-base 가 현재 main 과 같아야 하고 head 의 CI 가 초록이어야 한다. CI 12분 사이 main 이 움직이면 처음부터 — 병렬 세션 6개 환경에서 3회 반복했다. 리베이스·장부 재생성·check·푸시·CI 감시·요청을 스크립트로 묶어 대기 시간을 0 에 가깝게 줄였다.
- 충돌 상태 PR 은 검사가 안 뜬다: 첫 head 가 main 과 충돌(CONFLICTING)이면 GitHub 이 pull_request 검사를 시작하지 않는다 — 체크 목록에 verify 가 없으면 이것부터 본다.
ci:local이 e2e-browser 단계에서 포트 4173 을 다른 세션의 미리보기 서버가 점유하면 종료 코드 3 으로 멈춘다(수리 워크트리에서 1회).
Phase 4 — 문서·인계 (같은 PR)
계약 §6 인계(S04·S07), ADR §3-1 표 S02 행에 구현 링크, codec 계약 §6 S02 행 갱신, 도메인 계약 §17 OutboxPort 행에 구현 위치, G05 규칙 2줄(tests/react/outbox*·docs/contracts/outbox-atomic-replace.md → durable) + 장부 재생성, 이 기록 + 등록 2곳.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 수정 위 수정·삭제의 저장 | delete 트랜잭션 n개 + put 트랜잭션 1개 | readwrite 트랜잭션 1개(재검사 → put → delete) |
| 새 행 쓰기 실패 시 이전 미전송 행 | 사라짐(메모리 주입 재현 · 실제 Chromium abort 3지점·quota) | 그대로(메모리 6/6 · 실제 Chromium 8/8, 재시작 뒤 확인) |
| 격리 행 복구 중단 시 | 격리 행 + 복구 행 둘 다 남음 | 격리 행만 그대로(한 커밋) |
| 읽은 뒤 바뀐 같은 기록 위에 쓰기 | 모른 채 덮어씀 | 트랜잭션 안 재검사 → stale → 다시 계획(동시 교체 실측: committed 1 · stale 1) |
| 답을 못 받은 생성 기록의 삭제 | 생성 행 폐기(서버에는 남을 수 있음) | wait-for-create 로 보존, 답장 뒤 서버 id 로 삭제 |
| 화면 프로젝션 | 저장 실패 시에도 저장소와 어긋날 수 있음 | 커밋 뒤에만 갱신, 실패 시 변경 0(테스트) |
| 원자 교체의 브라우저 검증 | 없음(메모리 mock 만) | 실제 Chromium IndexedDB 8 시나리오, CI·ci:local 에 연결 |
| 저장 형식·쓰는 필드 집합 | v1 | 동일(변경 없음) — 옛 번들 공존·rollback 조건(S01 §5) 유지 |
- 자동 검증:
npm run checkPhase 마다 통과 ·npm run test:persistence-browser3회 8/8 ·npm run ci:local --full통과(verify 6단계 · pgTAP 109파일/1900 assert · e2e-local 11/11 · e2e-empty 7/7 · e2e-cardio 6/6 · e2e-persistence 8/8 · e2e-browser 38/38 · e2e-viewport 14/14 · 17분 11초, 2026-09-07 11:13) · CI full 초록 3회(run 34122101969 · 34123208191 · 34124006113, 각각 리베이스 head) · 랜딩 워크플로 34124635308 이 머지 직전 재확인 뒤 squash 머지2801239a· staging deploy run 34124661853 success. - 미검증: 실제 기기(iOS WKWebView·Android WebView)의 IndexedDB 트랜잭션 의미론은 Chromium 과 같다고 가정한다(표준). 옛 v0.17.0 탭과의 공존은 저장 형식이 그대로라 S01 CASE-039 결과를 그대로 쓴다(재실측하지 않음).
6. 이번 개선으로 향상된 것
오프라인 수정을 겹쳐 해도 앞의 수정이 사라지지 않는다
저장 공간이 꽉 차거나 탭이 닫히는 순간에도 기기에는 "이전 행 전부" 아니면 "새 행 하나" 만 남는다 — 실제 브라우저 8 시나리오가 그 증거다.
탭 여러 개가 같은 기록을 만져도 조용히 덮어쓰지 않는다
커밋 순간 같은 owner·target 행을 다시 읽어, 그 사이 바뀐 것이 있으면 아무것도 쓰지 않고 다시 계획한다.
서버에 있을지 모르는 기록을 기기에서 지워 버리지 않는다
한 번이라도 보내진 생성 기록의 삭제는 답장을 기다린다.
구조적으로 남는 것
persistence/outbox/순수 계획기 + 원자 커밋 port + 같은 계약의 메모리 adapter — S04(claim·successor)·S07(index·mirror)이 이 경계 위에 쌓는다(계약 §6).- 실제 Chromium IndexedDB 검증 스위트와 quota 재현 조건 — 앞으로의 저장소 변경은 같은 방식으로 실측된다.
- 조건표(계약 §1)가 코드가 아니라 문서로 있다.
남은 것
- 릴리스 v0.18.0 머지 뒤 이슈
[v0.18.0 반영완료]+ 닫기. - 총괄 S02 카드 상태 갱신(HQ 몫 — 이슈 마지막 댓글에 갱신안).
- S04: 전송 중 수정 행의 후속(successor) 보호는 이 트랙 범위 밖(조건표 §1 "어떤 상태든 지운다" 행 참조).