16.5 Angular Router — 모듈 라우팅, 가드(CanActivate), Lazy Loading
Angular Router 소개
Angular Router는 URL 경로와 컴포넌트를 연결하는 Angular 내장 라우팅 시스템입니다. SPA(Single Page Application)에서 페이지 전환 없이 URL에 따라 다른 컴포넌트를 렌더링합니다.
URL: /products → ProductListComponent
URL: /products/42 → ProductDetailComponent
URL: /admin/users → AdminUsersComponent (가드로 보호)
URL: /404 → NotFoundComponent
기본 라우터 설정
app.routes.ts — 라우트 정의
// src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
// 기본 리다이렉트
{
path: '',
redirectTo: '/home',
pathMatch: 'full'
},
// 기본 라우트
{
path: 'home',
loadComponent: () =>
import('./home/home.component').then(m => m.HomeComponent),
title: '홈 — My App' // 페이지 타이틀 (Angular 14+)
},
// 동적 파라미터
{
path: 'products',
loadComponent: () =>
import('./products/product-list.component').then(m => m.ProductListComponent)
},
{
path: 'products/:id',
loadComponent: () =>
import('./products/product-detail.component').then(m => m.ProductDetailComponent)
},
// 와일드카드 — 반드시 마지막에
{
path: '**',
loadComponent: () =>
import('./not-found/not-found.component').then(m => m.NotFoundComponent)
}
];
app.config.ts — 라우터 프로바이더 등록
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(), // 라우트 파라미터를 @Input으로 자동 바인딩
withViewTransitions() // 페이지 전환 애니메이션
),
]
};
RouterOutlet과 RouterLink
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav>
<!-- RouterLink: 클릭 시 해당 경로로 이동 -->
<a routerLink="/home" routerLinkActive="active">홈</a>
<a routerLink="/products" routerLinkActive="active">상품</a>
<!-- exact match -->
<a
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
>
루트
</a>
<!-- 동적 경로 -->
<a [routerLink]="['/products', productId]">상품 상세</a>
<!-- 쿼리 파라미터 포함 -->
<a [routerLink]="['/products']" [queryParams]="{ category: 'electronics', page: 1 }">
전자기기
</a>
</nav>
<!-- 현재 라우트에 맞는 컴포넌트가 여기에 렌더링 -->
<router-outlet />
`
})
export class AppComponent {
productId = 42;
}
동적 라우트 파라미터
ActivatedRoute로 파라미터 읽기
// products/product-detail.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'app-product-detail',
standalone: true,
template: `
@if (product()) {
<div>
<h1>{{ product()!.title }}</h1>
<p>{{ product()!.price | currency }}</p>
<button (click)="goBack()">뒤로</button>
<button (click)="goToEdit()">수정</button>
</div>
} @else {
<p>상품을 찾을 수 없습니다.</p>
}
`
})
export class ProductDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
product = signal<any>(null);
ngOnInit() {
// 방법 1: paramMap Observable 구독 (같은 컴포넌트를 재사용할 때 유용)
this.route.paramMap.pipe(
switchMap(params => {
const id = Number(params.get('id'));
return this.fetchProduct(id);
})
).subscribe(p => this.product.set(p));
// 방법 2: snapshot (한 번만 읽을 때)
// const id = this.route.snapshot.paramMap.get('id');
}
private fetchProduct(id: number) {
// 실제 서비스 호출
return Promise.resolve({ id, title: `상품 ${id}`, price: 99 });
}
goBack() {
this.router.navigate(['/products']);
}
goToEdit() {
this.router.navigate(['/products', this.product()!.id, 'edit']);
}
}
withComponentInputBinding() 활용 (Angular 16+)
provideRouter에 withComponentInputBinding()을 추가하면 라우트 파라미터를 @Input()/input()으로 직접 받을 수 있습니다.
// app.config.ts
provideRouter(routes, withComponentInputBinding())
// product-detail.component.ts
import { Component, input } from '@angular/core';
@Component({ ... })
export class ProductDetailComponent {
// URL /products/42 → id = '42' 자동 바인딩
id = input<string>('');
}
쿼리 파라미터
// 쿼리 파라미터: /products?category=electronics&page=2&sort=price
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [FormsModule],
template: `
<div>
<select [(ngModel)]="category" (change)="updateFilter()">
<option value="">전체</option>
<option value="electronics">전자기기</option>
<option value="clothing">의류</option>
</select>
<button (click)="clearFilters()">필터 초기화</button>
</div>
`
})
export class ProductListComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
category = '';
page = 1;
ngOnInit() {
// 쿼리 파라미터 읽기
this.route.queryParamMap.subscribe(params => {
this.category = params.get('category') || '';
this.page = Number(params.get('page')) || 1;
});
}
updateFilter() {
// 쿼리 파라미터 업데이트 (기존 파라미터 유지)
this.router.navigate([], {
relativeTo: this.route,
queryParams: { category: this.category || null, page: 1 },
queryParamsHandling: 'merge' // 기존 파라미터 유지
});
}
clearFilters() {
this.router.navigate([], {
relativeTo: this.route,
queryParams: {}
});
}
}
중첩 라우팅
// app.routes.ts — 중첩 라우트
export const routes: Routes = [
{
path: 'admin',
loadComponent: () =>
import('./admin/admin-layout.component').then(m => m.AdminLayoutComponent),
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
loadComponent: () =>
import('./admin/dashboard/dashboard.component').then(m => m.DashboardComponent)
},
{
path: 'users',
loadComponent: () =>
import('./admin/users/user-list.component').then(m => m.UserListComponent)
},
{
path: 'users/:id',
loadComponent: () =>
import('./admin/users/user-detail.component').then(m => m.UserDetailComponent)
}
]
}
];
// admin/admin-layout.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-admin-layout',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="admin-layout">
<aside class="sidebar">
<h2>관리자</h2>
<nav>
<a routerLink="dashboard" routerLinkActive="active">대시보드</a>
<a routerLink="users" routerLinkActive="active">사용자 관리</a>
</nav>
</aside>
<main class="content">
<!-- 자식 라우트가 여기에 렌더링 -->
<router-outlet />
</main>
</div>
`
})
export class AdminLayoutComponent {}
라우트 가드
가드는 라우트 이동을 제어합니다. 비로그인 사용자가 보호된 페이지에 접근하지 못하게 막는 데 사용합니다.
CanActivate 가드 (함수형, Angular 15+)
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) {
return true;
}
// 로그인 페이지로 리다이렉트 + 원래 URL 저장
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
// guards/admin.guard.ts
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.hasRole('admin')) {
return true;
}
return router.createUrlTree(['/403']);
};
CanDeactivate 가드 — 페이지 이탈 방지
// guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> =
(component) => {
if (component.hasUnsavedChanges()) {
return confirm('저장하지 않은 변경 사항이 있습니다. 정말 이동하시겠습니까?');
}
return true;
};
// 사용할 컴포넌트
@Component({ ... })
export class EditProductComponent implements HasUnsavedChanges {
isDirty = signal(false);
hasUnsavedChanges(): boolean {
return this.isDirty();
}
}
resolve 가드 — 데이터 미리 로드
// guards/product.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { ProductService, Product } from '../services/product.service';
export const productResolver: ResolveFn<Product> = (route) => {
const productService = inject(ProductService);
const id = Number(route.paramMap.get('id'));
return productService.getProduct(id);
};
// app.routes.ts
{
path: 'products/:id',
component: ProductDetailComponent,
resolve: { product: productResolver } // 컴포넌트 렌더링 전 데이터 로드
}
// product-detail.component.ts
export class ProductDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
ngOnInit() {
this.route.data.subscribe(({ product }) => {
this.product.set(product); // resolver에서 미리 로드한 데이터
});
}
}
가드 라우트에 적용
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, adminGuard], // 복수 가드
children: [
{
path: 'edit-product/:id',
component: EditProductComponent,
canDeactivate: [unsavedChangesGuard],
resolve: { product: productResolver }
}
]
}
];
Lazy Loading
코드 분할을 통해 초기 로딩 성능을 향상시킵니다.
loadComponent — 컴포넌트 지연 로딩
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent)
}
];
loadChildren — 라우트 설정 지연 로딩
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard],
loadChildren: () =>
import('./admin/admin.routes').then(m => m.adminRoutes)
// admin 기능 전체를 하나의 청크로 분리
}
];
// admin/admin.routes.ts
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./layout/admin-layout.component').then(m => m.AdminLayoutComponent),
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/admin-dashboard.component').then(m => m.AdminDashboardComponent)
},
{
path: 'users',
loadComponent: () =>
import('./users/admin-users.component').then(m => m.AdminUsersComponent)
}
]
}
];
실전 예제 — 인증 기반 라우트 보호
// services/auth.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';
interface AuthUser {
id: number;
name: string;
email: string;
roles: string[];
token: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private router = inject(Router);
private http = inject(HttpClient);
private _user = signal<AuthUser | null>(null);
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => !!this._user());
readonly isAdmin = computed(() => this._user()?.roles.includes('admin') ?? false);
constructor() {
// 페이지 새로고침 시 localStorage에서 복원
const stored = localStorage.getItem('auth_user');
if (stored) {
this._user.set(JSON.parse(stored));
}
}
login(email: string, password: string) {
return this.http.post<AuthUser>('/api/auth/login', { email, password }).pipe(
tap(user => {
this._user.set(user);
localStorage.setItem('auth_user', JSON.stringify(user));
})
);
}
logout() {
this._user.set(null);
localStorage.removeItem('auth_user');
this.router.navigate(['/login']);
}
hasRole(role: string): boolean {
return this._user()?.roles.includes(role) ?? false;
}
getToken(): string | null {
return this._user()?.token ?? null;
}
}
// pages/login.component.ts
import { Component, inject, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../services/auth.service';
@Component({
standalone: true,
imports: [FormsModule],
template: `
<div class="login-page">
<h1>로그인</h1>
@if (errorMessage()) {
<div class="error">{{ errorMessage() }}</div>
}
<form (submit)="onSubmit($event)">
<input
[(ngModel)]="email"
name="email"
type="email"
placeholder="이메일"
required
>
<input
[(ngModel)]="password"
name="password"
type="password"
placeholder="비밀번호"
required
>
<button type="submit" [disabled]="isLoading()">
{{ isLoading() ? '로그인 중...' : '로그인' }}
</button>
</form>
</div>
`
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
email = '';
password = '';
isLoading = signal(false);
errorMessage = signal('');
onSubmit(event: Event) {
event.preventDefault();
this.isLoading.set(true);
this.errorMessage.set('');
this.authService.login(this.email, this.password).subscribe({
next: () => {
// 로그인 성공 — 원래 가려던 URL로 이동
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.errorMessage.set(err.error?.message || '로그인에 실패했습니다.');
this.isLoading.set(false);
}
});
}
}
프로그래밍 방식 네비게이션
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({ ... })
export class NavigationExampleComponent {
private router = inject(Router);
navigateExamples() {
// 기본 이동
this.router.navigate(['/products']);
// 파라미터 포함
this.router.navigate(['/products', 42]);
// 쿼리 파라미터 포함
this.router.navigate(['/products'], {
queryParams: { category: 'electronics' }
});
// URL 문자열로 이동
this.router.navigateByUrl('/products?category=electronics');
// 현재 라우트 기준 상대 이동
this.router.navigate(['../edit'], { relativeTo: this.route });
// 뒤로 가기 (브라우저 히스토리)
history.back();
}
}
고수 팁
팁 1: 라우트 데이터(data) 활용
{
path: 'admin',
component: AdminComponent,
data: {
title: '관리자 패널',
breadcrumb: '관리자',
requiredRole: 'admin'
}
}
// 가드에서 라우트 데이터 읽기
export const roleGuard: CanActivateFn = (route) => {
const requiredRole = route.data['requiredRole'];
return inject(AuthService).hasRole(requiredRole);
};
팁 2: 라우터 이벤트 구독
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
router.events.pipe(
filter(e => e instanceof NavigationStart)
).subscribe(() => this.isLoading.set(true));
router.events.pipe(
filter(e => e instanceof NavigationEnd)
).subscribe(() => this.isLoading.set(false));
팁 3: preloadingStrategy로 성능 향상
import { PreloadAllModules } from '@angular/router';
provideRouter(
routes,
withPreloading(PreloadAllModules) // 백그라운드에서 모든 청크 미리 로드
)