Files
clean-architecture-frontend…/docs/architecture/typescript-state-and-data-flow.md

22 KiB

TypeScript, 상태 소유권, 데이터 흐름

1. 목적

이 문서는 다음 질문에 대한 저장소 표준을 정의한다.

  • JavaScript를 어떤 순서로 TypeScript로 전환하는가.
  • local, URL, server, form, global, persisted 상태를 어디에 둬야 하는가.
  • React 화면이 application use case와 TanStack Query를 어떻게 사용해야 하는가.
  • HTTP, retry, auth, error, validation, logging의 책임을 어떻게 나누는가.
  • 새 query, mutation, form을 추가할 때 어떤 파일과 테스트가 필요한가.

이 문서는 목표 설계다. 현재 구현 상태는 프론트엔드 플랫폼 역량 재검토를 따른다.

2. TypeScript 전환 원칙

2.1 왜 전환하는가

현재 strict + allowJs + checkJs는 JavaScript 상태에서 유용한 안전망이다. 그러나 JSDoc cast가 늘어나면 다음 계약을 정확히 닫기 어렵다.

  • RouteId, OperationId, ErrorCode, StorageKey, TelemetryEvent
  • Result<T, E>와 discriminated failure union
  • use case input/output와 gateway generic
  • route별 params/search type
  • query key tuple
  • component variant와 slot prop
  • runtime registry의 key와 executable implementation의 완전성

TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파일 단계에서 검증하는 것이다.

2.2 전환 전에 고칠 도구

다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다.

  1. ESLint가 js, jsx, mjs, ts, tsx, mts를 모두 검사한다.
  2. React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility 도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다.
  3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다.
  4. scripts/check-registries.mjs가 TS/TSX를 검색한다.
  5. config/contracts/registry-governance.json의 경로 갱신 절차를 만든다.
  6. Vite, Vitest, Playwright, scripts, source, tests를 각각 typecheck한다.
  7. invalid type fixture가 TS migration 후에도 “실패해야 통과”하는지 확인한다.
  8. architecture/security/registry gate가 TS fixture 위반을 실제로 잡는 negative test를 추가한다.

권장 project 구성:

tsconfig.base.json
tsconfig.app.json
tsconfig.node.json
tsconfig.test.json
tsconfig.json          # project references only

tsconfig.base.json의 초기 핵심 옵션:

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "useUnknownInCatchVariables": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

실제 TypeScript 7/Vite 호환 옵션은 설치된 공식 문서와 빌드 결과를 기준으로 확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새 예외를 금지한다.

현재 저장소의 VD-01 결정은 TypeScript 7과 ESLint 10의 점진적 전환 도구에 기록돼 있다. app, Node scripts/config, tests는 각각 독립된 project로 typecheck하며 JS에는 checkJs, TS에는 strict를 적용한다. TypeScript 7을 아직 지원하지 않는 parser plugin을 강제 설치하지 않고 Babel parser는 lint syntax/import/security 검사, tsc는 type semantics를 소유한다.

2.3 전환 순서

단계 대상 이유 종료 조건
0 lint/typecheck/scanner/architecture tooling TS 코드가 검사를 우회하지 않게 함 TS 위반 fixture가 각 게이트에서 실패
1 result, failure, ID, registry types 이후 모든 계층의 언어가 됨 stringly typed public ID 제거
2 application input/output ports와 use case 중심 계약을 먼저 고정 input/output compile fixture 통과
3 domain model과 mapper boundary DTO와 core model 혼합 차단 mapper contract test 통과
4 outbound adapters 외부 unknown을 경계에서 좁힘 HTTP/storage/auth failure type 통과
5 bootstrap/composition 누락 dependency를 컴파일로 검출 실제 composition type test 통과
6 React providers/controllers/routes typed application API 소비 route/runtime map 완전성 검사
7 primitives/pages/templates component API와 variant를 닫음 stories/component tests typecheck
8 tests/scripts/config 우회 없는 전체 저장소 source allowJs 제거 가능

각 단계는 빌드 가능한 작은 커밋으로 유지한다. JavaScript와 TypeScript가 공존하는 동안에는 공식 JavaScript migration 가이드의 점진적 방식을 사용한다.

2.4 기본 type 계약

다음 형태를 core application에 둔다.

export type Ok<T> = Readonly<{ ok: true; value: T }>;
export type Err<E> = Readonly<{ ok: false; error: E }>;
export type Result<T, E> = Ok<T> | Err<E>;

export type AppFailure =
  | Readonly<{ kind: "unauthenticated"; code: "AUTH_REQUIRED"; traceId?: string }>
  | Readonly<{ kind: "forbidden"; code: "FORBIDDEN"; traceId?: string }>
  | Readonly<{ kind: "not-found"; code: "NOT_FOUND"; traceId?: string }>
  | Readonly<{ kind: "conflict"; code: "CONFLICT"; traceId?: string }>
  | Readonly<{
      kind: "validation";
      code: "VALIDATION_FAILED";
      fields: Readonly<Record<string, readonly string[]>>;
      traceId?: string;
    }>
  | Readonly<{ kind: "rate-limited"; code: "RATE_LIMITED"; retryAt?: Date }>
  | Readonly<{ kind: "unavailable"; code: "UNAVAILABLE"; retryable: boolean }>
  | Readonly<{ kind: "unexpected"; code: "UNEXPECTED"; traceId?: string }>;

원칙:

  • adapter에서 받은 unknown은 adapter 경계에서 parse한다.
  • application은 Response, AxiosError, Zod 내부 오류 같은 vendor type을 노출하지 않는다.
  • UI copy는 failure에 저장하지 않고 message key mapper에서 결정한다.
  • 예상 가능한 실패는 Result; programmer bug와 render crash는 error boundary로 보낸다.
  • as, non-null assertion, any는 경계에서 근거가 있을 때만 사용하고 lint 예외에 사유를 기록한다.

3. 상태 소유권

3.1 상태 분류표

상태를 만들기 전에 아래 순서로 소유자를 결정한다.

질문 상태 종류 기본 도구 저장 위치
한 컴포넌트 상호작용에만 필요한가 local UI useState, useReducer component/controller
URL로 공유·복원되어야 하는가 navigation router params/search + codec URL
서버가 진실의 원천인가 server state TanStack Query inbound adapter Query cache
입력 중이고 제출 전인가 form local form facade form controller
앱 전체에서 낮은 빈도로 바뀌는가 cross-cutting Context 또는 typed external store provider/store
여러 feature의 복잡한 workflow인가 client workflow reducer, Zustand, Redux Toolkit, state machine feature-owned store
새로고침 후 남아야 하는가 persisted preference StoragePort versioned browser storage
인증 credential인가 auth secret external auth owner SDK memory 또는 HttpOnly cookie

금지:

  • server response를 global client store에 복사하지 않는다.
  • URL에 있어야 할 filter/sort/page를 숨은 store에만 두지 않는다.
  • component 내부에 머물 수 있는 modal open 상태를 전역화하지 않는다.
  • access token을 localStorage, sessionStorage, 일반 Redux/Zustand store에 넣지 않는다.
  • 모든 상태를 추상화하는 범용 StorePort를 만들지 않는다.

3.2 범용 store 선택 기준

기본 skeleton에는 빈 Zustand/Redux store를 만들지 않는다. 실제 요구가 생기면 다음 기준을 적용한다.

조건 권장
feature 한 곳의 단순 shared client state feature reducer 또는 작은 Zustand store
여러 팀이 action/state 규약, devtools, middleware, audit를 공유 Redux Toolkit
명시적 상태 전이, 병렬 상태, 취소/보상 workflow state machine
session/theme처럼 저빈도 cross-cutting useSyncExternalStore 또는 Context

vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가 store instance의 getStatesetState를 임의 호출하지 않게 한다.

3.3 persistence

persisted state는 다음 metadata를 가져야 한다.

type PersistedRecord<T> = Readonly<{
  version: number;
  writtenAt: string;
  value: T;
}>;
  • key는 typed registry가 소유한다.
  • read 시 schema parse와 migration을 거친다.
  • quota, unavailable, corrupt, version mismatch를 구분한다.
  • 민감정보와 credential을 저장하지 않는다.
  • server state persistence와 offline mutation queue는 별도 project-selected adapter다.

4. 표준 데이터 호출 경로

sequenceDiagram
  actor User
  participant Page as React page
  participant Controller as inbound query/mutation controller
  participant App as application input use case
  participant Gateway as output gateway
  participant Adapter as HTTP/generated client adapter
  participant API as Backend/BFF

  User->>Page: route or event
  Page->>Controller: typed input
  Controller->>App: query/command + AbortSignal
  App->>Gateway: capability request
  Gateway->>Adapter: DTO request
  Adapter->>API: path/query/body/auth
  API-->>Adapter: envelope or failure
  Adapter-->>Gateway: parsed DTO Result
  Gateway-->>App: mapped model Result
  App-->>Controller: view data or AppFailure
  Controller-->>Page: query/mutation state

페이지는 controller hook이 제공하는 상태만 렌더링한다. controller는 React, TanStack Query와 application input interface를 알 수 있지만 use case의 concrete 구현과 outbound adapter는 모른다.

4.1 Application API

export interface Application {
  readonly resources: {
    list(
      input: ListResourcesInput,
      context: Readonly<{ signal: AbortSignal }>,
    ): Promise<Result<readonly ResourceSummary[], AppFailure>>;
    create(
      command: CreateResourceCommand,
      context?: Readonly<{ signal?: AbortSignal }>,
    ): Promise<Result<Resource, AppFailure>>;
  };
}

ApplicationProvider는 이 API를 immutable value로 제공한다. controller 외의 presentation 코드에서 raw HTTP/storage/telemetry port를 가져오는 hook은 만들지 않는다. theme, locale 같은 UI platform provider는 별도다.

4.2 query adapter

export function useResourcesQuery(input: ListResourcesInput) {
  const application = useApplication();

  return useQuery({
    queryKey: resourceKeys.list(input),
    queryFn: async ({ signal }) =>
      unwrapResult(await application.resources.list(input, { signal })),
    retry: false,
    staleTime: resourceQueryPolicy.list.staleTime,
  });
}

실제 구현 규칙:

  • query key는 readonly tuple factory로만 만든다.
  • key에 들어간 filter는 실제 gateway request에도 동일하게 투영한다.
  • AbortSignal을 application과 HTTP transport까지 전달한다.
  • HTTP 계층이 bounded retry를 소유하면 query retry는 끈다.
  • background error와 initial error의 UI를 구분한다.
  • placeholder와 cached stale data가 있을 때 전체 화면 error로 교체하지 않는다.
  • select는 view-only projection에 쓰고 도메인 규칙을 넣지 않는다.
  • query hook은 feature public API에서 export한다.

4.3 mutation adapter

export function useCreateResourceMutation() {
  const application = useApplication();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (command: CreateResourceCommand) =>
      application.resources.create(command).then(unwrapResult),
    onSuccess: () =>
      queryClient.invalidateQueries({ queryKey: resourceKeys.all }),
  });
}

기본 mutation은 navigation만으로 취소됐다고 가정하지 않는다. 서버 작업의 취소가 안전한 operation만 controller가 보관한 AbortController와 명시적 cancel action을 사용한다. idempotency key는 UI 입력으로 받지 않고 application use case가 IdGeneratorPort로 만들며, 같은 논리 요청의 bounded HTTP retry와 auth replay는 동일한 key를 재사용한다.

실제 구현에서는 mutation마다 다음을 명시한다.

  • double-submit 방지와 idempotency key 소유자
  • cancel 가능 여부
  • optimistic update 적용 여부와 rollback snapshot
  • 성공 후 invalidate/update/navigation 순서
  • 409 conflict와 422 field validation mapping
  • offline 시 queue할지 즉시 실패할지
  • analytics/telemetry event와 redaction

optimistic update는 기본값이 아니다. 서버 규칙을 확실히 재현할 수 있고 rollback이 안전한 mutation에만 사용한다.

5. HTTP client 책임

5.1 목표 파이프라인

operation registry
  -> path/search/body builder
  -> credential attachment
  -> timeout and external cancellation
  -> bounded retry
  -> fetch transport
  -> status/envelope decoder
  -> response schema decoder
  -> feature DTO mapper
  -> AppFailure mapper

각 단계의 입력과 출력은 typed result다. feature-specific model mapper를 공통 HTTP client 안에 넣지 않는다.

5.2 request projection

operation definition은 최소 다음 계약을 갖는다.

type OperationDefinition<
  TPath,
  TSearch,
  TBody,
  TResponse,
> = Readonly<{
  id: OperationId;
  method: HttpMethod;
  pathTemplate: string;
  pathSchema: Schema<TPath>;
  searchSchema: Schema<TSearch>;
  bodySchema: Schema<TBody>;
  responseSchema: Schema<TResponse>;
  timeoutMs?: number;
  retryClass: "never" | "safe" | "idempotency-keyed";
}>;

규칙:

  • path segment는 encodeURIComponent에 해당하는 안전한 builder를 통과한다.
  • query의 array/null/undefined/boolean/date serialization을 한 곳에서 정의한다.
  • Zod parse 결과의 trim/default/coercion을 실제 request에 사용한다.
  • GET/HEAD에는 body를 보내지 않는다.
  • JSON content type은 body가 있을 때만 붙인다.
  • base URL과 path를 문자열 덧붙이기로 조립하지 않는다.
  • external URL은 별도 allowlist policy를 거친다.

5.3 timeout, cancellation, retry

소유권:

  • 사용자 navigation/unmount 취소: query/controller가 signal 생성
  • operation timeout: HTTP adapter
  • retry: HTTP adapter 또는 query adapter 중 하나
  • 인증 복구 후 단 한 번 replay: auth decorator
  • mutation idempotency: application/controller가 key 생성, operation이 허용 여부 선언

retry 조건:

  • safe method 또는 idempotency key가 있는 허용 operation만 대상
  • timeout, network, 명시된 429/5xx만 정책 대상
  • validation, auth denial, forbidden, not found, conflict는 자동 재시도하지 않음
  • Retry-After와 bounded exponential backoff/jitter 지원
  • tab hidden/offline 상태를 고려
  • 최대 횟수와 전체 elapsed budget을 함께 제한
  • 각 attempt와 final failure를 redacted telemetry로 기록

timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 경로에서 정리되어야 한다.

6. 인증과 token 소유권

기본 skeleton은 token manager를 제공하지 않는다.

지원 profile:

profile credential 소유자 frontend 역할
BFF/HttpOnly cookie browser cookie + backend credentials, CSRF 정책, session probe
external OIDC/Auth SDK SDK memory/cache attach/recover/login/logout를 auth adapter로 감쌈
SPA memory token auth adapter memory 프로젝트가 명시적으로 선택할 때만

공통 AuthSessionReader와 HTTP 전용 CredentialProvider를 분리한다.

interface AuthSessionReader {
  getSnapshot(): SessionSnapshot;
  subscribe(listener: () => void): () => void;
}

interface CredentialProvider {
  attach(request: RequestInit): Promise<RequestInit>;
  recover(failure: AuthFailure): Promise<"recovered" | "not-recovered">;
}

UI는 credential을 읽지 않는다. HTTP adapter는 UI session action을 호출하지 않는다. logout/login/redirect는 application input 또는 auth UI facade를 통해 실행한다.

7. 오류, 검증, logging

7.1 검증 계층

경계 책임
runtime config 앱을 안전하게 시작할 수 있는가 URL, timeout, auth mode
route codec URL을 typed input으로 읽고 쓸 수 있는가 page, sort, ID
transport DTO 외부 응답/요청 형식이 계약과 맞는가 envelope, date string
form 사용자가 수정 가능한 입력 형태가 유효한가 required, length, format
application use case precondition이 맞는가 command 조합
domain 항상 지켜야 할 불변식인가 valid state transition

같은 Zod schema를 무조건 모든 계층에서 재사용하지 않는다. transport DTO, form value, application command, domain model이 우연히 같은 모양이어도 소유권과 변경 이유가 다르다. 필요한 경우 mapper로 연결한다.

7.2 사용자 오류 표면

AppFailure를 다음 UI 상태로 매핑한다.

failure 기본 표면 자동 행동
unauthenticated auth required 또는 login transition auth policy에 따른 1회 복구
forbidden 권한 없음 없음
not-found route/detail not-found 없음
validation error summary + field errors 첫 오류 focus
conflict 현재 데이터 유지 + conflict action 자동 overwrite 금지
rate-limited inline retry time 허용된 query만 지연 재시도
unavailable cached data 또는 retry surface policy 범위 내 retry
unexpected safe generic copy + trace ID diagnostics emit

raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log에 노출하지 않는다.

7.3 diagnostics와 telemetry

현재 telemetry event contract와 별도로 개발·진단용 structured logger가 필요하다. 다음 두 설계 중 하나를 ADR로 결정한다.

  1. DiagnosticsPort가 log/event/span을 내부 method로 구분
  2. LoggerPortTelemetryPort를 분리

공통 요구:

  • log level과 event key는 닫힌 union
  • attribute allowlist와 중앙 redaction
  • dev adapter는 console을 사용하되 동일 redaction 적용
  • production adapter는 provider SDK를 감싸며 앱 코드는 SDK를 import하지 않음
  • 오류 객체 전체를 그대로 serialize하지 않음
  • trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결
  • logging failure가 제품 flow를 실패시키지 않음
  • consent가 필요한 analytics와 essential diagnostics를 분리

8. 폼 표준

form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에 둔다. vendor 선택과 무관하게 다음 API를 제공한다.

  • Form
  • FormField
  • Label
  • Description
  • FieldError
  • ErrorSummary
  • useAppForm
  • mapValidationFailureToFields
  • useDirtyNavigationGuard

필수 동작:

  1. label, description, error를 stable ID와 aria-describedby로 연결
  2. submit 시 error summary와 첫 오류 focus
  3. submitting 중 중복 제출 방지
  4. 422 응답의 알려진 field만 표시하고 나머지는 form-level failure로 처리
  5. 409 conflict는 validation error로 위장하지 않음
  6. 취소와 route 이탈 시 dirty policy 적용
  7. form value → application command mapper를 별도 함수로 둠
  8. browser autofill, IME composition, paste, password manager를 방해하지 않음
  9. loading skeleton으로 사용자의 입력을 덮지 않음
  10. form schema와 server contract mismatch를 integration test로 검증

9. 새 query 추가 recipe

  1. feature-owned input type과 application query use case를 추가한다.
  2. 필요한 output gateway를 ports/out에 추가한다.
  3. transport DTO schema와 mapper를 outbound feature adapter에 추가한다.
  4. operation registry에 path/search/response/retry class를 등록한다.
  5. readonly tuple query key factory를 추가한다.
  6. inbound query controller hook을 추가한다.
  7. page template에서 loading/empty/error/refreshing/success를 렌더링한다.
  8. 다음 테스트를 추가한다.
    • use case unit
    • DTO mapper/schema contract
    • path/search projection
    • MSW success/empty/401/403/429/500/schema mismatch
    • cancellation과 retry ownership
    • component state
    • route E2E
  9. registry, type, architecture, unit, integration, E2E gate를 실행한다.

금지:

  • page에서 fetch
  • page에서 raw QueryClient
  • cache key와 request filter의 별도 수동 조립
  • response DTO를 domain/application model로 사용
  • initial loading과 background refreshing을 같은 full-page skeleton으로 표시

10. 새 mutation/form 추가 recipe

  1. form value schema와 application command type을 분리한다.
  2. value → command mapper를 작성한다.
  3. input command use case와 output gateway method를 추가한다.
  4. operation의 idempotency/retry 정책을 선언한다.
  5. mutation controller에 invalidation/update/rollback 순서를 작성한다.
  6. FormPageTemplate과 공통 field primitive로 화면을 구성한다.
  7. 422, 409, unauthenticated, unavailable, abort를 각각 처리한다.
  8. dirty navigation과 double-submit을 검증한다.
  9. 다음 테스트를 추가한다.
    • form schema와 mapper unit
    • keyboard/label/error summary component
    • MSW success/422/409/network-lost
    • optimistic rollback을 쓰는 경우 cache snapshot
    • 브라우저 navigation blocker와 성공 후 이동

11. 완료 기준

  • TS/TSX가 lint, type, architecture, registry, security 검사를 우회하지 않는다.
  • source와 test가 strict typecheck된다.
  • UI는 application input API만 호출한다.
  • reference query와 mutation이 TanStack adapter를 통해 동작한다.
  • retry 책임이 단일 계층에 있고 cancellation/timeout과 충돌하지 않는다.
  • path/search/parsed body가 실제 전송값과 일치한다.
  • 상태 종류별 소유권이 테스트와 문서에서 확인된다.
  • token은 UI와 일반 storage/store에 노출되지 않는다.
  • failure와 validation의 각 계층이 typed mapper로 분리된다.
  • query/mutation/form recipe만으로 새 기능을 만들 수 있다.