# 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을 추가할 때 어떤 파일과 테스트가 필요한가. 이 문서는 목표 설계다. 현재 구현 상태는 [프론트엔드 플랫폼 역량 재검토](./frontend-platform-capability-review.md)를 따른다. ## 2. TypeScript 전환 원칙 ### 2.1 왜 전환하는가 현재 `strict + allowJs + checkJs`는 JavaScript 상태에서 유용한 안전망이다. 그러나 JSDoc cast가 늘어나면 다음 계약을 정확히 닫기 어렵다. - `RouteId`, `OperationId`, `ErrorCode`, `StorageKey`, `TelemetryEvent` - `Result`와 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 구성: ```text tsconfig.base.json tsconfig.app.json tsconfig.node.json tsconfig.test.json tsconfig.json # project references only ``` `tsconfig.base.json`의 초기 핵심 옵션: ```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의 점진적 전환 도구](./decisions/VD-01-typescript-lint-tooling.md)에 기록돼 있다. 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 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)의 점진적 방식을 사용한다. ### 2.4 기본 type 계약 다음 형태를 core application에 둔다. ```ts export type Ok = Readonly<{ ok: true; value: T }>; export type Err = Readonly<{ ok: false; error: E }>; export type Result = Ok | Err; 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>; 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의 `getState`와 `setState`를 임의 호출하지 않게 한다. ### 3.3 persistence persisted state는 다음 metadata를 가져야 한다. ```ts type PersistedRecord = 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. 표준 데이터 호출 경로 ```mermaid 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 ```ts export interface Application { readonly resources: { list( input: ListResourcesInput, context: Readonly<{ signal: AbortSignal }>, ): Promise>; create( command: CreateResourceCommand, context?: Readonly<{ signal?: AbortSignal }>, ): Promise>; }; } ``` `ApplicationProvider`는 이 API를 immutable value로 제공한다. controller 외의 presentation 코드에서 raw HTTP/storage/telemetry port를 가져오는 hook은 만들지 않는다. theme, locale 같은 UI platform provider는 별도다. ### 4.2 query adapter ```ts 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 ```ts 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 목표 파이프라인 ```text 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은 최소 다음 계약을 갖는다. ```ts type OperationDefinition< TPath, TSearch, TBody, TResponse, > = Readonly<{ id: OperationId; method: HttpMethod; pathTemplate: string; pathSchema: Schema; searchSchema: Schema; bodySchema: Schema; responseSchema: Schema; 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 모든 경로에서 정리되어야 한다. RP-03의 현재 구현은 다음 계약을 자동 검증한다. - `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를 보존한다. - operation의 `requestSource`가 search/body schema를 선택하고 Zod의 default/trim 결과만 URL 또는 JSON payload에 전달한다. - runtime `REQUEST_TIMEOUT_MS`와 `MAX_RETRY_ATTEMPTS`가 transport factory에 주입된다. - query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다. - `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/ mutation-conflict를 TypeScript union으로 배타화한다. - `useApplicationQuery`와 `useApplicationMutation`은 cancellation, stale latch, duplicate submit, optimistic rollback, conflict resolution을 제공한다. ## 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`를 분리한다. ```ts interface AuthSessionReader { getSnapshot(): SessionSnapshot; subscribe(listener: () => void): () => void; } interface CredentialProvider { attach(request: RequestInit): Promise; 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. `LoggerPort`와 `TelemetryPort`를 분리 공통 요구: - 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만으로 새 기능을 만들 수 있다.