모듈별 구조 리뷰에서 나온 두 설계를 spec으로 남기고, 그중 배럴 경계 작업의 실행 계획을 쓴다. 배럴 규칙은 실제 import 그래프에 돌려 위반 15건이 치환 대상 15줄과 일치함을 확인했고, 재수출할 심볼 82개는 실제 export와 대조했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
52 KiB
어댑터 배럴(index.ts)을 공개 경계로 승격 — 설계서
- 대상 레포:
/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template - 브랜치:
develop(기준 커밋5434760) - 전제(이미 확정): 배럴 폐지안은 기각.
src/adapters/<group>/index.ts를 진짜 공개 경계로 만든다. - 게이트 기준선:
check:architecturePASS,check:types:appPASS 유지. - 이 문서는 설계만 한다. 소스 수정·빌드·테스트 실행 없음.
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 관례 (문장으로)
- 소스 파일 단위로 블록을 만들고, 블록마다
export { ... } from "./파일.ts";로 이름을 전부 적는다. 블록이 전부 타입이면export type { ... } from "...";형태를 쓴다 (browser-files/index.ts:1,:28,:52,:53). - 블록 안 순서는 「값 먼저, 타입 나중」이고 각각 대소문자 무시 알파벳순이다.
근거:
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). - 블록(파일) 순서는 대체로 알파벳순이되 엄격하지 않다. 하위 폴더 블록은 뒤로 몰아둔다 (
web-push/index.ts:59,:66의./inbound/*). 엄격하지 않은 실례:cross-context-invalidation/index.ts는browser-cross-context-invalidation.ts(:1) 다음에browser-cross-context-host.ts(:20) — 역순. - 배럴은 그룹의 전체 export 목록이 아니다. 그룹 안에 배럴에 없는 파일이 실제로 존재한다.
근거:
src/adapters/realtime/result.ts는 export를 가지지만realtime/index.ts어디에도 없다.browser-files/browser-file-vault.ts도browser-files/index.ts에 없다. → 즉 이 레포는 이미 "배럴 = 선별된 공개 표면"을 실천하고 있다. 새 배럴도 같은 기준으로 고르면 된다. - (참고, 따라하지 말 것)
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.tssrc/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하지 않음 |
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 |
공개 | 선언된 상한값(계약 수치) |
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:49export { 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가 전부). 이 문서 범위에서는 건드리지 않고 배럴에서 제외만 한다.
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:
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가 커널 표면이다.
/**
* 어댑터 커널의 공개 경계.
*
* `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개를 읽는다. 배럴 작업은 이 간선을 건드리지 않는다(어댑터→어댑터 간선이므로 새 규칙 범위 밖).
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).
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:
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:
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 없음 |
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 보존.
근거:
src/adapters/http/client.ts:121에LegacyHttpInput이라는 타입이 있고, 공개 시그니처HttpClient.execute(:131-137)가 그걸 두 번째 인자로 받는다. 파일이 스스로 legacy라고 말한다.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에 있다.- 라이브 경로가 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). - 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/만 |
/**
* §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줄이 한 덩어리(:612를 합치면 그 아래 줄 번호가 전부 밀린다. 아래에서 위로 편집하거나 :29)다. 6·7을 합치고 9: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.
권고: 한꺼번에 옮기지 않는다. 파일을 건드릴 때 그 파일 것만 옮긴다.
이유 셋:
- 게이트가 강제하지 않는다.
check:architecture는src만 본다(scripts/check-architecture.ts:69,:116-123). 테스트를 지금 옮겨도 검증되는 게 없고, 안 옮겨도 깨지는 게 없다. 강제되지 않는 대량 변경은 리뷰 비용만 남는다. - 테스트의 절반 이상이 내부 심볼을 쓴다. 예:
tests/unit/opfs-byte-store.test.ts:25,28의PreparePhysicalObjectRequest·writeWithSyncAccessHandle은opfs/index.ts에 없다. http의retry-policy·schema-registry·bounded-body-reader테스트도 마찬가지로 §3.8에서 내부로 판정한 심볼을 직접 겨눈다. 일괄 치환은 곧 "배럴에 내부 심볼을 밀어넣자"는 압력이 되고, 그러면 배럴이 경계가 아니라 재수출 덤프가 된다. - 내부 심볼을 직접 겨누는 단위 테스트는 그래도 된다. 배럴 규칙은 "그룹 바깥 프로덕션 코드는 배럴만"이지 "아무도 내부를 못 본다"가 아니다. 테스트는 구현 계약을 검증하는 게 일이다.
실행 규칙 (문서에 남길 문장)
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 사이).
{
"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/에 매칭 자체가 안 됨 |
세 가지 질문에 대한 명시적 답:
- 같은 그룹 내부 import — 허용된다. 출발점이
^src/adapters/이면 규칙이 아예 평가되지 않는다. - 커널 접근 — 허용된다.
platform/은 위와 같은 이유로 통과.browser-file-storage/result.ts와cross-context-invalidation/index.ts도 출발점이 어댑터이므로 통과. (참고:cross-context-invalidation/index.ts는 마침index.ts라 도착점 면제에도 걸린다 — 이중으로 안전.) 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.ts1107줄 +http-execution-v3.ts1602줄). 지금도 둘 다 import하긴 한다.query-cache/index.ts는cursor-pagination-runtime.ts(234줄)를 추가로 끌어온다.
예산: config/performance/budgets.json의 bundle.initialJsGzipBytes = 204800. check:bundle(scripts/check-bundle.ts:47)이 초과 시 실패한다.
대응 (권장 순서)
- 배럴 커밋에서
corepack pnpm check:bundle을 반드시 돌린다. 이 문서에서는 실행하지 않았다. - 넘치면
package.json에"sideEffects": false를 추가한다.src/adapters아래에 최상위 부작용이 있는지 먼저 확인해야 한다(presentation/styles/theme.css같은 CSS import는"sideEffects": ["*.css"]형태로 보존). - 그래도 넘치면
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** 숫자도 파일 수와 같아야 한다.
같은 커밋에서 해야 할 일:
- 새
index.ts8개를git add한다 (추적되지 않으면git ls-files에 안 잡혀서 오히려 통과하지만, 커밋하는 순간 깨진다). 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 |
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개다). 나중에 누가 "완전성"을 이유로 추가하지 않도록 배럴에 주석을 남기는 것도 방법이다.
부수 발견 (이 문서 범위 밖, 별도 티켓)
src/adapters/telemetry/best-effort-telemetry.ts:49— 커널 심볼 재수출. 소비자 0, 같은 파일:8에 import가 이미 있음. 삭제 후보.src/adapters/browser-files/index.ts:1-16— 배럴이 application 포트 타입을 재수출. 어댑터 배럴이 하위 레이어의 통로가 되는 형태.src/adapters/storage/opfs/index.ts—tests/unit/opfs-byte-store.test.ts:25,28이 쓰는OPFS_WORKER_PROTOCOL_VERSION·PreparePhysicalObjectRequest·writeWithSyncAccessHandle3개가 빠져 있다..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. 실행 체크리스트 (한 커밋)
src/adapters/{auth,diagnostics,http,platform,query-cache,service-worker,storage,telemetry}/index.ts8개 생성 (§3 내용 그대로).- §4.1 표의 15줄 치환.
src/bootstrap/runtime-adapters.ts는 아래에서 위로 편집하거나:1-29블록 전체를 다시 쓴다. - (선택)
.storybook/preview.tsx:5,62줄 치환. .dependency-cruiser.json에 §5.1 규칙 추가 (현재:193과:194사이).docs/reviews/adapters/INVENTORY.md에 행 8개 추가 +:132의 합계를128/128로.docs/architecture/layers.md에 "어댑터 그룹의 공개 표면은 그 그룹의index.ts다. 커널은 예외로 파일 단위로 공유된다"를 한 문단 추가 (:29-45의 adapter kernel 절 뒤). 이 문서 규칙들은 실행 규칙과 짝을 이루게 되어 있다(layers.md:39-45).- 게이트 실행:
check:architecture→check:types:app→check:adapter-inventory→lint→check:bundle→test:browser-file-storage-removal. 마지막 두 개가 이번 변경의 실제 리스크 지점이다(§6 위험 1, 2).