v0.18.0 S07 — 기기 대기열 저장소 비용을 행 수와 무관하게: owner/target 메모리 카탈로그·행별 미러(tombstone·세대)·IDB 공통 timeout adapter (2026-09-08)
- 기간: 2026-09-08 (세션 1개
187e909f, 오너 지시 "1337 작업 진행해줘 나한테 물어보지 말고 phase끝까지 완주하고, 아직 선행작업 안끝났으면 기다렸다가 진행해" — S04 #1334 머지f8334135를 기다린 뒤 착수) - 랜딩: PR #1377 → main
37066827(Phase 0~5 한 PR, 랜딩 큐 run 34154691861 · staging Deploy run 34154713784 성공) — 마이그레이션·엣지 함수·서버 변경 없음. 앱 코드:src/react/persistence/idb/**(신규) ·persistence/outbox/{rowIndex(신규),idbOutboxStore,claimPlan,replacePlan,index}.ts·persistence/mirror/**(신규) ·services/pendingWorkoutSaves.ts(저장소 facade) ·services/workoutLocalCacheDb.ts(열기) ·contracts/persistence/pendingSaveRow.ts(추가 필드recovery1개) ·services/appPerformanceBudgets.ts(예산 항목) ·package.json스크립트 한 줄. 저장 형식 v1·IDB 버전 6 그대로. 총괄 S07 카드 · 계획 ID S07 · Phase 2 스텝 2-3 - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1337 계획 댓글
- 정본:
docs/contracts/outbox-storage-index-mirror.md(제약·IDB adapter·인덱스·미러 v2·계측·조합 검사표·인계) · ADR §3-1 S07 행 · 원자 교체 계약 §4·§6 · claim 계약 §8 · codec 계약 §3-2·§6 · 도메인 계약 §19 · 성능 예산 §2-1 +RELEASE_PERFORMANCE_BUDGETS.outbox - 도구:
tests/react/outboxCost.browser.mjs— 저장소 코드에 훅 없이IDBObjectStore·Storage프로토타입을 세는 shim 으로 1/100/1000 행 드레인 비용을 잰다(OUTBOX_COST_BASELINE=1기록 모드·OUTBOX_COST_OUTJSON).npm run test:persistence-browser에 새 브라우저 검사 4파일 추가 - 게이트: 단위 —
idbAdapter.test.mjs10 ·outboxRowIndex.test.mjs4 ·mirrorPlan.test.mjs6 ·performanceBudgetsRelease.test.mjs+1(§2-1 대조). 실제 Chromium —outboxTimeout.browser.mjs3 ·outboxMirrorRecovery.browser.mjs7 ·outboxCost.browser.mjs1(예산 판정) ·idbVersionPin.browser.mjs2 · 기존 원자성 7·두 탭 4·S01 미러 1(옛 배열 묶어 쓰기에 맞춰flushMirror()추가, manifest 미등재) 유지. e2e — CASE-039/040 유지(Phase 5ci:local --full). G05 규칙 2줄 + 장부 재생성 - 버그리포트: 없음(구조 트랙, 감사 보고서 F17·C06 의 수리)
- 계약:
outbox-storage-index-mirror.md신설 · ADR §3-1 표 S07 행 · 원자 교체 §4 미러 문단·§6 S07 행 완료 · claim 계약 §8 S07 행 완료 · codec §3-2 보존 원문 위치·§6 S07 행 · 도메인 §19 미러 v2 행 · 성능 예산 §2-1 신설
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 계측 도구 + S04 head 기준선(1/100/1000) + IDB 버전 올림이 옛 탭을 죽이는 실측 | ✅ 3c10457d |
| Phase 1 | IDB 공통 adapter — 열기·요청·트랜잭션 timeout, blocked typed 오류, 시간 초과를 "안 썼음 / 결과 미상 → 재조회" 로 | ✅ eb20d9f2 |
| Phase 2 | owner/target 메모리 카탈로그, 커밋 재검사를 키 스캔 + 필요한 id 읽기로, 전송기·적재·보류 해제·복구의 전체 읽기 제거, 200개 페이지 | ✅ 8fdff0fa |
| Phase 3 | 미러 v2(행별 키·본문/메타 분리·tombstone·세대·성공 ACK tombstone 먼저·옛 배열 묶어 쓰기·quota 표시·새 DB 판정 복구·부팅 수리) | ✅ 40b66c45 |
| Phase 4 | 정본 계약·ADR·S02/S04/codec/도메인 계약·성능 예산·G05·이 기록·등록 2곳·persistence 스크립트 | ✅ (같은 PR) |
| Phase 5 | npm run ci:local --full · PR · CI 1회 · 랜딩 큐(package.json 변경) · [v0.18.0 스테이징] | ✅ PR #1377 → 37066827 |
1. 배경
유저 A 가 산속 캠핑장에서 이틀 동안 운동 12개를 기록하고 그중 몇 개를 다시 고쳤다. 기기 대기열(IndexedDB pendingSaves)에 행 15개가 쌓였다. 하산해서 연결이 돌아오면 앱이 15개를 차례로 서버에 보낸다. S02(#1325)가 교체를 트랜잭션 하나로, S04(#1334)가 행 단위 claim·후속 행을 끝냈지만, 두 트랙 모두 "저장소를 어떻게 읽고 사본(미러)을 어떻게 적는가" 는 그대로 두고 S07 에 넘겼다(원자 교체 계약 §6·claim 계약 §8). 감사 보고서 F17(전체 큐 조회·mirror·index)·C06(네이티브 생애주기 — WKWebView IDB 행) 이 이 트랙의 출발점이다.
2. 문제 제기
행 하나를 보낼 때마다 전체를 다시 읽고 다시 적었다
전송기는 행마다 전체 getAll(본문 포함)을 여섯 번 — 루프 첫머리·커밋 재검사(트랜잭션 안)·미러 동기화·답장 처리·미러 동기화·마무리 — 하고, 미러(localStorage 한 키의 전체 배열)를 커밋마다 통째로 다시 적었다. 행 하나 2 KB 기준 1000행 드레인 152.8초, 본문 읽기 6 GB(전체의 3004배), 미러 재기록 1999회 2 GB(Phase 0 실측). ms/행이 4.9 → 13.8 → 152.8 로 N 에 비례 = 전체 O(N²).
오래된 미러가 처리된 행을 되살렸다
IndexedDB 가 비어 있고 미러에 행이 있으면 무조건 복원했다. 커밋(삭제) 뒤 미러 쓰기 전에 탭이 죽으면 미러에 그 행이 남고, 그 뒤 IndexedDB 가 비면(전부 서버 반영 뒤·브라우저 정리) 되살아나 다시 "동기화 대기" 로 보였다. S04 §8: 되살아난 행은 fence 가 되감긴다.
대기열 트랜잭션에 시간 상한이 없었다
열기만 8초 상한이 있었다. WKWebView 에서 요청이 영원히 pending 이면(#887) 플러시가 멈춘다. 상한을 두더라도 "늦게 완료된 트랜잭션" 을 실패로 보고 다시 쓰면 두 번 쓴다.
어떤 구조라서 가능했나(§22): ⓐ 저장소에 owner·target 별 조회 수단이 없어(키는 clientMutationId 하나) "같은 기록의 행" 을 고르려면 전부 읽어야 했다. ⓑ 미러가 "전체 배열 한 키" 라서 어느 행이 바뀌어도 전부 다시 적었다. ⓒ IndexedDB 커밋과 localStorage 쓰기가 한 트랜잭션이 될 수 없다는 사실이 모델에 없어 "미러 = 항상 최신" 을 가정했다. ⓓ 시간 초과 뒤 결과가 미상인 경우의 규칙이 없었다.
3. 해결 방안
원칙
- 오너 결정 없음 — 계획의 전제로 진행: ① IDB 버전 올림 없음 ② 미러 v2 는 localStorage 행별 키, 옛 배열은 묶어서 계속 유지 ③ 근거 없는 복구 행은 S04 고아 의도 행과 같은
blocked+ 코드 ④ 새 폴더persistence/idb/·persistence/mirror/⑤ Phase 0 계측은 S04 머지 전에 S04 head 로, 이후 코드는 머지 뒤. - §22(근본 구조): 디바운스로 미러 쓰기를 "덜 자주" 하는 땜질이 아니라, 저장소가 무엇을 읽어야 하는지(카탈로그)와 미러가 무엇을 안다고 주장할 수 있는지(tombstone·세대·새 DB 판정)를 모델로 바꿨다.
접근
| 대안 | 판정 |
|---|---|
| A. (채택) 메모리 카탈로그(owner·target·savedAt 불변 → 한 번 읽으면 영구) + 트랜잭션 안 키 스캔으로 다른 탭 변화 감지 + 필요한 id 만 본문 읽기 · 미러 v2(행별 키·본문/메타 분리·tombstone·세대·성공 ACK 는 tombstone 먼저·옛 배열 묶어 쓰기) · 복구는 "DB 가 새로 만들어졌을 때" 만, 근거 어긋나면 격리 · IDB 공통 adapter(시간 초과 세 갈래·재조회 계약) | 채택 |
| B. IDB 버전 올려 진짜 index·meta store | 기각 — 열려 있던 옛 탭·되돌린 번들이 VersionError 로 대기열·초안을 잃는다(Phase 0 실측 idbVersionPin.browser.mjs) |
| C. 별도 IndexedDB(index 전용) | 기각 — 두 DB 를 한 트랜잭션으로 못 묶어 S02 재검사가 다른 탭의 새 행을 못 본다 |
| D. 미러 쓰기 디바운스만 | 기각 — 전체 재직렬화 크기 그대로, 되살림 그대로 |
| E. 오래된 미러 행 전부 격리 | 기각 — 브라우저가 IDB 를 비운 정상 복구 경로에서 미전송 기록이 전부 "실패" 로 보인다 |
4. 적용한 내용
Phase 0 — 계측·기준선 (3c10457d)
tests/react/outboxCost.browser.mjs: 프로토타입 shim 으로 IDB 호출·본문 바이트·미러 쓰기·벽시계를 센다(개선 전·후 같은 도구). S04 head 2abca52c 기준선: N=1 4.9 ms/행 · N=100 13.8 ms/행(getAll 604·미러 재기록 199) · N=1000 152.8 s(getAll 6004·본문 읽기 6.03 GB·미러 2.02 GB). idbVersionPin.browser.mjs: 다른 탭이 DB 를 v7 로 올리면 현재 코드(v6)는 VersionError, 새 탭도 회복 불가, 연결을 닫지 않는 탭이 있으면 blocked 대기.
Phase 1 — IDB 공통 adapter (eb20d9f2)
persistence/idb/adapter.ts: 열기(blocked typed 오류·늦은 열기 닫기·createdFresh)·요청·트랜잭션(runIdbTransaction) 시간 상한. 시간 초과 때 tx.abort() 가 받아들여지면 aborted(안 썼음 → 재시도 안전), InvalidStateError 면 unknown(결과 미상 → 재조회, settled 로 늦은 결과 관찰). workoutLocalCacheDb 열기를 adapter 로 옮기고 workoutLocalCacheOpenInfo() 로 새 DB 판정 노출(종전 withIdbTimeout 이름 유지). 대기열 커밋의 재조회 계약(judgeUnknownReplace): 쓴 행이 보존 병합 모양 그대로 있고 지울 행이 없음 → committed / 관찰이 계획 때와 같음 → 미반영(1회 더) / 그 밖 → stale.
Phase 2 — 메모리 카탈로그 (8fdff0fa)
persistence/outbox/rowIndex.ts: id → 요약(본문 없음). 저장 port 에 index(owner)·readRows(ids)(없는 스토어는 전체 읽기 후퇴). 커밋 재검사(idbOutboxStore)는 카탈로그가 있으면 getAllKeys() → 계획 때 관찰한 행 ∪ 모르는 키 ∪ 이 열쇠들로 아는 행만 get. 전송기·적재·보류 해제·격리 복구가 "같은 기록의 행" 만 읽고, 첫 적재·전체 목록은 200개 페이지. 전송 가능 판정·후속 탐색은 요약으로(제네릭). 행 계약에 추가 전용 recovery 필드 등재.
작업 중 드러난 것: 요약만 믿고 claim 하면 다른 탭의 살아 있는 claim 을 가로챈다(두 탭 테스트 A 가 멈췄다 — 두 탭 모두 전송). 고른 행의 원문을 읽은 뒤 같은 판정을 원문으로 다시 하도록 고쳤다(요약은 힌트, 원문이 진실). 비용 재측정: 152.8 → 120.8 s — 남은 병목은 커밋마다 미러 전체를 다시 읽고 적는 syncMirror.
Phase 3 — 미러 v2 (40b66c45)
persistence/mirror/{mirrorPlan(순수),localStorageMirror}.ts: row:<id>(불변 본문, 한 번) · meta:<id>(시도 메타 + 지문, 시도마다) · tomb:<id> · gen · preserved. 성공 ACK(OutboxReplaceCommit.reason="uploaded")는 tombstone 을 커밋 전 에, 그 밖은 커밋 뒤. 옛 배열은 1.5초·플러시 끝·pagehide 에 묶어서 한 번. quota 초과는 manifest unmirrored 에만 남긴다(원문 유실 0). 부팅: DB 가 있으면 IDB 를 정본으로 수리(미러에만 있는 행 → tombstone repair), DB 가 새로 만들어졌고 행 0개면 복구 대조표(tombstone → 무복원 / v2·v1 합의 → 복원 / 어긋남·본 적 없음 → blocked(LG_RECOVERY_RECEIPT_UNKNOWN) + 원문 보존). 커밋 직후에는 카탈로그의 키 스캔 생략.
작업 중 드러난 것: 인덱스만으로는 20% 밖에 안 줄었고 미러가 빠지자 25배 — 진짜 병목은 미러 전체 재기록이었다. 키 스캔이 행당 4회여서(N=1000 에서 ≈4M 키 복사) 커밋 직후 생략으로 2회. S01 미러 브라우저 테스트는 묶어 쓰기에 맞춰 flushMirror() 를 부르게 고쳤고, 옛 배열에서 복원된 행에는 recovery{source:"mirror-v1", proof:"mirrored"} 표식이 붙는다. Phase 0 의 버전 고정 테스트는 "쓰기는 VersionError, 읽기는 미러 사본으로 읽기 전용 후퇴" 를 함께 보이게 됐다.
Phase 4 — 문서·예산·인계 ((같은 PR))
정본 계약 신설 · ADR §3-1 S07 행 · 원자 교체 §4·§6 · claim 계약 §8 · codec §3-2·§6 · 도메인 §19 · 성능 예산 §2-1(payloadReadFactor ≤ 12·mirrorFullRewriteFactor ≤ 6·fullScansPerDrain ≤ 40, 측정값·기준 SHA 대조 테스트) · G05 규칙 2줄 + 장부 재생성 · test:persistence-browser 4파일 추가 · 이 기록 + 등록 2곳.
Phase 5 — 검증·PR·랜딩
npm run ci:local -- --full1차(head1fd213c4, S04 머지 main 기준): verify 8단계 · db reset · schema.sql 스냅샷 · pgTAP 117파일/2036 assert · e2e-local 11/11 · empty 7/7 · cardio 6/6 · persistence 28/28 · browser 39/39(CASE-039/040 포함) · viewport 14/14 · 16분 11초.- 그 사이 main 이 세 번 앞서갔다(S05
73d6f84b→ A13d5e12e9d→ A121514fc52). 리베이스 3회 — 충돌은 전부 문서·생성 장부(config.mts·README·coverage-ledger·claim 계약 §8·coverage-inventory 규칙 2줄), 소스 충돌 0. 1차 리베이스 뒤ci:local --full2차는 verify·pgTAP·e2e 4묶음(persistence 28/28)까지 통과한 뒤 e2e-browser 단계에서 다른 세션의 미리보기 서버가 포트 4173 을 점유해 exit 3(환경) → 포트가 빈 뒤 남은 두 묶음만 재실행e2e-browser 39/39 · e2e-viewport 14/14 · 9분 59초. 2·3차 리베이스 뒤에는npm run check(3,196 → 3,217건, 실패 0)·tsc·S05 인접 단위로 확인하고 CI 에 맡겼다. - 사전 검증에서 드러난 것: S05 의
dispatchReconcile.test.mjs① 이 생성 답장 뒤 고정 30ms 만 기다려 전체 스위트 부하에서 간헐 실패(단독·CI 통과) → 후속 전송 조건 대기(상한 3초)로 고쳤다(기대 결과 불변, manifest 미등재). Phase 4npm run check1회 빨간불(앵커 게이트·unused 게이트)도 로컬에서 잡아 수리 — 사전 검증 누락 0. - PR #1377 CI(최종 head
168b64cb): verify·static-checks·unit-tests×2·docs-build·migration-smoke·browser-journeys×4·viewport-matrix·landing-queue·scope 전부 초록. 첫landing:request는 그 사이 main 이 또 앞서가 "PR is behind main" 으로 거부(run 34151701071) → 3차 리베이스 → 재요청712ee2e9→ run 34154691861 성공: squash merge37066827+ staging Deploy run 34154713784 성공 확인.
5. 적용 결과
| 항목 | 전(S04 head) | 후(S07) |
|---|---|---|
| 1000행 드레인 벽시계 | 152.8 s (152.8 ms/행) | 6.1 s (6.1 ms/행) — 25배 |
| 100행 드레인 | 1.38 s (13.8 ms/행) | 0.24 s (2.4 ms/행) |
| 본문(payload) 읽기, N=1000 | 6.03 GB (전체의 3004배 ≈ 3N) | 15.7 MB (7.8배, 상수) |
IDB 전체 읽기(getAll) 횟수, N=1000 | 6004 (≈6N) | 15 |
| 미러 전체 재기록, N=1000 | 1999회 2.02 GB (1005배 ≈ N) | 4회 3.7 MB (1.9배) + 행별 키 2.7 MB (1.3배) |
| 시도 메타만 바뀔 때 | 본문 전체 복사(미러) | meta: 만(본문보다 작음), row: 재기록 0 |
| 오래된 미러 + IDB 정상(행 0개) | 되살림 | 되살림 0(IDB 정본, tombstone 으로 수리) |
| 성공 ACK 뒤 DB 삭제 + 옛 배열에 행 잔존 | 되살림 | 되살림 0(tombstone 우선) |
| DB 가 비워진 뒤 복원 | 옛 배열 전부 무조건 | v2·옛 배열 합의 행 복원, 어긋나면 격리(receipt 확인 대상), 깨진 원문 개수 보존 |
| IDB 트랜잭션 hang | 무한 대기 | 8초 상한 → 안 썼음 / 결과 미상(재조회) 구분, 늦은 완료 중복 쓰기 0 |
| 저장 형식·IDB 버전·서버 | v1 · 6 · 무변경 | v1 · 6 · 무변경 (추가 필드 recovery 1개) |
- 자동 검증: Phase 마다
npm run check통과(마지막 3,144건 실패 0) · 실제 Chromium 복구 매트릭스 7/7 · timeout 3/3 · 버전 고정 2/2 · 비용 예산 판정 통과 · 기존 원자성 7·두 탭 4·S01 미러 1·authResume 3 통과 ·ci:local full(1차 전체 + 2차 verify~persistence + browser/viewport 재실행) · PR #1377 CI 초록 · 랜딩 큐 run 34154691861 성공 · staging Deploy run 34154713784 성공 - 미검증: 실기기(iOS WKWebView·Android WebView)의 IndexedDB 시간 초과·
abort()상태 의미론과 localStorage quota 는 Chromium 과 같다고 가정한다(N01 인계).blocked(LG_RECOVERY_RECEIPT_UNKNOWN)행의 화면 표시는 U02/U03, receipt 확인 RPC 는 S05·서버 트랙 밖. 커밋 뒤 미러 갱신 전에 죽은 마지막 커밋 하나 는 미러가 모른다 — 성공 ACK 는 tombstone 이 먼저라 안전하고, 그 밖의 삭제 하나가 DB 까지 비워지는 경우에만 되살아날 수 있다(서버 멱등 장부가 중복 반영을 막는다, 계약 §4-3 명시).
6. 이번 개선으로 향상된 것
대기열이 커져도 보내는 비용이 평평하다
행 하나를 보낼 때 그 기록의 행만 읽고 미러는 바뀐 행만 적는다. 1000개가 쌓여도 6초.
처리된 기록이 되살아나지 않는다
서버가 받았다고 확인한 삭제는 tombstone 이 먼저고, DB 가 그대로면 IDB 가 정본이다. 브라우저가 IDB 를 비웠을 때만 사본에서 복원하며, 근거가 어긋나는 행은 보내지 않고 격리해 둔다.
멈춘 IndexedDB 가 플러시를 영원히 막지 않는다
8초 상한 뒤 "안 썼음" 과 "결과 미상" 을 갈라, 안 썼으면 다시 시도하고 미상이면 다시 읽어 판정한다.
구조적으로 남는 것
- 저장 port
index(owner)·readRows(ids)— S05 dispatcher 가 소비한다. - IDB 공통 adapter(
persistence/idb) — 초안·사본 저장소도 같은 것을 쓸 수 있다. - 미러 계획기와 복구 대조표(문서 §4-3),
OutboxReplaceCommit.reason. - 기기 대기열 비용 예산(§2-1) + 실제 Chromium 계측 도구 — 앞으로의 대기열 변경은 같은 도구로 판정된다.
- IDB 버전 6 고정의 실측 근거(
idbVersionPin.browser.mjs).
남은 것
- 릴리스 v0.18.0 머지 뒤 이슈
[v0.18.0 반영완료]+ 닫기. - 총괄 S07 카드 상태 갱신(HQ 몫 — 이슈 마지막 댓글에 갱신안).
- N01: 실기기 IDB 시간 초과·복구 시나리오. R02: CASE-039/040 재실측·옛 탭 창의
unverified격리 빈도. R04: 저사양 기기 1000행 드레인. S05: receipt 확인 뒤 격리 해제. U02/U03: 격리 복구 행 표시.