저장 요청 조립을 예외 없는 순수 함수로, 요청 identity 정의 하나로 — v0.18.0 S03 (2026-09-07)
- 기간: 2026-09-07 (세션 2개 —
7550d3b6Phase 0~4 구현·문서,0c16be72이어받아 main 리베이스·ci:local·PR·머지). 오너 지시 "#1333 진행해주고, 혹시 선행작업 안끝났으면 끝나고 진행해줘". 계획 ID S03 / Phase 2 Step 2-2. - 랜딩: PR #1363 squash 머지
1bba53eb(2026-09-08 00:30 KST, staging Deploy run 34135983844 전 단계 초록: database·functions·frontend·smoke) — 마이그레이션·엣지 함수·Vercel 설정 변경 없음. 앱 코드(조립기 신설·전송 명령 재구성·컨트롤러 호출부 이식)와 테스트·문서만. - 설계서: 없음 — 착수 분석과 Phase 계획은 #1333 착수 댓글("예상 효과·개선사항" 표 포함).
- 정본:
docs/contracts/save-preparation.md·src/react/features/workout/commands/{mutationIdentity,preparationError,writeSource,prepareCompletedSession}.ts·src/react/features/plan/commands/{planWriteDto,preparePlan}.ts· 전송 명령src/react/features/completed-workout/completedWorkoutCommands.ts. 지문 정의는 S01pending-save-codec.md§3. - 도구: 없음(기존
check-coverage-inventory --render만). - 게이트: 새 행동 테스트 2파일 10건(
completedSessionPrepare6 ·planPrepare4), 컴파일 fixture 1파일(savePreparation.fixture.ts, 금지 조합 7줄), 호출부 cutover 테스트 5파일 fixture 현실화, 앵커 테스트 2파일 재조준(pending-changes.json신고). 마이그레이션·pgTAP 변경 없음. e2e 는npm run ci:local -- --full로 로컬 완주(§5). - 버그리포트: 없음(구조 개선).
- 계약: 신설 save-preparation.md. 갱신 pending-save-codec.md §2(새 계획 행 본문)·§3(S03 적용)·§6(인계 완료), v0-18-0-domain-contracts.md §21(G03 → S03).
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 선행(S01·A02) 머지 확인, 분석·계획 게시 | ✅ 이슈 댓글 |
| Phase 1 | identity primitive(버전 1)·준비 오류 5종·쓰기 원천 validator·완료 기록 생성/수정/삭제 조립기 | ✅ 5b569d9c |
| Phase 2 | 계획 수정·삭제 조립기(새 행에 identity)·옛 계획 행 준비·plannedSessionWriteDto 이동 | ✅ 4331254c |
| Phase 3 | 컨트롤러 호출 6곳 이식, 전송 명령 "조립 → transport", 예외 기반 prepare·identity Map 제거 | ✅ db276a2e |
| Phase 4 | 계약 문서·장부·작업 기록·ci:local·PR | ✅ PR #1363 → 1bba53eb |
1. 배경
v0.18.0 Phase 2는 "안전한 저장"을 완성한다. S01(#1285)이 기기 대기열 행의 codec과 통합 지문 정의를, A02(#1329)가 완료 기록·계획의 쓰기 codec·저장소를 자기 도메인 파일로 옮겨 두었다. 그 사이에 남은 것이 "서버에 보낼 요청을 만드는 자리"였다 — G03 계약 §12가 prepare(순수 조립) → DurableCommand → PreparedMutation 흐름을 선언했지만 실제 조립기는 없었고, S01의 toPreparedMutation이 옛 계획 행을 needs_preparation으로 돌려주는데 그것을 처리할 자리도 없었다. S05(dispatcher)·A12/A13(편집기)이 이 조립기를 소비한다.
2. 문제 제기
요청을 만들려면 전송을 실패시켜야 했다 (F15)
유저 A가 어제 기록의 메모를 고치고 [메인으로]를 누르면 앱은 서버에 보낼 요청 하나를 만들어 기기 대기열에 적는다. 이 조립 코드(completedWorkoutCommands.prepareSave)는 전송 함수 save()를 offline: true로 불러 "오프라인이라 못 보낸다"는 오류를 던지게 하고, 그 오류를 잡아 안에 실린 요청을 꺼냈다. 삭제(prepareDelete)도 같았다. 정상 조립에 예외 객체 생성·throw·catch가 필요했고, 조립 중에 브라우저 온라인 여부(navigator.onLine)까지 읽었다.
입력 오류와 기기 저장 실패가 같은 통로로 왔다
"세션 id 없음"(입력 오류)도 "기기 저장소 꽉 참"(적재 실패)도 그냥 Error였다. 호출부는 문구로 구분했다. 내용 검사(A02 codec)는 전송 시점에만 돌아, 잘못된 입력은 일단 대기열에 들어갔다가 서버 거부 뒤 "동기화 실패" 격리 행이 됐다.
요청 지문 정의가 둘이었고 계획 행은 지문 없이 적혔다
완료 기록은 정의 A({mutationKind, payload, sourceRef}), 계획은 정의 B({contractVersion, kind, userId, sourceRef, payload}). 계획 수정·삭제 행 본문에는 requestHash·(수정은) sourceRef·expectedRevision이 없고 전송 시점에 저장소가 identity를 만들었다. 수정·삭제의 멱등 키를 위해 모듈 메모리 Map 두 개가 "같은 내용이면 같은 키"를 흉내 냈다.
표시용 사본으로 수정 요청을 만드는 것을 타입이 막지 못했다
컨트롤러가 writeSource === "detail"|"receipt" 문자열을 검사할 뿐, 조립기 입력은 sessionId·sourceRef·expectedRevision 값이라 어느 사본에서 왔는지 타입에 남지 않았다(#1143 재발 방지 구조가 문자열 한 줄에 의존).
어떤 구조라서 가능했나: "요청을 만드는 일"과 "서버에 보내는 일"이 한 함수 안에 있어서, 만들기만 하려면 보내기를 실패시켜야 했다.
3. 해결 방안
원칙
- 오너 결정 없음 — 전제 10개로 진행(착수 댓글 §2): 지문은 S01 통합 정의 하나(내용 기준은 종전과 같고 봉투만 더해짐), 저장된 행은 재계산 0, 멱등 키는 생성=초안 operationId·수정/삭제/계획=지문 파생, 새 계획 행은 identity 4필드를 갖고 적힘, 옛 계획 행은 호출자가 넣은 canonical base로만 준비, 신규 계획 생성은 온라인 그대로, 수정 원천은
WritableCopyRole타입+validator, 준비 오류와 적재 오류는 다른 union, primitive는 버전 명시, 파일 위치는 총괄 소유 경계, S04/S05/S07 소유 파일은 수정하지 않음. - §22(근본 구조):
dryRun플래그를 더하는 땜질 대신 조립을 순수 함수로 분리했다 — 네트워크·저장소를 받지 않으니 부를 수가 없다.
접근
| 안 | 내용 | 판단 |
|---|---|---|
| 땜질 | save()에 dryRun 플래그 | 기각 — 한 함수가 두 일을 하는 구조 그대로, 지문·계획 행·사본 타입 문제 미해결 |
| 채택 | 순수 조립기 6종(Result<PreparedMutation, 준비 오류>) + 전송 명령은 "조립 → transport" 두 줄 + 컨트롤러가 조립기를 직접 호출 | 채택 |
| 검토 | 조립 시점에 codec 내용 검사를 생략(전송 시점만) | 기각 — 잘못된 입력이 대기열에 들어가 격리 행이 되는 F15 시대 동작이 남는다 |
4. 적용한 내용
Phase 1 — 완료 기록 조립기 (5b569d9c)
features/workout/commands/mutationIdentity.ts:MUTATION_IDENTITY_VERSION(= 지문 계약 버전 1),requestHashOf·clientMutationIdFromHash·randomClientMutationId·시계를 주입하는PreparationDeps.clientMutationIdFromOperationId는 순수(uuid 아니면 null).preparationError.ts:SavePreparationError5종(invalid_input·write_base_not_writable·revision_missing·identity_unavailable·needs_canonical_base), codec throw →invalid_input변환(한도 코드 보존), 예외 호출부용 봉투SavePreparationFailure(.code).writeSource.ts:CompletedSessionUpdateBase{writeSource: WritableCopyRole}+requireWritableUpdateBase(표시용·꼬리표 없음 거부) +requireExistingTarget(uuid·출처·양의 개정번호).prepareCompletedSession.ts:prepareCompletedSessionCreate/Update/Delete. 입력 검사는 A02encodeCompletedSession*재사용.- 테스트
completedSessionPrepare.test.mjs·컴파일 fixturesavePreparation.fixture.ts, 장부 규칙 2줄 +--render.
Phase 2 — 계획 조립기와 옛 계획 행 (4331254c)
features/plan/commands/planWriteDto.ts:plannedSessionWriteDto를workoutPlanCommands.ts에서 그대로 옮기고 옛 파일은 import·재수출(전송 명령과 순수 조립기가 같은 변환 위에서 지문을 계산).preparePlan.ts:preparePlanEdit·preparePlanDelete(저장소 전송 시점과 같은 codec payload·통합 지문 → 같은 identity),prepareLegacyPlanRow(옛 행: 원문 불변, base 없으면needs_canonical_base, 행 id 유지).- 테스트
planPrepare.test.mjs.
Phase 3 — 소비자 이식 (db276a2e)
workoutWriteController: 생성·수정·삭제 2·계획 수정·계획 삭제 호출부가 조립기의 typed 결과를 쓴다(SavePreparationFailure로 기존 안내 문구 경로 유지).CompletedSessionWriteBase.writeSource를 조립기까지 전달. 대기 중 생성의 수정·삭제는 운반 요청(prepareCompletedSessionPendingEdit/Delete)으로 — 종전requestHash: ""·무작위 키 자리표시자 제거. 생성 행 합치기 identity는mergedCreateIdentity주입.completedWorkoutCommands:prepareSave/prepareDelete/offline/onPrepared/멱등 키Map2개 제거.save/delete= 조립 → transport. 충돌 재전송은prepareCompletedSessionUpdateResend(서버 줄 번호표가 남아 있으면 거부).- 테스트: 명부 밖 5파일 fixture를 실제 저장 가능한 모양(uuid id·종목 1개 이상)으로, 앵커 2파일 재조준 +
pending-changes.json신고.
Phase 4 — 문서·검증 (PR #1363)
- 계약 save-preparation.md 신설, pending-save-codec §2·§3·§6, 도메인 계약 §21, 총괄 S03 카드, 이 기록 + 등록 2곳.
주요 결정과 그 근거
- 조립 시점에 codec 내용 검사를 돌린다. 이슈 수락 조건 "입력 오류는 구별 가능한 결과로 반환"의 핵심이고, 잘못된 입력이 대기열에 들어가지 않게 하는 구조적 자리다. 비용은 조립·전송 두 번 검사(순수 계산)와 테스트 fixture 현실화.
- 수정·삭제의 멱등 키는 지문에서 결정적으로. 메모리
Map없이 "같은 내용 = 같은 키"가 성립하고, 내용이 다르면 키도 달라LG001(같은 키에 다른 내용) 충돌이 구조적으로 나지 않는다. 생성은 초안operationId가 소유(쓰기 파이프라인 §10 그대로). - 옛 계획 행은 행에 identity를 되쓰지 않는다. S01 불변 묶음(
request)이 금지한다. 전송 때마다 메모리에서 준비하고 행 id를 유지한다. - 대기 중 생성의 수정·삭제는
DurableCommand가 아니다. 서버 세션이 없으므로 운반 요청으로 조립하고 대기열의 합치기 규칙(S02)이 처리한다 — 타입이 "서버 기록의 수정"과 "대기 중 초안의 수정"을 구분한다.
작업 중 드러난 것
- A02 codec은 종목 카탈로그(메모리
S.exerciseById)를 읽어 기록 규격을 판정한다 — port 호출이 아니며 조립기가 그대로 쓴다(계약 §3에 명시). - 종전
save({mode:"create"})는 모듈Map으로 같은 내용의 재시도에 같은 키를 주었다. 지금은 초안operationId가 identity를 소유하므로 재시도는 같은 operationId를 넘겨야 같은 키가 된다 — 제품 코드의 호출자는 모두 그렇게 하고 있었고, 테스트 2건만 operationId를 명시하도록 고쳤다. - 옛 계획 삭제 행(fixture)은 본문에 출처·개정번호가 있고 지문만 없어 base 없이도 준비된다(수정 행만 base 필요).
- 비현실 fixture(종목 0개·
"user-a"·"session-1")를 쓰던 테스트 5파일이 조립 시점 검사에 걸렸다 — 실제 저장 가능한 모양으로 고쳤다(테스트 의도 불변). - Bash 히어독 안의 JS 템플릿 문자열은 깨진다(메모리 함정 재확인) — 치환 스크립트는 Write 로 파일에 쓰고
node file.cjs. 백슬래시(\\.md)도 같은 이유로 깨진다. - 첫 세션의
ci:local --full은 브라우저 e2e 직전에 포트 4173 점유(다른 세션의 미리보기)로 코드 3 종료했다. 이어받은 세션이 main(B02 #1356·D02 #1352) 위로 리베이스한 뒤 다시 돌렸다 — 충돌 3파일(장부 규칙 파일·장부 문서·vitepress 사이드바)은 main 쪽을 받고 S03 규칙 2줄을 다시 넣은 뒤--render로 재생성. - 사전 검증 누락(첫 세션): 계약 문서
save-preparation.md가 장부 규칙에 없어check-coverage-inventory가 미분류로 실패했다 — Phase 4에서 문서를 만든 뒤 장부 검사를 다시 돌리지 않았다. 리베이스 때 발견해 규칙(durable문서)을 추가했다. main에는 아무 파일에도 안 걸리는 규칙 6개(삭제된contracts/package.json등)가 남아 있다 — 이 트랙 밖이라 손대지 않았다. - 워크트리의
node_modulesjunction이 사라져 있어check:ts-boundary-gate가typescript패키지를 못 찾았다(ci:local이 verify 실패를 기록하고도 다음 단계로 진행한다). junction을 다시 만들고 처음부터 재실행했다.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 정상 조립의 의도적 throw / 환경 읽기 | 2곳(prepareSave·prepareDelete) / navigator.onLine 1곳 | 0 / 0 (부르면 던지는 전역 아래 테스트) |
| 입력 오류의 표현 | Error 문구 | SavePreparationError 5종(코드 보존), 적재 오류(OutboxEnqueueError)와 다른 union(컴파일 fixture) |
| 잘못된 입력의 행방 | 대기열 적재 → 서버 거부 → 격리 행 | 조립 시점 typed 오류, 대기열 적재 0 |
| 앱 안 요청 지문 정의(새 명령 기준) | 2(A·B) | 1(통합). 정의 A 는 대기열 기본 발급기에만 잔존(주입 시 미사용, S05 제거) |
| 저장된 행의 identity 재계산 | — | 0 (fixture 12행 + 새 행 재생 테스트) |
| 수정·삭제 멱등 키 | 무작위 uuid + 메모리 Map 2개 | 지문 파생, Map 0 |
| 계획 수정·삭제 행의 identity | 전송 시점 발급(본문에 없음 → needs_preparation) | 조립 시점, 본문에 4/2필드 → prepared. 옛 행은 prepareLegacyPlanRow(base 없으면 needs_canonical_base) |
| 표시용 사본으로 수정 조립 | 컨트롤러 문자열 검사 1곳 | 타입(WritableCopyRole) + validator + 컴파일 fixture |
| 대기 중 생성 삭제의 자리표시자 | requestHash: ""·무작위 키 | 운반 요청(내용 지문·파생 키) |
| 테스트 | — | 새 10건 + 컴파일 fixture, npm run check 3,040건 통과 |
- 자동 검증:
npm run checkPhase 마다 통과(단위 3,040건·정적 게이트 전부).검증: ci:local full · verify 실패 (check:artifacts 에서 실패) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 117파일/2036 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-persistence 통과 11/11 · e2e-browser 통과 38/38 · e2e-viewport 통과 14/14 · 13분 24초(ba5ce868, verify 실패는 ci:local 도구가 관리자 셸 빌드에 local 대상을 새게 한 main 쪽 결함 — #1362 로 main 에서 수리됨) → #1362 위로 리베이스 뒤검증: ci:local verify-only · verify 통과 (8단계) · 1분 40초. CI 1회(PR #1363). - 미검증: Production 에서 새 지문(정의 B)으로 만든 완료 기록 요청의 첫 실제 전송 — 서버는 자기 지문으로 멱등 비교하고 앱 지문은 되돌려 대조만 하므로(S01 §3) 동작 차이는 없다고 판단했고, 브라우저 e2e(저장·수정·삭제 저니)가 로컬 스택에서 같은 경로를 지났다. 옛 계획 행(
needs_preparation)의 실제 전송에prepareLegacyPlanRow를 연결하는 것은 S05 몫이라 지금 전송기는 종전대로 전송 시점 발급을 쓴다.
6. 이번 개선으로 향상된 것
잘못된 입력이 대기열에 들어가지 않는다
유저가 저장을 누르는 순간 한도 초과·형식 오류가 원인 문구로 바로 보인다. 전에는 "기기에 보관했어요" 뒤 서버 거부로 "동기화 실패" 배지가 떴다.
조립기가 재사용 가능한 부품이 됐다
S05 dispatcher·A12/A13 편집기가 같은 6개 함수를 부른다. 결정적(고정 id·시계에 같은 출력)이라 fixture 로 대조할 수 있다.
요청 identity 규칙이 코드 한 곳·버전 하나다
MUTATION_IDENTITY_VERSION·지문 계약 버전이 짝이고, 저장된 행은 손대지 않는다는 규칙이 재생 테스트로 고정됐다.
구조적으로 남는 것
- 계약 save-preparation.md(결과·오류·identity 규칙·인계).
- 컴파일 fixture(표시용 사본 원천·준비/적재 오류 대입·명령 kind 교차·durable 계획 생성 금지).
- 옛 계획 행의 준비 경계(
prepareLegacyPlanRow)와needs_canonical_base— "근거 없으면 준비 미완료" 규칙.
남은 것
- S05: 전송 명령의
save({mode:"edit"})·delete()어댑터를 dispatcher 로 대체하고 옛 계획 행 전송에prepareLegacyPlanRow연결,pendingWorkoutSaves.defaultCreateIdentity(정의 A) 제거. - A12/A13: 편집기가
WritableCopy만 수정 원천으로 넘기도록 화면 계약에 반영. - 이슈 #1333 은 v0.18.0 릴리스까지 open(
[v0.18.0 스테이징]).