25 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,TelemetryEventResult<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을 시작하지 않는다.
- ESLint가
js,jsx,mjs,ts,tsx,mts를 모두 검사한다. - React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility 도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다.
- dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다.
scripts/check-registries.mjs가 TS/TSX를 검색한다.config/contracts/registry-governance.json의 경로 갱신 절차를 만든다.- Vite, Vitest, Playwright, scripts, source, tests를 각각 typecheck한다.
- invalid type fixture가 TS migration 후에도 “실패해야 통과”하는지 확인한다.
- 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의 getState와 setState를 임의 호출하지 않게 한다.
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 모든 경로에서 정리되어야 한다.
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를 분리한다.
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로 결정한다.
DiagnosticsPort가 log/event/span을 내부 method로 구분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를 분리
7.4 locale, message와 표시 값
RP-08부터 locale은 presentation-owned React context다. server/application state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가 locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를 복제하지 않는다.
API timestamp/number/failure kind
-> schema + mapper (의미 값 유지)
-> application result
-> presentation controller
-> useLocale().date/number/message
- message key는
MessageKeyunion이며 interpolation은 key별 tuple type이다. - unknown external key는
resolveMessage에 전달해도 raw key가 표시되지 않는다. - backend
message는 diagnostic input일 수 있지만 사용자 copy가 아니다. - form validation code는
ParameterlessMessageKeyallowlist로 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는 다음과 같다.
FormFormFieldLabelDescriptionFieldErrorErrorSummaryuseAppFormmapValidationFailureToFieldsuseDirtyNavigationGuard
구현 위치:
- 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로 사용하지 않는다.
필수 동작:
- label, description, error를 stable ID와
aria-describedby로 연결 - submit 시 error summary와 첫 오류 focus
- submitting 중 중복 제출 방지
- 422 응답의 알려진 field만 표시하고 나머지는 form-level failure로 처리
- 409 conflict는 validation error로 위장하지 않음
- 취소와 route 이탈 시 dirty policy 적용
- form value → application command mapper를 별도 함수로 둠
- browser autofill, IME composition, paste, password manager를 방해하지 않음
- loading skeleton으로 사용자의 입력을 덮지 않음
- form schema와 server contract mismatch를 integration test로 검증
9. 새 query 추가 recipe
- feature-owned input type과 application query use case를 추가한다.
- 필요한 output gateway를
ports/out에 추가한다. - transport DTO schema와 mapper를 outbound feature adapter에 추가한다.
- operation registry에 path/search/response/retry class를 등록한다.
- readonly tuple query key factory를 추가한다.
- inbound query controller hook을 추가한다.
- page template에서 loading/empty/error/refreshing/success를 렌더링한다.
- 다음 테스트를 추가한다.
- 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
- 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
- form value schema와 application command type을 분리한다.
- value → command mapper를 작성한다.
- input command use case와 output gateway method를 추가한다.
- operation의 idempotency/retry 정책을 선언한다.
- mutation controller에 invalidation/update/rollback 순서를 작성한다.
FormPageTemplate과 공통 field primitive로 화면을 구성한다.- 422, 409, unauthenticated, unavailable, abort를 각각 처리한다.
- dirty navigation과 double-submit을 검증한다.
- 다음 테스트를 추가한다.
- 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만으로 새 기능을 만들 수 있다.