# TypeScript, 상태 소유권, 데이터 흐름 ## 1. 목적 이 문서는 다음 질문에 대한 저장소 표준을 정의한다. - TypeScript-only 경계를 어떻게 유지하고 JavaScript 재유입을 막는가. - local, URL, server, form, global, persisted 상태를 어디에 둬야 하는가. - React 화면이 application use case와 TanStack Query를 어떻게 사용해야 하는가. - HTTP, retry, auth, error, validation, logging의 책임을 어떻게 나누는가. - 새 query, mutation, form을 추가할 때 어떤 파일과 테스트가 필요한가. 이 문서는 현재 구현된 TypeScript 경계와 상태/데이터 흐름의 저장소 표준이다. 세부 역량의 구현 상태는 [프론트엔드 플랫폼 역량 재검토](./frontend-platform-capability-review.md)를 따른다. memory query cache, Web Storage와 탭 간 invalidation의 세부 protocol은 [Client cache and browser storage platform](./client-cache-and-storage.md)에 기록한다. ## 2. TypeScript 전환 원칙 ### 2.1 왜 전환하는가 초기 기준선의 `strict + allowJs + checkJs`는 JavaScript 상태에서 유용한 중간 안전망이었다. 현재는 product source, test, Node script와 지원되는 config를 TS/TSX로 전환하고 `allowJs: false`로 닫았다. JSDoc cast 대신 실제 TypeScript 타입으로 다음 계약을 검사한다. - `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 현재 도구 경계 전환 이후에도 다음 조건을 blocking gate로 유지한다. 1. ESLint가 product와 negative fixture의 `ts`/`tsx`를 모두 검사한다. 2. React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility 도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다. 3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다. 4. `scripts/check-registries.ts`가 TS/TSX를 검색한다. 5. `config/contracts/registry-governance.json`과 승인 baseline은 `.ts/.tsx` source 경로를 사용한다. 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, "allowJs": false, "checkJs": false, "allowImportingTsExtensions": true, "isolatedModules": true } } ``` Node가 직접 실행하는 script/config project는 추가로 `NodeNext`, `verbatimModuleSyntax`, `rewriteRelativeImportExtensions`와 `erasableSyntaxOnly`를 적용한다. Vite app/test project는 Bundler resolution을 사용하되 저장소 내부 상대 import에 실제 `.ts/.tsx` 확장자를 기록한다. 현재 저장소의 VD-01 결정은 [TypeScript 7과 ESLint 10의 점진적 전환 도구](./decisions/VD-01-typescript-lint-tooling.md)에 기록돼 있다. app, Node scripts/config, tests는 각각 독립된 strict project로 typecheck한다. 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 | 우회 없는 전체 저장소 | `allowJs: false`, TS-only architecture gate | 이 순서는 기존 계약을 보존하며 수행한 migration 기록이다. 현재 실행 코드와 `tests/fixtures/**`에는 JavaScript와 TypeScript를 공존시키지 않는다. 실패를 의도한 fixture도 실제 `.ts/.tsx` 입력이며 product import 대상은 아니다. ### 2.4 기본 type 계약 공통 success/failure carrier는 `src/application/result.ts`에 한 번만 둔다. ```ts import type { AppFailure } from "../contracts/errors.ts"; export type Result = | Readonly<{ ok: true; value: Value }> | Readonly<{ ok: false; error: Failure }>; ``` `AppFailure.kind`의 `FailureKind`는 `keyof typeof ERROR_REGISTRY`에서 파생한다. 따라서 registry에 없는 failure kind는 컴파일되지 않으며, application, query/form controller와 feature API가 같은 default failure contract를 사용한다. 기존 HTTP 경계의 `ApiFailure` 이름은 `AppFailure`의 호환 alias로만 유지한다. 원칙: - 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 예외에 사유를 기록한다. ### 2.5 typed feature input contribution generic application은 concrete feature를 import하지 않는다. 대신 비어 있는 `ApplicationFeatureInputs`를 소유하고, 설치되는 feature의 application API가 module augmentation으로 ID와 input shape를 기여한다. ```ts export interface ApplicationFeatureInputs {} declare module "../../../application/ports/in/application-api.ts" { interface ApplicationFeatureInputs { "reference-feature": ReferenceFeatureInput; } } ``` `ApplicationFeatureId`는 이 interface의 string key에서 파생하며, `features.get(id)`는 해당 key의 정확한 input type을 반환한다. 잘못된 ID, 누락된 input method와 잘못된 method signature는 negative type fixture가 거절한다. runtime `has` type guard와 설치 누락 예외는 JavaScript나 외부 동적 입력 경계도 fail-closed로 유지한다. ## 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`를 임의 호출하지 않게 한다. 현재 `recipes/frontend-capabilities`의 `ClientWorkflowPort`와 `FakeClientWorkflowAdapter`가 vendor-neutral opt-in 예제를 제공한다. 기본 production에는 Zustand/Redux Toolkit/state-machine dependency가 없고, `check:optional-recipe-fixtures`가 server response collection을 client workflow store에 복제하는 패턴을 거절한다. ### 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다. 현재 `StoragePort` 구현은 registry가 선언한 local/session backend, `color-scheme-v1`/`opaque-string-v1` closed value codec, schema version, TTL과 16,384-byte hard cap을 적용한다. `QUERY_PERSISTENCE`는 `disabled`/`sensitive-forbidden`이고 installed query registry도 `persistence: "disabled"`만 허용한다. 기존 IndexedDB reference runtime은 `AVAILABLE_NOT_COMPOSED`이며 TanStack hydration에는 연결하지 않는다. ## 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(); return useApplicationMutation({ execute: (command: CreateResourceCommand) => application.resources.create(command), invalidate: [RESOURCE_INVALIDATION_TOPIC], }); } ``` 기본 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에만 사용한다. ### 4.4 탭 간 query invalidation 현재 installed query contract는 namespace와 별도로 opaque `invalidationTopic`, topic `version`, `crossContext: "invalidate-only"`와 `persistence: "disabled"`를 등록한다. `useApplicationMutation`은 server mutation 성공 뒤 `QueryInvalidationCoordinator`에 topic을 전달한다. coordinator는 local TanStack namespace를 active-query mode로 invalidate한 다음 2,048-byte 이하의 payload/query-key-free hint만 다른 context에 발행한다. ```text server mutation success -> registered topic -> local namespace invalidate -> BroadcastChannel hint -> failure: localStorage pulse -> failure: DEGRADED_LOCAL_ONLY ``` receiver는 exact protocol/topic/release cache epoch를 검증하고 self echo, duplicate와 stale out-of-order event를 버린다. sequence gap이면 모든 등록 namespace를 stale 처리해 다시 읽는다. remote event는 `removeQueries`, `resetQueries` 또는 `queryClient.clear()` 권한을 갖지 않는다. logout/account transition은 이후 session/account epoch와 local lifecycle authority를 추가해야 하며 현재 release epoch만 조립돼 있다. ## 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 VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic `TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context allowlist를, telemetry는 event별 required/optional attribute와 value policy를 사용한다. 공통 요구: - log level과 event key는 닫힌 union - attribute allowlist와 중앙 redaction - 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort HTTP queue를 사용 - production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를 import하지 않음 - 오류 객체 전체를 그대로 serialize하지 않음 - trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결 - logging failure가 제품 flow를 실패시키지 않음 - consent가 필요한 analytics와 essential diagnostics를 분리 HTTP는 route/operation/correlation ID를 logical execution context로 생성하고 success/recovered/failed/aborted 종료 시 diagnostics를 한 번만 기록한다. terminal non-abort failure만 telemetry를 한 번 발행한다. cache/storage와 boot producer는 raw key, value, message, stack을 버리고 error kind와 bounded operation만 남긴다. queue full과 sink failure는 제한된 reason bucket이며 drop observer가 실패해도 재귀 발행하지 않는다. ### 7.4 locale, message와 표시 값 RP-08부터 locale은 presentation-owned React context다. server/application state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가 locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를 복제하지 않는다. ```text API timestamp/number/failure kind -> schema + mapper (의미 값 유지) -> application result -> presentation controller -> useLocale().date/number/message ``` - message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다. - unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다. - backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다. - form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다. - date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가 명시한다. - pseudo/RTL locale state는 local interaction state이며 persistence와 server synchronization을 기본 제공하지 않는다. - key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration 기간을 둔다. ## 8. 폼 표준 VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며 Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation, 대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook Form 또는 TanStack Form adapter를 평가한다. 현재 public API는 다음과 같다. - `Form` - `FormField` - `Label` - `Description` - `FieldError` - `ErrorSummary` - `useAppForm` - `mapValidationFailureToFields` - `useDirtyNavigationGuard` 구현 위치: - controller와 mapping: `src/presentation/forms` - layout-only template: `src/presentation/templates` - feature form schema/command mapper: `src/features/reference-feature/presentation/reference-resource-form.ts` - 실제 create page: `src/features/reference-feature/presentation/reference-resource-form-page.tsx` `ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path`와 `code`만 담는다. backend message와 알 수 없는 path는 field copy로 사용하지 않는다. 필수 동작: 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로 분리된다. - common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다. - query/mutation/form recipe만으로 새 기능을 만들 수 있다. - query mutation invalidation은 registry topic을 통해 local cache와 다른 tab에 연결되고 query key/data는 wire에 노출되지 않는다. - query persistence는 명시적으로 disabled이고 기존 IndexedDB runtime과 암묵적으로 조합되지 않는다.