Skip to content

완료 기록·계획의 쓰기·영수증·상세 변환을 자기 도메인 파일로 — v0.18.0 A02 (2026-09-07)

  • 기간: 2026-09-07 (세션 2개 — 608acb0a Phase 0~3 + Phase 4 착수, 1a632888 Phase 4~5 완주). 오너 지시 "1329 진행해줘" → "1329 이어서 진행해줘 묻지말고 phase 끝까지 완주". 계획 ID A02 / Phase 2 Step 2-1.
  • 랜딩: PR #1354 (머지 해시는 이슈 #1329 종결 댓글에 기록) — 마이그레이션·엣지 함수·Vercel 설정 변경 없음. 앱 코드 자리 이동과 테스트·문서만.
  • 설계서: 없음 — 착수 분석과 Phase 계획은 #1329 댓글 앞의 분석 댓글("예상 효과·개선사항" 표 포함).
  • 정본: src/react/services/domains/repositories/{workout,plan}Repository.ts, src/react/services/domains/codecs/{completedWorkoutWriteCodec,workoutReceiptCodec,planWriteCodec,manualRecordWriteCodec,sessionDetailCodec,planDetailCodec}.ts, src/react/contracts/ports/writes.ts(결과 DTO 5종). 지도: RPC 전송·도메인 추출 경계 §3-1·§4.
  • 도구: 없음(기존 check-coverage-inventory --render·check-dual-key-tolerance --update만 사용).
  • 게이트: 새 행동 테스트 3파일 22건(workoutRepositoryDestination 10 · planRepositoryDestination 8 · sessionDetailCodec 4), 컴파일 fixture 2파일(sessionDetail·workoutPorts), domainPortWiring(codec import 허용), check:dual-key 기준선 253 → 227, check:ts-boundary-gate(옮긴 codec 6파일의 기존 any 등재). 마이그레이션·pgTAP 변경 없음. e2e는 npm run ci:local -- --full로 로컬 완주(§5).
  • 버그리포트: 없음(구조 이동).
  • 계약: G03 §18 확장 절차대로 contracts/ports/writes.ts의 자기 영역 port 반환을 좁혔다(core 계약 버전 1 유지). 쓰기 원천 계약writeSource 꼬리표는 이제 상세 codec만 붙인다.

Phase 현황

Phase내용상태
Phase 0착수 분석·이동 목록·임시 어댑터 장부✅ 이슈 댓글
Phase 1완료 기록 create/update/delete·영수증 검증·초안→행 codec 이동ab646503
Phase 2계획 저장·삭제, 직접 입력 4종 이동bc49df26
Phase 3상세 codec 소유, 사본 역할을 계약 브랜드로, 이중 키 제거e7b07b6e
Phase 4port 반환 타입 좁히기, legacy port를 factory 인스턴스로, 상세 조회 이동83986084
Phase 5전체 검증·문서·장부·인계PR #1354

1. 배경

v0.18.0은 서버가 정규 데이터·확정 통계를, 앱이 입력 중 상태·미전송 쓰기를 책임지도록 경계를 나눈다. Phase 1의 A01(#1288)은 공용 전송(rpcTransport)과 도메인별 목적지 파일 9개를 만들었지만, 완료 기록·계획의 실제 쓰기·검증·영수증·상세 변환 코드는 여전히 barbelicRepository.ts(5,325줄)와 barbelicMappers.ts(4,100줄)에 있었다. 후속 담당(S03 명령 builder, A09 첫 수직 통합, A12/A13 편집기)이 이 코드를 소비하려면 먼저 자기 도메인 파일에 살아야 했다.

2. 문제 제기

한 파일에 여러 도메인이 있어 소유가 나뉘지 않았다

유저 A가 운동을 마치고 저장하면 앱은 ① 초안을 서버 행 모양으로 바꾸고 ② 한도·필드 규칙을 검사하고 ③ save_session_v5를 부르고 ④ 돌아온 영수증을 검증해 서버가 발급한 종목·세트 id를 초안에 붙인다. 이 네 단계가 매퍼(①)·저장소(②③④)에 흩어져 있었고, 같은 파일에 카탈로그·소셜·그룹·통계 함수가 함께 있어 A03~A06이 각자 옮기려면 같은 5천 줄 파일을 동시에 고쳐야 했다.

서버 응답을 여러 층이 각자 해석했다

영수증은 저장소가, 상세는 매퍼가, 표시용 사본은 화면 매퍼가 각각 snake_case를 읽었다. 상세 변환은 set.duration_seconds ?? set.durationSeconds처럼 서버 키와 앱 키 두 이름을 다 받았다(이중 읽기 41곳). 어느 층이 어떤 모양을 만드는지 코드로 알 수 없었다.

"이 사본으로 수정해도 되는가"가 문자열 하나에 걸려 있었다

완료 기록 수정 요청은 상세 응답(writeSource: "detail")이나 영수증 채택 사본("receipt")으로만 만들어야 한다(#1143 재발 방지). 이 검사는 컨트롤러의 문자열 비교뿐이었고, G03이 만든 WritableCopy/PendingOverlay 타입은 아무 변환기도 만들지 않았다.

쓰기 경로를 혼자 실행할 수 없었다

쓰기 함수가 client.rpc를 직접 부르거나 저장소 안의 재시도·기기 사본 삭제를 직접 잡아, 행동 테스트가 전부 저장소 전체를 띄워야 했다.

3. 해결 방안

원칙

오너 결정 필요 항목 없음(이슈 계획 댓글). 전제: 기존 서버 id 발급·online/durable 정책·한도 값·1회 재시도는 그대로 두고 자리만 옮긴다. 이름은 이슈 소유 경계(domains/workout*·domains/plan*)를 따르고 codec은 domains/codecs/ 아래에 둔다.

접근

내용판정
A. 도메인 목적지 + codec 파일로 실제 이동, 공개 이름은 얇은 재수출로 유지쓰기·검증·영수증·상세 codec을 domains/repositories/*·domains/codecs/*로 옮기고 거대 파일은 이름만 재수출. 사본 역할은 G03 브랜드로 표시채택 — 같은 함수 복사본 0, 기존 테스트 12파일의 namespace 호출 유지
B. 파일은 두고 repositoryPorts만 타입으로 좁힌다작업량 작음기각 — 한 파일·이중 읽기·runtime만의 사본 검사가 그대로 남는 땜질
C. 저장 정책·재시도까지 새 dispatcher로 함께 옮긴다한 번에 끝남기각 — S03/S05/S06 소유. 이슈가 중복 구현을 금지

4. 적용한 내용

Phase 1 — 완료 기록 쓰기 3종·영수증·초안→행 codec (ab646503)

  • codecs/workoutReceiptCodec.ts: 영수증 v1/v2 검증 4함수, CompletedWorkoutRpcError·completedWorkoutRpcFailure, 검증 도우미 7개.
  • codecs/completedWorkoutWriteCodec.ts: 초안→행(buildWorkoutRowsFromDraft·completedSessionRowsFromSession + 검사 도우미 14개), 행→서버 payload(encodeCompletedSession{Create,Update,Delete}·validateCompletedWorkoutPayload), WORKOUT_WRITE_LIMITS.
  • repositories/workoutRepository.ts: createCompletedSession·updateCompletedSession·deleteCompletedSession, 계획→완료 전이 대상 조회. factory 의존 = callMutationRpc·callRpc·deleteOwnDataReplica·retryTransientFetch.
  • 새 테스트 workoutRepositoryDestination.test.mjs 10건: 가짜 transport로 factory 독립 실행 — RPC 이름·인자, 같은 요청 재전달 시 인자 동일, 손상·불일치 영수증 8종 거부, 서버 오류 참조 보존, 기기 사본 축출.

Phase 2 — 계획 저장·삭제, 직접 입력 4종 (bc49df26)

  • codecs/planWriteCodec.ts + repositories/planRepository.ts: savePlan(300줄 검증부 분리)·deletePlannedSession. 새 계획은 id 없이 전송해 서버가 발급, 수정은 id+개정번호, 요청 identity는 주입받은 발급기로 전송 시점에 생성(정본화 = S03).
  • codecs/manualRecordWriteCodec.ts + codecs/currentInput.ts: 1RM 직접 입력·기록 지표 4종의 인자 검사, 저장소 안에서만 쓰던 입력 도우미 4개를 한 정의로(Phase 5 의 main 합류에서 A03 정본 services/currentInputContract.ts 로 합쳐 사본 삭제).
  • 새 테스트 planRepositoryDestination.test.mjs 8건: 생성 id 없음·수정 id+개정번호, 개정번호 없는 삭제 거부, 유효한 0/null 구분, 한도 오류 코드 5종, 기기 사본 축출, 영수증 불일치 거부.

Phase 3 — 상세 codec 소유·사본 역할 타입 (e7b07b6e)

  • codecs/sessionDetailCodec.ts·planDetailCodec.ts: 서버 상세 행 → 앱 사본. 서버 키(snake_case)만 읽는다 — camelCase 이중 읽기 41곳 제거. 상세 결과는 WritableCopy(writeSource detail)로 표시하고, 컨트롤러 isWriteCapableCompletedSession은 문자열 비교 대신 codec의 isWritableCompletedSessionCopy를 쓴다(역할은 만든 쪽만 정한다).
  • 새 테스트 sessionDetailCodec.test.mjs 4건: 서버 상세 행 → 사본 → 편집 입력 → v5 요청 → 영수증 자식 id 채택 왕복(id·단위·null·유효한 0 보존), camelCase만 있는 값은 읽지 않음, 표시용 사본·개정번호 0 거부. 컴파일 fixture sessionDetail.fixture.ts: 브랜드 없는 화면 사본과 PendingOverlay는 수정 요청 base에 못 들어감.

Phase 4 — port 좁히기·factory 인스턴스 연결·상세 조회 이동 (83986084)

  • contracts/ports/writes.ts: WorkoutWritePort(3)·PlanWritePort(2)·ManualRecordPort.loadManualRecords의 반환 unknownCompletedSessionWriteResult·CompletedSessionDeleteResult·PlanSaveResult·PlanDeleteResult·ManualRecordsDto. 직접 입력 저장·삭제 4종은 서버 행을 검증 없이 통과시키는 현행 동작이라 unknown 유지(화면은 반환값을 읽지 않고 목록을 다시 조회한다 — 응답 codec은 A09).
  • repositoryPorts.workoutRepositoryPort: 멤버 10개가 조립된 factory 인스턴스(Legacy.workoutRepository·Legacy.planRepository)를 가리킨다. legacy 참조는 processCurrentUserStatsRefresh 하나(A06 이식).
  • loadSessionDetailRows(완료 기록 상세 조회)를 workout factory로. stats port는 같은 참조를 가리키므로 statsDomain은 그대로.
  • 컴파일 fixture workoutPorts.fixture.ts: 결과 DTO에서 영수증·자식 id 표·개정번호를 좁히기 없이 읽고, unknown으로 넓힌 옛 모양은 port를 만족하지 못한다.
  • 소스 앵커 테스트 2곳(workoutFlowDraftSafety·onDemandDataLoading)이 저장소 본문에서 옮겨 간 상세 조회를 찾아 깨져, factory를 가짜 transport로 실행해 get_session_detail 한 번·원래 신호 전달을 확인하는 행동 검사로 전환(명부 등재분 2건 pending-changes.json 신고).

Phase 5 — 검증·문서·장부 (PR #1354)

  • 추출 지도 §3-1(A02 이전 결과 표)·§4(임시 어댑터 장부 4행) 갱신, coverage 규칙 4줄(codec 6파일·새 테스트 3파일) + workout/plan repository 장부 행 증거 갱신 + --render.
  • 이 문서, 사이드바·README 등록, 총괄 A02 카드 연결.

주요 결정과 그 근거

  • 공개 이름은 지우지 않고 재수출로 남긴다. 기존 테스트 12파일이 BarbelicRepository.createCompletedSession(client, …)처럼 namespace로 부른다. 본문 없이 이름만 남기면 깨지지 않고, 제거 담당(A01/R01·A16)은 장부에 적었다.
  • 직접 입력 4종의 반환은 좁히지 않았다. 지금 서버 행을 검증 없이 돌려주므로 타입만 붙이면 거짓 확신이 된다. 응답 codec을 만드는 자리는 A09.
  • 재시도는 주입으로 남긴다. 계획 저장·삭제와 완료 기록 삭제의 1회 재시도(retryTransientFetch)는 옮기기 전 동작이다. S05 dispatcher 통합 때 주입을 뺀다.
  • codec은 domains/codecs/에, 소유 경계는 파일 단위로. 이슈 소유 경계는 domains/workout*·domains/plan*인데 codec 6개는 두 도메인이 나눠 쓰므로(영수증 codec은 그룹 보드 저장 A04도 씀) 한 폴더에 두고 coverage 규칙으로 영역을 배정했다.

작업 중 드러난 것

  • 소스 문장을 읽던 테스트 7곳이 자리 이동으로 깨졌다 (Phase 1: perceivedRpeRoundtrip·ownDataReplica·exerciseRefInventory, Phase 2: ownDataReplica 계획·limitsRegistry·completedWorkoutWriteBoundary, Phase 3: completedSessionWriteSource·statsCentralizationPhase2, Phase 4: workoutFlowDraftSafety·onDemandDataLoading). 전부 factory·codec을 실행하는 행동 검사로 바꿨고 명부 등재분은 신고했다.
  • dual-key 게이트 정규식이 row.end_time || S.formatTime(…)을 오탐해서 상세 codec은 이름 import로 피했다.
  • 카탈로그 조회 인벤토리 게이트는 S.exerciseById( 모양만 센다 — codec에서도 namespace 형태를 유지했다.
  • 사본을 { ...copy }로 펼치면 타입 브랜드도 따라간다(runtime writeSource도 따라가므로 일관). fixture의 부정 사례는 declare const로 브랜드 없는 값을 만들었다.
  • A03(#1330)과 같은 도우미를 각자 추출했다. 공통 입력 검사기 4개(currentInputRecord 등)와 workoutMutationContractError를 A02는 domains/codecs/에, A03은 services/currentInputContract.ts·completedWorkoutWriteContract.ts에 뽑아 두 복사본이 됐다. main 합류(A03이 먼저 머지)에서 A03 쪽을 정본으로 두고 A02 사본(codecs/currentInput.ts, 영수증 codec의 지역 함수)을 지웠다. 거대 파일 충돌 5곳은 양쪽이 서로 다른 본문을 지운 것이라 둘 다 삭제로 해소.
  • 이전 세션의 실수 — Phase 2 커밋에 completedWorkoutWriteBoundary.test.mjs.bak이 딸려 들어갔다. Phase 4에서 제거.
  • 세션 교대 — 첫 세션이 Phase 4 도중 사용량 한도로 멈췄고(인수인계 댓글 없음), 두 번째 세션이 같은 워크트리의 미커밋 변경을 검토해 이어받았다. 이슈에 이어받기 댓글·세션 ID를 남겼다.

5. 적용 결과

항목전 → 후
barbelicRepository.ts 줄 수5,325 → 3,802 (−1,523)
barbelicMappers.ts 줄 수4,100 → 3,124 (−976)
도메인 목적지·codec 파일workout/plan factory 2파일(43줄) → 8파일 약 2,900줄(같은 함수 복사본 0 — A03 정본과 겹친 도우미는 합류 때 제거)
workout/plan 쓰기 경로의 factory 독립 행동 테스트0 → 22건(3파일) + 컴파일 fixture 2파일
상세 codec의 snake/camel 이중 읽기41곳 → 0 (check:dual-key 매퍼 기준선 253 → 227, 새 파일 0)
"표시용 사본으로 수정" 차단컨트롤러 문자열 비교 → WritableCopy 타입 + codec 가드 + 컴파일 fixture(금지 조합 2줄)
workout/plan/manual-record port 반환 unknown10개 → 4개(직접 입력 저장·삭제, 사유 위)
repositoryPorts.workoutRepositoryPort의 legacy 참조11 → 1 (processCurrentUserStatsRefresh, A06)
앱 동작·서버·마이그레이션변경 0 — 서버 id 발급·online/durable 정책·한도·재시도·v4/raw fallback 없음 모두 그대로
전체 게이트Phase 4 npm run check: 단위 2,903 통과·0 실패·DB 연결 14 skip(정적 게이트 13종 포함)
로컬 CI 재현npm run ci:local -- --full: npm run ci:local -- --full(2026-09-07 20:08~20:16): verify 정적 게이트 13종·단위 2,903 통과 · migration-smoke 샌드박스 마이그레이션 전체 적용 + pgTAP 109파일·1,899 assert 통과 · e2e-local 11 · e2e-empty 7 · e2e-cardio 6 · e2e-persistence 1 통과 — 브라우저 단계는 다른 세션의 미리보기 포트 4173 점유로 종료 코드 3 → 포트가 빈 뒤 --full --only browser,viewport 재실행(20:26~20:38): e2e-browser 38/38 · e2e-viewport 14/14 · 11분 25초 · main 합류(0e41de56, A03·B01·#1345 포함) 뒤 재실행(20:50~21:06): verify 정적 게이트·단위 2,979 통과 · pgTAP 109파일·1,900 assert · e2e-local 11·empty 7·cardio 6·persistence 4 · (포트 4173 점유로 한 번 중단 뒤) e2e-browser 38/38 · e2e-viewport 14/14 · 10분 15초
원격 CIPR #1354 1회(결과는 PR 체크에 기록)
실기기·시각 품질해당 없음(화면 표현 변경 없음)

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

후속 담당이 자기 파일에서 시작할 수 있다

S03(명령 builder)·A09(첫 수직 통합)·A12/A13(편집기)은 domains/codecs/*의 encode/validate 함수와 contracts/ports/writes.ts의 결과 DTO를 직접 import한다. 거대 저장소를 읽지 않아도 된다.

서버 응답 해석 자리가 한 곳이다

영수증은 workoutReceiptCodec, 상세는 sessionDetailCodec/planDetailCodec만 서버 키를 읽는다. 화면·컨트롤러가 snake_case나 서버 자식 행 모양을 해석하지 않는다는 완료 증거를 이 두 파일과 dual-key 기준선이 지킨다.

쓰기 경로를 가짜 transport로 혼자 검사한다

RPC 이름·인자·영수증 대조·오류 참조를 저장소 전체 없이 22건의 행동 테스트로 확인한다. 같은 요청을 다시 보내도 id·hash·payload를 재생성하지 않는 것도 여기서 잡는다.

구조적으로 남는 것

codec 파일 6개와 factory 2개(소유 경계), 결과 DTO 5종, 임시 어댑터 장부(제거 담당 명시), WritableCopy 가드, coverage 규칙.

남은 것

  • 인계 — S03/A09/A12/A13: 정규 DTO 예제 = tests/react/sessionDetailCodec.test.mjs(상세 → 편집 → v5 → 영수증 왕복), workoutRepositoryDestination.test.mjs(영수증 v2 fixture tests/support/sessionPayloadV5.mjsreceiptV2와 거부 사례 8종), planRepositoryDestination.test.mjs(생성/수정/삭제 payload와 한도 오류 코드). 실패 사례 = 손상 영수증(mutation id·hash·session id·source_ref 불일치, children 행 수 어긋남), 개정번호 0/없음, 표시용 사본으로 수정 요청 조립.
  • 제거 담당: 저장소 재수출 12개(A01 최종 export 정리, R01 확인), 매퍼 재수출·상세 codec 위임(A16·A06), processCurrentUserStatsRefresh legacy 참조(A06), retryTransientFetch 주입(S05), 직접 입력 4종 반환 codec(A09), codec 6파일의 기존 any(boundary gate 등재, A16).
  • 이슈 #1329는 v0.18.0 릴리스까지 open([v0.18.0 스테이징]).