docs: audit frontend platform capabilities
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
# 라우팅, 페이지 템플릿, 재사용 패턴
|
||||
|
||||
## 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. 현재 상태와 문제
|
||||
|
||||
현재 구현에는 다음 장점이 있다.
|
||||
|
||||
- route registry가 path와 access policy를 소유한다.
|
||||
- route component를 lazy import한다.
|
||||
- 앱 셸과 보호 route, not-found surface가 있다.
|
||||
- route heading focus와 비동기/render error boundary가 있다.
|
||||
- redirect loop와 chunk recovery에 대한 policy 함수가 일부 존재한다.
|
||||
|
||||
하지만 `src/contracts/routes.js`의 metadata와
|
||||
`src/presentation/routes/app-router.jsx`의 executable route tree가 별도 수동 목록이다.
|
||||
그 결과 다음 필드는 선언돼도 실제 행동을 보장하지 않는다.
|
||||
|
||||
- params/search schema
|
||||
- loading/error surface
|
||||
- chunk ID
|
||||
- route title/navigation label
|
||||
- redirect loop guard
|
||||
- chunk recovery policy
|
||||
|
||||
페이지도 공통 `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를 소유 | 현재 구현이 사용 중인 기준선 |
|
||||
| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | 목표 skeleton의 navigation lifecycle에 적합 |
|
||||
| Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 |
|
||||
|
||||
목표 결정:
|
||||
|
||||
- client-only SPA와 TanStack Query/application use case를 유지한다.
|
||||
- 현재 `BrowserRouter` 기반 Declarative Mode에서 `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를 기본값으로 만들지
|
||||
않는다.
|
||||
|
||||
전환 브랜치 전까지 현재 Declarative router에 새 custom scroll/blocker
|
||||
implementation을 추가하지 않는다. 전환할 수 없는 프로젝트만 별도 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와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.
|
||||
Reference in New Issue
Block a user