v0.18.0 S05 — 대기열 전송·영수증 대조·화면 반응의 책임 분리: 기기 행만 보내는 dispatcher, 보낸 요청과 대조하는 reconciler, typed 결과 하나를 받는 화면 binding (2026-09-08)
- 기간: 2026-09-08 (세션 1개
49541b10-0ef8-4b6a-964f-56a478dc6eef, 오너 지시 "1336 작업 진행해줘 나한테 물어보지 말고 phase끝까지 완주하고, 아직 선행작업 안끝났으면 기다렸다가 진행해" — S04 #1334 랜딩(f8334135)을 기다린 뒤 그 위에서 진행) - 랜딩: PR #1374 → main
73d6f84b(Phase 0~5 한 PR) — 마이그레이션·엣지 함수·서버·package.json 변경 없음(랜딩 큐 대상 아님). 앱 코드 변경은src/react/persistence/dispatch/{failure,dispatcher,receiptReconciler,types,index}.ts(신규) +src/react/features/completed-workout/pendingSavesFeatureBinding.ts·pendingSavesDispatchPorts.ts(신규) +src/react/services/pendingWorkoutMutationFlush.ts(단일 sender 로 재작성) +src/react/controllers/pendingWorkoutSavesStore.ts(655 → 약 300줄) +pendingWorkoutSavesController.ts·workoutWriteController.ts·completedWorkoutCommands.ts·directWorkoutWrite.ts·appController.tsx(배선·죽은 코드). 총괄 S05 카드 · 계획 ID S05 · Phase 2 스텝 2-3 - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1336 착수 댓글
- 정본:
docs/contracts/outbox-dispatch-reconcile.md(유저 이야기·책임 넷·단일 분류기·영수증 대조 표·typed 결과·인계) · 도메인 계약 §13·§14·§17 · 쓰기 파이프라인 구현 줄·§6 · ADR §3-2 · S04 계약 §8 인계 완료 - 도구: 테스트 하네스
tests/support/pendingSavesHarness.mjs(스토어 + dispatcher 포트 + 실제 feature binding 을 한 번에 조립) · 영수증 v2 픽스처tests/support/receipts.mjs(보낸 요청에서 결정적으로) - 게이트: 단위 —
tests/react/dispatchReconcile.test.mjs4(결함 재현 → 통과) ·dispatchScenarios.test.mjs4(이슈의 증명 시나리오) ·dispatchBoundaries.test.mjs4(persistence 의 화면 import 0·ownDataReplica쓰기 호출처·이중 sender 부재) ·pendingWorkoutMutationFlush.test.mjs6(typed 포트) · 기존 대기열 스위트 12개 파일 하네스 이전(manifest 등재 2개controllerBoundaries·calendarReadModelMapper는tests/audit/pending-changes.json신고).npm run check통과. G05 규칙 1줄(^tests/react/(…|dispatch|…)) + 장부 재생성. e2e·pgTAP 변경 없음 - 버그리포트: 없음(구조 트랙, 감사 보고서 F16 의 수리). 다만 §4 Phase 0 의 결함 ㉥은 사용자에게 보이는 결함이었다 — v0.17.x 에는 없고(의도 행은 S04 가 처음 도입) S04 가 main 에 든 뒤 이 트랙이 같은 릴리스(v0.18.0) 안에서 고쳤으므로 Production 노출 0
- 계약:
outbox-dispatch-reconcile.md신설 · 도메인 계약 §13(대조 구현)·§14(분류기 하나 채택)·§17(DispatchPorts·FeatureInvalidationPort행) · 쓰기 파이프라인 구현 줄·§6 · ADR §3-2 한 문단 · S04 계약 §8 S05 행 완료
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 결함 재현 테스트 8건 먼저 실패(S04 의도 행 포트가 S03 뒤 죽음 · 영수증 대조 없이 채택 · 채택 장부 없음 · persistence 의 화면 import) | ✅ b11281d9 |
| Phase 1 | 단일 분류기 persistence/dispatch/failure.ts(G03 classifyWriteFailure 채택) · 전송기를 typed 포트의 단일 sender 로 · 보낸 identity 반환 · 직접 쓰기 판정 3함수 위임 · 의도 행 변환을 S03 조립기로 | ✅ 4948325d |
| Phase 2 | receiptReconciler.ts(대조 표·채택 장부·G03 매핑) · dispatcher.ts(outbox 루프의 send/isolate/hold/prepareSuccessor 포트, typed DispatchResult) | ✅ 6ec72414 |
| Phase 3 | feature binding + 포트 배선 신설 · 스토어 축소(프로젝션·핸들·비행) · 컨트롤러 배선 · wait-for-create 분기 2·retryUpdate/retryDelete/mode "retry" 제거 · 경계 테스트 · 기존 테스트 12개 파일 하네스 이전 | ✅ 6ec72414 |
| Phase 4 | 시나리오 4건 · 계약 신설·갱신 5곳 · G05 규칙·장부 · 이 기록 + 등록 2곳 | ✅ (같은 PR) |
| Phase 5 | npm run ci:local · PR · CI 1회 · 머지 · 이슈 [v0.18.0 스테이징] | ✅ PR #1374 → 73d6f84b |
1. 배경
유저 A 가 지하철에서 어제 기록을 고치고 새 기록도 하나 남겼다. 연결이 돌아오면 앱은 기기 대기열의 행을 서버로 보내고, 서버가 준 영수증으로 달력·홈 통계·피드·열린 상세를 갱신하고 "동기화했어요" 를 띄운다. v0.18.0 총괄은 이 흐름을 "기기 보관(outbox) → 전송(dispatcher) → 영수증 확정(reconciler) → 화면 반응(feature)" 의 네 책임으로 나누기로 했고(S05, 감사 F16 "Outbox/UI/retry 책임 혼합"), 선행 S03(순수 조립기)·S04(claim·후속 행)·D02(영수증 세대의 뜻)가 끝난 뒤 이 트랙이 그 분리를 실제로 했다.
2. 문제 제기
저장 모듈이 화면 함수 17개를 직접 잡고 있었다
pendingWorkoutSavesStore.ts(655줄) 한 파일이 대기열 읽기·어느 API 로 보낼지·실패 판정·영수증을 화면 사본으로 바꾸기·달력·프로필·피드 재조회·토스트·통계 재검증을 다 했다. setEnv 로 setPlans·showToast·reloadRemoteData… 17개를 받았고, barbelicViewMappers·BarbelicApi·completedSessionReceiptIdentity 를 import 했다.
영수증을 대조 없이 채택했다
전송이 돌아오면 무조건 성공으로 넣고 saved.id || saved.receipt?.sessionId 만 읽었다. 영수증의 멱등 키·앱 지문·종류·개정번호·세대가 이 행의 것인지 보는 곳이 없었고, 영수증이 없는 응답({})도 성공으로 그려 행을 지웠다. 채택 장부가 없어 같은 영수증이 두 번 오면 확정 사본·재조회·안내도 두 번이었다.
실패 판정이 세 벌, sender 가 두 겹
directWorkoutWrite.classifyDirectWorkoutWriteError(+격리·보류 판정) / pendingWorkoutMutationFlush.isRevisionConflictError / G03 참조 구현이 같은 표를 각자 구현했다. 대기열 → sendPendingWorkoutMutation → completedWorkoutCommands.retryUpdate/retryDelete/save({mode:"retry"}) → BarbelicApi 로 두 겹을 지났고, completedWorkoutCommands 의 그 메서드는 이 경로 말고는 부르는 곳이 없었다.
S04 의 의도 행 변환 포트가 실제로는 죽어 있었다 (결함)
스토어의 prepareSuccessor 가 completedWorkoutCommands.prepareSave/prepareDelete 를 부르는데 S03(#1333)이 그 두 함수를 지웠다. env 타입이 AnyRecord 라 컴파일은 통과하고, 실행하면 "prepareSave is not a function" → catch → 의도 행이 항상 blocked(LG_SUCCESSOR_UNPREPARED). 유저 이야기: 새 기록을 보내는 중에 그 기록을 고치면 그 수정이 "동기화 실패" 로 격리된다. S04 의 단위 테스트는 포트를 주입해서, e2e CASE-040 은 의도 행이 아닌 후속 행 경로라 잡히지 않았다.
어떤 구조라서 가능했나(§22): "보내기" 와 "영수증으로 화면 갱신" 이 한 함수 안에 있고 그 함수가 화면 함수 17개를 직접 잡고 있어서, 영수증 검증·중복 방지·실패 분류 같은 저장 쪽 규칙을 넣을 자리가 없었다. env 가 AnyRecord 라 죽은 호출도 컴파일이 잡지 못했다.
3. 해결 방안
원칙
- 오너 결정 없음 — 계획의 전제로 진행: ① "진행해줘" 를 착수 go 로, S04 머지 뒤 착수 ② 영수증 미달 행은 삭제하지 않고
blocked보존(유저 원본 불변 §23) ③ 재시도 정책은 종전 값 유지(타이머 backoff 신설 없음) ④findCreateReceipt서버 RPC 는 신설하지 않고 포트만 dispatcher 로 옮긴다. - §22(근본 구조): 스토어에 if 문(영수증 검증)과 Set(처리한 영수증)을 붙이는 땜질 대신 책임을 넷으로 나눴다. 17개 콜백을 이벤트 버스로 옮기는 것은 이슈가 "완료가 아니다" 로 못 박았으므로 typed 결과 하나를 받는 port 로 했다.
접근
| 대안 | 판정 |
|---|---|
| A. 책임 넷으로 분리 — Outbox(S02/S04 그대로) · Dispatcher(기기 행만, 단일 sender, 단일 분류기, 재시도 정책 소유) · ReceiptReconciler(대조 표·채택 장부) · feature binding(typed 결과 → 화면). 스토어는 프로젝션·핸들·비행만 | 채택 |
| B. 스토어에 영수증 검증 if 문 + "처리한 영수증 Set" 만 추가 | 기각 — 화면 의존 17개·두 겹 sender·세 벌 분류기가 그대로 남는다(§22 자기 검사 "예") |
| C. 17개 콜백을 이벤트 버스로 | 기각 — 이슈가 명시로 "완료가 아니다" |
D. 전송 루프(retryPendingWorkoutSaves)까지 dispatch/ 로 이사 | 기각 — pendingWorkoutSaves.ts 는 queue 담당(S02→S04→S07) 소유. dispatcher 는 그 루프의 포트를 채우고 결과를 typed 로 바꾸는 층으로 두어 S07 과 같은 파일을 동시에 고치지 않았다 |
4. 적용한 내용
Phase 0 — 결함 재현 (b11281d9)
tests/react/dispatchReconcile.test.mjs(실제 명령 모듈 배선으로 의도 행이 LG_SUCCESSOR_UNPREPARED 격리 · 다른 멱등 키·지문의 영수증이 uploaded · 영수증 없는 응답이 uploaded · 채택 장부 모듈 부재)와 dispatchBoundaries.test.mjs(persistence 의 화면 import·17개 콜백·wait-for-create·retry* 존재) 8건 전부 실패 확인.
Phase 1 — 단일 분류기·단일 sender (4948325d)
persistence/dispatch/failure.ts: 오류 객체(코드·HTTP 상태·상세·메시지·cause 사슬) → G03 신호 →classifyWriteFailure. 코드·메시지 근거가 없는 실패는 일시가 아니라 격리(fail-closed, #924 규칙). 온라인 명령의 옛 4분류는directWriteDispositionOf가 같은 판정에서 옮긴다(인증 실패는 함께 온 전송 근거로).pendingWorkoutMutationFlush.ts: 어댑터 9개 묶음 → typed 포트(workoutWrites= G03WorkoutWritePort,planWrites,resend= S03 조립기, 개정번호 재조회 2). 결과에 실제로 보낸 identity(충돌 재전송이면 새 id·새 지문)를 싣는다.isRevisionConflictError·isTargetMissingError·revisionFromConflictError삭제.directWorkoutWrite.ts:classifyDirectWorkoutWriteError·isolatePendingWorkoutSaveFailure·holdPendingWorkoutSaveFailure가 단일 분류기에 위임.- 스토어의 의도 행 변환을 S03 조립기로 바꿔 결함 ㉥이 이 시점에 수리됐다(
dispatchReconcile① 통과).
Phase 2 — dispatcher·reconciler (6ec72414)
receiptReconciler.ts: 대조 표(owner → receipt 유무 →WIRE_MUTATION_KIND→ 완료 기록의 멱등 키·지문(계획은 repository 가 identity 를 만들어 생략) → sessionId → serverRevision(보낸 기대 개정번호 이상) → 세대(완료 ≥ 1, 계획 0)) + 채택 장부(멱등 키|개정번호|확정 시각64개).dispatcher.ts: 보류 해제 → outbox 루프에 send(단일 sender + 대조, 어긋나면ReceiptRejectedError로 격리 보존)·isolate·hold·prepareSuccessor(S03 조립기)·findCreateReceipt 포트를 채워 돌림 →DispatchOutcome(committed·already_gone·deferred·held·blocked·unknown)·stoppedBy.types.ts에 G03 매핑(receiptOutcomeOf·persistenceStageOf).
Phase 3 — binding·스토어 축소·배선·죽은 코드 (6ec72414)
features/completed-workout/pendingSavesFeatureBinding.ts:applyDispatchResult하나 — 확정 사본(줄 번호표 채택)·무효화·배경 재조회·통계 재검증·안내·연결 상태 강등. 중복(duplicate)은 0회,unknown은 상세·목록 무효화 + 배경 재조회 + 텔레메트리(확정 0), 다른 owner 행은 걸러냄.features/completed-workout/pendingSavesDispatchPorts.ts:BarbelicApi+ 10초 타임아웃 ·workoutPlanCommands· S03 조립기 · 개정번호 재조회.- 스토어: env =
{ isCurrentRemoteUser, dispatch: { sender }, features }셋. 답장 핸들에already_gone·unknown{rejection}추가. 복구 결과를 typed(recovered|not_found|stale_owner|failed)로 돌려주고 안내 문구는 컨트롤러가 고른다.BarbelicApi·barbelicViewMappers·completedSessionReceiptIdentityimport 0. workoutWriteController:queueCompletedSessionEdit의 답장 대기 루프와deleteUiSession의wait-for-create분기 제거(S04 뒤 도달 불가).completedWorkoutCommands:retryUpdate·retryDelete·save({mode:"retry"})제거.appController: 대기열 훅에completedWorkoutCommands를 넘기지 않는다.- 테스트: 하네스
tests/support/pendingSavesHarness.mjs(스토어 + 포트 + 실제 binding) · 영수증 v2 픽스처tests/support/receipts.mjs· 기존 12개 파일 이전(completedWorkoutReceiptApply·pendingWorkoutOutboxStates·smallStores·pendingQueueHeadPoison·outboxClaimSuccessor⑦·outboxAtomicReplace⑥·faultInjectionTools·completedSessionRecoveryScenarios·workoutWriteSurvival·calendarReadModelMapper·controllerBoundaries·mobilePostSaveReadModelRegression·issue928Cleanup·rpcContractsAndConventions·completedWorkoutCommands).
Phase 4 — 시나리오·문서 (같은 PR)
tests/react/dispatchScenarios.test.mjs: ① 같은 생성 명령이 오프라인 → 일시 실패(연결 강등 1) → 인증 만료(보류) → 답장 유실(대기 유지) → 성공(같은 id 4회 전송, 확정·재검증·피드·안내 각 1회), 영구 거부(42501+503)는 격리·안내 1회 ② owner·receipt·kind·id·hash·serverRevision·세대·sessionId 거부 + 계획 행의 identity 생략 + unknown 은 상세 강제 조회 1·목록 무효화 1·배경 재조회 1·확정 0 ③ 중복 영수증 2회 → 확정·재검증·피드·안내 각 1회, 다른 owner 결과 → 캐시 0 ④ 수정 행 LG409 → 새 id·새 지문·최신 개정번호로 재전송, 영수증이 새 id 와 대조돼 확정 사본(rev 8). 계약 신설 + 4곳 갱신, G05 규칙 1줄·장부, 이 기록·등록 2곳.
주요 결정과 그 근거
- 영수증 미달 행은
blocked보존, 결과는unknown: 같은 id 를 다시 보내면 서버가 같은 영수증을 재생해 무한 반복이 된다. 지우면 유저 원본 유실(§23). 격리 배지로 사용자가 보게 두고 화면은 상세 조회로 수렴한다. - 계획 행은 멱등 키·지문 대조 생략:
planRepository.savePlan이 DTO 에서 identity 를 만들어 행은 값을 모른다(sent.repositoryDerived). repository 가 이미 응답을 대조하므로 종류·sessionId·개정번호·세대만 본다. - 근거 없는 실패는 격리(fail-closed): 종전
classifyDirectWorkoutWriteError도 코드·메시지 근거가 없으면blocked였다. 대기열 머리에서 무한히 재시도하며 뒤 행을 막는 것(#924 머리 포이즌)보다 낫다. - 온라인 명령의 인증 실패는 함께 온 전송 근거로 판정: 종전 4분류에는 "보류" 가 없어 401+오프라인 →
offline, 401+5xx →retryable, 401 단독 →blocked였다(workoutWriteSurvival고정). 대기열 쪽은 G03 대로hold. - 전송 루프는 옮기지 않음: queue 담당(S07) 파일이므로 포트만 채웠다.
작업 중 드러난 것
- S04 의도 행 포트 결함(위 §2) — 계획 단계 코드 읽기에서 발견, Phase 0 에서 실제 배선으로 재현, Phase 1 에서 수리. S04 의 스토어 배선 테스트(
outboxClaimSuccessor⑦)는prepareSave를 mock 에 넣어 통과하고 있었다 → 실제 조립기를 쓰도록 고쳤다. - python 으로 고친 파일이 CRLF 가 됐다(Windows 텍스트 모드) →
npm run check의 줄바꿈 게이트(G02)가 18개 파일을 잡았고node scripts/normalize-line-endings.mjs로 되돌렸다. 이후 편집은newline=""로. RESPONSE_LOST(테스트 도구의 답장 유실 오류)는 실제 브라우저 오류 모양이 아니다 — 코드가 표에 없어 단일 분류기는 격리로 본다(종전 판정도 같았다). 시나리오 테스트는 실제 모양(TypeError: Failed to fetch+ECONNRESET)으로 답장 유실을 냈다.- 기존 테스트 12개 파일이 스토어 내부 문자열·
completedWorkoutCommandsmock 을 고정하고 있어 하네스로 옮겼다. manifest 등재 2개는pending-changes.json신고. rpcContractsAndConventionsWRITE-01 이 옛 분류기 본문(code === "40001" … "LG409")을 문자열로 고정하고 있었다 → G03 참조 구현 + 위임 문장으로 재조준.
Phase 5 — 검증·PR·랜딩
npm run ci:local -- --full(head7e9487ca) 17분 34초: db reset(마이그레이션 전체 적용)·schema.sql 스냅샷--check·pgTAP 117파일/2036 assert·동시 저장 세대·영수증 검사·e2e-local 11/11·empty 7/7·cardio 6/6·persistence 15/15·browser 39/39·viewport 14/14 전부 통과. verify 만check:unused1건 —deleteUiSession의 미사용setPlans(wait-for-create분기 제거의 잔재,npm run check에는 없는 게이트라 로컬 check 로는 안 잡혔다) → 1줄 제거(f6dd823a) 뒤ci:local --verify-only8단계 통과(1분 39초).- PR #1374 → CI 1회 통과 → 머지
73d6f84b→ staging Deploy run 34147128411 성공(database·functions·frontend·smoke 전부 success, release tag 는 production 전용이라 skipped). 랜딩 큐 대상 아님(마이그레이션·package.json 없음). - 사전 검증 누락: 없음 —
check:unused는npm run check밖의 게이트이고 PR 전ci:local이 잡았다.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 새 기록 전송 중 수정·삭제(의도 행) | 실제 배선에서 항상 LG_SUCCESSOR_UNPREPARED 격리 | 생성 영수증 뒤 서버 id 요청으로 이어져 전송(dispatchReconcile ①·outboxClaimSuccessor ⑦) |
| 다른 요청의 영수증·영수증 없는 응답 | 성공으로 채택, 행 삭제 | 거부 7종(owner·receipt·kind·id·hash·serverRevision·세대·sessionId) → 행 격리 보존 + unknown + 상세 조회 1회, 확정 0 |
| 같은 영수증 2회 | 확정 사본·재조회·통계 재검증·안내 2회 | 각 1회(채택 장부) |
| 다른 owner 행의 결과 | (owner 세대 검사만) | reconciler 거부 + binding 필터 → 캐시 변경 0 |
| 저장 모듈의 화면 import | barbelicViewMappers·BarbelicApi·completedSessionReceiptIdentity·env 콜백 17 | 0 (dispatchBoundaries) |
| 실패 판정 구현 | 3벌 | 1(dispatch/failure.ts, G03 채택) |
| sender | 2겹(flush → commands.retry* → API) | 1(sendDurableMutation → 포트) |
| 재시도 정책 소유 | 스토어·전송기·컨트롤러에 분산 | dispatcher(stoppedBy) + binding(연결 강등) — 계약 §3 |
| 죽은 코드 | wait-for-create 분기 2, retryUpdate·retryDelete·mode "retry" | 0 |
pendingWorkoutSavesStore.ts | 655줄 | 약 300줄(프로젝션·핸들·비행) |
| 서버·저장 형식 | — | 무변경(기기 행 blockedCode 값 2개 추가) |
- 자동 검증:
npm run check통과(단위 3,108 통과·건너뜀 27·실패 0 — ci:local verify) · 시나리오 4/4 · 결함 재현 4/4 · 경계 4/4 ·ci:local --full전 레인 통과(위 Phase 5) · CI 1회 통과 · 머지73d6f84b - 미검증: 실제 두 탭에서 같은 영수증이 늦게 도착하는 경우의 중복 방지는 메모리 하네스(주입된 전송 루프)로만 확인했다 — S04 의 실제 Chromium 두 탭 스위트(
outboxClaim.browser.mjs)는 행 상태를 검증하며 화면 반영 횟수는 세지 않는다. 격리 배지의LG_RECEIPT_*원인 문구는 U02/U03 몫(지금은 기본 문구).
6. 이번 개선으로 향상된 것
새 기록을 보내는 중에 고쳐도 "동기화 실패" 가 되지 않는다
S04 가 약속한 의도 행 변환이 실제 배선에서 동작한다. 유저는 새 기록을 저장하자마자 고쳐도 서버 id 로 이어진 수정이 올라간다.
잘못된 답장이 화면을 바꾸지 못한다
영수증이 이 행의 것일 때만 확정으로 그린다. 없거나 어긋나면 "결과 미상" 으로 두고 상세를 다시 읽는다. 같은 영수증이 두 번 와도 화면 갱신·안내는 한 번이다.
저장 모듈이 화면을 모른다
달력·프로필·피드·토스트·편집기 함수는 feature binding 이 갖고, 저장 모듈은 typed 결과만 낸다. A09(화면 재편)·S06(충돌 영속화)·S07(index) 가 각자의 층만 만지면 된다.
구조적으로 남는 것
DispatchOutcome/DispatchResult/FeatureInvalidationPort(계약 §5) — 화면 쪽 재편(A09)의 접점.- 영수증 대조 표(계약 §4)와 채택 장부 — 앞으로 영수증 필드가 늘면 표에 한 줄.
- 단일 분류기(계약 §3) — 새 오류 코드는 G03 표 한 곳.
- 단일 sender 포트 — S06 이 충돌 재전송 영속화를 이 앞에 붙인다.
- persistence 경계 테스트(
dispatchBoundaries)와 하네스(pendingSavesHarness) — 대기열 테스트가 화면 콜백 mock 없이 실제 binding 을 거친다.
남은 것
- 릴리스 v0.18.0 머지 뒤 이슈
[v0.18.0 반영완료]+ 닫기. 총괄 S05 카드 상태 갱신(HQ 몫 — 이슈 마지막 댓글에 갱신안). - S06: 충돌 재전송을 후속 행으로 영속화(단일 sender 의 충돌 분기 앞). A09:
PendingSavesFeatureEnv13개를 화면 재편에 맞게 좁히기. S07:LG_RECEIPT_*격리 코드의 index 보존. U02/U03: 격리 배지 원인 문구. 서버 트랙:findCreateReceiptRPC. - 범위 밖 잔재:
completedWorkoutCommands.save({mode:"create"|"edit"})·delete는 제품 호출처가 없다(테스트만).queuePendingWorkoutMutation결과 union 의wait-for-create멤버(queue 담당 파일).