Files
clean-architecture-frontend…/docs/architecture/frontend-ports-adapters-and-boundaries.md
T
2026-08-01 19:39:59 +09:00

1357 lines
50 KiB
Markdown

# 프론트엔드 포트·어댑터와 경계 설계
> **정본 안내 (non-authoritative for runtime capability decisions)**
>
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
> Service Worker, offline command와 Background Sync의 구현 결정은
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
## 1. 문서 목적
이 문서는 이 저장소에서 도메인 기능을 추가할 때 사용할 경계와 확장
절차를 정의한다. 다음 질문에 대한 단일 기준 문서다.
- 프론트엔드에서 inbound와 outbound는 무엇인가?
- `presentation`은 왜 inbound adapter인가?
- application이 노출해야 할 API와 외부 기술 포트는 어떻게 다른가?
- React, Router, TanStack Query, HTTP, 인증, 저장소, 로깅을 어디에
배치하는가?
- 새 기술 adapter나 새 feature를 어떤 순서로 추가하는가?
- 예제 feature가 제품 코드에 남지 않았음을 어떻게 증명하는가?
포트의 방향은 네트워크 패킷의 방향이 아니라 **application을 기준으로
누가 누구를 호출하는지**로 결정한다.
```text
사용자·브라우저 이벤트
-> inbound adapter
-> input port
-> application use case
-> output port
-> outbound adapter
-> 외부 시스템 또는 브라우저 기능
```
화살표는 런타임 호출 방향이다. 소스 의존성은 가능한 한 안쪽을 향한다.
구체 기술의 선택과 조립은 `bootstrap`에서만 수행한다.
## 2. 핵심 용어
### 2.1 Input port
외부가 application에 요청할 수 있는 작업의 계약이다. 사용자의 의도를
나타내며 기술 이름 대신 업무 동사를 사용한다.
예:
- `ListReferenceItems`
- `CreateReferenceItem`
- `BeginSignIn`
- `ChangeColorScheme`
- `RecoverSession`
Input port는 React hook, HTTP request, router loader 자체가 아니다. React나
router가 없어도 테스트할 수 있는 함수 또는 인터페이스여야 한다.
### 2.2 Inbound adapter
사용자 입력이나 외부 이벤트를 input port 호출로 변환한다. 현재 저장소의
`src/presentation`은 이름에 `adapter`가 없지만 이 역할을 담당한다.
대표적인 inbound adapter:
- React page와 component
- React Router route, loader, action, guard
- form controller와 feature hook
- 키보드, pointer, browser lifecycle event handler
- WebSocket이나 Service Worker가 수신한 이벤트를 use case로 전달하는
event handler
### 2.3 Output port
application이 작업을 완료하기 위해 외부에 요구하는 capability 계약이다.
application이 소유하고 outbound adapter가 구현한다.
대표적인 output port:
- 도메인별 gateway 또는 repository
- session gateway
- clock, ID generator
- preference persistence
- logger, telemetry, error reporter
- feature flag reader
Output port에는 `fetch`, `localStorage`, 특정 SDK 객체처럼 기술 구현이
드러나지 않아야 한다.
### 2.4 Outbound adapter
output port를 실제 기술로 구현한다.
대표적인 outbound adapter:
- Fetch 기반 API gateway
- 외부 인증 SDK session gateway
- `localStorage`, `sessionStorage`, IndexedDB persistence
- telemetry 또는 error-reporting sink
- browser clock, random, crypto UUID
- feature flag SDK
### 2.5 Bootstrap
구체 구현을 선택하고 연결하는 composition root다. bootstrap만 다음
결정을 알고 있어야 한다.
- 어떤 HTTP transport를 사용할지
- 어떤 인증 owner를 연결할지
- 어떤 logger와 telemetry sink를 사용할지
- QueryClient 설정은 무엇인지
- 어떤 input port 구현을 `ApplicationProvider`에 공급할지
bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다.
## 3. 현재 저장소의 구조 해석
현재 구조는 다음과 같이 해석한다.
| 현재 경로 | 현재 역할 | 목표 역할 |
| --- | --- | --- |
| `src/domain` | 순수 model과 invariant | framework-neutral domain |
| `src/application` | port, policy, 일부 use case와 view model | input/output port와 use case |
| `src/presentation` | React UI, route, provider, 상태 화면 | inbound React adapter |
| `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter |
| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root |
| `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 |
| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 제거 gate 유지 |
현재 구조가 잘 제공하는 기반은 다음과 같다.
- bootstrap 이전 runtime config와 release 검증
- 인증 credential을 UI에 노출하지 않는 session seam
- bounded retry와 idempotency 정책
- HTTP envelope 및 payload runtime validation
- 저장소 key registry와 민감정보 저장 금지
- TanStack Query client 기본 정책
- semantic telemetry allowlist
- render boundary와 async state surface
- route registry와 lazy page
RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
- `src/bootstrap/composition-root.ts`가 만든 application을 production
`ApplicationProvider`가 실제 React tree에 주입한다.
- `createApplication`은 session, preference, diagnostics, runtime query와 typed
feature input registry만 반환하며 storage, telemetry, release output port를
숨긴다.
- bootstrap composition 결과는 raw output port를 반환하지 않고
application과 React infrastructure만 반환한다.
- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
application의 React/concrete adapter import는 negative fixture가 거절한다.
RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
- `src/presentation/adapters/query`만 TanStack Query import를 허용하며
application query/mutation을 cancellation, invalidation, deduplication,
optimistic rollback과 conflict 해제에 연결한다.
- HTTP request builder는 path escaping과 canonical search를 소유하고 Zod가
변환한 search/body를 실제 request에 사용한다.
- runtime timeout과 max retry attempts가 transport factory에 주입되며
validation, success, abort, timeout과 exhausted retry의 timer/listener
정리를 테스트한다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는
비활성화한다.
- reference HTTP operation map은 operation ID마다 route ID, request shape와
성공 model type을 결합한다. runtime schema와 mapper를 통과한 raw executor는
단일 binder에서만 typed executor로 승격되므로 gateway별 응답 cast가 없다.
위 항목은 reference REST vertical의 `COMPOSED` 증거다. auth owner final-request
invariant, auth unavailable fail-close, total deadline, bounded response decoder,
typed Schema/Mapper proof, complete pagination과 mutation concurrency 같은
production hardening delta는
[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)에
별도 `DESIGNED_NOT_IMPLEMENTED`로 기록한다.
RP-04에서 route 실행 불일치는 닫혔다. route registry와 runtime map은
Data Router tree, codec, surface, title, navigation, chunk/release recovery의
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
contract/presentation은 `src/features/reference-feature`가 소유하고, generic
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
generic application의 `ApplicationFeatureInputs`는 concrete feature를 import하지
않는 open interface다. 각 feature application API가 module augmentation으로
자신의 literal ID와 input shape를 기여하고, `features.get(id)`는 ID별 정확한
input type을 반환한다. 잘못된 ID/input과 설치 누락은 각각 compile-time negative
fixture와 runtime guard로 닫는다.
RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
presentation schema, controlled field state, error focus, 422 allowlist,
pending/deduplication과 dirty navigation을 local facade로 감싼다.
`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다.
RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다.
`src/presentation/design-system/index.ts`는 token → primitive → pattern →
template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는
`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application
port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도
presentation이 소유하고 use case나 outbound adapter로 올리지 않는다.
RP-08에서 i18n은 application output port가 아니라 React inbound adapter의
local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고
timestamp, number, failure kind 같은 의미 값만 반환한다.
`src/presentation/i18n`이 typed message catalog, formatter, fallback,
`<html lang/dir>`과 pseudo/RTL smoke를 소유한다. backend raw `message`
application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative
fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의
adapter만 교체한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
## 4. Inbound와 outbound 분류표
| 관심사 | 방향 | Port 소유자 | Adapter 예 | 기본 제공 여부 |
| --- | --- | --- | --- | --- |
| React page와 component | inbound | application input API 소비 | React | 필수 |
| Routing과 navigation | inbound | route input/controller | React Router | 필수 |
| Form submit과 validation 표시 | inbound | command input port 소비 | React form controller | 필수 |
| Server-state query hook | inbound bridge | application input API 소비 | TanStack Query hook | 필수 |
| validated external server event | inbound | event input port | SSE/WebSocket listener, Service Worker push handler | 선택 |
| 도메인 API 접근 | outbound | application | Fetch gateway | 필수 |
| 인증 session | outbound | application | 외부 auth owner/SDK | 필수 seam |
| Credential attachment | outbound | transport 또는 auth integration | auth request decorator | 필수 seam |
| Preference persistence | outbound | application | browser storage | 필수 |
| 시간·ID·random | outbound | application | browser platform | 필수 |
| Logging·telemetry | outbound | application | console/remote sink | 필수 |
| Offline persistence | outbound | application | IndexedDB | 선택 |
| Feature flag | outbound | application | flag SDK | 선택 |
| Locale/message/formatter | UI platform | React inbound 계약 | `Intl` + local catalog | 필수 seam, vendor 선택 |
| 외부 오류 수집 | outbound | application | error-reporting SDK | 선택 |
WebSocket은 한 단어로 항상 inbound 또는 outbound가 아니다. 연결을 열고
메시지를 전송하는 기능은 application이 요구하는 output capability일 수
있고, 수신된 메시지를 use case로 전달하는 handler는 inbound adapter다.
두 책임을 하나의 거대한 interface로 합치지 않는다.
## 5. 왜 `presentation`이 inbound adapter인가
React component는 사용자의 클릭, 입력, URL 이동을 해석한다. 그 결과를
application input port가 이해하는 command나 query로 바꾸고, 반환된
view state를 HTML로 표현한다. 이는 전형적인 driving/inbound adapter다.
```text
Button click
-> React event handler
-> CreateReferenceItem command
-> CreateReferenceItem input port
-> use case
```
따라서 `presentation`이 adapter라는 사실은 이름에 `adapter`가 붙었는지와
관계없다. 다음 두 구조 모두 가능하다.
```text
src/presentation # presentation을 inbound로 문서화
src/adapters/outbound
```
또는 더 명시적으로:
```text
src/adapters/inbound/react
src/adapters/outbound
```
이 저장소의 장기 목표는 두 번째 구조다. 점진적 전환 중에는
`presentation = inbound/react`로 동일하게 취급한다.
Presentation이 inbound라는 이유로 모든 component가 use case interface를
가져야 하는 것은 아니다. `Button`, `Card`, `Dialog` 같은 순수 UI primitive는
입력 props와 DOM behavior만 소유한다. page, controller hook, route
loader/action처럼 application 작업을 시작하는 경계에서 input port를
사용한다.
## 6. 브라우저가 서버 데이터 기술을 직접 연결하지 않는 이유
브라우저 adapter는 Redis, PostgreSQL, MongoDB, Kafka, MinIO 같은 서버
인프라에 직접 연결하지 않는다.
그 이유는 다음과 같다.
- 브라우저 bundle과 runtime config는 사용자가 읽을 수 있어 서버
credential을 안전하게 보관할 수 없다.
- 데이터베이스나 broker를 인터넷에 직접 노출하면 네트워크와 권한 경계가
무너진다.
- 브라우저는 신뢰할 수 없는 실행 환경이므로 authorization과 데이터
invariant를 강제할 수 없다.
- DB driver, broker protocol, connection pool과 migration은 브라우저의
lifecycle 및 bundle 제약과 맞지 않는다.
- 클라이언트 버전은 사용자마다 다를 수 있으므로 저장소 schema와 직접
결합하면 호환성 관리가 불가능해진다.
브라우저는 HTTPS API, BFF, GraphQL, WebSocket 또는 SSE처럼 서버가
노출한 제한된 계약과 통신한다.
```text
Browser
-> HTTP/WebSocket adapter
-> Backend API/BFF
-> Redis/PostgreSQL/MongoDB/Kafka/MinIO
```
MinIO object URL을 받아 파일을 업로드하는 경우에도 브라우저 adapter는
MinIO 관리자 credential이나 내부 API를 소유하지 않는다. 서버가 발급한
짧은 수명의 제한된 URL과 업로드 정책만 사용한다.
## 7. 권장 목표 디렉터리 구조
```text
src/
core/
kernel/
result.ts
failure.ts
identifiers.ts
domain/
application/
ports/
in/
out/
use-cases/
policies/
adapters/
inbound/
react/
app/
application-provider.tsx
app-router.tsx
routing/
query/
pages/
components/
design-system/
state/
outbound/
http/
fetch-transport.ts
request-builder.ts
response-decoder.ts
retry-decorator.ts
auth/
persistence/
observability/
platform/
features/
reference-feature/
domain/
application/
ports/
in/
out/
use-cases/
adapters/
inbound/
react/
outbound/
http/
contracts/
index.ts
bootstrap/
composition-root.ts
runtime-config.ts
main.tsx
```
`core`에는 모든 feature가 공유하는 작은 kernel과 truly cross-cutting
application capability만 둔다. `shared`, `common`, `utils` 같은 이름으로
기술과 업무 규칙을 무제한 혼합하지 않는다.
`features/reference-feature`는 구조를 설명하기 위한 완전한 수직
슬라이스다. 이 폴더를 삭제하고 route 등록 한 곳만 제거했을 때
typecheck, architecture check, test와 build가 모두 통과해야 한다.
## 8. Input port 설계 규칙
### 8.1 사용자의 의도를 이름으로 표현한다
좋은 예:
```ts
export interface ListReferenceItems {
execute(
query: ListReferenceItemsQuery,
context: RequestContext,
): Promise<Result<readonly ReferenceItemView[], ApplicationFailure>>;
}
```
피해야 할 예:
```ts
export interface HttpClient {
get(url: string): Promise<unknown>;
}
```
Input port는 “HTTP GET을 실행한다”가 아니라 “항목을 조회한다”를
표현한다.
### 8.2 React와 browser type을 포함하지 않는다
Input port에 다음 type을 넣지 않는다.
- `ReactNode`, `SyntheticEvent`
- `Request`, `Response`, `Headers`
- `QueryClient`, `UseQueryResult`
- `Location`, router navigate function
- `Storage`, `Window`, `Document`
Inbound adapter가 해당 type을 application command와 query로 변환한다.
### 8.3 예측 가능한 실패를 typed result로 반환한다
검증 실패, 인증 필요, conflict, network failure처럼 사용자 흐름에
포함되는 실패는 `Result<Value, Failure = AppFailure>`로 반환한다. programmer error와
불변식 위반을 모두 일반 API 실패로 숨기지는 않는다.
```ts
export type Result<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
```
`AppFailure.kind`는 error registry의 key에서 파생한다. adapter가 받은 외부
오류 code는 이 닫힌 vocabulary로 매핑한 뒤 application 경계를 통과하며,
transport 문맥의 `ApiFailure`는 같은 type을 가리키는 호환 alias다.
### 8.4 Input port를 기술별로 합치지 않는다
`ApplicationService` 한 개에 모든 메서드를 계속 추가하지 않는다.
feature 단위 또는 응집된 capability 단위로 분리한다.
```ts
export interface ReferenceFeatureApplication {
readonly list: ListReferenceItems;
readonly create: CreateReferenceItem;
}
```
## 9. Output port 설계 규칙
### 9.1 Application이 port를 소유한다
도메인별 gateway는 feature의 application 경로에 둔다.
```ts
export interface ReferenceItemGateway {
list(
query: ListReferenceItemsQuery,
context: RequestContext,
): Promise<Result<readonly ReferenceItem, GatewayFailure>>;
create(
command: CreateReferenceItemCommand,
context: RequestContext,
): Promise<Result<ReferenceItem, GatewayFailure>>;
}
```
Fetch adapter는 이 interface를 구현하지만 application은 Fetch adapter를
import하지 않는다.
### 9.2 기술보다 capability를 표현한다
다음 이름을 우선한다.
- `ReferenceItemGateway`
- `SessionGateway`
- `PreferenceStore`
- `Clock`
- `IdGenerator`
- `Logger`
다음처럼 concrete 기술을 core port 이름에 포함하지 않는다.
- `AxiosPort`
- `LocalStoragePort`
- `RedisPort`
- `TanStackPort`
- `SentryPort`
### 9.3 Interface segregation을 지킨다
화면의 session 작업과 HTTP credential 부착은 호출 주체가 다르므로
분리한다.
```ts
export interface SessionGateway {
snapshot(): SessionSnapshot;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<SessionRecovery>;
}
export interface CredentialAttacher {
attach(request: Request): Promise<Request>;
}
```
`Request` type을 완전히 application 밖으로 유지하려면
`CredentialAttacher`는 outbound HTTP 내부 계약으로 둔다. Session use
case는 `SessionGateway`만 안다.
### 9.4 Clock과 ID도 concrete 구현과 분리한다
Application port 파일에 `Date.now`, `setTimeout`, `crypto.randomUUID`
구현을 함께 두지 않는다.
```text
core/application/ports/out/clock.ts
adapters/outbound/platform/browser-clock.ts
adapters/outbound/platform/browser-id-generator.ts
```
### 9.5 범용 HTTP client를 application port로 노출하지 않는다
범용 transport는 outbound adapter 내부의 재사용 기술이다. 각 feature의
gateway adapter가 transport를 사용해 DTO를 domain model로 매핑한다.
```text
Application -> ReferenceItemGateway
ReferenceItemHttpGateway -> HttpTransport
HttpTransport -> fetch
```
이렇게 하면 application이 URL, method, header, response envelope를 알지
않으며 API 기술을 교체할 수 있다.
## 10. Composition root와 `ApplicationProvider`
### 10.1 조립 순서
권장 조립 순서는 다음과 같다.
```text
runtime config 검증
-> release coherence 검증
-> platform adapters 생성
-> outbound gateways 생성
-> use cases 생성
-> application input API 생성
-> React infrastructure provider 생성
-> ApplicationProvider
-> Router
```
예:
```ts
export function createApplicationComposition(runtime: RuntimeConfig) {
const clock = createBrowserClock();
const logger = createRedactedLogger(runtime);
const sessionGateway = createExternalSessionGateway(runtime.auth);
const httpTransport = createHttpTransport({
baseUrl: runtime.apiBaseUrl,
timeoutMs: runtime.requestTimeoutMs,
maxRetryAttempts: runtime.maxRetryAttempts,
logger,
});
const referenceGateway = createReferenceItemHttpGateway(httpTransport);
const reference = Object.freeze({
list: createListReferenceItems({ gateway: referenceGateway, clock }),
create: createCreateReferenceItem({ gateway: referenceGateway, clock }),
});
const session = createSessionApplication({ gateway: sessionGateway, logger });
return Object.freeze({
application: Object.freeze({ reference, session }),
infrastructure: Object.freeze({
queryClient: createQueryClient(),
themeStorage: createThemeStorage(),
}),
});
}
```
### 10.2 `ApplicationProvider`가 노출할 것
`ApplicationProvider`는 presentation이 호출할 input port만 노출한다.
```ts
export interface ApplicationApi {
readonly reference: ReferenceFeatureApplication;
readonly session: SessionApplication;
}
```
다음 concrete 객체는 provider value에 넣지 않는다.
- Fetch client
- raw HTTP client
- browser storage
- telemetry SDK
- auth SDK owner
- domain gateway adapter
QueryClient처럼 React infrastructure provider가 직접 요구하는 concrete
객체는 bootstrap에서 해당 provider에 전달할 수 있다. 다만 feature page가
QueryClient를 직접 가져가 application을 우회하지 않도록 query bridge를
둔다.
### 10.3 현재 runtime의 교정 목표
현재 `composition.application`이 만들어지고도 사용되지 않는 상태를
허용하지 않는다. 다음 중 하나를 CI에서 검증한다.
- `main``composition.application``ApplicationProvider`에 전달한다.
- presentation production source는 `composition.ports`나 concrete
outbound adapter를 import하거나 props로 받지 않는다.
## 11. TanStack Query bridge
TanStack Query는 두 역할을 가진다.
1. QueryClient와 cache는 React 서버 상태 infrastructure다.
2. `useQuery`, `useMutation`은 React lifecycle을 application 호출에
연결하는 inbound bridge다.
따라서 application이 TanStack type을 알면 안 되지만, inbound React
adapter 내부의 feature hook은 TanStack을 사용할 수 있다.
```ts
export function useReferenceItems(query: ReferenceListQuery) {
const application = useApplication();
return useQuery({
queryKey: referenceQueryKeys.list(query),
queryFn: async ({ signal }) => {
const result = await application.reference.list.execute(query, {
routeId: "REFERENCE_LIST",
operationId: "LIST_REFERENCE_ITEMS",
signal,
});
if (!result.ok) {
throw toQueryFailure(result.error);
}
return result.value;
},
});
}
```
Page는 `useQuery`를 직접 조합하지 않고 feature hook을 사용한다.
```text
ReferenceListPage
-> useReferenceItems
-> ListReferenceItems input port
-> ReferenceItemGateway output port
```
Query bridge는 다음을 한곳에서 책임진다.
- canonical query key
- AbortSignal 전달
- application failure를 UI query failure로 투영
- stale/refreshing 상태 변환
- mutation 성공 후 namespace invalidation
- optimistic update와 rollback policy
- retry 소유권
HTTP transport가 retry를 소유하면 TanStack Query의 자동 retry는 기본적으로
끄고, 두 계층에서 중복 retry하지 않는다. UI의 “다시 시도” 버튼은 새
사용자 시도이며 transport의 자동 retry 횟수와 구분한다.
Application이 실제로 cache 일관성 자체를 업무 규칙으로 요구하는 경우에만
별도 `CachePort`를 둔다. 일반적인 server-state 표시를 위해 TanStack
Query의 모든 기능을 `read/write/invalidate` 세 메서드로 추상화하지 않는다.
## 12. 상태 관리 경계
모든 상태를 한 global store에 넣지 않는다.
| 상태 종류 | 기본 도구 | 저장 위치 |
| --- | --- | --- |
| 단일 component 표시 상태 | `useState`, `useReducer` | inbound React |
| URL과 공유 가능한 상태 | typed route params/search | router |
| 서버에서 소유한 상태 | TanStack Query bridge | inbound React infrastructure |
| session처럼 외부 store가 소유한 상태 | `useSyncExternalStore` | provider/bridge |
| theme 같은 작은 cross-page 설정 | context + preference port | provider/application |
| 복잡한 장기 client workflow | reducer 또는 state machine | feature application/inbound |
| 새로고침 후 유지할 공개 설정 | persistence output port | outbound persistence |
Zustand, Redux Toolkit, Jotai, XState 등은 실제 상태 복잡성이 확인될 때
선택한다. 기본 skeleton에는 상태 분류 규칙과 작은 typed external-store
예제가 필요하지만 범용 global store 의존성은 필수가 아니다.
Global store를 도입할 때도 domain invariant를 selector와 action 내부에
숨기지 않는다. 업무 규칙은 domain/application에 두고 store adapter는
입력과 표시 상태를 연결한다.
## 13. HTTP outbound adapter 계약
공통 HTTP transport가 지원해야 하는 최소 기능은 다음과 같다.
- base URL과 상대 path의 안전한 결합
- typed path parameter encoding
- canonical search parameter serialization
- parsed/normalized request body 전송
- Content-Type과 response envelope 검증
- operation별 auth 정책
- runtime 기본 timeout과 operation override
- AbortSignal과 navigation cancellation
- safe/keyed/non-idempotent retry 구분
- bounded exponential backoff와 `Retry-After`
- idempotency key의 logical request 단위 재사용
- 401 recovery 1회 제한
- request/trace ID의 안전한 투영
- raw request/response/token을 버리는 error normalization
- terminal failure logging과 telemetry
Operation registry는 문자열을 모아 둔 표가 아니라 TypeScript map으로
요청과 응답 type을 연결해야 한다.
```ts
interface ApiOperationMap {
LIST_REFERENCE_ITEMS: {
request: ListReferenceItemsRequest;
response: ReferenceItemListDto;
};
CREATE_REFERENCE_ITEM: {
request: CreateReferenceItemRequest;
response: ReferenceItemDto;
};
}
```
Zod 등 runtime schema가 반환한 `data`를 실제 request와 mapper 입력에
사용한다. `safeParse` 성공 여부만 확인하고 원본 값을 계속 사용하지 않는다.
## 14. 인증과 token 소유권
이 skeleton은 일반적인 browser token manager를 기본 제공하지 않는다.
이는 누락이 아니라 보안 기본값이다.
권장 순서는 다음과 같다.
1. 가능하면 BFF와 HttpOnly/Secure/SameSite cookie를 사용한다.
2. 외부 인증 SDK가 필요하면 SDK adapter가 token 획득, 갱신, 저장을
소유한다.
3. application과 presentation에는 credential이 아닌 session state와
sign-in/sign-out use case만 노출한다.
4. HTTP adapter는 opaque credential attachment capability만 사용한다.
5. localStorage나 일반 application store에 access/refresh token을 넣지
않는다.
SPA가 직접 bearer token을 사용해야 하는 프로젝트는 별도 보안 검토 후
memory 중심 token owner adapter를 구현한다. 그 경우에도 이 저장소의
`AUTH_TOKEN` storage 금지 계약을 우회하지 않는다.
## 15. Error, validation, logger 경계
### 15.1 Error
오류는 최소한 다음 경계를 거친다.
```text
unknown thrown value
-> outbound adapter normalization
-> typed application failure
-> query/controller projection
-> localized safe user message
```
UI에는 다음을 전달하지 않는다.
- raw stack
- backend message/details
- request/response body
- authorization header
- raw URL과 query
- storage value
### 15.2 Validation
Validation은 목적별로 분리한다.
| 위치 | 검증 대상 |
| --- | --- |
| bootstrap | runtime config, release manifest |
| route inbound adapter | params와 search |
| form/controller | 사용자 입력과 field feedback |
| outbound API adapter | request DTO와 response DTO |
| domain | invariant와 value object |
Form schema와 API request schema가 같은 모양이어도 역할이 다르므로
무조건 하나로 합치지 않는다. 필요한 경우 application command로
명시적으로 변환한다.
### 15.3 Logger와 telemetry
Logger와 telemetry는 같은 것이 아니다.
- Logger: 운영 진단을 위한 level 기반 structured record
- Telemetry: registry에 정의된 semantic event와 metric
- Error reporter: 예외 집계와 release correlation
VD-07에 따라 기본 구현은 임의 message 문자열을 받는 `Logger`가 아니라 닫힌
event ID와 safe context만 받는 `DiagnosticsPort`다.
```ts
export interface DiagnosticsPort {
record(input: {
level: DiagnosticLevel;
eventId: DiagnosticEventId;
context?: DiagnosticContext;
}): void;
}
```
기본 runtime에는 bounded in-memory diagnostics와 설정 기반 best-effort
telemetry adapter, 테스트에는 recording 또는 no-op adapter를 연결한다.
direct `console`은 redaction 경계를 우회하므로 source gate가 거절한다.
민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서
강제한다.
현재 production producer는 boot, logical HTTP outcome, cache, storage, route,
render, release mismatch와 telemetry delivery drop을 포함한다. HTTP terminal
telemetry는 모든 retry가 끝난 뒤 한 번만 발행하며 abort에는 발행하지 않는다.
raw path/query/body/error 대신 route/operation/correlation ID와
status/attempt/duration bucket만 전달한다.
## 16. Feature 경계와 removable reference feature
Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야
한다.
```text
registered route
-> lazy page
-> controller/query bridge
-> application input port
-> use case
-> output gateway port
-> HTTP adapter
-> request schema
-> response schema
-> DTO mapper
-> domain model
-> view model
-> loading/empty/success/error UI
```
Reference feature 폴더가 소유해야 할 항목:
- domain model과 value object
- input/output port
- use case
- API operation과 DTO schema
- HTTP gateway와 mapper
- query key와 query/mutation bridge
- route page와 feature component
- unit/component/integration/E2E test
공통 registry가 필요한 경우 reference feature가 registration object를
export하고 bootstrap 또는 route composition이 이를 수집한다. sample
operation을 전역 core registry 안에 하드코딩하지 않는다.
제거 테스트는 다음을 수행해야 한다.
1. `src/features/reference-feature` 전체 삭제
2. reference route registration 삭제
3. reference test 삭제 또는 제외
4. 잔여 import와 registry owner 검색
5. typecheck
6. architecture check
7. production build
8. built asset에서 reference operation/schema 문자열 부재 확인
제품 feature는 reference feature를 import할 수 없다. Reference feature도
제품에서만 존재하는 feature를 import하지 않는다.
## 17. 필수 adapter catalog
| Adapter | 책임 | 기본 구현 |
| --- | --- | --- |
| Runtime config loader | public runtime config fetch와 검증 | Fetch + Zod |
| Release manifest loader | build/release coherence 확인 | Fetch + Zod |
| Browser clock | 현재 시간과 abortable sleep | browser timer |
| ID generator | idempotency/correlation용 opaque ID | `crypto.randomUUID` |
| HTTP transport | request, timeout, retry, decode | Fetch |
| Domain API gateway | DTO와 domain 변환 | feature HTTP gateway |
| Session gateway | session snapshot와 login lifecycle | external owner |
| Credential attacher | opaque request credential 처리 | auth owner |
| Preference store | 공개 preference persistence | browser storage |
| Query infrastructure | server-state cache 정책 | TanStack Query |
| Query bridge | input port와 React query 연결 | feature hook |
| Logger | redacted structured diagnostics | console/no-op/remote |
| Telemetry | allowlist semantic event 전달 | best-effort sink |
| Browser lifecycle | visibility/pagehide/online 상태 | browser event adapter |
| Router | URL을 page/controller로 변환 | React Router |
| Design system | 접근 가능한 primitive와 token | React/CSS |
| Locale/formatter | message key와 날짜·숫자·방향성 | local locale facade |
| UI workshop/visual gate | 격리 상태·interaction·회귀 검증 | Storybook + local Playwright |
필수라는 의미는 모든 concrete library를 고정한다는 뜻이 아니다. 해당
capability의 기본 정책, port 또는 안전한 no-op 구현과 composition
위치가 정의되어 있어야 한다는 뜻이다.
## 18. 선택 adapter catalog
| Adapter | 도입 조건 | 기본 상태 |
| --- | --- | --- |
| SSE/WebSocket/bounded polling | active document의 server event 또는 duplex protocol 필요 | common target `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` |
| Web Push | inactive browser의 user-visible notification 필요 | target `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` |
| IndexedDB/OPFS | 큰 offline data, durable queue 또는 large local object 필요 | native reference runtime 제공, 미조립 |
| Cache Storage | 승인된 public HTTP representation offline cache 필요 | native reference runtime 제공, 미조립 |
| Service Worker/PWA | offline shell과 installability 필요 | lifecycle recipe 제공, 미설치 |
| Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe |
| Feature flag | remote rollout/kill switch 필요 | opt-in recipe 제공, 미설치 |
| Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 |
| Analytics | 사용자 동의 기반 product analytics 필요 | opt-in recipe 제공, 미설치 |
| Error-reporting SDK | 운영 예외 집계 필요 | opt-in recipe 제공, 미설치 |
| OpenTelemetry | 조직 trace 연계 필요 | opt-in recipe 제공, 미설치 |
| Web Worker | CPU 작업이 main thread를 막음 | opt-in recipe 제공, 미설치 |
| Notification | 사용자 권한 기반 browser notification 필요 | opt-in recipe 제공, 미설치 |
| File/Blob/picker/download | 해당 file workflow 필요 | native reference runtime 제공, 미조립 |
| Clipboard/Media | 해당 browser capability 필요 | opt-in recipe 제공, 미설치 |
| Image CDN adapter | responsive image transform 필요 | 미설치 recipe |
| Virtualization | 대량 list rendering이 측정상 병목 | opt-in recipe 제공, 미설치 |
| OpenAPI generator | backend 계약에서 client 생성 필요 | opt-in recipe 제공, 미설치 |
| Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | opt-in recipe 제공, 미설치 |
| XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | opt-in recipe 제공, 미설치 |
| Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 |
선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입
조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다.
SSE, WebSocket, Web Push와 bounded polling의 서로 다른 delivery 의미,
inbound/outbound 분리, resume·gap·lifecycle과 현재 상태는
[Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)을
따른다.
Web Push는 subscription/provider/worker delivery를, 별도 Notification 행은
permission과 user-visible rendering facade를 뜻한다. bounded polling은 inbound
push adapter가 아니라 Query bridge 또는 application orchestrator가 기존 HTTP
operation을 schedule하는 policy다.
현재 구현된 공통 catalog, TypeScript contract/fake, browser-native reference
runtime과 blocking gate는
`docs/architecture/optional-adapter-recipes.md`
`config/recipes/frontend-capability-recipes.json`을 따른다. 이 recipe source를
production에서 직접 import하는 것은 금지하며 선택한 contract만 application
소유 경계로 이동한다. 실제 API를 호출하는 `referenceRuntime`은 catalog에
`sourceRoots`가 등록된 browser file/IndexedDB/OPFS/Cache/transfer 계열에만
존재한다. realtime target은 아직 `DESIGNED_NOT_IMPLEMENTED`다. 구현된
reference runtime도 dataset/schema/codec/query/policy와 제품 owner가 없으면
bootstrap에 연결하지 않는다.
## 19. 새 outbound adapter 추가 recipe
### 단계 1: 해결할 문제를 capability로 정의한다
예: “LaunchDarkly를 붙인다”가 아니라 “feature flag를 평가한다”로
정의한다.
### 단계 2: Port가 정말 필요한지 판단한다
- application 정책이 이 capability를 호출하는가?
- React 화면 한 곳의 순수 표현 문제인가?
- 기존 port로 충분한가?
- 기술 교체와 테스트 격리가 실제로 필요한가?
Application이 호출하지 않는 순수 UI library에는 억지 output port를
만들지 않는다.
### 단계 3: Application-owned output port를 작성한다
- 기술 중립 이름
- 최소 메서드
- typed input/output
- timeout/cancellation 필요 여부
- 예측 가능한 failure union
- 민감정보 분류
### 단계 4: Contract와 정책을 작성한다
- runtime config
- retry/idempotency
- cache와 TTL
- fallback
- telemetry
- security/redaction
- browser compatibility
### 단계 5: Concrete adapter를 구현한다
`src/adapters/outbound/<capability>` 또는 feature의
`adapters/outbound`에 구현한다. Concrete SDK type은 이 경로 밖으로
노출하지 않는다.
### 단계 6: Composition root에서만 연결한다
Application이나 page에서 concrete constructor를 호출하지 않는다.
### 단계 7: 테스트한다
- port contract test
- adapter unit test
- failure normalization
- timeout/cancellation
- redaction
- integration test
- deliberately failing negative fixture
### 단계 8: 운영 계약을 갱신한다
- registry와 runtime schema
- dependency inventory
- bundle budget
- security policy
- runbook
- adapter catalog와 제거 절차
## 20. 새 inbound adapter 추가 recipe
외부 이벤트를 application에 전달하는 adapter는 다음 순서를 따른다.
1. 어떤 외부 event가 어떤 application 의도를 나타내는지 정의한다.
2. event payload를 runtime schema로 검증한다.
3. raw payload를 application command로 매핑한다.
4. input port를 호출한다.
5. duplicate, ordering, cancellation 정책을 정의한다.
6. 실패를 raw event source로 무한 재전파하지 않는다.
7. connect/disconnect lifecycle과 subscription cleanup을 테스트한다.
예를 들어 WebSocket listener가 받은 JSON을 domain object로 바로
사용하지 않는다. schema 검증과 command mapping 후 input port를 호출한다.
## 21. 새 feature 추가 recipe
### 단계 1: Feature 경계를 선언한다
- feature 이름과 owner
- 사용자 목표
- route
- 입력 command/query
- 외부 gateway
- 성공/빈 화면/오류/권한 상태
### 단계 2: Domain을 작성한다
- entity/value object
- invariant
- 순수 policy
- framework와 transport type 금지
Domain 규칙이 없는 단순 표시 feature라면 빈 domain layer를 억지로
만들지 않는다.
### 단계 3: Input/output port와 use case를 작성한다
- input port는 사용자 의도
- output port는 외부 capability
- `Result` failure 정의
- cancellation context 정의
### 단계 4: Outbound gateway를 작성한다
- operation registration
- request DTO schema
- path/search/body mapping
- response DTO schema
- domain mapper
- error mapping
### 단계 5: Inbound query/controller bridge를 작성한다
- canonical query key
- application input 호출
- async state projection
- mutation invalidation 또는 optimistic rollback
- retry 버튼 behavior
### 단계 6: Route를 등록한다
- route ID와 path
- typed params/search
- access hint
- lazy chunk
- loading/error surface
- title/navigation
Registry metadata와 실제 route object를 별도로 두 번 작성하지 않는다.
하나의 typed definition에서 route object와 navigation을 생성한다.
### 단계 7: Page와 상태를 조립한다
- page header와 focus 이동
- loading
- empty
- success
- refreshing/stale
- terminal error
- 401/403/404
- mutation pending/conflict
- responsive/keyboard/screen-reader
### 단계 8: 테스트한다
- domain/use case unit
- port contract
- outbound adapter + MSW integration
- query/controller hook
- component
- route integration
- Playwright E2E
- axe와 수동 접근성 scope
### 단계 9: 제거 가능성을 확인한다
Reference feature라면 전체 폴더 삭제 후 build가 통과해야 한다. 제품
feature라면 다른 feature가 내부 구현 경로를 직접 import하지 않고 public
entry point만 사용해야 한다.
## 22. Routing 규칙
Route definition은 다음 값을 type-safe하게 결합한다.
- route ID
- path와 parameter type
- search schema와 normalized type
- access hint
- lazy component
- loading surface
- error boundary
- title
- navigation metadata
- prefetch policy
Client route access는 UX 정책일 뿐 authorization이 아니다. 서버가
최종 권한을 검증해야 한다.
다음 규칙을 적용한다.
- string path를 page에 하드코딩하지 않는다.
- URL search를 `Object.fromEntries` 결과 그대로 신뢰하지 않는다.
- route parameter를 domain ID로 사용하기 전에 검증한다.
- route 전환 시 진행 중 request에 AbortSignal을 전달한다.
- chunk load 실패는 release mismatch 정책에 따라 한 번만 복구한다.
- redirect loop guard는 실제 redirect 경로에 연결하거나 제거한다.
- browser-history routing을 사용하면 hosting의 SPA fallback을 release 계약으로
검증한다.
## 23. Design system 경계
Design system은 inbound React adapter의 공유 UI capability다.
최소 public surface:
- semantic token
- typography와 spacing
- Button, IconButton
- TextField, TextArea, Select
- Checkbox, Radio, Switch
- Card, Alert, Badge
- Dialog, Drawer
- Loading, Empty, Error, Forbidden surface
- Tooltip, Tabs, Toast
- Table/List, Pagination
- Form field와 validation message
Icon library는 design-system의 `Icon``IconButton` 뒤에서 사용한다.
Page가 `lucide-react` 등 concrete icon package를 무제한 import하지 않도록
허용 icon과 접근성 규칙을 한곳에서 관리한다. 정적 named import로
tree-shaking을 유지하고 전체 icon registry를 runtime dynamic import하지
않는다.
Design system은 domain use case를 호출하지 않는다. Feature component가
design-system primitive를 조합하고 controller hook을 통해 application을
호출한다.
## 24. 금지 패턴
다음 패턴은 architecture gate에서 차단해야 한다.
### 경계 우회
- page가 `src/adapters/outbound`를 import
- presentation이 raw `fetch`, `localStorage`, auth SDK를 직접 사용
- application이 React, Router, TanStack Query를 import
- domain이 application, browser global 또는 framework를 import
- bootstrap 외부에서 concrete adapter를 생성
- `composition.ports`를 product presentation에 직접 전달
### Port 오용
- application port에 Axios, TanStack, SDK type 노출
- input port 이름을 HTTP method나 UI event 이름으로 정의
- 모든 capability를 하나의 `ApplicationService` 또는 `StorePort`로 합침
- auth UI operation과 credential attachment를 하나의 과대 interface로
강제
- application이 raw cache read/write를 page에 재노출
### HTTP와 retry
- GET filter를 cache key에만 반영하고 request에서 누락
- schema parse 결과를 버리고 원본 body 전송
- keyed mutation retry 시 idempotency key 재생성
- HTTP와 TanStack이 동시에 자동 retry
- navigation abort를 unknown error로 기록
- validation early return에서 timeout/listener cleanup 누락
### 상태
- 서버 상태를 generic global store에 복제
- URL에 있어야 할 filter를 별도 store에만 보관
- token을 React state, persisted store, localStorage에 저장
- domain invariant를 Zustand/Redux action 내부에만 구현
### 오류와 관측
- raw backend message 또는 stack을 사용자에게 표시
- request/response body, raw URL, token을 logger/telemetry에 전달
- `console.error`를 production error strategy로 간주
- registry에 event만 선언하고 실제 발생 경로에서 emit하지 않음
### Registry와 예제
- route metadata를 선언하고 실제 route가 사용하지 않음
- `contracts`를 모든 계층이 자유롭게 import하는 우회 폴더로 사용
- product가 reference feature를 import
- reference feature를 삭제해도 sample domain/API schema가 초기 bundle에
남음
### 외부 인프라
- 브라우저에서 Redis, PostgreSQL, MongoDB, Kafka, MinIO 관리자 API에
직접 연결
- server credential을 build/runtime config에 포함
- 클라이언트 route guard를 authorization으로 간주
## 25. 테스트와 증적 기준
경계별 최소 테스트는 다음과 같다.
| 대상 | 최소 검증 |
| --- | --- |
| Input use case | 성공, 각 failure branch, cancellation |
| Output port | contract fixture와 negative fixture |
| HTTP transport | path/query/body, timeout, retry, 401, abort, schema |
| DTO mapper | valid mapping과 invariant breach normalization |
| Query bridge | query key, loading, refresh, mutation invalidation, rollback |
| Route | registry-tree 일치, params/search, access, 404 |
| Provider | 실제 composition application 주입 |
| Logger/telemetry | allowlist, redaction, sink failure |
| Design system | keyboard, focus, label/error association, axe |
| Reference feature | full vertical integration과 complete removal |
TypeScript 전환 후에는 source뿐 아니라 test와 architecture/security
fixture도 typecheck 또는 lint 대상이어야 한다. 모든 실행 fixture를
`.ts/.tsx`로 유지해 확장자별 검사 우회를 허용하지 않는다.
Coverage는 단순 report 생성이 아니라 branch/function/line threshold를
blocking gate로 둔다. 수치만 올리기 위한 구현 세부 테스트보다 port
contract와 실패 분기를 우선한다.
## 26. 완료 기준
### 26.1 Architecture
- [ ] 모든 application 작업에 명시적인 input port가 있다.
- [ ] 모든 외부 capability는 application-owned output port 뒤에 있다.
- [ ] Presentation은 concrete outbound adapter를 모른다.
- [ ] `main`은 raw ports 대신 application API를 제공한다.
- [ ] `contracts`는 unrestricted 우회 계층이 아니다.
- [ ] dependency rule과 문서의 source of truth가 하나다.
- [x] TypeScript와 TSX도 동일한 architecture/security lint를 받는다.
### 26.2 Runtime composition
- [x] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다.
- [x] QueryClient와 feature query bridge가 실제 route에서 동작한다.
- [x] session UI API와 credential attachment가 분리되어 있다.
- [x] diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에
연결된다.
- [x] `pagehide`에서 bounded telemetry queue를 flush하고 adapter `dispose`
lifecycle listener를 정리한다.
### 26.3 HTTP와 validation
- [ ] path, search, body를 각각 검증하고 직렬화한다.
- [x] schema가 반환한 normalized data를 실제 request에 사용한다.
- [x] 생성된 timeout과 AbortSignal listener가 현재 HTTP attempt의 terminal
경로에서 정리된다.
- [ ] retry는 runtime cap, idempotency와 `Retry-After`를 따른다.
- [ ] raw payload와 credential이 failure나 log에 포함되지 않는다.
### 26.4 Routing과 상태
- [x] route registry와 실행 route tree가 동일 source에서 생성된다.
- [x] params/search schema가 실제 navigation에서 실행된다.
- [x] loading/error/chunk/access metadata가 실행 behavior와 연결된다.
- [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다.
- [x] reference server state를 별도 global store에 중복 보관하지 않는다.
### 26.5 Reference feature
- [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
- [x] list/create 등 최소 query와 mutation 예제가 있다.
- [x] loading/empty/error/refresh/conflict 상태가 있다.
- [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다.
- [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다.
### 26.6 품질
- [x] source와 test가 strict TypeScript 검사를 받는다.
- [ ] React Hooks와 JSX accessibility lint가 blocking이다.
- [ ] coverage threshold가 blocking이다.
- [ ] MSW integration, component, 3-engine E2E와 axe가 통과한다.
- [ ] bundle budget과 dependency inventory가 갱신된다.
- [ ] 선택 adapter는 도입 조건과 제거 절차가 문서화되어 있다.
## 27. 구현 우선순위
### P0: 경계와 정확성
1. TypeScript 검사 도구와 `.ts/.tsx` architecture/security glob 준비
2. input/output port 분리와 `ApplicationProvider`
3. HTTP path/query/body, parsed data, runtime retry/timeout, cleanup 교정
4. Query bridge를 통한 실제 application 실행
5. typed routing, registry-tree 단일화와 chunk recovery 실행 연결
6. 완전한 removable reference feature와 제거 검증
### P1: 기본 플랫폼 완성도
1. 완료: error/result/validation/form kernel
2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계
6. Storybook, shared MSW, 시각 회귀와 built-output E2E
7. source/test TypeScript 전환 및 위험 기반 coverage/lint 강화
### P2: 프로젝트별 선택 capability
SSE/WebSocket, Web Push, bounded polling, offline/IndexedDB, Service Worker, feature flag, product
analytics, vendor error reporting, worker, virtualization, OpenAPI generation,
GraphQL, Connect/gRPC-Web, Protobuf REST Gateway, global store와 cloud
visual-review service는 실제 프로젝트 요구와 측정 결과에 따라 추가한다.
GraphQL과 browser Protobuf/Gateway의 선택·설치 조건은 VD-26과
VD-27/VD-29/VD-30을 따른다.
P2 adapter를 많이 설치하는 것은 skeleton 완성도의 기준이 아니다.
안전한 경계, 도입 recipe, 테스트 계약과 제거 가능성이 준비되어 있는지가
기준이다.