491 lines
18 KiB
Markdown
491 lines
18 KiB
Markdown
# 라우팅, 페이지 템플릿, 재사용 패턴
|
|
|
|
## 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 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에 연결돼 있다.
|
|
|
|
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을 분리한다.
|
|
|
|
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
|
|
상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
|
|
구현해야 한다.
|
|
|
|
## 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<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하지 않는다.
|
|
|
|
```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<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가 다음을 조합한다.
|
|
|
|
```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별 `<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 <InvalidRouteSurface failure={input.error} />;
|
|
|
|
return <ResourceListController input={input.value} />;
|
|
}
|
|
|
|
function ResourceListController({ input }: ResourceListControllerProps) {
|
|
const controller = useResourceListController(input);
|
|
return <ResourceListPage controller={controller} />;
|
|
}
|
|
```
|
|
|
|
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 | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
|
|
|
|
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. 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와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.
|