v0.18.0 계층 의존 허용표 (이슈 #1282, G03)
한 문장 — 앱 안의 계층이 어느 계층을 import 해도 되는가를 표 하나로 정하고, 표의 규칙을 테스트가 지킨다. 표에 없는 방향은 금지다. 목표 구조(총괄 §2)로 가는 동안 지금 코드가 어긋나는 곳은 "기준선" 으로 적어 두고 늘어나지 못하게만 한다 — 줄이는 것은 각 담당(A01·A08·A15)의 몫이다.
- 이슈: #1282 (계획 ID G03). 계약 정본: v0.18.0 도메인 계약. 배경: 총괄 §2 목표 구조, ADR §3-2·§3-5, 현행 도메인 서비스 경계.
- 검사:
tests/react/frontendImportBoundaries.test.mjs(현행 — UI ↛ services/controllers, repository 는 domains 만, 모바일 ↮ 데스크톱) +tests/react/contractsImportBoundaries.test.mjs(이 표가 추가한 세 규칙). 둘 다npm test에 든다.
1. 유저 A 의 기록으로 보는 계층
유저 A 가 스쿼트를 기록하면 그 데이터는 여덟 계층을 지난다(장부 §1-1). 아래 표의 "계층" 은 그 길 위의 폴더다. 화살표는 "import 해도 된다" 이고, 표에 없으면 못 한다.
2. 허용표
| 계층 (폴더) | import 해도 되는 것 | 절대 안 되는 것 | 지키는 검사 |
|---|---|---|---|
계약 core src/react/contracts/core/** | contracts/core/** 끼리, types/common·types/exerciseIdentity(브랜드 재사용) | 그 밖 전부 — 계약의 뿌리는 아무것도 모른다 | contractsImportBoundaries ③ |
계약 contracts/{commands,resources,ports,persistence}/** | contracts/**, types/**(현행 DTO 를 좁히는 용도) | services/**·controllers/**·features/**·ui/**·React·Supabase | contractsImportBoundaries ② |
기존 타입 src/react/types/** | types/** 끼리 | 구현 계층 전부 | (현행 0건 — G03 확인) |
화면 ui/** | contracts/core·contracts/ports(port 타입만)·contracts/workout/**(플랫폼 공통 순수 정책 — A12, ui/shared 는 같은 이름 재수출)·types/{mobileUi,desktopUi,reactComponents,recording,recordingRequirement,calendarReadModel,auth,admin}·ui/shared/** | services/**·controllers/**·app*.tsx·types/supabase·types/screenRpc(DB/RPC 행 모양)·다른 플랫폼 UI | frontendImportBoundaries(UI 표현·플랫폼 분리) + contractsImportBoundaries ① |
기능 바인딩 features/** | contracts/**(contracts/workout/** 포함 — features/workout/editor 가 읽는 순수 정책)·types/**(행 모양 제외)·services/barbelicApi(파사드)·controllers/**(자기 영역)·resources/**(조회 캐시 store 의 데이터·판정 함수 — feature 선택자 입력, A09 #1342) | services/barbelicRepository·types/supabase·types/screenRpc·다른 feature 의 내부 | frontendImportBoundaries(쓰기 소유) + contractsImportBoundaries ① |
컨트롤러·스토어 controllers/** | contracts/**·types/**(행 모양 제외)·services/**(파사드·도메인 서비스·순수 매퍼) | services/barbelicRepository·types/supabase·types/screenRpc(A01에서 직접 import 0개로 이전 완료) | frontendImportBoundaries(repository) + contractsImportBoundaries ①(래칫) |
앱 runtime src/react/app/**(owner·auth·connectivity 수명주기, A07 #1335) | contracts/**·types/**(행 모양 제외)·services/**(파사드·도메인 서비스)·controllers/**(store 세트) — controllers 와 같은 규칙. React 를 모른다(앱 셸이 주입) | services/barbelicRepository·types/supabase·types/screenRpc·ui/**·features/** | frontendImportBoundaries(app-facing) + contractsImportBoundaries ① |
resource cache src/react/resources/**(owner 범위 조회 캐시 primitive·React 구독 훅, A08 #1338) | contracts/**·services/readModelObservability(계측)·React(useResourceSnapshot.ts 훅 파일만 — store 본체는 React 를 모른다) — controllers 와 같은 규칙 | services/barbelicRepository·types/supabase·types/screenRpc·ui/**·features/**·controllers/**(캐시는 호출자를 모른다) | frontendImportBoundaries(app-facing) + contractsImportBoundaries ① |
앱 셸 app.tsx·appController.tsx·mobileApp.tsx·desktopApp.tsx·sharedShell.tsx·vite/** | controllers/**·features/**·contracts/**·services/barbelicApi (플랫폼 앱은 파사드 직접 금지) | types/supabase·types/screenRpc(A01에서 실제 API 반환 타입으로 이전 완료)·raw loader 이름 | frontendImportBoundaries(app-facing raw loader·플랫폼 앱 파사드) + contractsImportBoundaries ①(래칫) |
도메인 서비스·매퍼 services/domains/**·services/*Mappers.ts·services/screenRpcAdapters.ts | services/barbelicRepository(domains 만)·types/** 전부(DB 행 → Dto 변환 지점)·contracts/** | controllers/**·ui/** | frontendImportBoundaries(repository 는 domains 만) |
저장소·전송 services/barbelicRepository.ts·screenRpcContracts.ts·runtimeSchemaValidation.ts | types/supabase·types/screenRpc·contracts/core·contracts/ports/runtime(TransportPort 구현) | controllers/**·ui/**·features/** | frontendImportBoundaries + boundary gate(any 허용 목록) |
기기 저장물 services/pendingWorkoutSaves.ts·workoutDraftCache.ts·workoutLocalCacheDb.ts·(S01) persistence/codecs/** | contracts/core·contracts/commands·contracts/persistence(Decoder 구현) | ui/**·controllers/**·transport | (S01 이 codec 테스트로 추가) |
읽는 법: 위에서 아래로 갈수록 바깥(화면)에서 안쪽(서버)이다. 화살표는 아래로만 간다 — 안쪽 계층이 바깥 계층을 import 하면 위반이다. 같은 계층 안의 import 는 자유다(플랫폼 UI 사이는 예외 — 모바일 ↮ 데스크톱).
3. 모양의 출처와 변환 지점
같은 세션이 네 모양으로 앱을 지난다(계약 §7). 변환은 세 지점에서만 한다.
| 변환 | 어디서 | 누가 소유 |
|---|---|---|
DB 행(DbRow, snake_case) → 내부 DTO(Dto, camelCase) | services/screenRpcAdapters.ts(읽기 22개 adapt*) · services/barbelicMappers.ts(행 → 도메인) · services/sessionWriteContractV5.ts(쓰기 wire) | 도메인 codec A02~A06(장부 §3 "서비스·도메인·매퍼" 행) |
바깥 unknown(ExternalUnknown: IndexedDB·localStorage·bridge) → 내부 DTO | contracts/persistence 의 Decoder 구현 = S01 persistence/codecs/** · bridge 는 services/nativeAuthSessionPolicy.ts(N01) | S01 · N01 |
내부 DTO → 화면 모델(ViewModel) | services/*ViewMappers.ts·mobileExperienceViewMappers.ts·desktopExperienceViewMappers.ts | 표현 계약 U01 · 매퍼 A 계열 |
검증 없는 as 로 unknown 을 DTO 로 바꾸지 않는다(이슈 지시). 현행 as unknown as 17곳·as any 11곳은 boundary gate 허용 목록에 사유와 함께 있고, A16 이 dual-key 기준선과 함께 0 으로 내린다.
4. 기준선과 그 처리
| 기준선 | 지금 | 누가 0 으로 | 검사 |
|---|---|---|---|
| controllers·앱 셸이 화면 RPC payload 타입을 직접 읽음 | 3 → 0파일: 부팅 사본은 domains/bootReadModelAdapters.ts, 수동 기록은 실제 API 반환 추론 | A01 완료. A08/A15는 0개 경계 유지 | contractsImportBoundaries ① — 늘면 실패, 줄면 기준선 목록에서 지운다(테스트가 알려 준다) |
| UI·features 가 DB 행 타입을 읽음 | 0 | — | 같은 검사(0 유지) |
AnyRecord 사용 | 56파일(UI 2) | A16·U01 | types/controllers.ts 는 boundary gate 허용 목록 "temporary controller helper types" |
5. 새 라이브러리 결정 (ADR-5 이행)
기존 store/coordinator 를 typed 로 완성한다. 새 조회·상태 라이브러리는 도입하지 않는다. 근거(2026-09-07 실측): services/readModelQueryCache.ts 가 키 버전(늦은 답장 무시)·stale·invalidated·비행 중 합류·중단 신호를 이미 갖췄고, owner 범위는 controllers/ownerStores.ts 가 owner 전환 때 스토어 12개를 통째로 바꾸는 방식으로 이미 한다. TanStack Query 류를 넣으면 ① owner 전환·통계 세대 대기(statsRecoveryCoordinator)를 라이브러리 밖에서 다시 만들어야 하고 ② remoteDataController.ts 4,092줄의 데이터 흐름을 React 트리에 다시 매달아야 하며 ③ 두 구현이 한동안 공존한다(ADR-5 가 금지). 계약 ResourceSnapshot·ResourceCachePort(계약 §10·§17)는 현행 캐시 모양에 이름을 붙인 것이라 A08 은 이름을 맞추는 일만 남는다. 이 결정은 한 번이며, 되돌리려면 "기존 계약으로 충족할 수 없는 요구" 를 근거로 HQ 가 다시 연다.
A01의 이전 대상은 calendarReadModelStore.ts, remoteDataController.ts, appController.tsx였다. 세 파일 모두 서비스 어댑터의 출력 또는 실제 API 반환 타입을 사용하며 직접 행 타입 import 예외는 남아 있지 않다.