# 라우팅, 페이지 템플릿, 재사용 패턴 ## 1. 목적 이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다. - typed route와 안전한 URL - session, permission, feature flag guard - lazy chunk의 loading/error/recovery - route 이동 시 focus, scroll, 취소, dirty form 처리 - list, detail, form, status 등 공통 page template - page controller와 application input use case의 연결 - 프론트엔드에서 반복 사용하는 설계 패턴 ## 2. 현재 상태와 구현 기준 RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다. - route registry가 path와 access policy를 소유한다. - contract에서 Data Router route object와 navigation을 생성한다. - runtime map이 route component를 lazy import하고 codec을 연결한다. - 앱 셸과 보호 route, not-found surface가 있다. - route heading focus와 비동기/render error boundary가 있다. - redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다. - `src/presentation/templates`가 standard/collection/detail/form/status slot, landmark와 responsive layout을 제공한다. - `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping, duplicate submit과 dirty route blocker를 소유한다. - reference feature의 list/detail/create/status route가 네 template variation을 production composition에서 실행한다. platform route 계약, `src/features/installed-feature-contracts.js`, `src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry negative fixture가 함께 검사한다. params/search codec, loading/error surface, access, title, navigation, chunk ID는 `src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite manifest의 dynamic entry는 release manifest route chunk map과 검증되며, `ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다. template는 데이터를 가져오지 않는다. reference page controller가 route input과 application input을 query/form facade에 연결하고, template에는 render할 slot과 안전한 callback만 전달한다. 이 분리는 `page-templates-own-layout-only` dependency rule과 forbidden import fixture가 검증한다. ## 3. React Router mode 결정 [React Router 공식 mode 설명](https://reactrouter.com/start/modes)과 [Data Mode custom setup](https://reactrouter.com/start/data/custom)은 Declarative, Data, Framework Mode를 구분한다. 저장소에는 현재 React Router `7.18.1`이 고정돼 있다. 구현 브랜치는 공식 문서의 동일 버전 API를 기준으로 하고 router version upgrade를 Data Mode 구조 변경과 같은 브랜치에 섞지 않는다. 위 공식 링크의 기본 표시 버전이 바뀌면 version selector를 `7.18.1`로 맞춰 확인한다. | mode | 선택 조건 | 이 저장소에서의 판단 | | --- | --- | --- | | Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 | | Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 | | Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 | 채택한 결정: - client-only SPA와 TanStack Query/application use case를 유지한다. - `createBrowserRouter`와 `RouterProvider` 기반 Data Mode를 사용한다. - Data Mode를 선택하는 이유는 route object, navigation blocker, scroll restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로 서버 상태를 다시 소유하기 위해서가 아니다. - loader/action을 추가할 때는 TanStack Query/application input을 prefetch하거나 호출하는 한 가지 소유 경로만 사용한다. - 같은 데이터를 route loader와 TanStack Query가 각각 가져오지 않는다. - SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지 않는다. 결정 근거와 rollback 경계는 `docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다. Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과 `NavigationLifecycleAdapter`를 구현한다. ## 4. route 계약과 runtime map ### 4.1 두 종류의 레지스트리 직렬화 가능한 contract와 React implementation을 분리한다. ```ts export const routeContracts = { home: { id: "home", path: "/", access: "public", navigation: "primary", titleKey: "route.home.title", loadingSurface: "page", errorSurface: "page", chunkId: "home", }, resourceDetail: { id: "resourceDetail", path: "/examples/resources/:resourceId", access: "authenticated", navigation: "hidden", titleKey: "route.resourceDetail.title", loadingSurface: "detail", errorSurface: "detail", chunkId: "reference-resource-detail", }, } as const satisfies RouteContractRegistry; ``` ```tsx export const routeRuntime = { home: { Component: lazy(() => import("../pages/home-page")), paramsCodec: emptyParamsCodec, searchCodec: emptySearchCodec, }, resourceDetail: { Component: lazy(() => import("../features/resources/resource-detail-page")), paramsCodec: resourceDetailParamsCodec, searchCodec: resourceDetailSearchCodec, }, } satisfies Record; ``` 요구 사항: - contract key와 `id`가 다르면 typecheck 실패 - contract에는 함수, component, schema instance처럼 직렬화 불가능한 값을 넣지 않음 - runtime map에는 실제 lazy component와 codec/guard만 둠 - contract의 모든 route가 runtime에 있고 runtime의 모든 key가 contract에 있음 - navigation은 contract에서 파생 - build chunk manifest와 `chunkId` 대응을 검증 - public runtime config나 server가 route component 이름을 임의 지정할 수 없음 ### 4.2 params와 search codec URL은 외부 입력이다. page에서 `useParams()` 결과를 cast하지 않는다. ```ts const resourceDetailParamsSchema = z.object({ resourceId: z.string().trim().min(1).max(100), }); const resourceListSearchSchema = z.object({ q: z.string().trim().max(100).catch(""), page: z.coerce.number().int().min(1).catch(1), sort: z.enum(["updated-desc", "name-asc"]).catch("updated-desc"), }); ``` path params와 search params는 입력 형태와 serialization 규칙이 다르므로 같은 interface로 뭉치지 않는다. 각각 parse와 serialize를 제공한다. ```ts interface PathParamsCodec { parse( input: Readonly>, ): Result; serialize(value: T): Readonly>; } interface SearchParamsCodec { parse(input: URLSearchParams): Result; serialize(value: T): URLSearchParams; } ``` 규칙: - route input parse 실패와 backend 404를 구분한다. - 알 수 없는 search key를 보존할지 제거할지 route별로 선언한다. - default value를 URL에 항상 쓸지 생략할지 codec이 결정한다. - array/date/boolean encoding을 feature마다 다르게 만들지 않는다. - navigation link도 codec 기반 builder를 사용한다. - query key에는 parsed value만 사용한다. - 검색어·식별자를 telemetry에 기록하기 전에 sensitivity policy를 적용한다. ### 4.3 route object 생성 하나의 factory가 다음을 조합한다. ```text route contract + runtime component/codecs + access guard + feature flag guard + suspense surface + render/chunk error surface + title/focus/scroll behavior -> executable route object/tree ``` JSX에서 route별 ``를 다시 나열하지 않는다. nested layout이 필요한 경우 contract에 parent ID를 두고 cycle/orphan/duplicate path를 registry gate에서 검사한다. ## 5. guard와 권한 ### 5.1 guard 순서 권장 순서: 1. runtime/bootstrap readiness 2. route 존재와 URL parse 3. feature flag 4. session readiness 5. authentication 6. coarse client permission hint 7. route component 8. server authorization result client guard는 UX 최적화일 뿐 보안 경계가 아니다. API/BFF가 항상 최종 권한을 검사한다. ### 5.2 guard 결과 ```ts type GuardDecision = | { kind: "allow" } | { kind: "redirect"; to: SafeLocation; reason: RedirectReason } | { kind: "render"; surface: "auth-required" | "forbidden" | "not-found" }; ``` - redirect에는 origin route와 bounded return URL을 사용한다. - 외부 redirect는 allowlist를 거친다. - 동일한 route 쌍을 반복하는 redirect loop를 차단한다. - session이 아직 resolving이면 forbidden으로 단정하지 않는다. - server가 403을 반환하면 client claim을 신뢰해 화면을 계속 보여 주지 않는다. ## 6. navigation lifecycle ### 6.1 loading loading surface를 route metadata에 연결한다. | surface | 사용 | | --- | --- | | shell | 초기 앱 셸 진입 | | page | 새로운 전체 페이지 | | collection | table/list 구조 유지 | | detail | metadata/content 구조 유지 | | form | 필드 layout 구조 유지 | | inline | 부분 action | cached data가 있으면 full-page skeleton으로 교체하지 않고 refreshing indicator를 사용한다. `prefers-reduced-motion`에서 skeleton animation을 줄인다. ### 6.2 error와 lazy chunk recovery route error boundary는 다음을 구분한다. - render/programmer error - dynamic import/chunk load error - application `AppFailure` - URL parse failure - not found chunk recovery 순서: 1. 현재 build ID와 release manifest를 확인한다. 2. 새 manifest가 확인되고 같은 build에 대해 reload하지 않았다면 한 번만 reload한다. 3. 같은 failure가 반복되면 reload loop를 막는다. 4. 안전한 support surface와 trace/build ID를 표시한다. 5. recovery 결과를 redacted diagnostics에 기록한다. custom fallback을 넘겨 retry/reset 기능을 잃지 않게 한다. boundary는 `location.key` 또는 route ID가 바뀌면 적절히 reset된다. ### 6.3 focus와 scroll - route 성공 후 `main`의 page heading에 programmatic focus - mouse 사용자가 불필요한 focus ring을 보지 않게 할 수는 있지만 keyboard focus indication을 전역으로 제거하지 않음 - modal/drawer가 닫히면 opener에 focus 복원 - backward/forward navigation은 저장한 scroll 복원 - 새 primary route는 top으로 이동 - hash target은 fixed header offset과 focus 가능 여부를 처리 - screen reader용 route title/live announcement는 중복 발표를 피함 ### 6.4 취소와 dirty form - route 이동 시 진행 중 query signal을 취소한다. - mutation은 취소 안전성이 명확할 때만 취소한다. - dirty form blocker는 browser unload와 in-app navigation을 구분한다. - 성공 저장 후 blocker를 해제한 다음 이동한다. - autosave가 있는 form은 pending/failed 상태를 별도로 알린다. - confirm dialog는 공통 accessible primitive를 사용한다. ## 7. 페이지 템플릿 template은 데이터를 가져오거나 application을 호출하지 않는다. 슬롯, landmark, focus target, responsive layout, 상태 위치만 소유한다. ### 7.1 `StandardPageTemplate` 슬롯: - breadcrumb 또는 back link - title, description, status badge - primary/secondary actions - notices - content - contextual aside 작은 화면에서 action wrapping 순서와 heading hierarchy를 보장한다. ### 7.2 `CollectionPageTemplate` 슬롯과 상태: - title/actions - search/filter/sort toolbar - active filter summary와 reset - result count - table/list/card view - pagination 또는 load-more - initial loading, refreshing, empty-first-use, empty-filtered, error - bulk selection/action URL이 filter, sort, page를 소유한다. template은 query 상태를 직접 읽지 않는다. ### 7.3 `DetailPageTemplate` - breadcrumb/back - title/status/actions - summary metadata - main sections - related/context aside - loading/not-found/forbidden/error - destructive action confirmation 위치 식별자가 바뀔 때 이전 entity 내용과 새 loading 상태를 혼동하지 않게 key/reset 정책을 명시한다. ### 7.4 `FormPageTemplate` - title/description - error summary - field groups - optional aside/help - sticky 또는 normal action bar - submit/cancel - submitting/saved/conflict/unavailable - dirty navigation confirmation template은 특정 form vendor를 import하지 않는다. ### 7.5 `StatusPageTemplate` 다음 변형을 제공한다. - unauthenticated - forbidden - not found - unavailable - offline - maintenance - unexpected 각 변형은 heading, 짧은 설명, 안전한 primary/secondary action, 선택적 trace ID를 갖는다. raw stack/response를 표시하지 않는다. ### 7.6 선택 template 다음은 project 필요가 있을 때 추가한다. - `SettingsPageTemplate` - `DashboardGridTemplate` - `SplitPaneTemplate` - `WizardTemplate` - `FullScreenTaskTemplate` ## 8. page controller 패턴 page를 세 부분으로 나눈다. ```text route adapter parses URL and guard context ↓ controller hook invokes application query/mutation and maps UI events ↓ page view renders template and design-system components ``` 예: ```tsx export function ResourceListRoute() { const input = useRouteInput(resourceListRoute); if (!input.ok) return ; return ; } function ResourceListController({ input }: ResourceListControllerProps) { const controller = useResourceListController(input); return ; } ``` route parse boundary와 controller component를 분리하므로 controller hook은 조건부로 호출되지 않는다. controller가 소유하는 것: - parsed route input을 application input으로 변환 - query/mutation state - pagination/filter/navigation event - retry/refresh/action callbacks - view model projection view가 소유하는 것: - semantic markup - template/component 조립 - focus target - 사용자의 local-only interaction controller가 소유하지 않는 것: - HTTP URL 조립 - credential - transport DTO parse - 도메인 invariant - raw vendor SDK ## 9. 권장 패턴 카탈로그 | 패턴 | 적용 위치 | 쓰는 이유 | 오용 | | --- | --- | --- | --- | | Ports and Adapters | application 외부 경계 | 정책과 기술 교체 분리 | 모든 작은 UI library에 port 생성 | | Command/Query | application input | 읽기/변경 의도와 정책 분리 | CQRS 인프라를 필요 없이 도입 | | Gateway | output port | 외부 데이터 capability 표현 | `get/post` 범용 HTTP를 application에 노출 | | Anti-Corruption Mapper | outbound feature adapter | DTO 변화가 core로 전파되지 않게 함 | 단순 object spread로 타입만 바꿈 | | Result | 예상 실패 | 실패 종류와 처리 경로를 닫음 | programmer error까지 모두 Result로 숨김 | | Controller/View | inbound React | data lifecycle과 markup 분리 | 거대한 hook 하나에 모든 feature 로직 집중 | | Strategy | retry/cache/auth recovery | 정책 교체와 테스트 가능성 | 설정 한 줄도 interface로 과도 추상화 | | Observer/External Store | session/theme/realtime | React 외부 소유 상태 구독 | server state를 다시 external store에 복제 | | State Machine | 복잡한 workflow | 유효 전이와 보상 명시 | 단순 modal open에 도입 | | Headless/Compound Component | 복합 UI | behavior와 style/slot 분리 | vendor primitive를 제품 전역에 직접 노출 | | Adapter Facade | icon/form/i18n vendor | React inbound 내부 vendor 교체 경계 | application port로 승격 | | Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 | | Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 | RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며 실제 document title, navigation, loading/error/access surface는 `route..title|navigation` typed catalog key를 해석한다. route params, search, backend message를 translation key로 조립하지 않는다. locale 변경은 현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만 다시 렌더링한다. 패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다. ## 10. 새 route/page 추가 recipe 1. feature public 경계와 route ID를 정한다. 2. serializable route contract를 등록한다. 3. params/search Zod schema와 bidirectional codec을 작성한다. 4. safe URL builder를 export한다. 5. lazy page module과 runtime map entry를 추가한다. 6. session/permission/flag guard를 선언한다. 7. 적절한 page template을 선택한다. 8. controller hook을 application input API에 연결한다. 9. loading, empty, refreshing, error, auth, forbidden, not-found를 결정한다. 10. title/message key, focus, scroll, chunk ID를 연결한다. 11. 다음 검증을 추가한다. - contract/runtime map type completeness - codec round-trip/property cases - guard decision unit - page component state - query/mutation integration - keyboard/focus/axe - direct URL, back/forward, refresh E2E - chunk failure recovery가 필요한 route의 E2E 12. registry, type, architecture, component, integration, E2E gate를 실행한다. ## 11. 금지 패턴 - page 안에서 raw `fetch`, storage, auth SDK, telemetry SDK 호출 - `useParams()`/`URLSearchParams` 값을 cast만 하고 사용 - route contract와 JSX route tree를 각각 수동 관리 - protected route를 server authorization 대체 수단으로 취급 - 모든 실패를 redirect 또는 full-page error로 처리 - query data가 있는데 background error 때문에 내용을 제거 - chunk load error에서 제한 없는 `location.reload` - route heading focus outline을 CSS로 무조건 제거 - template이 data fetching 또는 feature-specific copy를 소유 - generic `BasePage` prop 하나에 모든 layout variation을 boolean으로 추가 ## 12. 완료 기준 - 모든 route ID가 contract와 runtime map에서 compile-time 완전성을 가진다. - params/search parse와 URL serialize가 같은 codec을 사용한다. - route metadata가 loading/error/chunk/title/navigation 행동에 실제 연결된다. - redirect와 chunk reload loop가 차단된다. - route 이동 시 query 취소, focus, scroll, dirty policy가 검증된다. - collection/detail/form/status reference page가 template을 사용한다. - page view가 application input 외의 외부 capability를 직접 호출하지 않는다. - 새 route recipe와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.