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:
DongHyeonka
2026-09-16 18:50:02 +09:00
co-authored by Claude Opus 5
parent 5434760ddf
commit a42d96185f
11 changed files with 2735 additions and 0 deletions
@@ -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개 | 64103 | 없음. 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