# 프론트엔드 포트·어댑터와 경계 설계 ## 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/sample` | 제거 가능한 예제 일부 | 완전한 removable reference feature | 현재 구조가 잘 제공하는 기반은 다음과 같다. - 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.js`가 만든 application을 production `ApplicationProvider`가 실제 React tree에 주입한다. - `createApplication`은 session, preference, diagnostics, runtime query의 input API만 반환하며 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가 거절한다. 후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다. 1. `QueryClientProvider`는 존재하지만 실제 product route에서 `useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다. 2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, `errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. 3. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API operation, Zod schema, mapper, domain model과 query key는 다른 경로에 남는다. 4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 concrete HTTP client 구성에 전달되지 않는다. 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 불일치를 제거하는 것이다. ## 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 | 필수 | | 외부 push event 처리 | inbound | event input port | WebSocket/SSE listener | 선택 | | 도메인 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>; } ``` 피해야 할 예: ```ts export interface HttpClient { get(url: string): Promise; } ``` 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`로 반환한다. programmer error와 불변식 위반을 모두 일반 API 실패로 숨기지는 않는다. ```ts export type Result = | Readonly<{ ok: true; value: Value }> | Readonly<{ ok: false; error: Failure }>; ``` ### 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>; create( command: CreateReferenceItemCommand, context: RequestContext, ): Promise>; } ``` 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; signOut(): Promise; recover(): Promise; } export interface CredentialAttacher { attach(request: Request): Promise; } ``` `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 기본 `Logger` output port는 safe context만 받는다. ```ts export interface Logger { debug(message: string, context?: SafeLogContext): void; info(message: string, context?: SafeLogContext): void; warn(message: string, context?: SafeLogContext): void; error(message: string, context?: SafeLogContext): void; } ``` 개발 환경에는 redacted console adapter, production에는 allowlist 기반 remote adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. 민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서 강제한다. ## 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 | 도입 조건 | 기본 상태 | | --- | --- | --- | | WebSocket/SSE | 실시간 server event 필요 | 미설치 recipe | | IndexedDB | 큰 offline data 또는 durable queue 필요 | 미설치 recipe | | Service Worker/PWA | offline shell과 installability 필요 | 미설치 recipe | | Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe | | Feature flag | remote rollout/kill switch 필요 | 미설치 recipe | | Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 | | Analytics | 사용자 동의 기반 product analytics 필요 | 미설치 recipe | | Error-reporting SDK | 운영 예외 집계 필요 | 미설치 recipe | | OpenTelemetry | 조직 trace 연계 필요 | 미설치 recipe | | Web Worker | CPU 작업이 main thread를 막음 | 미설치 recipe | | Notification | 사용자 권한 기반 browser notification 필요 | 미설치 recipe | | Clipboard/File/Media | 해당 browser capability 필요 | 미설치 recipe | | Image CDN adapter | responsive image transform 필요 | 미설치 recipe | | Virtualization | 대량 list rendering이 측정상 병목 | 미설치 recipe | | OpenAPI generator | backend 계약에서 client 생성 필요 | 미설치 recipe | | Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | 미설치 recipe | | XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | 미설치 recipe | | Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 | 선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입 조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다. ## 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/` 또는 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 대상이어야 한다. `.ts/.tsx`가 JS 전용 glob을 우회하지 않도록 한다. 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가 하나다. - [ ] TypeScript와 TSX도 동일한 architecture/security lint를 받는다. ### 26.2 Runtime composition - [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. - [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다. - [ ] session UI API와 credential attachment가 분리되어 있다. - [ ] logger와 telemetry가 HTTP/render/storage/cache failure 경로에 연결된다. - [ ] page lifecycle에서 필요한 telemetry flush/cleanup이 수행된다. ### 26.3 HTTP와 validation - [ ] path, search, body를 각각 검증하고 직렬화한다. - [ ] schema가 반환한 normalized data를 실제 request에 사용한다. - [ ] timeout과 AbortSignal listener가 모든 반환 경로에서 정리된다. - [ ] retry는 runtime cap, idempotency와 `Retry-After`를 따른다. - [ ] raw payload와 credential이 failure나 log에 포함되지 않는다. ### 26.4 Routing과 상태 - [ ] route registry와 실행 route tree가 동일 source에서 생성된다. - [ ] params/search schema가 실제 navigation에서 실행된다. - [ ] loading/error/chunk/access metadata가 실행 behavior와 연결된다. - [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다. - [ ] server state를 별도 global store에 중복 보관하지 않는다. ### 26.5 Reference feature - [ ] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다. - [ ] list/create 등 최소 query와 mutation 예제가 있다. - [ ] loading/empty/error/refresh/conflict 상태가 있다. - [ ] reference feature 전체 삭제 후 typecheck/test/build가 통과한다. - [ ] built asset에 reference operation, schema, mapper가 남지 않는다. ### 26.6 품질 - [ ] 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 WebSocket/SSE, offline/IndexedDB, Service Worker, feature flag, product analytics, vendor error reporting, worker, virtualization, OpenAPI generation, global store와 cloud visual-review service는 실제 프로젝트 요구와 측정 결과에 따라 추가한다. P2 adapter를 많이 설치하는 것은 skeleton 완성도의 기준이 아니다. 안전한 경계, 도입 recipe, 테스트 계약과 제거 가능성이 준비되어 있는지가 기준이다.