Skip to content

v0.18.0 G03 — 식별자·시각·단위가 전부 string/number 이고 저장 단계·owner 세대·조회 스냅샷에 이름이 없어 층마다 추측하던 것에서 공유 계약 층·명령별 정책표·계층 의존 허용표까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 1개 08b11132, 오너 지시 "1282 진행해줘" → 분석·계획 게시 → "고")
  • 랜딩: PR #1311(Phase 1~6 한 PR, squash) — 마이그레이션·엣지 함수·앱 화면 변경 없음. 앱 번들에 실리는 것은 src/react/contracts/** 의 validator 런타임 코드뿐이며 기존 코드가 import 하지 않으므로 동작 변화 0. 선행 G01 e6150af0·G05 80572704 머지 확인 뒤 착수(main b0e5076a)
  • 설계서: 없음 — 분석·Phase 계획은 이슈 #1282 댓글("예상 효과·개선사항" 절 포함)
  • 정본: docs/contracts/v0-18-0-domain-contracts.md(계약 §1~§21) · docs/architecture/v0-18-0-layer-dependency-table.md(허용표·기준선·ADR-5 이행) · 코드 src/react/contracts/{core,commands,resources,ports,persistence}/**(20파일, CONTRACTS_CORE_VERSION = 1) · 총괄 문서 2026-09-07-v0-18-0-architecture-roadmap.md §12 G03 행
  • 도구: 없음(새 스크립트 없음). 장부 규칙 scripts/architecture/coverage-inventory.json 4줄 추가 + --render
  • 게이트: 새 행동 테스트 tests/react/contracts/{coreIds,coreTime,coreValues,commands,persistence}.test.mjs 30건 + 컴파일 fixture tests/react/contracts/fixtures/{coreBrands,commands,ports}.fixture.ts(compileFixtures.test.mjs 1건이 tsc 실행, 금지 조합 16줄 전부 오류) + 경계 래칫 tests/react/contractsImportBoundaries.test.mjs 4건. 전부 npm test 자동 편입. 로컬 npm run check 전체 통과(정적 게이트 14·테스트 2,694+). 마이그레이션·pgTAP·e2e 미접촉
  • 버그리포트: 없음(수리 건 아님)
  • 계약: 새 계약 문서 1(위). 기존 계약(쓰기 파이프라인·쓰기 원천·ADR·화면 RPC)은 한 문장도 바꾸지 않았다 — 값을 타입으로 옮겼을 뿐. package.json·기존 types/*.ts·tests/audit/manifest.json 무변경

Phase 현황

Phase내용상태
Phase 0조사·기준선(코드 실측 F1~F13)·계획 게시·착수 댓글✅ 이슈 댓글
Phase 1핵심 타입 — 브랜드 식별자 12종·달력 날짜/절대 시각·단위 8종 정밀도·입력 중 상태 union·Result/DecodeResult·모양 출처 브랜드f84aeebe
Phase 2명령 종류 통일 + wire 대응표·PreparedMutation(불변/가변)·영수증·실패 분류 참조 구현·충돌 사본·저장 단계 3+1·owner 세대·조회 스냅샷·통계 세대·명령별 정책표(durable 5·online 18·auth 4·upload 2)2be72dbc
Phase 3feature 별 좁은 port 16 + runtime port 4, 파사드가 port 를 만족하는 컴파일 fixture9f0c517f
Phase 4저장물 형식·버전 envelope·decoder 레지스트리·대기열 v1 참조 decoder·fixture 7행daabfb59
Phase 5계층 의존 허용표 문서·경계 래칫 테스트(기준선 3파일)·장부 규칙·--render347bf1a6
Phase 6계약 문서·이 기록·등록 2곳·총괄 G03 행 갱신안·후속 인계·PR✅ PR #1311

1. 배경

v0.18.0 은 기존 단일 앱을 62개 작업으로 모듈화한다. Phase 1-4 부터 S01(저장 codec)·D01(통계 DAG)·A01(transport 분리)·U01(표현 토큰)·G04(관측 기준선)가 같은 앱을 동시에 고친다. 이들이 "이 값은 서버 id 인가 앱 임시 id 인가", "이 사본으로 수정 요청을 만들어도 되나", "저장 완료가 기기에 적힌 것인가 서버 확정인가 통계 반영까지인가" 를 각자 코드 안에서 추측하면 타입만 늘어도 결합이 남는다(총괄 §2, ADR §3-2). G03 은 그 의미를 한 곳의 타입·계약 문서로 고정해 넘기는 작업이다.

2. 문제 제기

식별자·시각·단위가 전부 원시 타입이라 잘못된 조합이 컴파일을 통과했다

Uuid·IsoDateString·IsoDateTimeStringstring 별칭(합계 341곳), 브랜드는 ExerciseId 하나뿐. 세션 id 자리에 종목 id 를, 달력 날짜 자리에 UTC 시각을 넣어도 통과.

저장 단계 3종이 여섯 필드에 흩어져 있었다

기기 보관(대기열 행 state) · 서버 확정(영수증) · 통계 반영(freshness.applied_versionstatsRequestedVersion)이 실제로 있는데, 이를 말하는 타입이 없어 writeSource·syncPending(7파일)·sync_pending|sync_blocked 문자열(10파일)·statsRequestedVersion(3파일) 로 각자 표현.

명령 종류 union 이 둘이고 요청 지문 구현이 둘이었다

대기열 PendingWorkoutMutationKind(5) 와 영수증 WorkoutMutationKind(5)가 서로 달랐고, 지문 생성은 durableMutationIdentity·directWorkoutWrite.workoutMutationRequestHash 두 곳.

owner 세대·조회 스냅샷·decoder 결과에 이름이 없었다

isCurrentRemoteUser(userId, revision) 의 두 값을 14개 파일이 손으로, readModelQueryCache·bootReadModelCache·owner 스토어 7개가 각자 모양, 저장물 형식 버전은 대기열 행에만.

UI 가 DB 행 타입을 import 하지 않는 것은 관행이었지 검사가 아니었다

types/ 한 폴더에 DB 행 모양(supabase.ts 1,030줄은 손으로 쓴 것·screenRpc.ts 1,582줄)·내부 DTO·화면 모델이 섞여 있고, 기존 게이트는 UI ↛ services/controllers 만 봤다.

3. 해결 방안

원칙 (오너 결정 없음 — 전제로 진행, 2026-09-07)

  • 전제 1: 제품 정책 P1~P10 은 값 그대로, 이름만 붙인다. 정책표 값 = 쓰기 파이프라인 §4·§10.
  • 전제 2: 새 경로 src/react/contracts/{core,commands,resources,ports,persistence}. 기존 types/*.ts 는 옮기지 않는다(소비자 수백 곳, A01·U01 과 충돌). codec 구현은 S01, 도메인 변환 구현은 A02~A06.
  • 전제 3: 검증은 행동 검사 + 컴파일 fixture(tests/** 라 boundary gate 의 suppression 금지 대상 밖). package.json 무변경.
  • 전제 4: 새 라이브러리 없음(ADR-5). 새 게이트는 새 테스트 파일.
  • 전제 5: ADR 파일은 손대지 않고 새 계약 문서 1개.
  • 전제 6: CI 1회 — src/react/** 런타임 코드가 번들에 실리고 verify 잡이 실제로 검증.

접근

내용채택
A. 공유 계약 층 + 의존 방향 검사브랜드 타입·union·port 를 contracts/ 에 두고, 허용표를 테스트가 지킨다. 기존 코드는 옮기지 않고 A 계열 이식 때 채택채택 — 필드 하나씩 고치면 다음 담당이 또 다른 필드를 만든다(§22 근본 구조)
B. 기존 types/*.ts 를 재배치·개명폴더 이름으로 계층을 표현불채택 — 16파일 소비자 수백 곳, 동시 진행 중인 A01·U01 과 같은 파일 충돌, 앱 동작 위험
C. 범용 Entity/Repository<T> 프레임워크모든 feature 를 한 generic 으로불채택 — 이슈 지시("generic AnyRecord 하나로 묶지 않는다"), feature 별 좁은 port 가 요구
D. TanStack Query 도입조회 캐시를 라이브러리로불채택 — ADR-5. 근거는 허용표 §5(현행 캐시가 키 버전·stale·무효화·합류를 이미 갖춤, owner 전환·세대 대기를 다시 만들어야 함)

4. 적용한 내용

Phase 1 — 핵심 타입 (f84aeebe)

contracts/core/{result,ids,time,units,input,shapes}.ts. Result/ValidationError/DecodeResult(원문 보존 3갈래) · Brand<T,Tag> 와 canonical id 8·mutation id 2·source identity·LocalRowKey · CalendarDate(실재 날짜만)·Instant(Z/오프셋 필수)·TimeZoneId·RecordOrderKey · 단위 8 브랜드와 정밀도(상한은 호출자) · InputCell(empty/typing/valid/invalid)과 storedValueOf · DbRow/ExternalUnknown/Dto/ViewModel + RowCodec/ViewCodec. 테스트 14건.

Phase 2 — 명령·영수증·충돌·단계·자원·세대 (2be72dbc)

contracts/commands/{capability,mutation,receipt,conflict,stage}.ts · contracts/resources/{owner,generation,snapshot}.ts. COMMAND_CAPABILITY(Record 완전성으로 새 명령은 표 필수)·WRITE_WAIT_POLICY · MutationKind 하나 + WIRE_MUTATION_KIND·MUTATION_RPC_NAME · DurableCommand/PreparedMutation{request, attempt}(claim·successorOf 자리, 비활성) · Receipt·ReceiptOutcome(unknown 포함) · WriteFailure·classifyWriteFailure·dispositionOf·Conflict/ConflictCopy · PersistenceStage·stageAfterReceipt·statsReflect · OwnerScope · ResourceKey/ResourceSnapshot/WritableCopy vs PendingOverlay · StatsGeneration/isGenerationPublished. 테스트 9건 + fixture 2파일(금지 조합 14줄).

Phase 3 — port (9f0c517f)

contracts/ports/{queries,writes,runtime}.ts. 조회 7·쓰기 9·인증 1·runtime 4(TransportPort·ResourceCachePort·OutboxPort·ClockPort). fixture ports.fixture.ts 가 파사드 barbelicApi 함수를 port 에 그대로 대입.

Phase 4 — 저장물 형식·decoder (daabfb59)

contracts/persistence/{envelope,decoder}.ts. PERSISTED_ORIGINS 5형식·readEnvelope·Decoder·createDecoderRegistry(중복·현재 버전 누락 거부)·decodePersisted·참조 pendingSaveV1Decoder. fixture 7행 + 테스트 7건.

Phase 5 — 허용표·게이트·장부 (347bf1a6)

docs/architecture/v0-18-0-layer-dependency-table.md · tests/react/contractsImportBoundaries.test.mjs(ui·features·앱 셸 ↛ DB 행 타입 — 기준선 3파일 래칫 / contracts/** ↛ 구현 / core 는 core 만 / 문서 앵커) · coverage-inventory.json 규칙 4줄 · 장부 §3 재생성.

Phase 6 — 문서·인계 (PR #1311)

계약 문서 §1~§21 · 이 기록 · 사이드바·README 등록 · 총괄 G03 행 갱신안(#1279 댓글) · G04/S01/D01/A01/U01 인계 댓글.

주요 결정과 그 근거

  • MutationKind 는 대기열 이름을 정본으로(save_workout 등): 기기에 이미 적힌 행이 이 이름을 갖는다. 영수증 쪽 이름은 wire 대응표로 — S01 decoder 가 이름 변환을 하지 않게.
  • 상한 값을 계약에 복제하지 않음: 한도 장부와 COMPLETED_WORKOUT_WRITE_LIMITS 가 정본. 같은 숫자 두 곳 = 다음 개정에서 어긋남.
  • port 결과 타입은 unknown: 파사드가 아직 any 를 돌려주는데 여기서 모양을 지어내면 거짓 계약. 도메인 codec 이 좁힐 때 그 줄에서 바꾼다(문서 §18).
  • 기준선 래칫(DB 행 타입 import 3파일): 지금 고치면 A01·A15 파일과 충돌. 늘지 못하게만 막고 소유자를 적었다.
  • 참조 구현 둘(classifyWriteFailure·pendingSaveV1Decoder)을 계약 층에 둔 이유: 표를 타입만으로 두면 테스트할 수 없다. S05/S06·S01 이 채택할 때 한쪽만 남기는 조건을 문서에 적었다.

작업 중 드러난 것

  • Date.parse("2026-02-30T…Z") 가 3월 2일로 통과 — parseInstant 가 날짜 부분을 따로 검사.
  • 컴파일 fixture 의 @ts-expect-error 가 정말 오류를 요구하는지 probe 파일로 확인(TS2578, exit 2). fixture tsconfig 는 src/react/global.d.ts(window 확장)·allowJs(app-config.js) 가 있어야 파사드를 import 할 수 있다.
  • withdrawUserConsent 는 파사드에 없다 — port fixture 가 잡아 port 에서 뺐다(계약이 파사드와 어긋나지 않게 하는 앵커가 첫 실행에 작동).
  • 새 경계 테스트가 appController.tsx → types/screenRpc(직접 입력 기록 행 타입)를 잡았다 — controllers 2파일과 함께 기준선 3파일.
  • 장부 도구가 main 에서 이미 실패: G02(#1310)·#1277 의 파일 12개(tests/db/**·tests/fixtures/stats-oracle/**·tests/react/{dateIndependence,faultInjectionTools,lineEndingPolicy,ownerSwitchFixture,testSupportDeterminism,browserClockFixture}.test.mjs·src/react/services/authSessionGuard.ts)와 .gitattributes 가 규칙 없이 들어왔다(장부 §4-5 ⑤ 미이행). 이 PR 은 내 경로만 규칙을 넣었다 — HQ 갱신안에 적음.
  • Bash 도구의 작업 디렉터리가 cd 뒤에 세션 안에서 유지된다 — 절대 경로로 되돌려야 한다.

5. 적용 결과

항목전 → 후
브랜드 식별자 종류1(ExerciseId) → 12(+LocalRowKey)
식별자·날짜·단위·명령·사본 잘못된 조합의 컴파일 실패0 → 부정 fixture 16줄 전부 실패 확인
저장 단계를 말하는 타입없음(흩어진 필드 6종) → PersistenceStage 1(+unknown)
명령 종류 union2개(불일치) → 1개 + wire 대응표
명령별 capability산문 → COMMAND_CAPABILITY 29개(durable 5·online 18·auth 4·upload 2) + 문서 대조 테스트
export 된 feature port0(지역 선언 4) → 16 + runtime 4, 파사드 대입 fixture 통과
저장물 decoder 결과 모양없음 → DecodeResult 3갈래 + fixture 7행
UI/features ↛ DB 행 타입 검사없음(관행 0건) → 래칫 테스트(기준선 3파일, 0 유지)
계약 층 ↛ 구현 검사없음 → 테스트(0건)
행동 테스트 / 컴파일 fixture / 경계 래칫— → 30건 / 3파일(+실행 테스트 1) / 4건 = 35건
로컬 npm run check통과(정적 14·테스트 2,694+)
앱 동작 변화0 — 기존 코드가 새 계약을 import 하지 않음(채택은 S01·A 계열)
미검증계약의 실제 채택 효과(F3~F7 흩어진 필드 감소)는 S01·A12/A13·A08 랜딩 뒤에야 측정된다. 구형 탭 공존 안전성은 S01 몫

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

병렬 작업자가 같은 이름으로 말한다

S01·D01·A01·U01·G04 가 "서버 id / 멱등 키 / 임시 키", "기기 보관 / 서버 확정 / 통계 반영", "쓰기 가능 사본 / 표시용 사본" 을 한 파일의 타입으로 가리킨다. 추측이 컴파일 오류로 바뀐다.

오프라인 정책을 실수로 넓힐 수 없다

DurableCommand<"create_plan"> 은 타입이 없다. 새 명령은 정책표에 한 줄을 넣어야 컴파일된다 — P1 이 문서가 아니라 Record 완전성으로 지켜진다.

옛 저장물을 지우지 않는 모양이 정해졌다

모르는 버전·깨진 행이 원문째 돌아오는 DecodeResult 와 fixture 7행이 S01 codec 의 합격 기준이다.

구조적으로 남는 것

계약 층 contracts/{core,commands,resources,ports,persistence} · 계층 의존 허용표 + 래칫 테스트 · 명령별 정책표 상수 + 문서 대조 테스트 · 계약 소유자 표(§20)·DTO 확장 절차(§18) · ADR-5 결정 기록 · 장부 규칙 4줄.

남은 것

  • S01: 요청 지문 정의 통합(§3), PreparedMutation 채택, 버전별 codec 과 구형 탭 공존 실험 — 이 계약은 선언이지 안전 증거가 아니다.
  • A01/A08/A15: DB 행 타입 기준선 3파일을 0 으로(허용표 §4). A02~A06: port 결과 타입 unknownDto. A12/A13: InputCell 채택. U02/U03: PersistenceStage 로 상태 표시.
  • HQ: 총괄 §12 G03 행 갱신(제안 댓글), 장부 도구 실패(G02·#1277 파일 12개 미분류) 처리, package.json 별칭 여부(계약 테스트는 이미 npm test 에 든다 — 별칭 불필요).
  • 릴리스 v0.18.0 뒤 [v0.18.0 반영완료] + 이슈 닫기(G01·G02·G05 와 같은 처리).