Files
tech-log-frontend/docs/architecture/routing-pages-and-patterns.md
T

20 KiB

라우팅, 페이지 템플릿, 재사용 패턴

정본 안내 (non-authoritative for runtime capability decisions)

Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, Service Worker, offline command와 Background Sync의 구현 결정은 프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계가 정본이다. 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.

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.ts, 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을 분리한다.

route ID와 parsed input type은 route-contract.ts, parse/canonical URL 생성은 route-codecs.ts, React context/provider/hook은 route-input.tsx가 소유한다. router는 codec 결과를 provider에 넣고 lazy feature page는 좁은 useRouteInput()만 소비한다. 이 분리로 feature runtime이 자신을 load하는 app-router.tsx를 역참조하지 않으며, 같은 유형의 TypeScript 순환 의존은 architecture graph fixture가 거절한다.

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 설명Data Mode custom setup은 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를 유지한다.
  • createBrowserRouterRouterProvider 기반 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을 분리한다.

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;
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<RouteId, RouteRuntime>;

요구 사항:

  • 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하지 않는다.

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를 제공한다.

interface PathParamsCodec<T> {
  parse(
    input: Readonly<Record<string, string | undefined>>,
  ): Result<T, RouteInputFailure>;
  serialize(value: T): Readonly<Record<string, string>>;
}

interface SearchParamsCodec<T> {
  parse(input: URLSearchParams): Result<T, RouteInputFailure>;
  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가 다음을 조합한다.

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별 <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 결과

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를 세 부분으로 나눈다.

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

예:

export function ResourceListRoute() {
  const input = useRouteInput();
  return <ResourceListController input={input} />;
}

function ResourceListController({ input }: ResourceListControllerProps) {
  const controller = useResourceListController(input);
  return <ResourceListPage controller={controller} />;
}

router의 parse/invalid-route boundary가 성공한 ParsedRouteInput만 provider에 넣고, page controller는 그 context를 바로 읽는다. 따라서 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.<ROUTE_ID>.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와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.