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

50 KiB

프론트엔드 포트·어댑터와 경계 설계

정본 안내 (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 저장소 정합형 구현 결정 폐쇄 상세 설계가 정본이다. 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.

1. 문서 목적

이 문서는 이 저장소에서 도메인 기능을 추가할 때 사용할 경계와 확장 절차를 정의한다. 다음 질문에 대한 단일 기준 문서다.

  • 프론트엔드에서 inbound와 outbound는 무엇인가?
  • presentation은 왜 inbound adapter인가?
  • application이 노출해야 할 API와 외부 기술 포트는 어떻게 다른가?
  • React, Router, TanStack Query, HTTP, 인증, 저장소, 로깅을 어디에 배치하는가?
  • 새 기술 adapter나 새 feature를 어떤 순서로 추가하는가?
  • 예제 feature가 제품 코드에 남지 않았음을 어떻게 증명하는가?

포트의 방향은 네트워크 패킷의 방향이 아니라 application을 기준으로 누가 누구를 호출하는지로 결정한다.

사용자·브라우저 이벤트
  -> 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에 별도 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다.

Button click
  -> React event handler
  -> CreateReferenceItem command
  -> CreateReferenceItem input port
  -> use case

따라서 presentation이 adapter라는 사실은 이름에 adapter가 붙었는지와 관계없다. 다음 두 구조 모두 가능하다.

src/presentation                 # presentation을 inbound로 문서화
src/adapters/outbound

또는 더 명시적으로:

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처럼 서버가 노출한 제한된 계약과 통신한다.

Browser
  -> HTTP/WebSocket adapter
  -> Backend API/BFF
  -> Redis/PostgreSQL/MongoDB/Kafka/MinIO

MinIO object URL을 받아 파일을 업로드하는 경우에도 브라우저 adapter는 MinIO 관리자 credential이나 내부 API를 소유하지 않는다. 서버가 발급한 짧은 수명의 제한된 URL과 업로드 정책만 사용한다.

7. 권장 목표 디렉터리 구조

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 사용자의 의도를 이름으로 표현한다

좋은 예:

export interface ListReferenceItems {
  execute(
    query: ListReferenceItemsQuery,
    context: RequestContext,
  ): Promise<Result<readonly ReferenceItemView[], ApplicationFailure>>;
}

피해야 할 예:

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 실패로 숨기지는 않는다.

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 단위로 분리한다.

export interface ReferenceFeatureApplication {
  readonly list: ListReferenceItems;
  readonly create: CreateReferenceItem;
}

9. Output port 설계 규칙

9.1 Application이 port를 소유한다

도메인별 gateway는 feature의 application 경로에 둔다.

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 부착은 호출 주체가 다르므로 분리한다.

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 구현을 함께 두지 않는다.

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로 매핑한다.

Application -> ReferenceItemGateway
ReferenceItemHttpGateway -> HttpTransport
HttpTransport -> fetch

이렇게 하면 application이 URL, method, header, response envelope를 알지 않으며 API 기술을 교체할 수 있다.

10. Composition root와 ApplicationProvider

10.1 조립 순서

권장 조립 순서는 다음과 같다.

runtime config 검증
  -> release coherence 검증
  -> platform adapters 생성
  -> outbound gateways 생성
  -> use cases 생성
  -> application input API 생성
  -> React infrastructure provider 생성
  -> ApplicationProvider
  -> Router

예:

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만 노출한다.

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에서 검증한다.

  • maincomposition.applicationApplicationProvider에 전달한다.
  • 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을 사용할 수 있다.

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을 사용한다.

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을 연결해야 한다.

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

오류는 최소한 다음 경계를 거친다.

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다.

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가 아니라 다음 경로를 모두 실행해야 한다.

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을 따른다. 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.mdconfig/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의 IconIconButton 뒤에서 사용한다. 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가 하나다.
  • TypeScript와 TSX도 동일한 architecture/security lint를 받는다.

26.2 Runtime composition

  • runtime timeout/retry 설정이 실제 HTTP transport에 반영된다.
  • QueryClient와 feature query bridge가 실제 route에서 동작한다.
  • session UI API와 credential attachment가 분리되어 있다.
  • diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에 연결된다.
  • pagehide에서 bounded telemetry queue를 flush하고 adapter dispose가 lifecycle listener를 정리한다.

26.3 HTTP와 validation

  • path, search, body를 각각 검증하고 직렬화한다.
  • schema가 반환한 normalized data를 실제 request에 사용한다.
  • 생성된 timeout과 AbortSignal listener가 현재 HTTP attempt의 terminal 경로에서 정리된다.
  • 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가 분류 규칙을 따른다.
  • reference server state를 별도 global store에 중복 보관하지 않는다.

26.5 Reference feature

  • route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
  • list/create 등 최소 query와 mutation 예제가 있다.
  • loading/empty/error/refresh/conflict 상태가 있다.
  • reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/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

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, 테스트 계약과 제거 가능성이 준비되어 있는지가 기준이다.