본문으로 건너뛰기

라우팅과 네비게이션

본 문서는 라우트 구조와 pages 슬라이스의 대응 관계, URL이 소유하는 상태의 범위, 가드의 역할과 한계를 정의합니다.

1. 라우트와 슬라이스의 대응

라우트 하나에 pages 슬라이스 하나가 대응합니다.

라우트 슬라이스
/assessments pages/assessment-list
/assessments/:id pages/assessment-detail
/assessments/:id/edit pages/assessment-edit

목록과 상세를 하나의 슬라이스로 묶지 않습니다. 두 화면은 조회 형태도 상태도 다르며, 묶으면 한쪽 변경이 다른 쪽 파일을 건드리게 됩니다.

슬라이스가 많아져 탐색이 어려워지면 슬라이스 그룹 폴더로 묶습니다. 그룹 폴더는 세그먼트도 공개 API도 갖지 않으며 탐색 목적만 갖습니다.

pages/
└── assessment/
    ├── assessment-list/
    │   └── index.ts
    └── assessment-detail/
        └── index.ts

assessment/는 그룹이므로 index.ts를 갖지 않습니다. 공개 API 는 각 슬라이스가 소유합니다.

주의

그룹 이름은 shared의 세그먼트 이름과 겹칠 수 없습니다

Steiger의 fsd/ambiguous-slice-names가 두 이름이 같으면 빌드를 실패시킵니다. ui·lib·api·config·model 같은 표준 세그먼트뿐 아니라 shared/auth처럼 직접 만든 세그먼트도 대상입니다. 그룹을 먼저 만든 뒤 같은 이름의 세그먼트를 추가해도 같은 오류가 나므로, 양쪽 이름을 함께 관리합니다.

1.1 URL 슬러그

경로 세그먼트는 ASCII 로 작성합니다. 한글을 포함한 경로는 정적 생성까지는 통과하지만 서빙 단계에서 404가 됩니다. 빌드가 성공하고 산출물도 존재하므로 실제로 요청해 보기 전에는 드러나지 않습니다. 실측 근거는 개발 환경 7절에 있습니다.

한글 제목을 가진 리소스는 식별자와 표시 이름을 분리합니다. URL 은 ASCII 슬러그를 쓰고 화면에 보이는 제목은 별도 필드가 소유합니다.

2. 라우트 정의

라우트 정의는 app/app.routes.ts가 단독으로 소유합니다. pages 슬라이스가 자신의 경로를 정의하지 않습니다.

export const routes: Routes = [
  {
    path: 'assessments',
    loadComponent: () =>
      import('@/pages/assessment-list').then((m) => m.AssessmentList),
  },
  {
    path: 'assessments/:id',
    loadComponent: () =>
      import('@/pages/assessment-detail').then((m) => m.AssessmentDetail),
    canActivate: [authGuard],
  },
];

모든 화면 라우트는 loadComponent 또는 loadChildren으로 지연 로딩합니다. 예외를 두지 않습니다. 정적 임포트는 초기 번들에 모든 화면을 포함시킵니다.

랜딩 화면만 즉시 로딩하는 절충안은 채택하지 않습니다. 실측 결과 빌드가 정적 생성 경로의 지연 청크에 <link rel="modulepreload"> 힌트를 심어 주므로 브라우저가 초기 번들과 병렬로 받습니다. 직렬 왕복이 없어 즉시 로딩으로 얻을 것이 없습니다.

pages 슬라이스의 index.ts는 라우트 진입 컴포넌트만 내보냅니다. 근거는 패키지 배치와 참조 규칙 7.4절에 있습니다.

2.1 경로 상수

경로 문자열을 프로그래밍 방식 이동(router.navigate)과 템플릿 링크에 직접 기재하지 않습니다. shared/config/routes.ts에 모읍니다.

export const ROUTES = {
  assessmentList: () => '/assessments',
  assessmentDetail: (id: string) => `/assessments/${id}`,
} as const;

경로가 흩어지면 URL 구조를 바꿀 때 문자열 검색에 의존하게 되며, 오타는 런타임에만 드러납니다.

3. URL이 소유하는 상태

다음은 반드시 URL에 둡니다.

대상 위치
리소스 식별자 경로 파라미터 (/assessments/:id)
목록 필터 조건 쿼리 파라미터
정렬 기준과 방향 쿼리 파라미터
페이지 번호와 크기 쿼리 파라미터
공유·북마크되어야 하는 탭 위치 쿼리 파라미터

이유는 구현 절약입니다. 새로고침 복원, 뒤로가기 대응, 링크 공유가 별도 코드 없이 동작합니다. 컴포넌트 signal에 두면 이 셋을 각각 구현해야 합니다.

3.1 읽기

라우트 입력을 시그널로 받습니다.

// app.config.ts
provideRouter(routes, withComponentInputBinding())
export class AssessmentDetail {
  readonly id = input.required<string>();          // 경로 파라미터
  readonly page = input(1, { transform: numberAttribute });  // 쿼리 파라미터
}

ActivatedRoute를 주입해 구독하는 방식보다 입력 바인딩을 우선합니다. 시그널이므로 httpResource의 요청 계산식에 그대로 넣을 수 있습니다.

3.2 쓰기

필터 변경은 상태 갱신이 아니라 이동입니다.

this.router.navigate([], {
  queryParams: { page: 2, keyword: 'x' },
  queryParamsHandling: 'merge',
  replaceUrl: true,     // 필터 조작마다 히스토리를 쌓지 않습니다
});

replaceUrl을 쓰지 않으면 뒤로가기를 여러 번 눌러야 이전 화면으로 돌아갑니다. 필터 조작은 히스토리 항목이 될 만한 이동이 아닙니다.

3.3 URL에 두지 않는 것

대상 위치
열린 아코디언, 선택된 행 컴포넌트 상태
편집 중인 임시 입력값 컴포넌트 상태
다이얼로그 열림 여부 컴포넌트 상태. 단, 딥링크로 열려야 하면 URL
민감정보 (토큰, 개인식별정보) 어디에도 URL에 두지 않습니다. 히스토리·리퍼러·서버 로그에 남습니다

4. 가드

4.1 가드의 역할

주의

라우트 가드는 인가 수단이 아닙니다

가드는 사용자가 접근할 수 없는 화면으로 이동해 빈 화면이나 에러를 보는 것을 막는 사용자 경험 장치입니다. 클라이언트 코드는 전부 조작 가능하므로 가드를 우회한 요청이 서버에 도달할 수 있습니다. 모든 인가 판정의 최종 책임은 서버에 있으며, 프론트엔드는 서버가 거부할 것을 미리 숨길 뿐입니다. 가드가 있으니 서버 검증이 느슨해도 된다는 판단은 금지합니다.

4.2 가드의 배치

가드는 shared/auth/에 두고 app/app.routes.ts에서 사용합니다. pages 슬라이스가 가드를 정의하지 않습니다.

// shared/auth/auth-guard.ts
export const authGuard: CanActivateFn = () => {
  const session = inject(SessionStore);
  if (session.isAuthenticated()) return true;
  return inject(Router).createUrlTree([ROUTES.login()]);
};

가드는 boolean이 아니라 UrlTree를 반환해 이동 대상을 명시합니다. false만 반환하면 사용자는 아무 일도 일어나지 않은 화면에 남습니다.

4.3 가드의 종류

가드 용도
CanActivateFn 인증 여부, 권한에 따른 화면 접근 제어
CanDeactivateFn 저장하지 않은 입력이 있을 때 이탈 확인
ResolveFn 화면 진입에 필수인 데이터 조회. 베일과 함께 사용합니다

ResolveFn은 화면 진입 전에 데이터를 받는 수단이며, 대기 중에는 이전 화면을 유지한 채 베일을 덮습니다. 리졸버를 베일 없이 단독으로 사용하는 것을 금지합니다. 전환이 멈춘 채 아무 피드백이 없으면 사용자는 조작이 무시된 것으로 인식합니다.

리졸버 작성 규칙, 시간 정책, 층별 적용 경계는 로딩 전략이 원본입니다.

4.4 리졸버 재실행 설정

필터·정렬·페이지를 쿼리 파라미터에 두었으므로, 쿼리 변경 시 리졸버가 다시 실행되도록 지정해야 합니다.

{
  path: 'assessments',
  loadComponent: () => import('@/pages/assessment-list').then((m) => m.AssessmentList),
  resolve: { assessments: assessmentListResolver },
  runGuardsAndResolvers: 'paramsOrQueryParamsChange',
}

기본값 'paramsChange'는 쿼리 파라미터 변경을 감지하지 않습니다. 지정을 빠뜨리면 필터를 바꿔도 데이터가 갱신되지 않으며, 오류 없이 조용히 실패하므로 발견이 늦습니다.

5. 네비게이션 구조

전역 네비게이션(헤더, 사이드바, 하단 탭)은 app 계층에 둡니다. 전역 레이아웃이므로 pages가 소유하지 않습니다.

app/ui/ 폴더명은 Steiger의 no-ui-in-app 규칙에 걸리므로 app/layout/ 등 목적을 드러내는 이름을 사용합니다.

네비게이션 항목 목록은 shared/config/에 데이터로 두고 레이아웃 컴포넌트가 이를 렌더링합니다. 항목을 템플릿에 하드코딩하면 권한별 노출 제어를 넣을 때 템플릿을 고쳐야 합니다.

기기별 네비게이션 형태 전환(사이드바 ↔ 하단 탭)은 적응형 UI의 대상입니다.

6. 금지 사항

금지 사유
화면 라우트의 정적 임포트 초기 번들에 모든 화면이 포함됩니다
runGuardsAndResolvers 미지정 필터를 바꿔도 데이터가 갱신되지 않으며 조용히 실패합니다
리졸버를 베일 없이 단독 사용 전환이 멈춘 채 피드백이 없습니다
경로 문자열을 호출부에 직접 기재 변경 시 검색에 의존하며 오타가 런타임에만 드러납니다
필터·페이지를 컴포넌트 상태로 보관 새로고침과 뒤로가기에서 소실됩니다
필터 변경 시 히스토리 누적 뒤로가기가 여러 번 필요해집니다
URL에 토큰·개인식별정보 기재 히스토리와 서버 로그에 남습니다
ResolveFn으로 조회 후 화면 전환 전환이 멈추고 피드백이 없습니다
가드를 인가 수단으로 신뢰 클라이언트 코드는 조작 가능합니다
© 2026 dev.goraebap