Skip to content

저장 요청 조립을 예외 없는 순수 함수로, 요청 identity 정의 하나로 — v0.18.0 S03 (2026-09-07)

  • 기간: 2026-09-07 (세션 2개 — 7550d3b6 Phase 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. 지문 정의는 S01 pending-save-codec.md §3.
  • 도구: 없음(기존 check-coverage-inventory --render 만).
  • 게이트: 새 행동 테스트 2파일 10건(completedSessionPrepare 6 · planPrepare 4), 컴파일 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 1identity 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: SavePreparationError 5종(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. 입력 검사는 A02 encodeCompletedSession* 재사용.
  • 테스트 completedSessionPrepare.test.mjs·컴파일 fixture savePreparation.fixture.ts, 장부 규칙 2줄 + --render.

Phase 2 — 계획 조립기와 옛 계획 행 (4331254c)

  • features/plan/commands/planWriteDto.ts: plannedSessionWriteDtoworkoutPlanCommands.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/멱등 키 Map 2개 제거. 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_modules junction이 사라져 있어 check:ts-boundary-gatetypescript 패키지를 못 찾았다(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 check Phase 마다 통과(단위 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 스테이징]).