포탈 라우팅 규약 (ROUTING_CONVENTION)
0. 결정 요약 (ADR)
| 항목 | 내용 |
|---|---|
| 상태 | 채택 (2026-09-19) |
| 범위 | portal-foundation 을 소비하는 모든 포탈의 클라이언트 화면 전환 |
| 결정 | 경로(URL)를 화면의 유일한 원천으로 삼고, 라우터 구현은 wouter 로 통일한다 |
| 대상 | AgentCenter · ProcessCenter · PortalConsole · UiPlatform · GuideCenter · DocumentLibrary · lunchapp |
검토한 대안
| 대안 | 채택하지 않은 이유 |
|---|---|
| 상태(state)만으로 화면 전환 | 딥링크·뒤로가기·새로고침 유지가 되지 않는다. 메뉴 모델이 경로를 요구하는 §1 계약과도 어긋난다 |
| 라이브러리 없이 직접 구현 | 경로 매칭·파라미터·popstate 를 포탈마다 다시 만든다(ProcessCenter 는 그렇게 29곳에 흩어져 있다). 유지 비용이 2KB 의존성보다 크다 |
| portal-foundation 에 라우터 내장 | ui-react 는 react/react-dom 만 peer 로 두는 표현 계층이다. 라우팅은 앱 관심사이므로 플랫폼이 소유하지 않는다 |
1. 근거: 플랫폼은 이미 경로를 계약으로 요구한다
| 계약 | 위치 |
|---|---|
LINK 메뉴는 / 로 시작하는 경로가 필수 | menuManagement.tsx validatePortalMenu() — INVALID_PATH, "내부 메뉴 경로는 /로 시작해야 합니다" |
| 활성 메뉴는 경로에서 파생 | buildPortalNavigationItems(items, { activeMenuKey }) |
| 셸은 경로를 네이티브 링크로 렌더 | menuAdapter.tsx — href 가 있는 항목을 <a href={item.href}> 로 렌더 |
| 셸은 라우터를 제공하지 않는다 | packages/ui-react/package.json peer: react, react-dom |
| 서버는 경로 이동을 전제 | 소비 포탈 모두 SPA fallback(index.html) 보유 |
즉 "URL 이 화면의 원천"이라는 전제는 이미 플랫폼에 박혀 있고, 비어 있는 것은 클라이언트 구현의 통일이다.
2. 통일 규칙 (필수)
- 경로 스킴 — §3 표를 따른다. 화면을 추가하면 경로도 함께 정한다.
activeNavigationId는 현재 경로에서 파생한다. 화면 id 를 별도 state 로 두고 경로와 이중 관리하지 않는다.- 경로 → 뷰 해석은 앱 루트의 단일
<Switch>한 곳에 모은다. 화면 컴포넌트 안에서 다시 분기하지 않는다. - 메뉴는
PortalMenuItem(경로 필수) → 빌더로 만든다. 내비게이션 항목에는href를 넘겨 셸이 링크로 렌더하게 한다. - 같은 출처 내부 링크 클릭은 SPA 이동으로 처리한다. 단 앱이 아는 경로일 때만 가로챈다(§4).
- 진입 전용 경로(
/setup,/login,/connect/authorize)는 외부 리다이렉트 대상이므로 항상 실제 URL 로 유지한다.
3. URL 스킴
| 종류 | 규칙 | 예 |
|---|---|---|
| 홈 | / | / |
| 1 Depth 화면 | /<메뉴 키> | /agents, /process, /my |
| 하위 화면 | /<메뉴 키>/<하위 키> | /agents/new |
| 관리자 | /admin/<패널> | /admin/users, /admin/policy |
| 매뉴얼 | 고정 경로(셸 슬롯 진입) | /manual, /admin/manual |
| 검토 업무 | /review/<종류> | /review/mappings |
| 목록의 선택·필터 | 쿼리스트링 | /process?node=12, /admin/users?page=2 |
| 진입 전용 | 고정 | /setup, /login, /connect/authorize |
| 미매칭 | /404 로 리다이렉트하지 않고 미매칭 뷰를 그대로 렌더 | — |
경로 문자열은 메뉴 정의(
MENU_TREE/PortalMenuItem)의href를 단일 원천으로 삼는다. 라우터 표와 메뉴가 어긋나면 규약 위반이다. 메뉴 행이 아닌 화면(예: 매뉴얼)은 경로를 라우팅 표에만 둔다 — 메뉴에 행을 만들지 않고 셸 슬롯으로 진입한다(MANUAL_CONVENTION.md). 이때도 경로는 한 곳에만 적는다.
4. 구현 예시 (wouter)
tsx
// 라우팅 표는 메뉴 정의에서 파생한다 — 같은 경로를 두 곳에 적지 않는다.
const ROUTES = collectRoutes(MENU_TREE); // [{ path: "/", viewId: "overview" }, …]
function PortalRoutes() {
return (
<Switch>
{ROUTES.map(route => (
<Route key={route.path} path={route.path}>{renderView(route.viewId)}</Route>
))}
<Route component={NotFoundView} /> {/* 마지막 fallback */}
</Switch>
);
}
function AppShell() {
const [location] = useLocation();
const activeView = resolveViewId(location); // 경로 → 화면 id
return (
<PortalShell navigationItems={buildNavigationItems()} activeNavigationId={activeView ?? undefined} …>
<PortalRoutes />
</PortalShell>
);
}메뉴 항목은 href 를 넘긴다(셸이 <a href> 로 렌더). 그대로 두면 메뉴 이동마다 전체 로드가 되므로, 앱 루트에서 위임 핸들러 하나로 앱이 아는 경로만 SPA 이동으로 바꾼다.
ts
const anchor = (event.target as Element | null)?.closest?.("a");
const href = anchor?.getAttribute("href");
if (!href?.startsWith("/") || href.startsWith("//")) return;
if (!KNOWN_PATHS.includes(new URL(href, window.location.origin).pathname)) return; // 다운로드·API 경로 보호
event.preventDefault();
navigate(href);수정자 키(⌘/Ctrl/Shift/Alt)·가운데 클릭·target·download 는 가로채지 않는다(새 탭·저장 동작 유지).
5. 금지
- 화면 id 만 바꾸고 경로를 갱신하지 않는 것 — 뒤로가기·새로고침에서 상태가 사라진다.
- 경로를 두 곳 이상에서 정의하는 것 — 메뉴 정의와 라우터 표가 어긋난다.
- 화면 컴포넌트 내부에서
window.location을 읽어 분기하는 것 — 판단은 §2.3 의 한 곳에서만 한다. - 앱이 모르는 경로(다운로드·API·외부 링크)를 SPA 이동으로 가로채는 것.
6. 마이그레이션 현황
| 포탈 | 현재 | 목표 | 상태 |
|---|---|---|---|
| AgentCenter | 라우팅 없음(viewId state) | wouter + 경로 파생 | 파일럿 완료 |
| ProcessCenter | 직접 구현 29곳(pathname.match, pushState) | wouter 로 교체 | 대기 |
| PortalConsole | wouter <Switch> 7경로 + onSelect 탭 | 경로 파생으로 정리 | 대기 |
| UiPlatform | useLocation 2곳 | 루트 <Switch> | 대기 |
| GuideCenter | wouter <Switch> | §2 규칙 점검 | 대기 |
| DocumentLibrary | wouter <Switch> | §2 규칙 점검 | 대기 |
| lunchapp | wouter <Switch> | §2 규칙 점검 | 대기 |
7. 검증
- 딥링크: 서버가 임의 경로에
index.html을 돌려주는지(SPA fallback) 확인하고, 그 경로로 직접 진입해 화면이 뜨는지 본다. - 뒤로가기: 메뉴 이동 후 브라우저 뒤로가기가 직전 화면으로 돌아오는지.
- 활성 메뉴: 새로고침 후에도 현재 화면의 메뉴가 활성인지.
- 회귀: 각 포탈의
pnpm verify:full+ PR CI.