Skip to content

포탈 라우팅 규약 (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. 통일 규칙 (필수) ​

  1. 경로 스킴 — §3 표를 따른다. 화면을 추가하면 경로도 함께 정한다.
  2. activeNavigationId 는 현재 경로에서 파생한다. 화면 id 를 별도 state 로 두고 경로와 이중 관리하지 않는다.
  3. 경로 → 뷰 해석은 앱 루트의 단일 <Switch> 한 곳에 모은다. 화면 컴포넌트 안에서 다시 분기하지 않는다.
  4. 메뉴는 PortalMenuItem(경로 필수) → 빌더로 만든다. 내비게이션 항목에는 href 를 넘겨 셸이 링크로 렌더하게 한다.
  5. 같은 출처 내부 링크 클릭은 SPA 이동으로 처리한다. 단 앱이 아는 경로일 때만 가로챈다(§4).
  6. 진입 전용 경로(/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 로 교체대기
PortalConsolewouter <Switch> 7경로 + onSelect 탭경로 파생으로 정리대기
UiPlatformuseLocation 2곳루트 <Switch>대기
GuideCenterwouter <Switch>§2 규칙 점검대기
DocumentLibrarywouter <Switch>§2 규칙 점검대기
lunchappwouter <Switch>§2 규칙 점검대기

7. 검증 ​

  • 딥링크: 서버가 임의 경로에 index.html 을 돌려주는지(SPA fallback) 확인하고, 그 경로로 직접 진입해 화면이 뜨는지 본다.
  • 뒤로가기: 메뉴 이동 후 브라우저 뒤로가기가 직전 화면으로 돌아오는지.
  • 활성 메뉴: 새로고침 후에도 현재 화면의 메뉴가 활성인지.
  • 회귀: 각 포탈의 pnpm verify:full + PR CI.