docs: record the barrel boundary and IndexedDB kernel designs with a plan
모듈별 구조 리뷰에서 나온 두 설계를 spec으로 남기고, 그중 배럴 경계 작업의 실행 계획을 쓴다. 배럴 규칙은 실제 import 그래프에 돌려 위반 15건이 치환 대상 15줄과 일치함을 확인했고, 재수출할 심볼 82개는 실제 export와 대조했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5434760ddf
commit
a42d96185f
@@ -0,0 +1,751 @@
|
||||
# 어댑터 배럴(`index.ts`)을 공개 경계로 승격 — 설계서
|
||||
|
||||
- 대상 레포: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
|
||||
- 브랜치: `develop` (기준 커밋 `5434760`)
|
||||
- 전제(이미 확정): 배럴 폐지안은 기각. `src/adapters/<group>/index.ts`를 **진짜 공개 경계**로 만든다.
|
||||
- 게이트 기준선: `check:architecture` PASS, `check:types:app` PASS 유지.
|
||||
- 이 문서는 설계만 한다. 소스 수정·빌드·테스트 실행 없음.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (먼저 읽을 것)
|
||||
|
||||
| 항목 | 판정 |
|
||||
|---|---|
|
||||
| 배럴 표기 표준 | **명명 재수출(named re-export)**. `export *`는 "이미 명시적인 서브배럴을 합칠 때"만 허용 |
|
||||
| 새로 만들 배럴 | 8개, 합계 114줄 (auth 9 / diagnostics 7 / telemetry 7 / storage 4 / service-worker 17 / query-cache 21 / platform 22 / http 27) |
|
||||
| `platform/` | 배럴은 만든다. 단 **어댑터→커널 간선은 파일 직접 import를 유지**한다 (게이트가 그걸 요구함) |
|
||||
| `storage/` | 최상위 배럴은 **서브폴더를 재수출하지 않는다**. `indexeddb/index.ts`·`opfs/index.ts`가 곧 경계다 |
|
||||
| `http/` | V3(`createContractHttpExecutor`)가 권장 경로, V2(`createHttpClient`)는 legacy 보존. 둘 다 배럴에 넣고 주석으로 표시 |
|
||||
| 치환할 import | `src/` 15줄 (배럴 경유 2줄은 이미 합격) + `.storybook/` 2줄(선택) |
|
||||
| 게이트 | `.dependency-cruiser.json`에 규칙 1개 추가. `tests/`는 대상 아님(스캔 범위가 `src`뿐) |
|
||||
| 최대 위험 | 번들 예산. `package.json`에 `sideEffects` 선언이 없어 배럴이 초기 청크를 키울 수 있다 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 기존 배럴 8개의 지배적 관례
|
||||
|
||||
읽은 파일: `src/adapters/{browser-files,browser-file-storage,browser-rpc,browser-transfer,cache-storage,cross-context-invalidation,realtime,web-push}/index.ts`
|
||||
추가로 서브배럴 8개: `browser-transfer/{image-cdn,presigned,resumable-upload}/index.ts`, `realtime/{polling,sse,websocket}/index.ts`, `storage/{indexeddb,opfs}/index.ts`
|
||||
|
||||
### 1.1 관례 (문장으로)
|
||||
|
||||
1. **소스 파일 단위로 블록을 만들고, 블록마다 `export { ... } from "./파일.ts";` 로 이름을 전부 적는다.**
|
||||
블록이 전부 타입이면 `export type { ... } from "...";` 형태를 쓴다 (`browser-files/index.ts:1`, `:28`, `:52`, `:53`).
|
||||
2. **블록 안 순서는 「값 먼저, 타입 나중」이고 각각 대소문자 무시 알파벳순이다.**
|
||||
근거: `web-push/index.ts:42-51` — `createLinkedAbortController, failureCode, nativeFailure, observeWebPush, systemTimeoutScheduler, withAbortableDeadline, type LinkedAbortController, type TimeoutScheduler`.
|
||||
상수도 값이므로 같은 줄에 섞인다: `realtime/index.ts:13-22` — `parseRetryAfterDelay, REALTIME_RECONNECT_CEILINGS, reconnectBudgetRemaining` (대소문자 무시로 `parse < realtime_ < reconnect`).
|
||||
타입은 인라인 `type X` 접두어로 쓴다 (`browser-rpc/index.ts:9-18`).
|
||||
3. **블록(파일) 순서는 대체로 알파벳순이되 엄격하지 않다.** 하위 폴더 블록은 뒤로 몰아둔다 (`web-push/index.ts:59`, `:66`의 `./inbound/*`).
|
||||
엄격하지 않은 실례: `cross-context-invalidation/index.ts`는 `browser-cross-context-invalidation.ts`(:1) 다음에 `browser-cross-context-host.ts`(:20) — 역순.
|
||||
4. **배럴은 그룹의 전체 export 목록이 아니다.** 그룹 안에 배럴에 없는 파일이 실제로 존재한다.
|
||||
근거: `src/adapters/realtime/result.ts`는 export를 가지지만 `realtime/index.ts` 어디에도 없다. `browser-files/browser-file-vault.ts`도 `browser-files/index.ts`에 없다.
|
||||
→ 즉 이 레포는 이미 "배럴 = 선별된 공개 표면"을 실천하고 있다. 새 배럴도 같은 기준으로 고르면 된다.
|
||||
5. (참고, 따라하지 말 것) `browser-files/index.ts:1-16`은 어댑터가 아니라 **application 포트 타입**을 재수출한다. 배럴이 하위 레이어의 통로가 되는 형태라 새 배럴에서는 재현하지 않는다. 필요하면 소비자가 포트에서 직접 가져오면 된다.
|
||||
|
||||
### 1.2 `export *`는 표준인가 — 판정
|
||||
|
||||
**표준은 명명 재수출이다. `export *`는 예외가 아니라 "서브배럴 합성" 전용 관용구다.**
|
||||
|
||||
근거:
|
||||
|
||||
- `export *`가 쓰인 곳은 단 두 파일, 여섯 줄이다.
|
||||
- `src/adapters/browser-transfer/index.ts:1-3` — `./image-cdn/index.ts`, `./presigned/index.ts`, `./resumable-upload/index.ts`
|
||||
- `src/adapters/realtime/index.ts:56-58` — `./polling/index.ts`, `./sse/index.ts`, `./websocket/index.ts`
|
||||
- **여섯 줄 전부 대상이 `index.ts`(서브배럴)다.** 구현 파일(`.ts`)을 `export *`로 푼 사례는 0건이다.
|
||||
- 그리고 그 서브배럴들은 자기 자신이 전부 명명 재수출이다 (`browser-transfer/image-cdn/index.ts:1-6`, `realtime/sse/index.ts:1-9` 등).
|
||||
- `realtime/index.ts`는 한 파일 안에서 두 형태를 동시에 쓴다: 1-55줄은 구현 파일에 대한 명명 재수출, 56-58줄은 서브배럴에 대한 `export *`. 즉 `browser-transfer`만 특이한 게 아니라, **대상이 서브배럴이냐 구현 파일이냐**가 형태를 가른다.
|
||||
|
||||
왜 이 구분이 옳은가: 지시대로 `export *`는 "무엇이 공개되는지 파일을 열어야 안다"는 문제가 있다. 그런데 대상이 서브배럴이면 그 파일 자체가 이미 명시적 목록이므로, `export *` 한 줄을 따라가면 곧바로 명시적 목록에 도달한다. 목록이 사라지는 게 아니라 한 단계 아래에 있는 것뿐이다. 반대로 구현 파일을 `export *`하면 목록이 어디에도 없어진다.
|
||||
|
||||
**채택 규칙**
|
||||
|
||||
> 배럴은 구현 파일(`*.ts`)에 대해서는 반드시 이름을 하나씩 적는다.
|
||||
> `export *`는 대상이 같은 그룹의 서브배럴(`*/index.ts`)일 때만 쓴다.
|
||||
|
||||
새로 만드는 8개 중 `export *`를 쓸 자리는 **없다** (아래 §3.7에서 `storage`가 서브배럴을 재수출하지 않기로 판정하므로).
|
||||
|
||||
---
|
||||
|
||||
## 2. 공개/내부 판정 기준
|
||||
|
||||
판정 근거는 실제 사용처다. 측정 명령:
|
||||
|
||||
```
|
||||
grep -rnE 'from "[^"]*adapters/(auth|browser-files|browser-file-storage|browser-rpc|browser-transfer|cache-storage|cross-context-invalidation|diagnostics|http|platform|query-cache|realtime|service-worker|storage|telemetry|web-push)/' src/ --include='*.ts' --include='*.tsx' | grep -v '^src/adapters/'
|
||||
```
|
||||
|
||||
- **공개**: `src/bootstrap/**`, `src/features/**`, `src/presentation/**`이 import하는 심볼 + 그 심볼의 시그니처에 이름으로 등장하는 타입(의존성/옵션/반환 파사드).
|
||||
- **내부**: 그룹 안에서만 쓰이는 헬퍼.
|
||||
- **내부(테스트 전용)**: `tests/`만 import하는 심볼. 배럴에 넣지 않고, 테스트는 깊은 경로를 유지한다. 각 그룹에서 별도로 표시했다.
|
||||
|
||||
예외 처리 하나: 같은 파일의 동급 팩토리 형제는 오늘 테스트만 쓰더라도 공개로 올린다(예 `createAnonymousSessionAdapter`). 근거는 §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. 새로 만들 배럴 8개 (전문)
|
||||
|
||||
아래 내용은 전부 **그대로 파일로 저장 가능**하다. 모든 심볼은 기계 대조로 존재를 확인했다(82개 전수, §7).
|
||||
|
||||
### 3.1 `src/adapters/auth/index.ts`
|
||||
|
||||
그룹 파일: `external-session-adapter.ts` 1개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createExternalAuthSessionAdapter` | `external-session-adapter.ts:51` | 공개 | `src/bootstrap/runtime-adapters.ts:3` |
|
||||
| `createDemoSessionAdapter` | `:104` | 공개 | `src/bootstrap/runtime-adapters.ts:2`, `:279` |
|
||||
| `createUnavailableSessionAdapter` | `:140` | 공개 | `src/bootstrap/runtime-adapters.ts:4` |
|
||||
| `createAnonymousSessionAdapter` | `:77` | 공개(승격) | 위 셋과 같은 파일·같은 반환형(`AuthSessionPort`)의 형제 팩토리. 오늘 `src` 소비자는 `.storybook/preview.tsx:5`뿐이라 배럴이 없으면 깊은 경로가 남는다 |
|
||||
| `DEMO_AUTHORIZATION_MARKER` | `:98` | 공개 | 계약 문서가 이름으로 참조: `docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md:305` |
|
||||
| `ExternalSessionOwner` | `:10` | 공개 | `src/bootstrap/runtime-adapters.ts:5` |
|
||||
| `DemoSessionAdapter` | `:89` | 공개 | `createDemoSessionAdapter`의 반환 타입 |
|
||||
| `validateCredentialPatch` | `:26` | **내부** | `src/`·`tests/` 어디서도 import하지 않음 |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
DEMO_AUTHORIZATION_MARKER,
|
||||
type DemoSessionAdapter,
|
||||
type ExternalSessionOwner,
|
||||
} from "./external-session-adapter.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `src/adapters/diagnostics/index.ts`
|
||||
|
||||
그룹 파일: `bounded-diagnostics.ts` 1개. export 5개 전부 공개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createDiagnosticsAdapter` | `bounded-diagnostics.ts:18` | 공개 | `src/bootstrap/runtime-adapters.ts:7` |
|
||||
| `recordBootFailure` | `:80` | 공개 | `src/bootstrap/main.tsx:3` |
|
||||
| `getLastBootEvidence` | `:108` | 공개 | `recordBootFailure`의 짝(부팅 증거 읽기). 오늘 사용처는 `tests/`만 |
|
||||
| `noOpDiagnostics` | `:11` | 공개 | `DiagnosticsPort`의 null object. 다른 그룹이 기본값으로 기대하는 형태 |
|
||||
| `MAX_DIAGNOSTIC_ENTRIES` | `:16` | 공개 | 선언된 상한값(계약 수치) |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createDiagnosticsAdapter,
|
||||
getLastBootEvidence,
|
||||
MAX_DIAGNOSTIC_ENTRIES,
|
||||
noOpDiagnostics,
|
||||
recordBootFailure,
|
||||
} from "./bounded-diagnostics.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `src/adapters/telemetry/index.ts`
|
||||
|
||||
그룹 파일: `best-effort-telemetry.ts` 1개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createTelemetryAdapter` | `best-effort-telemetry.ts:52` | 공개 | `src/bootstrap/runtime-adapters.ts:29` |
|
||||
| `noOpTelemetry` | `:31` | 공개 | null object |
|
||||
| `MAX_TELEMETRY_QUEUE` | `:47` | 공개 | 선언된 상한값 |
|
||||
| `TelemetryAdapter` | `:10` | 공개 | 팩토리 반환 타입 |
|
||||
| `TelemetryAdapterOptions` | `:20` | 공개 | 팩토리 인자 타입 |
|
||||
| `safeTraceparent` | `:229` | **내부(테스트 전용)** | `tests/`만 import. traceparent 정규화는 어댑터 내부 동작 |
|
||||
| `assertBoundedCapacity` 재수출 | `:49` | **배럴에 넣지 않음** | 이건 telemetry의 export가 아니라 **커널(`../platform/bounded-capacity.ts`) 심볼의 재수출**이다. 배럴에 올리면 커널로 가는 두 번째 문이 생긴다 |
|
||||
|
||||
> **부수 발견 / 별도 처리 권고**
|
||||
> `src/adapters/telemetry/best-effort-telemetry.ts:49`
|
||||
> ```ts
|
||||
> export { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||
> ```
|
||||
> 이 줄은 이 배럴 작업과 무관하게 지우는 게 맞다. 같은 파일 `:8`에서 이미 같은 심볼을 import해서 `:61`에서 쓰고 있으므로 재수출은 순수 잉여이고, 실제 소비자도 없다(`grep -rn 'assertBoundedCapacity' src/ tests/` → `platform/bounded-capacity.ts` 정의부, `telemetry:8,49,61`, `diagnostics:9,25`가 전부). 이 문서 범위에서는 **건드리지 않고** 배럴에서 제외만 한다.
|
||||
|
||||
```ts
|
||||
export {
|
||||
createTelemetryAdapter,
|
||||
MAX_TELEMETRY_QUEUE,
|
||||
noOpTelemetry,
|
||||
type TelemetryAdapter,
|
||||
type TelemetryAdapterOptions,
|
||||
} from "./best-effort-telemetry.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `src/adapters/platform/index.ts` — 커널
|
||||
|
||||
#### 판정: 배럴은 만들되, **어댑터→커널 간선은 파일 직접 import를 유지한다.**
|
||||
|
||||
질문은 "`browser-transfer/presigned/presigned-transfer-executor.ts:15`의 `../../platform/abortable-operation.ts`가 `../../platform/index.ts`로 바뀌어야 하는가"였다. **아니다. 바꾸면 게이트가 깨진다.**
|
||||
|
||||
**근거 1 (결정적) — `check:adapter-inventory`가 파일 경로를 직접 검증한다.**
|
||||
`scripts/check-adapter-inventory.ts:63-83`:
|
||||
|
||||
```ts
|
||||
const PRIMITIVE_PATH = path.resolve("src/adapters/platform/abortable-operation.ts");
|
||||
for (const consumer of REQUIRED_ABORT_CONSUMERS) {
|
||||
...
|
||||
const specifiers = [...source.matchAll(/from\s+"([^"]*platform\/abortable-operation\.ts)"/gu)]...;
|
||||
const resolved = specifiers.some(
|
||||
(specifier) => path.resolve(path.dirname(consumer), specifier) === PRIMITIVE_PATH,
|
||||
);
|
||||
if (!resolved) { problems.push(`abortable-operation: ${consumer} does not resolve...`); }
|
||||
}
|
||||
```
|
||||
|
||||
`REQUIRED_ABORT_CONSUMERS`(`scripts/check-adapter-inventory.ts:134-137`)는 정확히 네 파일이다:
|
||||
`browser-transfer/presigned/presigned-capability-http-provider.ts`, `.../presigned-transfer-executor.ts`, `browser-transfer/image-cdn/browser-image-probe.ts`, `browser-transfer/resumable-upload/fetch-json-transport.ts`.
|
||||
이들이 `../../platform/index.ts`로 바뀌면 정규식이 매칭되지 않아 `check:adapter-inventory`가 실패한다.
|
||||
|
||||
**근거 2 — 규칙상으로는 둘 다 가능하지만, 커널의 정체성은 "파일"이다.**
|
||||
`.dependency-cruiser.json:191`의 carve-out은 `^src/adapters/($1/|platform/|browser-file-storage/result\.ts$|cross-context-invalidation/index\.ts$)` 로 `platform/` 폴더 전체를 허용한다. 즉 규칙은 중립이다.
|
||||
그런데 `docs/architecture/layers.md:34`는 커널을 "the system clock, the shared abort primitive and the bounded-capacity guard" — **세 개의 프리미티브**로 정의한다. 런타임 합성물이 아니라 원시 도구다. 원시 도구는 "어느 파일에서 왔는지"가 곧 정체성이고, 실제로 위 게이트가 그 정체성을 파일 경로로 확인한다.
|
||||
|
||||
**근거 3 — 그래서 새 게이트 규칙은 `src/adapters/**`를 출발점에서 제외한다.**
|
||||
어댑터끼리의 간선은 이미 `adapters-do-not-know-other-concrete-adapters`(`.dependency-cruiser.json:183-193`)가 담당한다. 새 규칙이 그 위에 겹칠 이유가 없다. §5의 `from.pathNot: "^src/adapters/"`가 이 판정의 실행형이다.
|
||||
|
||||
#### 그러면 배럴에는 뭘 넣나
|
||||
|
||||
커널 프리미티브도 **전부** 넣는다. 이유: 배럴을 쓰는 쪽은 `src/bootstrap`과 `tests`인데, 이들은 `systemClock`(tests), `createAbortableOperation`(tests), `createBrowserLifecycleRuntime`(`src/bootstrap/optional-runtime-host.ts:2`), `createBrowserMutationIntentFactory`(`src/bootstrap/runtime-adapters.ts:28`)를 쓴다. 일부만 넣으면 "바깥은 배럴만"이라는 규칙에 예외가 생긴다. 두 개의 문이 생기는 게 아니라 **문이 소비자별로 하나씩**이다: 어댑터는 파일, 그 외는 배럴.
|
||||
|
||||
| 파일 | 공개 심볼 | 소비자 근거 |
|
||||
|---|---|---|
|
||||
| `abortable-operation.ts` | `createAbortableOperation:90`, `compensateLateHandle:261`, `snapshotAbortTimers:70`, `AbortableOperation:24`, `AbortableOperationInput:51`, `AbortRace:19`, `AbortTerminalReason:11`, `AbortTimerSnapshot:59` | 어댑터 4곳(직접 경로 유지) + `tests/` |
|
||||
| `bounded-capacity.ts` | `assertBoundedCapacity:10` | `diagnostics:9`, `telemetry:8` (직접 경로 유지) |
|
||||
| `browser-lifecycle.ts` | `createBrowserLifecycleRuntime:57`, `BrowserLifecycleEvent:20`, `BrowserLifecycleRuntime:36`, `BrowserLifecycleSnapshot:13` | `src/bootstrap/optional-runtime-host.ts:1-4` |
|
||||
| `browser-mutation-intent-factory.ts` | `createBrowserMutationIntentFactory:9`, `BrowserMutationIntentFactoryDependencies:4` | `src/bootstrap/runtime-adapters.ts:28` |
|
||||
| `system-clock.ts` | `systemClock:3` | 어댑터 6곳(직접 경로 유지) + `tests/` |
|
||||
|
||||
내부: 없음. 이 그룹은 모든 export가 커널 표면이다.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 어댑터 커널의 공개 경계.
|
||||
*
|
||||
* `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일
|
||||
* 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts`가
|
||||
* `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게
|
||||
* 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다.
|
||||
* 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다.
|
||||
*/
|
||||
export {
|
||||
compensateLateHandle,
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortableOperation,
|
||||
type AbortableOperationInput,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
type AbortTimerSnapshot,
|
||||
} from "./abortable-operation.ts";
|
||||
export { assertBoundedCapacity } from "./bounded-capacity.ts";
|
||||
export {
|
||||
createBrowserLifecycleRuntime,
|
||||
type BrowserLifecycleEvent,
|
||||
type BrowserLifecycleRuntime,
|
||||
type BrowserLifecycleSnapshot,
|
||||
} from "./browser-lifecycle.ts";
|
||||
export {
|
||||
createBrowserMutationIntentFactory,
|
||||
type BrowserMutationIntentFactoryDependencies,
|
||||
} from "./browser-mutation-intent-factory.ts";
|
||||
export { systemClock } from "./system-clock.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 `src/adapters/query-cache/index.ts`
|
||||
|
||||
파일 5개, 파일마다 팩토리 1~3개. 전부 공개(4개는 bootstrap이 직접, `createCursorPaginationRuntime`·`createQueryCacheAdapter`는 동급 팩토리).
|
||||
|
||||
| 심볼 | 위치 | 근거 |
|
||||
|---|---|---|
|
||||
| `createConditionalValidatorStore` | `conditional-validator-store.ts:55` | `src/bootstrap/runtime-adapters.ts:26` |
|
||||
| `ConditionalValidatorBinding` / `ConditionalValidatorStore` | `:3` / `:10` | 위 팩토리의 반환·요소 타입 |
|
||||
| `createCursorPaginationRuntime` | `cursor-pagination-runtime.ts:49` | 동급 런타임 팩토리(오늘 소비자는 `tests/`만) |
|
||||
| `createServerStateScopeRuntime` | `server-state-scope-runtime.ts:34` | `src/bootstrap/runtime-adapters.ts:25` |
|
||||
| `ScopeResetParticipant` / `ServerStateScopeDependencies` | `:19` / `:26` | 위 팩토리의 인자 타입 |
|
||||
| `createTanStackCacheCoordinator` | `tanstack-cache-coordinator.ts:33` | `src/bootstrap/runtime-adapters.ts:22` |
|
||||
| `TanStackCacheCoordinatorDependencies` | `:21` | 인자 타입 |
|
||||
| `createQueryClient` | `tanstack-query-cache.ts:21` | `src/bootstrap/runtime-adapters.ts:24`, `.storybook/preview.tsx:6` |
|
||||
| `createQueryCacheAdapter` | `:59` | 동급 팩토리(`QueryCachePort` 구현) |
|
||||
| `QUERY_CACHE_DEFAULTS` | `:12` | 선언된 기본값 |
|
||||
| `QueryCacheDependencies` | `:8` | 인자 타입 |
|
||||
|
||||
내부: 없음.
|
||||
|
||||
> 주의: 이 그룹은 `.dependency-cruiser.json:184`가 명시적으로 이름 붙인 미해결 간선의 출발점이다 — `tanstack-cache-coordinator.ts:19`가 `../cross-context-invalidation/index.ts`에서 협력자 타입 2개를 읽는다. 배럴 작업은 이 간선을 건드리지 않는다(어댑터→어댑터 간선이므로 새 규칙 범위 밖).
|
||||
|
||||
```ts
|
||||
export {
|
||||
createConditionalValidatorStore,
|
||||
type ConditionalValidatorBinding,
|
||||
type ConditionalValidatorStore,
|
||||
} from "./conditional-validator-store.ts";
|
||||
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
||||
export {
|
||||
createServerStateScopeRuntime,
|
||||
type ScopeResetParticipant,
|
||||
type ServerStateScopeDependencies,
|
||||
} from "./server-state-scope-runtime.ts";
|
||||
export {
|
||||
createTanStackCacheCoordinator,
|
||||
type TanStackCacheCoordinatorDependencies,
|
||||
} from "./tanstack-cache-coordinator.ts";
|
||||
export {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
QUERY_CACHE_DEFAULTS,
|
||||
type QueryCacheDependencies,
|
||||
} from "./tanstack-query-cache.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 `src/adapters/service-worker/index.ts`
|
||||
|
||||
#### 제약 하나 먼저
|
||||
|
||||
`tsconfig.app.json:14-19`의 `exclude`에 `src/adapters/service-worker/service-worker-entry.ts`가 들어 있다. **배럴은 이 파일을 절대 참조하면 안 된다** — 참조하면 app 타입체크가 제외된 파일을 끌어들인다. 다행히 `service-worker-entry.ts`는 export가 0개라(`grep -n '^export ' → 없음`) 넣을 것도 없다.
|
||||
|
||||
| 파일 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `service-worker-page-controller.ts` | 공개 | `src/bootstrap/register-service-worker.ts:5`가 `createServiceWorkerPageController`를 씀 |
|
||||
| `service-worker-lifecycle.ts` | 공개 | 워커 realm 진입점(`service-worker-entry.ts:11`)이 합성하는 런타임. 그룹 바깥(워커 번들·tests)이 실제 소비자 |
|
||||
| `service-worker-protocol.ts` | 공개 | 페이지↔워커 메시지 코덱. 양쪽 realm이 공유하는 어휘 |
|
||||
| `service-worker-removal.ts` | **내부** | 소비자는 `service-worker-page-controller.ts:20`(그룹 내) + `tests/`뿐 |
|
||||
| `service-worker-static-assets.ts` | **내부** | 소비자는 `service-worker-lifecycle.ts:17`(그룹 내) + `tests/`뿐 |
|
||||
| `service-worker-entry.ts` | 대상 아님 | export 0개 + `tsconfig.app.json` 제외 |
|
||||
|
||||
내부(테스트 전용)로 남아 깊은 경로를 유지할 심볼: `removeOwnedRegistration`, `purgeOwnedResources`, `isOwnedRegistration`, `expectedServiceWorkerUrls`(`service-worker-removal.ts:33,65,89,138`), `installStaticAssets`, `classifyFetch`, `validateStaticAssetManifest`, `selectCachesToDelete`(`service-worker-static-assets.ts:35,85,119,377`).
|
||||
|
||||
```ts
|
||||
export {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerClientLike,
|
||||
type WorkerRuntimeConfig,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
export {
|
||||
createServiceWorkerPageController,
|
||||
type ActivationBlocker,
|
||||
type PageControllerDependencies,
|
||||
} from "./service-worker-page-controller.ts";
|
||||
export {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
type ParsedMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 `src/adapters/storage/index.ts`
|
||||
|
||||
#### 판정: 최상위 배럴은 서브폴더를 재수출하지 **않는다**. 서브폴더 배럴이 곧 경계다.
|
||||
|
||||
`browser-transfer/index.ts:1-3`과 `realtime/index.ts:56-58`의 선례를 따라 `export * from "./indexeddb/index.ts"`를 넣고 싶어지지만, **storage에서는 그게 게이트를 깬다.**
|
||||
|
||||
**근거 (결정적) — 제거 드릴이 서브폴더만 삭제한다.**
|
||||
`scripts/test-browser-file-storage-runtime-removal.ts:23-34`:
|
||||
|
||||
```ts
|
||||
const runtimePaths = [
|
||||
...
|
||||
"src/adapters/storage/indexeddb",
|
||||
"src/adapters/storage/opfs",
|
||||
...
|
||||
] as const;
|
||||
```
|
||||
|
||||
이 스크립트는 fixture 트리에서 위 경로를 `rm -rf`한 뒤(`:56-61`) `assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, ...)`를 호출한다(`:129-133`). 그 함수는 `scripts/lib/removal-fixture.ts:251-260`:
|
||||
|
||||
```ts
|
||||
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
|
||||
if (graph.importingFiles.length > 0) {
|
||||
throw new Error(`Removed ${capability} runtime is still imported by: ...`);
|
||||
}
|
||||
```
|
||||
|
||||
즉 `src/adapters/storage/index.ts`가 `./indexeddb/index.ts`를 재수출하면, 삭제 후에도 그 파일이 살아남아 삭제된 런타임을 import하는 상태가 되어 드릴이 **즉시 예외로 실패**한다. 그 뒤에 이어지는 `check:types` / `lint` / `check:architecture` / `build`(`:135-143`)까지 전부 못 간다.
|
||||
|
||||
**대조 — realtime·browser-transfer는 왜 괜찮은가.**
|
||||
`scripts/test-realtime-runtime-removal.ts:21-31`은 `src/adapters/realtime` **폴더 전체**를 지운다. `browser-file-storage` 드릴도 `src/adapters/browser-transfer` 전체를 지운다(`test-browser-file-storage-runtime-removal.ts:28`). 배럴이 폴더와 함께 사라지므로 문제가 없다. **storage만 부분 삭제 대상**이다 — 최상위 `browser-storage-adapter.ts`(localStorage/sessionStorage KV)는 남고 IndexedDB·OPFS 런타임만 빠진다.
|
||||
|
||||
**따라서:**
|
||||
- `src/adapters/storage/index.ts` = 최상위 파일들만.
|
||||
- `src/adapters/storage/indexeddb/index.ts`, `src/adapters/storage/opfs/index.ts` = 각 제거 가능 런타임의 공개 경계. **이미 존재하고 이미 명명 재수출이다.** 새로 만들 필요 없다.
|
||||
- §5의 게이트 정규식은 그래서 1단계 중첩 `index.ts`까지 배럴로 인정해야 한다.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createBrowserStorageAdapter` | `browser-storage-adapter.ts:35` | 공개 | `src/bootstrap/runtime-adapters.ts:27` |
|
||||
| `BrowserStorageDependencies` | `:20` | 공개 | 팩토리 인자 타입. 필드가 전부 원시형/포트라 codec 타입을 노출하지 않는다(`:20-27` 확인) |
|
||||
| `browser-storage-codec.ts` 전체 (`DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES:1`, `encodeBrowserStorageEnvelope:31`, `decodeBrowserStorageEnvelope:57`, `assertValidBrowserStorageByteLimit:88`, `BrowserStorageEnvelope:11`, `BrowserStorageCodecFailure:17`, `BrowserStorageCodecResult:22`) | — | **내부** | 유일한 소비자가 `browser-storage-adapter.ts:14-18`(그룹 내). `src/`·`tests/` 어디서도 직접 import 없음 |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createBrowserStorageAdapter,
|
||||
type BrowserStorageDependencies,
|
||||
} from "./browser-storage-adapter.ts";
|
||||
```
|
||||
|
||||
> **서브배럴 보완 권고 (별건, 이 문서 범위 밖)**
|
||||
> `tests/unit/opfs-byte-store.test.ts:25,28`가 `OPFS_WORKER_PROTOCOL_VERSION`(`opfs/opfs-worker-protocol.ts:23`), `PreparePhysicalObjectRequest`(`:179`), `writeWithSyncAccessHandle`(`opfs/opfs-worker-runtime.ts:1363`)를 쓰는데 이 셋은 `opfs/index.ts`에 없다. 테스트를 배럴로 옮길 때(§4.2) 함께 결정해야 한다 — 올리거나, 내부로 확정하고 테스트가 깊은 경로를 유지하거나.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 `src/adapters/http/index.ts`
|
||||
|
||||
#### V2 / V3 판정
|
||||
|
||||
**V3(`createContractHttpExecutor`, `http-execution-v3.ts:377`)가 권장 경로.** V2(`createHttpClient`, `client.ts:138`)는 legacy 보존.
|
||||
|
||||
근거:
|
||||
1. `src/adapters/http/client.ts:121`에 `LegacyHttpInput`이라는 타입이 있고, 공개 시그니처 `HttpClient.execute`(`:131-137`)가 그걸 두 번째 인자로 받는다. 파일이 스스로 legacy라고 말한다.
|
||||
2. `src/adapters/http/http-execution-v3.ts:45-51` 헤더 주석: "§7–§8. Descriptor-driven HTTP execution. ... the runtime owns bounds, the total deadline, the single retry authority and the effect-certainty verdict." — 계약 기반 실행이 V3에 있다.
|
||||
3. 라이브 경로가 V3다. `src/bootstrap/runtime-adapters.ts:423`이 `createContractHttpExecutor`를 조립해 실제 런타임에 넣는다. 반면 V2 래퍼 `createRuntimeHttpClient`(`src/bootstrap/runtime-adapters.ts:163`)의 유일한 호출자는 `tests/unit/runtime-adapters.test.ts:371`이다 (`grep -rn 'createRuntimeHttpClient' src/ tests/` 결과 3줄: 정의 1 + 테스트 2).
|
||||
4. V3 타입은 이미 feature 경계를 넘는다: `src/features/reference-feature/adapters/create-reference-feature-input.ts:9`가 `HttpExecutionOutcome`를 import한다.
|
||||
|
||||
둘 다 배럴에 넣되 **블록 순서로 V3를 먼저 두고 주석으로 표시**한다.
|
||||
|
||||
#### 공개 표면
|
||||
|
||||
| 파일 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `http-execution-v3.ts` | 공개 | `src/bootstrap/runtime-adapters.ts:10,11`, `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` |
|
||||
| `client.ts` | 공개(legacy) | `src/bootstrap/runtime-adapters.ts:8` |
|
||||
| `http-contract-bridge.ts` | `CredentialPatchOutcome`만 공개 | `http-execution-v3.ts:292`의 공개 시그니처 `attachCredentials(...): Promise<CredentialPatchOutcome> \| CredentialPatchOutcome`에 이름으로 등장 → bootstrap이 그 콜백을 구현한다(`src/bootstrap/runtime-adapters.ts:434` 부근) |
|
||||
| `request-builder.ts` | `OperationRequestInput`만 공개 | `client.ts:133`의 공개 시그니처 `execute(request: string \| OperationRequestInput, ...)`에 이름으로 등장 |
|
||||
| `bounded-body-reader.ts` | **내부** | 소비자는 `bounded-json.ts:1`, `http-execution-v3.ts:18-20`(그룹 내) + `tests/` |
|
||||
| `bounded-json.ts` | **내부** | 소비자는 `client.ts:45`(그룹 내) + `tests/` |
|
||||
| `http-effect-certainty.ts` | **내부** | 소비자는 `http-execution-v3.ts:38-42`(그룹 내) + `tests/` |
|
||||
| `retry-policy.ts` | **내부** | 소비자는 `client.ts:9`, `http-execution-v3.ts:43`(그룹 내) + `tests/` |
|
||||
| `schema-registry.ts` | **내부** | 소비자는 `client.ts:14`(그룹 내) + `tests/` |
|
||||
| `resource-mapper.ts` | **내부** | 소비자는 `client.ts:8`(그룹 내) + `tests/` |
|
||||
| `http-contract-bridge.ts` 나머지 11개 | **내부** | 그룹 내 + `tests/` |
|
||||
| `request-builder.ts` 나머지 2개 (`buildRequestTarget:28`, `RequestTargetResult:14`) | **내부(테스트 전용)** | `tests/`만 |
|
||||
|
||||
```ts
|
||||
/**
|
||||
* §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된
|
||||
* 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을
|
||||
* 런타임이 소유한다.
|
||||
*/
|
||||
export {
|
||||
createContractHttpExecutor,
|
||||
type AuthIntegrationFailureReason,
|
||||
type AuthOperationContext,
|
||||
type CancellationOwner,
|
||||
type ContractHttpExecutor,
|
||||
type ContractHttpExecutorDependencies,
|
||||
type HttpContractViolation,
|
||||
type HttpContractViolationKind,
|
||||
type HttpEffectCertainty,
|
||||
type HttpExecutionContext,
|
||||
type HttpExecutionObservation,
|
||||
type HttpExecutionOutcome,
|
||||
type HttpTransportFailure,
|
||||
type SafeResponseMetadata,
|
||||
} from "./http-execution-v3.ts";
|
||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
||||
/**
|
||||
* V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다.
|
||||
* 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는
|
||||
* 오퍼레이션을 위한 이행 경로이기 때문이다.
|
||||
*/
|
||||
export {
|
||||
createHttpClient,
|
||||
type HttpClient,
|
||||
type HttpClientDependencies,
|
||||
type HttpFailure,
|
||||
type HttpResult,
|
||||
type LegacyHttpInput,
|
||||
type Scheduler,
|
||||
} from "./client.ts";
|
||||
/** V2 `HttpClient.execute`의 첫 인자 타입. */
|
||||
export type { OperationRequestInput } from "./request-builder.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 호출처 치환 목록
|
||||
|
||||
### 4.1 `src/` — 전수 (17줄)
|
||||
|
||||
측정: §2의 grep. 17줄 중 2줄은 이미 배럴 경유라 변경 없음. 실제 치환 대상 **15줄**.
|
||||
|
||||
| # | 파일:줄 | 현재 specifier | 바뀔 specifier | 비고 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `src/bootstrap/main.tsx:3` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `recordBootFailure` |
|
||||
| 2 | `src/bootstrap/optional-runtime-host.ts:4` | `../adapters/platform/browser-lifecycle.ts` | `../adapters/platform/index.ts` | 블록 시작은 `:1` |
|
||||
| 3 | `src/bootstrap/register-service-worker.ts:5` | `../adapters/service-worker/service-worker-page-controller.ts` | `../adapters/service-worker/index.ts` | `createServiceWorkerPageController` |
|
||||
| 4 | `src/bootstrap/runtime-adapters.ts:6` | `../adapters/auth/external-session-adapter.ts` | `../adapters/auth/index.ts` | 블록 시작 `:1` |
|
||||
| 5 | `src/bootstrap/runtime-adapters.ts:7` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `createDiagnosticsAdapter` |
|
||||
| 6 | `src/bootstrap/runtime-adapters.ts:8` | `../adapters/http/client.ts` | `../adapters/http/index.ts` | **7번과 한 블록으로 합칠 것** |
|
||||
| 7 | `src/bootstrap/runtime-adapters.ts:12` | `../adapters/http/http-execution-v3.ts` | `../adapters/http/index.ts` | 블록 시작 `:9` |
|
||||
| 8 | `src/bootstrap/runtime-adapters.ts:20` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 |
|
||||
| 9 | `src/bootstrap/runtime-adapters.ts:23` | `../adapters/query-cache/tanstack-cache-coordinator.ts` | `../adapters/query-cache/index.ts` | 블록 시작 `:21`. **9~12를 한 블록으로 합칠 것** |
|
||||
| 10 | `src/bootstrap/runtime-adapters.ts:24` | `../adapters/query-cache/tanstack-query-cache.ts` | `../adapters/query-cache/index.ts` | `createQueryClient` |
|
||||
| 11 | `src/bootstrap/runtime-adapters.ts:25` | `../adapters/query-cache/server-state-scope-runtime.ts` | `../adapters/query-cache/index.ts` | `createServerStateScopeRuntime` |
|
||||
| 12 | `src/bootstrap/runtime-adapters.ts:26` | `../adapters/query-cache/conditional-validator-store.ts` | `../adapters/query-cache/index.ts` | `createConditionalValidatorStore` |
|
||||
| 13 | `src/bootstrap/runtime-adapters.ts:27` | `../adapters/storage/browser-storage-adapter.ts` | `../adapters/storage/index.ts` | `createBrowserStorageAdapter` |
|
||||
| 14 | `src/bootstrap/runtime-adapters.ts:28` | `../adapters/platform/browser-mutation-intent-factory.ts` | `../adapters/platform/index.ts` | `createBrowserMutationIntentFactory` |
|
||||
| 15 | `src/bootstrap/runtime-adapters.ts:29` | `../adapters/telemetry/best-effort-telemetry.ts` | `../adapters/telemetry/index.ts` | `createTelemetryAdapter` |
|
||||
| 16 | `src/bootstrap/server-state-generation-store.ts:3` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 |
|
||||
| 17 | `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | `../../../adapters/http/http-execution-v3.ts` | `../../../adapters/http/index.ts` | `type HttpExecutionOutcome` |
|
||||
|
||||
**작업 순서 주의.** `runtime-adapters.ts`는 12줄이 한 덩어리(`:6`~`:29`)다. 6·7을 합치고 9~12를 합치면 그 아래 줄 번호가 전부 밀린다. **아래에서 위로 편집**하거나 `:1`~`:29` 블록을 한 번에 다시 쓴다.
|
||||
|
||||
**선택 사항 (게이트 범위 밖, 일관성 목적).** `check:architecture`는 `src`만 스캔하므로(`scripts/check-architecture.ts:69`, `:116-123`) 아래 두 줄은 강제되지 않는다. 같은 커밋에서 정리하는 걸 권한다.
|
||||
|
||||
| 파일:줄 | 현재 | 바뀔 것 |
|
||||
|---|---|---|
|
||||
| `.storybook/preview.tsx:5` | `../src/adapters/auth/external-session-adapter.ts` | `../src/adapters/auth/index.ts` |
|
||||
| `.storybook/preview.tsx:6` | `../src/adapters/query-cache/tanstack-query-cache.ts` | `../src/adapters/query-cache/index.ts` |
|
||||
|
||||
**곁다리 정리 기회 하나.** `src/bootstrap/runtime-adapters.ts:65`가 `type HttpClientDependencies = Parameters<typeof createHttpClient>[0];`로 타입을 역추출한다. 배럴이 `HttpClientDependencies`를 직접 내보내므로 이 줄은 지우고 import로 대체할 수 있다. 필수는 아니다.
|
||||
|
||||
### 4.2 `tests/` — 그룹별 집계와 권고
|
||||
|
||||
측정(2026-09-16, `develop` `5434760`):
|
||||
|
||||
```
|
||||
grep -rnE 'from "[^"]*adapters/<16개 그룹>/' tests/ --include='*.ts' --include='*.tsx' | grep -v '^tests/fixtures/'
|
||||
```
|
||||
|
||||
**166줄 / 83개 파일.** (`tests/fixtures` 포함 시 169줄. 지시문의 184와 다른데, `tests/fixtures`는 `.dependency-cruiser.json:208`에서 제외 대상이고 그 안의 `indexeddb-repository.ts` 같은 경로는 존재하지 않는 금지 recipe fixture다.)
|
||||
|
||||
| 그룹 | 줄 수 | 배럴 존재 | 배럴 경유 |
|
||||
|---|---:|---|---:|
|
||||
| browser-transfer | 26 | 기존 | 0 |
|
||||
| realtime | 24 | 기존 | 1 |
|
||||
| http | 22 | **신규** | 0 |
|
||||
| storage | 20 | **신규**(+서브배럴 2) | 0 |
|
||||
| web-push | 17 | 기존 | 0 |
|
||||
| browser-files | 17 | 기존 | 1 |
|
||||
| query-cache | 9 | **신규** | 0 |
|
||||
| auth | 9 | **신규** | 0 |
|
||||
| browser-file-storage | 5 | 기존 | 0 |
|
||||
| service-worker | 4 | **신규** | 0 |
|
||||
| platform | 3 | **신규** | 0 |
|
||||
| browser-rpc | 3 | 기존 | 2 |
|
||||
| diagnostics | 2 | **신규** | 0 |
|
||||
| cross-context-invalidation | 2 | 기존 | 2 |
|
||||
| cache-storage | 2 | 기존 | 0 |
|
||||
| telemetry | 1 | **신규** | 0 |
|
||||
| **합계** | **166** | | **6** |
|
||||
|
||||
배럴 경유 6줄의 정확한 위치:
|
||||
`tests/unit/browser-file-runtime.test.ts:13`, `tests/unit/browser-rpc/browser-rpc-remediation.test.ts:9`, `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:11`, `tests/unit/cross-tab-invalidation.test.ts:21`, `tests/unit/realtime/realtime-reconnect-coordinator.test.ts:20`, `tests/unit/tanstack-cache-coordinator.test.ts:7`.
|
||||
|
||||
#### 권고: 한꺼번에 옮기지 않는다. 파일을 건드릴 때 그 파일 것만 옮긴다.
|
||||
|
||||
이유 셋:
|
||||
|
||||
1. **게이트가 강제하지 않는다.** `check:architecture`는 `src`만 본다(`scripts/check-architecture.ts:69`, `:116-123`). 테스트를 지금 옮겨도 검증되는 게 없고, 안 옮겨도 깨지는 게 없다. 강제되지 않는 대량 변경은 리뷰 비용만 남는다.
|
||||
2. **테스트의 절반 이상이 내부 심볼을 쓴다.** 예: `tests/unit/opfs-byte-store.test.ts:25,28`의 `PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle`은 `opfs/index.ts`에 없다. http의 `retry-policy`·`schema-registry`·`bounded-body-reader` 테스트도 마찬가지로 §3.8에서 내부로 판정한 심볼을 직접 겨눈다. 일괄 치환은 곧 "배럴에 내부 심볼을 밀어넣자"는 압력이 되고, 그러면 배럴이 경계가 아니라 재수출 덤프가 된다.
|
||||
3. **내부 심볼을 직접 겨누는 단위 테스트는 그래도 된다.** 배럴 규칙은 "그룹 바깥 **프로덕션 코드**는 배럴만"이지 "아무도 내부를 못 본다"가 아니다. 테스트는 구현 계약을 검증하는 게 일이다.
|
||||
|
||||
**실행 규칙 (문서에 남길 문장)**
|
||||
|
||||
> `tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다.
|
||||
> 어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는 심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다.
|
||||
> 배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 §2 기준으로 "공개"임을 논증하는 게 먼저다.
|
||||
|
||||
우선순위를 굳이 매긴다면: `auth`(9줄, 전부 공개 심볼), `diagnostics`(2줄), `telemetry`(1줄), `query-cache`(9줄) — 이 넷은 배럴이 그룹 표면을 100% 덮으므로 기계적 치환이 가능하다. 21줄. `http`·`storage`·`service-worker`는 내부 심볼 비중이 커서 파일 단위로만 접근한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 게이트로 강제하기
|
||||
|
||||
### 5.1 추가할 규칙 (그대로 붙여넣기)
|
||||
|
||||
`.dependency-cruiser.json`의 `forbidden` 배열에서 **`adapters-do-not-know-other-concrete-adapters` 바로 다음, `no-circular-dependencies` 앞**에 넣는다 (현재 `:193`과 `:194` 사이).
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "adapter-groups-are-reached-through-their-barrel",
|
||||
"comment": "docs/architecture/layers.md §4: 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 `src/adapters`를 뺀 이유는 어댑터끼리의 간선은 바로 위 `adapters-do-not-know-other-concrete-adapters`가 이미 담당하고, 커널(`platform/**`)은 파일 단위로 공유되기 때문이다 — `scripts/check-adapter-inventory.ts`가 네 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 `index.ts`를 허용한 이유는 `storage/indexeddb`와 `storage/opfs`가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/",
|
||||
"pathNot": "^src/adapters/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/[^/]+/",
|
||||
"pathNot": "^src/adapters/[^/]+/(?:[^/]+/)?index\\.ts$"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
규칙 형태 적합성: `scripts/check-architecture.ts:799-815`의 `validateArchitectureRules`는 `from`에 `path`/`pathNot`, `to`에 `path`/`pathNot`/`circular`만 허용한다. 이 규칙은 그 안에 있다. 정규식은 `new RegExp(pattern, "u")`로 평가되므로(`:739`, `:747`) 비캡처 그룹 `(?:...)`도 문제없다. `$1` 역참조는 쓰지 않았다.
|
||||
|
||||
### 5.2 이 규칙이 무엇을 잡고 무엇을 안 잡는가 — 실측
|
||||
|
||||
레포의 실제 import 그래프(`src` 전체, 상대 specifier 해석)에 규칙을 그대로 돌려본 결과:
|
||||
|
||||
**잡는 것 — 오늘 기준 위반 15건** (= §4.1의 치환 대상 15줄과 정확히 일치)
|
||||
|
||||
```
|
||||
src/bootstrap/main.tsx:3 -> src/adapters/diagnostics/bounded-diagnostics.ts
|
||||
src/bootstrap/optional-runtime-host.ts:4 -> src/adapters/platform/browser-lifecycle.ts
|
||||
src/bootstrap/register-service-worker.ts:5 -> src/adapters/service-worker/service-worker-page-controller.ts
|
||||
src/bootstrap/runtime-adapters.ts:6 -> src/adapters/auth/external-session-adapter.ts
|
||||
src/bootstrap/runtime-adapters.ts:7 -> src/adapters/diagnostics/bounded-diagnostics.ts
|
||||
src/bootstrap/runtime-adapters.ts:8 -> src/adapters/http/client.ts
|
||||
src/bootstrap/runtime-adapters.ts:12 -> src/adapters/http/http-execution-v3.ts
|
||||
src/bootstrap/runtime-adapters.ts:23 -> src/adapters/query-cache/tanstack-cache-coordinator.ts
|
||||
src/bootstrap/runtime-adapters.ts:24 -> src/adapters/query-cache/tanstack-query-cache.ts
|
||||
src/bootstrap/runtime-adapters.ts:25 -> src/adapters/query-cache/server-state-scope-runtime.ts
|
||||
src/bootstrap/runtime-adapters.ts:26 -> src/adapters/query-cache/conditional-validator-store.ts
|
||||
src/bootstrap/runtime-adapters.ts:27 -> src/adapters/storage/browser-storage-adapter.ts
|
||||
src/bootstrap/runtime-adapters.ts:28 -> src/adapters/platform/browser-mutation-intent-factory.ts
|
||||
src/bootstrap/runtime-adapters.ts:29 -> src/adapters/telemetry/best-effort-telemetry.ts
|
||||
src/features/reference-feature/adapters/create-reference-feature-input.ts:9 -> src/adapters/http/http-execution-v3.ts
|
||||
```
|
||||
|
||||
§4.1을 적용하면 이 15건이 0이 된다. **즉 배럴 8개 생성 + import 15줄 치환 + 규칙 추가를 한 커밋에 넣어야 `check:architecture`가 계속 PASS한다.** 순서를 나누면 중간 커밋이 빨간불이 된다.
|
||||
|
||||
**안 잡는 것 (의도대로)**
|
||||
|
||||
| 경우 | 건수 | 왜 안 잡히나 |
|
||||
|---|---:|---|
|
||||
| 같은 그룹 내부 파일끼리 | 229건 중 189건 | `from.pathNot: "^src/adapters/"`가 출발점을 제외 |
|
||||
| 어댑터 → 커널 (`platform/**`, `browser-file-storage/result.ts`, `cross-context-invalidation/index.ts`) | 40건 | 같은 이유. 이 간선들은 `adapters-do-not-know-other-concrete-adapters`(`:183-193`)의 `pathNot` carve-out이 계속 담당 |
|
||||
| 바깥 → 배럴 (이미 합격) | 2건 | `to.pathNot`이 `index.ts`를 면제. `src/bootstrap/runtime-adapters.ts:20`, `src/bootstrap/server-state-generation-store.ts:3` |
|
||||
| `tests/**` | 166줄 전부 | 스캔 범위 밖 (아래 §5.3) |
|
||||
| `src/presentation/adapters/query/**` | — | 경로가 `src/presentation/...`이라 `to.path` `^src/adapters/`에 매칭 자체가 안 됨 |
|
||||
|
||||
세 가지 질문에 대한 명시적 답:
|
||||
|
||||
1. **같은 그룹 내부 import — 허용된다.** 출발점이 `^src/adapters/`이면 규칙이 아예 평가되지 않는다.
|
||||
2. **커널 접근 — 허용된다.** `platform/`은 위와 같은 이유로 통과. `browser-file-storage/result.ts`와 `cross-context-invalidation/index.ts`도 출발점이 어댑터이므로 통과. (참고: `cross-context-invalidation/index.ts`는 마침 `index.ts`라 도착점 면제에도 걸린다 — 이중으로 안전.)
|
||||
3. **`tests/`는 대상이 아니다.** `scripts/check-architecture.ts:69`가 `sourceRoot = resolve(projectRoot, "src")`이고 `:116-123`이 `depcruise src --config ...`를 돌린다. 두 그래프 모두 `src`만 본다.
|
||||
|
||||
### 5.3 기존 규칙과의 충돌 검토
|
||||
|
||||
`.dependency-cruiser.json` 전체(18개 규칙)를 읽고 대조했다.
|
||||
|
||||
| 기존 규칙 | 줄 | 충돌 |
|
||||
|---|---:|---|
|
||||
| `domain-is-framework-neutral` | 4 | 없음. domain→adapters는 어차피 전면 금지 |
|
||||
| `application-does-not-know-concrete-runtime` | 13 | 없음. 동일 |
|
||||
| `presentation-does-not-know-adapters` | 24 | 없음. presentation→adapters 전면 금지가 상위 |
|
||||
| `page-templates-own-layout-only` | 34 | 없음 |
|
||||
| `icon-vendor-is-facade-only` | 44 | 없음 (외부 패키지 대상) |
|
||||
| `adapters-do-not-know-presentation` | 54 | 없음 (방향 반대) |
|
||||
| `feature-*` 4개 | 64–103 | 없음. feature adapters→`src/adapters`는 이 규칙들이 막지 않으므로 새 규칙이 유효하게 작동 (실측 15번째 위반이 그 경우) |
|
||||
| `concrete-adapters-compose-only-in-bootstrap` | 104 | 없음. `src/(domain\|application\|presentation\|contracts)` → `^src/adapters` 전면 금지. 새 규칙은 그 나머지(`bootstrap`, `features`)에서만 실효 |
|
||||
| `external-contract-package-single-import-path` | 114 | 없음 |
|
||||
| `presentation-does-not-fetch-directly` | 126 | 없음. `^src/adapters/(http\|realtime\|service-worker\|web-worker\|storage)` 를 겨누는데 presentation은 이미 전면 금지 |
|
||||
| `generic-worker-has-no-network-or-credentials` | 137 | 없음. `^src/adapters/web-worker` 출발이라 새 규칙 출발점 제외와 겹칠 뿐. (`src/adapters/web-worker`는 현재 존재하지 않는 예방 규칙) |
|
||||
| `service-worker-entry-is-not-page-code` | 148 | 없음 |
|
||||
| `contracts-do-not-know-application` | 159 | 없음 |
|
||||
| `generic-presentation-does-not-compose-the-product` | 170 | 없음 |
|
||||
| **`adapters-do-not-know-other-concrete-adapters`** | **183** | **없음 — 상보적.** 저쪽은 `from: ^src/adapters/([^/]+)/`, 이쪽은 `from.pathNot: ^src/adapters/`. 정확히 반대 집합이라 이중 판정이 생기지 않는다 |
|
||||
| `no-circular-dependencies` | 194 | 주의 필요 → 아래 |
|
||||
|
||||
**순환 검토.** `no-circular-dependencies`(`:194-201`)가 error다. 배럴 도입이 순환을 만들려면 그룹 안 파일이 자기 그룹 `index.ts`를 import해야 한다.
|
||||
|
||||
```
|
||||
grep -rn 'from "\./index\.ts"\|from "\.\./index\.ts"' src/adapters/ --include='*.ts' → 0건
|
||||
```
|
||||
|
||||
또 `src/bootstrap/**`는 어댑터에서 import되지 않는다(`adapters-do-not-know-presentation`이 `^src/(presentation|bootstrap)`을 막음, `:54-62`). 따라서 **새 순환 없음.** 단 §3.7의 판정을 뒤집어 `storage/index.ts`가 서브배럴을 재수출하면, 순환은 아니지만 제거 드릴이 깨진다(§6 위험 2).
|
||||
|
||||
### 5.4 ESLint 쪽은 손대지 않는다
|
||||
|
||||
`eslint.config.ts:9-34`의 `layerPatterns`는 레이어 단위(`**/adapters/**`)만 다루고 그룹 내부 경로를 구분하지 않는다. 배럴 규칙을 여기에도 복제하면 두 곳에서 같은 사실을 관리하게 된다. dependency-cruiser 쪽 한 곳만 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 위험과 부수 작업
|
||||
|
||||
### 위험 1 (최대) — 번들 예산
|
||||
|
||||
`package.json`에 `"sideEffects"` 필드가 없다. 번들러는 모든 모듈을 부작용 있을 수 있는 것으로 보고, 배럴 재수출을 통해 들어온 모듈을 트리셰이킹에서 살려둘 수 있다.
|
||||
|
||||
구체적으로 위험한 세 곳:
|
||||
- `src/bootstrap/register-service-worker.ts`가 `service-worker/index.ts`를 import하면 `service-worker-lifecycle.ts`(666줄) → `service-worker-static-assets.ts`(394줄)가 **페이지 번들 그래프**에 들어온다. 지금은 `service-worker-page-controller.ts`(494줄) → `service-worker-protocol.ts` + `service-worker-removal.ts`만 들어온다.
|
||||
- `src/bootstrap/runtime-adapters.ts`가 `http/index.ts`를 import하면 V2·V3가 항상 함께 들어온다 (`client.ts` 1107줄 + `http-execution-v3.ts` 1602줄). 지금도 둘 다 import하긴 한다.
|
||||
- `query-cache/index.ts`는 `cursor-pagination-runtime.ts`(234줄)를 추가로 끌어온다.
|
||||
|
||||
예산: `config/performance/budgets.json`의 `bundle.initialJsGzipBytes = 204800`. `check:bundle`(`scripts/check-bundle.ts:47`)이 초과 시 실패한다.
|
||||
|
||||
**대응 (권장 순서)**
|
||||
1. 배럴 커밋에서 `corepack pnpm check:bundle`을 반드시 돌린다. 이 문서에서는 실행하지 않았다.
|
||||
2. 넘치면 `package.json`에 `"sideEffects": false`를 추가한다. `src/adapters` 아래에 최상위 부작용이 있는지 먼저 확인해야 한다(`presentation/styles/theme.css` 같은 CSS import는 `"sideEffects": ["*.css"]` 형태로 보존).
|
||||
3. 그래도 넘치면 `service-worker/index.ts`에서 `service-worker-lifecycle.ts` 블록을 빼고, 워커 realm은 파일 직접 import를 유지한다(`platform`과 같은 논리 — realm이 다르면 문도 다르다).
|
||||
|
||||
### 위험 2 — `storage/index.ts`에 서브배럴을 넣고 싶은 충동
|
||||
|
||||
`browser-transfer`/`realtime` 선례만 보고 `export * from "./indexeddb/index.ts"`를 넣으면 `test:browser-file-storage-removal`이 `assertNoRuntimeImports` 단계에서 예외로 죽는다(§3.7). 배럴 파일에 그 이유를 주석으로 못 박아 두는 걸 권한다.
|
||||
|
||||
### 위험 3 — `check:adapter-inventory`가 새 파일 8개를 거부한다
|
||||
|
||||
`scripts/check-adapter-inventory.ts:58-74`가 `git ls-files src/adapters` 결과와 `docs/reviews/adapters/INVENTORY.md`의 행 목록을 **정확히 일치**시키고, `합계: **N/N**` 숫자도 파일 수와 같아야 한다.
|
||||
|
||||
**같은 커밋에서 해야 할 일:**
|
||||
1. 새 `index.ts` 8개를 `git add` 한다 (추적되지 않으면 `git ls-files`에 안 잡혀서 오히려 통과하지만, 커밋하는 순간 깨진다).
|
||||
2. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가:
|
||||
|
||||
| 추가할 경로 | 상세 리뷰 링크 (같은 그룹 기존 행과 동일하게) |
|
||||
|---|---|
|
||||
| `src/adapters/auth/index.ts` | `[Network/state](./01-network-and-state.md)` — 기존 행 `:11` |
|
||||
| `src/adapters/diagnostics/index.ts` | `[Network/state](./01-network-and-state.md)` — `:58` |
|
||||
| `src/adapters/http/index.ts` | `[Network/state](./01-network-and-state.md)` — `:59-68` |
|
||||
| `src/adapters/platform/index.ts` | `[Network/state](./01-network-and-state.md)` — `:69-73` |
|
||||
| `src/adapters/query-cache/index.ts` | `[Network/state](./01-network-and-state.md)` — `:74-78` |
|
||||
| `src/adapters/telemetry/index.ts` | `[Network/state](./01-network-and-state.md)` — `:119` |
|
||||
| `src/adapters/service-worker/index.ts` | `[Worker/push](./05-service-worker-and-web-push.md)` — `:96-101` |
|
||||
| `src/adapters/storage/index.ts` | `[Storage/files](./03-storage-and-browser-files.md)` — `:102-103` |
|
||||
|
||||
3. `docs/reviews/adapters/INVENTORY.md:132`의 `합계: **120/120**` → `합계: **128/128**`.
|
||||
|
||||
행 번호(`| N |`)는 `inventoryRows`(`scripts/check-adapter-inventory.ts:33-40`)가 아래 정규식으로 **경로만** 뽑고 순서·연속성은 검사하지 않는다.
|
||||
|
||||
```
|
||||
^\|\s*\d+\s*\|\s*`([^`]+)`\s*\|
|
||||
```
|
||||
|
||||
그래도 읽는 사람을 위해 정렬 위치에 끼워 넣고 번호를 다시 매기는 걸 권한다.
|
||||
|
||||
### 위험 4 — `tsconfig.app.json` 제외 파일
|
||||
|
||||
`tsconfig.app.json:18`이 `src/adapters/service-worker/service-worker-entry.ts`를 제외한다. `service-worker/index.ts`가 이 파일을 참조하면 app 타입체크가 제외 대상을 끌어들인다. §3.6의 배럴은 참조하지 않는다(그 파일은 export가 0개다). 나중에 누가 "완전성"을 이유로 추가하지 않도록 배럴에 주석을 남기는 것도 방법이다.
|
||||
|
||||
### 부수 발견 (이 문서 범위 밖, 별도 티켓)
|
||||
|
||||
1. `src/adapters/telemetry/best-effort-telemetry.ts:49` — 커널 심볼 재수출. 소비자 0, 같은 파일 `:8`에 import가 이미 있음. 삭제 후보.
|
||||
2. `src/adapters/browser-files/index.ts:1-16` — 배럴이 application 포트 타입을 재수출. 어댑터 배럴이 하위 레이어의 통로가 되는 형태.
|
||||
3. `src/adapters/storage/opfs/index.ts` — `tests/unit/opfs-byte-store.test.ts:25,28`이 쓰는 `OPFS_WORKER_PROTOCOL_VERSION`·`PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle` 3개가 빠져 있다.
|
||||
4. `.dependency-cruiser.json:141` — `^src/adapters/web-worker` 를 겨누는 규칙이 있으나 해당 디렉터리는 존재하지 않는다(어댑터 그룹은 16개). 예방 규칙인지 잔재인지 확인 필요.
|
||||
|
||||
---
|
||||
|
||||
## 7. 심볼 존재 검증 (기계 대조)
|
||||
|
||||
제시한 8개 배럴의 **모든 재수출 심볼 82개**를, 각 대상 파일의 실제 `export` 선언과 이름 단위로 대조했다.
|
||||
|
||||
- 대조 방법: 각 `export { ... } from "./X.ts"` 블록의 이름을 뽑아, `src/adapters/<group>/X.ts`에서 `^export (declare )?(async function|function|const|let|var|class|type|interface|enum) <name>` 으로 선언된 이름 집합에 들어 있는지 확인.
|
||||
- 결과: **82/82 존재. 누락 0, 오타 0.**
|
||||
- 대상 파일 존재 여부도 함께 확인(8개 배럴이 참조하는 소스 파일 16개 전부 존재).
|
||||
|
||||
그룹별 심볼 수: auth 7, diagnostics 5, telemetry 5, platform 16, query-cache 13, storage 2, service-worker 11, http 23.
|
||||
|
||||
---
|
||||
|
||||
## 8. 실행 체크리스트 (한 커밋)
|
||||
|
||||
1. `src/adapters/{auth,diagnostics,http,platform,query-cache,service-worker,storage,telemetry}/index.ts` 8개 생성 (§3 내용 그대로).
|
||||
2. §4.1 표의 15줄 치환. `src/bootstrap/runtime-adapters.ts`는 아래에서 위로 편집하거나 `:1-29` 블록 전체를 다시 쓴다.
|
||||
3. (선택) `.storybook/preview.tsx:5,6` 2줄 치환.
|
||||
4. `.dependency-cruiser.json`에 §5.1 규칙 추가 (현재 `:193`과 `:194` 사이).
|
||||
5. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가 + `:132`의 합계를 `128/128`로.
|
||||
6. `docs/architecture/layers.md`에 "어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 커널은 예외로 파일 단위로 공유된다"를 한 문단 추가 (`:29-45`의 adapter kernel 절 뒤). 이 문서 규칙들은 실행 규칙과 짝을 이루게 되어 있다(`layers.md:39-45`).
|
||||
7. 게이트 실행: `check:architecture` → `check:types:app` → `check:adapter-inventory` → `lint` → `check:bundle` → `test:browser-file-storage-removal`.
|
||||
마지막 두 개가 이번 변경의 실제 리스크 지점이다(§6 위험 1, 2).
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user