Skip to content

v0.18.0 S01 — 기기 대기열 행을 "지금 코드의 타입" 으로만 읽고 통째로 덮어쓰던 것에서, 버전 있는 형식 계약·codec 한 곳·모르는 필드 보존 병합·실제 옛 번들 공존 실측까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 1개 9e2872ef, 오너 지시 "1285 진행해줘" → 분석·Phase 계획 게시 → "ㄱ")
  • 랜딩: PR #1320 (Phase 0~6 한 PR, squash, CI 1회) — 마이그레이션·엣지 함수·서버 변경 없음. 앱 코드 변경은 src/react/services/pendingWorkoutSaves.ts(읽기 경로 codec·update 보존 병합) 하나이고 저장 형식·쓰는 필드 집합은 종전과 같다. 총괄 S01 카드 · 계획 ID S01 · Phase 1 스텝 1-4
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1285 댓글
  • 정본: docs/contracts/pending-save-codec.md(형식·읽기 보정·지문·공존 실측표·활성화/rollback 조건·인계) · src/react/contracts/persistence/pendingSaveRow.ts(형식 계약) · contracts/persistence/requestHash.ts(통합 지문 정의) · src/react/persistence/codecs/pendingSave/**(codec) · 도메인 계약 §3·§12·§19 · 쓰기 파이프라인 §3 두 문장
  • 도구: scripts/e2e/build-legacy-bundle.mjs(릴리스 태그를 임시 워크트리에 받아 같은 의존성으로 vite build%TEMP%/barbelic-legacy-bundles/<태그>/, 레포 밖 캐시) · tests/fixtures/pending-rows/generate.mjs(옛 번들 체크아웃에서 실제 writer 로 fixture 생성) · error-cases/CASE-039-…/legacyBundle.mjs(옛 번들을 같은 origin 에서 Playwright route 로 서빙)
  • 게이트: 단위 24건 — tests/react/pendingRowFixtures.test.mjs 3 · contracts/pendingSaveRow.test.mjs 3 · contracts/persistence.test.mjs 6(재앵커) · pendingSaveCodec.test.mjs 7 · pendingSaveStorePreservation.test.mjs 5 (npm testnpm run check → CI verify). e2e CASE-039(fault-injection 프로필, 실제 v0.17.0 번들 탭 + 새 번들 탭) — 로컬 샌드박스(%TEMP%/barbelic-ci-local/2c15b806, 미리보기 4179)에서 통과(run 9, 아래 §5). 기존 대기열 테스트(pendingWorkoutSaves·pendingWorkoutOutboxStates·pendingWorkoutSavesStore) 기대값 무변경
  • 버그리포트: 없음(구조 트랙). 실측이 뒤집은 것 하나 — "옛 탭이 새 필드를 지운다" 는 코드 읽기 예측이 틀렸다(§4 작업 중 드러난 것)
  • 계약: pending-save-codec.md 신설 · 도메인 계약 §3(지문 정의 하나·옛 정의 둘은 역사) §12(AttemptMetadata.evidence·추가 전용 메타) §19(대기열 행 형식·fixture 둘) · 쓰기 파이프라인 §3(리더 선출 문장 뒤 활성화 조건 위치·행 읽기/쓰기 규칙 정본 위치) · G05 규칙 파일 4줄(src/react/persistence/·tests/fixtures/pending-rows/·scripts/e2e/·CASE-039 → durable/verification) + 장부 재생성

Phase 현황

Phase내용상태
Phase 0실제 번들(v0.16.0·v0.17.0) writer 가 남긴 행 fixture 11행×2 + 손으로 정한 보존 기대값 + 번들별 행 모양 표 + 기준선 테스트a4d68238
Phase 1형식 계약 pendingSaveRow.ts(kind 5종 union·전송 메타·추가 전용 메타·불변 묶음·target key) + 통합 지문 정의 + 도메인 계약 §3·§12·§19f02da4d3
Phase 2codec codecs/pendingSave/v1.ts(읽기·보존 병합·시도 메타·PreparedMutation) + 지문 helper + G03 참조 decoder 제거·재앵커 + 보존 테스트d03b2c2f
Phase 3스토어 통합 — 읽기 경로 codec(깨진 행·미래 행은 원문 보존 목록), update 는 저장소 행과 보존 병합(불변 묶음 변경 거부), 보존 행 조회 APIcbb108c8
Phase 4옛 번들 빌드 도구 + CASE-039 공존 실험(실제 v0.17.0 탭 + 새 탭, 같은 origin·같은 IDB) + 실측표f3402db7
Phase 5계약 문서 pending-save-codec.md · 쓰기 파이프라인 §3 · G05 규칙·장부 · 이 기록 · 등록 2곳✅ (Phase 4 뒤 같은 PR — 문서·장부만)
Phase 6npm run ci:local(full) · PR · CI 1회 · 머지 · 이슈 [v0.18.0 스테이징]✅ PR #1320

1. 배경

유저 A 가 지하철에서 어제 기록의 무게를 고치면 앱은 편집기를 바로 닫고 그 수정을 기기 안(IndexedDB pendingSaves, 보조로 localStorage 미러)에 행 하나 로 남긴다(#1199 단일 쓰기 파이프라인). 연결이 돌아오면 그 행을 서버로 보낸다. v0.18.0 은 이 대기열 위에 원자 적재(S02)·행 단위 claim·fence(S04)·명령 조립기(S03)를 쌓는 릴리스인데, ADR §3-1은 "새 writer 는 S01 이 실제 옛 번들 + 다중 탭 으로 공존 실험을 통과하기 전에는 켜지 않는다" 고 정했다. G03 이 내부 표현(PreparedMutation{request, attempt}·DecodeResult)을 선언했지만 그것은 계약 선언이고, 실제 기기 행이 어떤 모양이며 옛 번들이 새 필드를 어떻게 다루는지는 아무도 실측하지 않았다.

2. 문제 제기

저장 형식이 "지금 코드의 TypeScript 타입" 과 같은 것이었다

행에 형식 이름·버전 규칙이 없고, 읽기는 코드가 아는 필드만 보고, 쓰기(store.update(save))는 메모리에 있는 객체로 행 전체를 덮어쓴다. 두 버전의 코드가 같은 저장소를 같이 쓰는 순간(배포 직후 옛 탭 + 새 탭, 옛 앱 설치본 + 새 웹) 서로의 필드를 어떻게 다루는지가 코드 어디에도 규칙으로 없었다. 옛 번들(v0.15·v0.16)이 남긴 행에는 state·kind 가 없고, 그 보정 규칙(kind 없으면 생성, state 없으면 blockedAt 로 판정)이 두 함수와 G03 참조 decoder 세 곳에 따로 있었다.

깨진 행을 "원문 보존" 할 자리가 없었다

요청 본문이 문자열인 행, 모르는 kind 의 행을 getAll() 이 그대로 돌려주고 전송기가 보내려다 실패를 반복했다. 지우지도 못하고(유저 원본일 수 있다) 보낼 수도 없는 행을 따로 두는 명시 상태가 없었다.

요청 지문 정의가 둘이었다

workoutMutationRequestHash({mutationKind, payload, sourceRef}, 완료 기록)와 createDurableMutationIdentity({contractVersion:1, kind, userId, sourceRef, payload}, 계획·그룹 보드). 서버는 자기 지문으로 멱등 비교하므로 당장 틀리는 것은 없지만, 새 명령 조립기(S03)가 어느 것을 써야 하는지 정본이 없었다.

어떤 구조라서 가능했나: 저장물의 형식과 코드의 타입이 분리되지 않아 "형식은 그대로인데 코드가 바뀐다" 는 상황을 표현할 수 없었다.

3. 해결 방안

원칙

  • 오너 결정 없음 — 계획의 전제 5개로 진행: ① 저장 형식 v1 그대로, 새 필드는 추가만 ② 이 이슈는 쓰는 모양을 바꾸지 않는다(새 writer 비활성 유지, 바뀌는 쓰기 동작은 "모르는 필드를 지우지 않기" 하나) ③ 공존 실험의 옛 번들 = v0.17.0 태그(Production 현재) ④ 지문 정의는 owner·종류를 포함하는 쪽 하나로 통합, 저장된 행의 지문은 재계산 금지 ⑤ contracts/core/**(HQ 슬롯)·package.json·barbelicRepository.ts(A01) 는 만지지 않는다.
  • §22(근본 구조): "옛 행이면 이렇게 보정" 조건문을 곳곳에 두는 대신, (형식, 버전) 하나만 아는 codec 한 곳과 "모르는 필드는 보존, 모르는 행은 원문째 분리" 규칙을 두고, 그 규칙이 맞는지를 실제 옛 번들 로 실측했다.

접근

대안판정
A. 형식 = 버전 있는 v1 평면 행 + 추가 전용 메타. codec 이 행 ↔ 메모리 표현을 오가고, 모든 쓰기는 보존 병합, 모르는 버전·깨진 행은 원문 보존 상태로 분리. 실제 v0.17.0 번들과 새 번들을 같은 origin 에 띄워 공존 규칙 실측채택 — 옛 탭이 새 행을 읽고 보낼 수 있고(rollback 안전), 새 탭은 옛 행을 그대로 읽는다. 재생성·재발급 없음
B. v2 중첩 구조({request, attempt})로 새로 쓰고 옛 행을 마이그레이션기각 — 옛 탭이 v2 행의 request 를 못 찾는다. 저장물을 새 버전으로 다시 쓰는 것은 재생성 없음 위반
C. 저장소 이름을 바꿔 옛 행을 한 번 옮긴다기각 — 옮기는 순간 옛 탭은 자기 행을 잃고 같은 요청이 두 스토어에 갈라진다
D. capability 플래그로 옛 탭을 멈춘다기각 — 이미 열린 옛 탭은 새 플래그를 읽지 않는다

4. 적용한 내용

Phase 0 — 실측·fixture (a4d68238)

태그 v0.16.0·v0.17.0 를 scratchpad 워크트리에 받아 그 시점의 pendingWorkoutSaves.ts 를 고정 시계·고정 id 로 돌려(메모리 스토어) 대기열 행 11종씩(kind 5종 × 상태, 옛 행, 다른 owner 같은 target)을 tests/fixtures/pending-rows/{v0.16.0,v0.17.0}.json 으로 저장했다. 기대값(expect)은 생성 입력에서 손으로 정했다(앱 출력 복사 금지). README 에 번들별 행 모양 표. 기준선 테스트 3건은 현재 listPendingWorkoutSaves 가 두 fixture 를 그대로 읽는지 고정했다. v0.15.0 은 v0.16.0 과 writer 코드가 같았다(diff 0줄). v0.17.0 과 main 도 행 모양이 같았다(#1199 이후 변경 0줄).

Phase 1 — 형식 계약 (f02da4d3)

src/react/contracts/persistence/pendingSaveRow.ts: PENDING_SAVE_FORMAT·PENDING_SAVE_ROW_VERSION=1, kind 5종 ↔ slug ↔ 대상 묶음(create/completed/plan), 요청 본문 union(PendingCreateRequestPendingPlanDeleteRequest), 전송 메타, 추가 전용 메타(claim·successorOf·evidence·writer·format — 없으면 null), PendingSaveRowV1<K>, 불변 묶음 집합, target key ${owner}|${group}|${target}, 보정 규칙의 유일한 자리 inferPendingSaveKind/State. requestHash.ts: 통합 정의 {contractVersion:1, kind, userId, sourceRef, payload} 의 정규 직렬화. G03 AttemptMetadataevidence 자리. 도메인 계약 §3 지문 표(통합 정의 vs 옛 정의 둘)·§12·§19.

Phase 2 — codec (d03b2c2f)

src/react/persistence/codecs/pendingSave/v1.ts: 읽기는 너그럽고 identity 만 엄격(uuid·owner·요청 본문이 객체·kind/state 값), row·request 는 같은 참조, 모르는 버전 → unknown_version, 깨진 행 → malformed(둘 다 원문 참조 보존). decodePendingSaveRows(rows) → {decoded, preserved}. mergePendingSaveRow(base, next): base 의 모르는 필드 보존, 불변 묶음 변경 → immutable_field_changed, undefined 는 삭제. withAttempt·toPreparedMutation·pendingSaveOwnerKey/TargetKey. codecs/requestHash.ts: requestHashOf·clientMutationIdFromHash·durableIdentityOf. G03 참조 pendingSaveV1Decoder 삭제, persistence.test.mjs 를 codec 으로 재앵커(6건). 새 테스트 7건.

Phase 3 — 스토어 통합 (cbb108c8)

pendingWorkoutSaves.ts: listPendingWorkoutSavesdecodePendingSaveRows().decoded 만 돌려주고, 보존 행은 새 listPreservedPendingWorkoutRows 로 따로 조회(지우지도 다시 쓰지도 않는다). IDB update() 는 한 트랜잭션에서 get → mergePendingSaveRow → compact → put(메모리 스토어도 병합). pendingWorkoutSaveKind/State 는 codec 보정 규칙에 위임. 불변 묶음 충돌은 PendingWorkoutSaveIdentityConflictError. 쓰는 필드 집합은 종전과 동일. 새 테스트 5건(섞인 저장소에서 정상 행만 전송·보존 행 생존·claim 보존·G02 실패 주입 뒤 원문 유지). 기존 대기열 테스트 3파일 기대값 무변경 통과.

Phase 4 — 공존 실험 (f3402db7)

scripts/e2e/build-legacy-bundle.mjs: 태그를 임시 워크트리에 받아(node_modules 는 junction) 현재 의존성으로 vite build, 결과를 레포 밖 캐시에 bundle.json(태그·커밋·시각)과 함께 둔다. --print 로 캐시 조회. error-cases/CASE-039-legacy-tab-pending-row-coexistence/: 탭 A 는 옛 번들을 같은 origin 에서 Playwright route 로 디스크에서 내려받아 뜨고(legacyBundle.mjs), 탭 B 는 새 번들(미리보기 서버). 경로: A 저장(온라인) → A 가 선언 503 아래 수정 → 행에 새 writer 메타(claim·writer·evidence·모르는 필드)를 직접 적음 → B 열어 읽기 확인·닫기 → A 재전송(두 번째 503) 뒤 행·미러 대조 → 장애 해제 → 서버 반영 1회·행 0 → 새로고침 → 일회용 계정 정리. 선언 장애 2회, 연결 상태 이벤트 4개 순서 고정. 결과 표는 첨부 case-039-coexistence-matrix.json. 계약 문서 §4 에 실측표 R1~R7 과 허용/금지 표.

Phase 5 — 계약 문서·장부

pending-save-codec.md(7절: 유저 이야기·행 모양 표·지문·공존 실측표·활성화/rollback 조건·인계 표·검사 표). 쓰기 파이프라인 §3 두 문장. G05 규칙 4줄 + 장부 재생성. fixture README 의 예측 문단을 실측으로 정정. 이 기록 + 등록 2곳.

Phase 6 — 랜딩

npm run ci:local(full) → PR #1320 → CI 1회 → squash 머지 → 기본 체크아웃 fast-forward → 이슈 [v0.18.0 스테이징].

주요 결정과 그 근거

  • 형식은 v1 그대로, 메모리 표현만 {request, attempt}. 옛 번들이 새 행을 종전처럼 읽고 보내고 지울 수 있어야 rollback 이 성립한다. 중첩 구조로 바꾸면 옛 탭이 요청 본문을 못 찾는다.
  • 읽기는 너그럽게, identity 만 엄격하게. 본문 안 필드를 더 따지면 실제 옛 번들이 남긴 행을 "깨진 행" 으로 몰아 전송을 막는다 — 그 판정은 서버가 한다.
  • 보존 병합은 저장소의 현재 행을 기준으로. 메모리에 있는 객체가 아니라 저장소에서 읽은 행에 병합해야 다른 탭이 더한 필드가 남는다. 병합 뒤 compact(값 없는 상태 필드는 키째 제거) 순서여야 "undefined = 삭제" 가 유지된다(처음 반대로 하다 기존 테스트가 잡았다).
  • 새 탭은 실험에서 읽기만. 연결 상태가 탭 사이에 공유되어 강등 중 열린 새 탭은 부팅 전송을 하지 않는다. 새 번들의 보존 쓰기는 단위 테스트가 고정하고, e2e 는 "실제 옛 번들이 무엇을 하는가" 에 집중했다.

작업 중 드러난 것

  • 코드 읽기 예측이 틀렸다. Phase 0 은 "옛 update() 가 행 전체를 덮어쓰니 새 필드는 지워진다" 고 예측했다. 실측(R4): 덮어쓰는 값이 저장소에서 읽어 메모리에 들고 있던 행({...row, state, sentAt})이라 모르는 필드도 함께 다시 써서 보존된다(행·미러 모두). 대신 claim 은 무시한다(R5). 실험 없이 문장을 굳혔다면 S04 가 틀린 전제로 설계됐을 것이다.
  • 연결 상태는 탭 사이에 공유된다(connectivityRuntime 의 leader 선출 — 한 탭만 서버를 두드려 보고 나머지는 따른다). 강등 중 열린 새 탭은 connectivityStatusonline 이 아니어서 부팅 플러시를 하지 않는다. 실험 순서를 이 사실에 맞춰 다시 짰다(run 1~5 는 "새 탭의 부팅 전송" 을 기다리다 실패).
  • 재강등의 재시도는 61초 뒤에 온다. 회복 뒤 60초 안에 다시 강등되면 탐색 간격이 30초로 늘고, 재수화 전 탐색 성공 2회가 필요하다(connectivityPolicy). 두 번째 재수화 대기는 90초로 잡았다(run 6·7).
  • Playwright route 로 내린 문서는 Chromium 로컬 네트워크 접근 검사에 걸린다("Permission was denied … loopback address space"). 배포 환경에는 없는 테스트 배치의 인공물이라 CASE-039 파일에서만 --disable-features=LocalNetworkAccessChecks,… 로 끈다(run 1).
  • 닫은 탭을 관찰 목록에 두면 완료 게이트가 실패한다. assertNoUnexpectedErrors 가 관찰 페이지 전부에 locator 를 거는데 닫힌 페이지는 예외를 던진다 — 닫힌 페이지를 빼는 한 줄을 공용 fixture 에 넣었다(run 8).
  • 도구 함정: Bash 히어독·node -e 가 백슬래시를 먹어 regex·JSON 이스케이프가 망가진다(Edit 도구로) · G05 규칙 파일은 JSON.stringify 재기록이 1,500줄 diff 를 만들어 줄 단위로만 고친다 · 옛 번들 빌드 스크립트의 PowerShell | Out-Nullshell:true 에서 실패해 spawn 인자로 넘긴다 · 4173 이 다른 세션의 미리보기라 E2E_APP_URL 로 자기 포트(4179)를 지정했다.
  • 탭을 닫는 순간·새로고침하는 순간 진행 중이던 요청이 끊기면(net::ERR_ABORTED) 오류 관찰에 잡힌다. ci:local 첫 실행에서 CASE-039 가 이걸로 1회 실패 뒤 재시도 통과(플레이키 1). 새 탭을 닫기 전과 마지막 새로고침 전에 waitForLoadState("networkidle") 를 넣어 고정했다(run 10 통과, 브라우저 묶음 재실행 아래 §5).
  • G05 --strict 는 main 에서 이미 미분류 17건(G02 #1280 의 tests/react/dateIndependence 등·.gitattributes·authSessionGuard.ts·tests/db/**)이었다 — 이 트랙의 경로 4줄만 더했고 나머지는 해당 트랙 몫으로 남긴다.
  • main 이 G04(#1315, beff760b)로 전진해 PR 전 리베이스했다(충돌 없음 — 파일 겹침 없음).

5. 적용 결과

항목
행을 읽는 보정 규칙의 위치3곳(pendingWorkoutSaveKind·pendingWorkoutSaveState·G03 참조 decoder)1곳(inferPendingSaveKind/State, codec 이 호출)
실제 번들 행의 보존 대조0fixture 22행(v0.16.0·v0.17.0 각 11) + G03 손 fixture — id·지문·sourceRef·payload·owner 전부 동일(테스트 24건)
깨진 행·모르는 버전 행의 처리전송 시도 → 실패 반복원문 참조 보존 목록으로 분리, 정상 행은 계속 전송(테스트 + 스토어 API)
update 가 모르는 필드를메모리 객체로 덮어씀(보존은 우연)저장소 행과 병합해 보존(테스트 단언), 불변 묶음 변경은 거부
요청 지문 정의21(통합) + 옛 정의 2 는 문서의 역사 표. 저장된 지문 재계산 0
옛 번들 공존 실측없음(ADR 은 "실험 전 비활성" 만)CASE-039 통과 — 옛 v0.17.0 탭 재전송 3회 본문 바이트 동일 · 새 메타 보존(행·미러) · claim 무시 · 서버 server_revision 1 → 2(정확히 한 번) · 오류 표면 0 · 새 탭 전송 0
새 writer 활성화 조건없음계약 §5 조건 5개 + rollback 조건 3개(실측 근거 첨부)
앱이 쓰는 행의 필드 집합v0.17동일(변경 없음)
  • 자동 검증: npm run check Phase 마다 통과 · npm run ci:local(full) 1차 검증: ci:local full · verify 통과 (6단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 109파일/1899 assert · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-browser 실패 37/37 (플레이키 1) · e2e-viewport 통과 14/14 · 15분 31초(플레이키 = CASE-039 끊긴 요청 1건, §4 참조) → 대기 두 줄 수정 뒤 브라우저 묶음 재실행 검증: ci:local full · e2e-browser 통과 38/38 · 7분 19초 · CI 1회 초록(PR #1320).
  • 미검증: Production 배포본에서의 CASE-039 재실행(옛 번들 캐시가 runner 에 필요 — productionVerification: pending). v0.16.0 번들의 실제 브라우저 실험(전제 3: fixture 로만 다룸 — v0.16 writer 는 미러가 없어 행만 대조했다).

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

옛 앱·옛 탭 사용자의 미전송 기록이 새 코드에서 그대로 읽힌다

v0.15~v0.17 설치본이 남긴 행(상태·종류 없음)을 새 codec 이 같은 요청으로 읽어 보낸다 — fixture 22행이 그 증거다.

깨진 행 하나가 나머지 전송을 막지 못한다

모르는 버전·깨진 행은 원문째 따로 두고 정상 행만 보낸다. 유저 원본일 수 있는 행을 지우지 않는다.

두 버전의 코드가 같은 저장소를 써도 서로의 필드를 지우지 않는다

새 쪽은 보존 병합으로 보장하고, 옛 쪽(v0.17.0)은 실측으로 확인했다. S04 는 "claim 이 있어도 옛 탭이 보낼 수 있다" 는 사실 위에서 설계한다.

구조적으로 남는 것

  • (형식, 버전) 하나만 아는 codec 과 "모르는 필드는 보존, 모르는 행은 원문째 분리" 규칙 — 다음 형식 변경(S02·S04)은 fixture·CASE-039 로 먼저 검사된다.
  • 실제 릴리스 태그 번들을 빌드해 같은 origin 에서 띄우는 도구(build-legacy-bundle.mjs + legacyBundle.mjs) — 앞으로 어떤 저장 형식 변경도 "옛 번들이 실제로 무엇을 하는가" 를 같은 방식으로 실측할 수 있다.
  • 활성화·rollback 조건이 문장으로 있다(계약 §5).

남은 것

  • 릴리스 v0.18.0 머지 뒤 이슈 [v0.18.0 반영완료] + 닫기.
  • S04 가 새 writer 를 켤 때 계약 §5 조건 5(그 시점 Production 태그로 CASE-039 재실측) 이행.
  • 보존 행(listPreservedPendingWorkoutRows)의 화면 표시는 U02/U03 몫.
  • G05 --strict 의 나머지 미분류 17건은 G02(#1280) 등 해당 트랙 몫.