다크 모드
Chapter 11. 함수형 라우터 가드(CanActivateFn)를 활용한 인가 AOP
"로그인하지 않은 사용자가 브라우저 주소창에
/write를 직접 치고 침입하려 한다. 컴포넌트마다if (!isLoggedIn)복붙을 멈추고, Angular의 함수형 라우터 가드로 중앙 통제 관문(Gateway)과 안전한 자동 복귀 파이프라인을 세우자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| AOP(횡단 관심사 분리) | 여러 화면에 공통으로 필요한 인증·권한 검사를 라우터 한 곳에 모으기 | 11.1 |
AuthStore | JWT 토큰, 사용자명, 역할(Role)을 Signals로 관리하는 인증 스토어 | 11.2 [코드 11-1] |
CanActivateFn | 클래스 없이 순수 화살표 함수와 inject()로 화면 진입을 심사하는 가드 | 11.3 [코드 11-2] |
router.createUrlTree | 가드 안에서 부작용 없이 리다이렉트 목적지와 쿼리를 반환하는 표준 방식 | 11.3 🆕 박스 |
지연 로딩 (loadComponent) | 해당 경로에 처음 진입할 때만 컴포넌트 JS 청크를 내려받는 최적화 | 11.3 [코드 11-3] |
returnUrl & 오픈 리다이렉트 방어 | 로그인 성공 후 원래 가려던 경로로 안전하게 돌려보내는 검증 기법 | 11.4 [코드 11-4] |
TIP
🏁 이 장의 시작 파일frontend/src/app/board-app/board-app.component.ts가 10장 완료 상태에서 시작합니다.
이 장에서 작성·수정하는 파일:
frontend/src/app/stores/auth.store.ts(전역 인증·권한 스토어)frontend/src/app/guards/auth.guard.ts(authGuard,roleGuard함수형 가드)frontend/src/app/app.routes.ts(라우트 정의 및canActivate장착)frontend/src/app/views/login-page.component.ts(로그인 화면 및returnUrl안전 복귀)
11.1 왜 "라우터 한 곳"에서 막아야 하는가?
사내 인트라넷 포털은 경로(URL)마다 접근 권한이 다릅니다:
/: 기술 Q&A 게시판 목록 (BoardAppComponent, 누구나 열람 가능)/write: 새 기술 질문 작성 (사내 로그인 필수)/admin: 게시판 관리자 설정 (ADMIN권한 필수)/login: 사내 통합 로그인 (LoginPageComponent)
만약 각 화면 컴포넌트의 ngOnInit()마다 if (!this.auth.isLoggedIn()) this.router.navigate(['/login'])을 복사해 붙여 넣으면 두 가지 문제가 생깁니다:
- 보호할 화면이 30개로 늘어나면 30군데에 똑같은 검사 코드가 중복됩니다.
- 컴포넌트가 일단 화면에 잠깐 마운트되었다가 튕겨 나가므로(Flash of Unauthenticated Content), 불필요한 API 요청이나 깜빡임이 발생할 수 있습니다.
따라서 **화면 컴포넌트가 생성되기도 전에 라우터 입구에서 진입 여부를 판정하는 가드(CanActivateFn)**를 세웁니다. 백엔드 Spring의 HandlerInterceptor나 SecurityFilterChain과 똑같은 위치입니다.
IMPORTANT
라우터 가드는 UX 장치이고, 진짜 보안 인가는 백엔드의 몫입니다!
브라우저의 자바스크립트 코드는 사용자가 개발자 도구(F12)로 얼마든지 조작하거나 우회할 수 있습니다. 프론트엔드 가드의 목적은 "로그인하지 않은 사우를 친절하게 로그인 화면으로 안내하는 것"이며, 실제 데이터 변경(POST /api/posts, DELETE /api/posts/{id})의 최종 권한 검증은 반드시 백엔드 Spring Security와 JWT 필터에서 수행해야 합니다.
🏬 비유로 이해하는 라우터 가드 vs 백엔드 보안
- 프론트엔드 라우터 가드 (
CanActivateFn): 백화점 VIP 라운지 입구의 친절한 안내원입니다. "고객님, VIP 카드가 없으시면 1층 안내 데스크(/login)에서 발급 후 다시 모시겠습니다~" 하고 상냥하게 방향을 틀어 줍니다(UX 개선).- 백엔드 보안 필터 (Spring Security): 은행 금고 앞을 지키는 무장 경비원입니다. 누군가 안내원을 몰래 지나쳐 창문으로 침입(자바스크립트 변조)하더라도, 금고 문 앞에서 위조 불가능한 신분증(JWT 서명)이 없으면 가차 없이
401 Unauthorized/403 Forbidden으로 차단합니다.
11.2 [실습 1] 캡슐화된 인증 스토어 AuthStore 구축 (auth.store.ts)
9장에서 배운 Private Writable Signal + Public .asReadonly() 캡슐화 원칙을 인증 스토어에도 그대로 적용합니다. 특히 로그아웃할 때 localStorage.clear()를 호출하면 10장에서 만든 DraftStore의 임시저장 글 초안(BOARD_APP_POST_DRAFT_V1)까지 날아가므로, 반드시 인증 관련 키만 선택적으로 지웁니다!
[코드 11-1] 전역 인증 스토어 (frontend/src/app/stores/auth.store.ts)
typescript
// frontend/src/app/stores/auth.store.ts
import { Injectable, computed, signal } from '@angular/core';
export type UserRole = 'USER' | 'ADMIN';
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
const USERNAME_KEY = 'USERNAME';
const USER_ROLE_KEY = 'USER_ROLE';
@Injectable({ providedIn: 'root' })
export class AuthStore {
// 1. 내부 쓰기 전용 신호 (브라우저 스토리지에서 초기 복원)
private readonly _token = signal<string | null>(this.readStorage(ACCESS_TOKEN_KEY));
private readonly _username = signal<string | null>(this.readStorage(USERNAME_KEY));
private readonly _role = signal<UserRole>(
(this.readStorage(USER_ROLE_KEY) as UserRole) || 'USER'
);
// 2. 외부 공개 읽기 전용 신호
readonly token = this._token.asReadonly();
readonly username = this._username.asReadonly();
readonly role = this._role.asReadonly();
// 3. 파생 상태: 로그인 여부 및 관리자 여부
readonly isLoggedIn = computed(() => Boolean(this._token()));
readonly isAdmin = computed(() => this._role() === 'ADMIN');
// 4. 로그인 액션 (실습용 모의 JWT 발급, 12장 인터셉터와 연동)
async login(inputUsername: string, _password?: string): Promise<void> {
const cleanUser = inputUsername.trim();
if (!cleanUser) {
throw new Error('아이디를 입력해 주세요.');
}
const assignedRole: UserRole = cleanUser.toLowerCase() === 'admin' ? 'ADMIN' : 'USER';
const mockAccessToken = `mock-jwt-access-token-for-${cleanUser}`;
const mockRefreshToken = `mock-jwt-refresh-token-for-${cleanUser}`;
this._token.set(mockAccessToken);
this._username.set(cleanUser);
this._role.set(assignedRole);
this.writeStorage(ACCESS_TOKEN_KEY, mockAccessToken);
this.writeStorage(REFRESH_TOKEN_KEY, mockRefreshToken);
this.writeStorage(USERNAME_KEY, cleanUser);
this.writeStorage(USER_ROLE_KEY, assignedRole);
}
// 12장 토큰 자동 갱신 인터셉터가 새 Access Token을 주입할 때 사용
updateAccessToken(newToken: string): void {
this._token.set(newToken);
this.writeStorage(ACCESS_TOKEN_KEY, newToken);
}
// 5. 로그아웃 액션: 10장의 임시저장 초안을 보호하기 위해 인증 키만 선택 제거!
logout(): void {
this._token.set(null);
this._username.set(null);
this._role.set('USER');
this.removeStorage(ACCESS_TOKEN_KEY);
this.removeStorage(REFRESH_TOKEN_KEY);
this.removeStorage(USERNAME_KEY);
this.removeStorage(USER_ROLE_KEY);
}
private readStorage(key: string): string | null {
return typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
}
private writeStorage(key: string, value: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
}
private removeStorage(key: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
}
}11.3 [실습 2] 초간결 함수형 가드(CanActivateFn)와 라우트 구성
과거 Angular에서는 가드 하나를 만들려고 @Injectable() 클래스를 선언하고 implements CanActivate를 작성해야 했습니다. 모던 Angular에서는 CanActivateFn 타입의 화살표 함수 안에서 inject()로 필요한 스토어와 라우터를 바로 꺼내 씁니다.
[코드 11-2] 인증 가드와 역할(Role) 인가 가드 (frontend/src/app/guards/auth.guard.ts)
typescript
// frontend/src/app/guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore, UserRole } from '../stores/auth.store';
/**
* 1. 로그인 여부 확인 가드 (Authentication)
* 미로그인 시 원래 가려던 주소(state.url)를 returnUrl 쿼리에 담아 /login으로 리다이렉트합니다.
*/
export const authGuard: CanActivateFn = (_route, state) => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.isLoggedIn()) {
return true;
}
console.warn(`🚨 [authGuard] 미인증 사용자의 보호 경로 진입 차단: ${state.url}`);
// 🌟 router.navigate()를 부수효과로 호출하는 대신 UrlTree를 반환하는 것이 공식 표준!
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
/**
* 2. 역할 권한 확인 가드 팩토리 (Authorization)
* 라우트의 허용 역할 목록에 현재 사용자의 역할이 포함되는지 검사합니다.
*/
export function roleGuard(allowedRoles: UserRole[]): CanActivateFn {
return () => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.isLoggedIn() && allowedRoles.includes(authStore.role())) {
return true;
}
alert('해당 메뉴에 접근할 권한이 없습니다.');
return router.createUrlTree(['/']);
};
}🆕 처음 보는 문법: 왜 가드 안에서 router.navigate() 대신 router.createUrlTree()를 반환하나요?
가드 함수 안에서 router.navigate(['/login']); return false;처럼 명령형으로 이동시키면, 여러 개의 가드가 동시에 실행될 때 어떤 리다이렉트가 우선해야 하는지 라우터가 조율하기 어렵고 네비게이션 취소 로그가 지저분해집니다.
반면 **router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })**로 이동할 목적지 값(UrlTree) 자체를 반환하면, Angular Router가 진행 중이던 네비게이션을 깔끔하게 새 목적지로 갈아타게 해 주며 테스트 코드에서도 반환값만 검사하면 되어 훨씬 유리합니다.
[코드 11-3] 라우트 설정 및 지연 로딩 (frontend/src/app/app.routes.ts)
typescript
// frontend/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard, roleGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: '',
title: '사내 기술 Q&A 게시판',
loadComponent: () =>
import('./board-app/board-app.component').then((m) => m.BoardAppComponent)
},
{
path: 'write',
title: '새 질문 작성 - 사내 게시판',
canActivate: [authGuard], // 🌟 1차 관문: 로그인 필수!
loadComponent: () =>
import('./board-app/board-app.component').then((m) => m.BoardAppComponent)
},
{
path: 'admin',
title: '관리자 콘솔 - 사내 게시판',
canActivate: [authGuard, roleGuard(['ADMIN'])], // 🌟 2중 관문: 로그인 + ADMIN 역할 필수!
loadComponent: () =>
import('./board-app/board-app.component').then((m) => m.BoardAppComponent)
},
{
path: 'login',
title: '사내 통합 로그인',
loadComponent: () =>
import('./views/login-page.component').then((m) => m.LoginPageComponent)
},
{
path: '**',
redirectTo: ''
}
];11.4 [실습 3] LoginPageComponent와 returnUrl 안전 복귀 (Open Redirect 방어)
사용자가 /write?category=DEV로 가려다 가드에 막혀 /login?returnUrl=%2Fwrite%3Fcategory%3DDEV로 왔다면, 로그인 성공 직후 원래 가려던 /write?category=DEV로 돌려보내 주어야 매끄러운 UX가 완성됩니다.
단, 해커가 ?returnUrl=https://evil-phishing.com이나 ?returnUrl=//evil.com처럼 외부 피싱 사이트 주소를 심어 놓은 링크를 유포하는 오픈 리다이렉트(Open Redirect) 공격을 막기 위해 반드시 getSafeReturnUrl() 검증 함수를 거칩니다!
[코드 11-4] 로그인 페이지 컴포넌트 (frontend/src/app/views/login-page.component.ts)
typescript
// frontend/src/app/views/login-page.component.ts
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { AuthStore } from '../stores/auth.store';
/**
* 🌟 오픈 리다이렉트(Open Redirect) 방어 검증 함수
* 오직 우리 앱 내부의 상대 경로('/'로 시작하되 '//'나 '/\'가 아닌 경로)만 허용합니다.
*/
export function getSafeReturnUrl(rawUrl: unknown): string {
if (
typeof rawUrl === 'string' &&
rawUrl.startsWith('/') &&
!rawUrl.startsWith('//') &&
!rawUrl.startsWith('/\\') &&
!rawUrl.startsWith('/login')
) {
return rawUrl;
}
return '/';
}
@Component({
selector: 'app-login-page',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="login-wrapper">
<div class="login-card">
<h2>🔐 사내 통합 로그인</h2>
@if (isExpired()) {
<p class="expired-banner" role="alert">
⚠️ 인증 세션이 만료되었습니다. 다시 로그인해 주세요.
</p>
}
<p class="return-hint">
로그인 후 이동 경로: <code>{{ targetReturnUrl() }}</code>
</p>
<form (ngSubmit)="handleLogin()" class="login-form">
<div class="field">
<label for="username">사번 / 아이디</label>
<input
id="username"
name="username"
type="text"
[ngModel]="username()"
(ngModelChange)="username.set($event)"
placeholder="admin 입력 시 ADMIN 권한 부여"
required
/>
</div>
<div class="field">
<label for="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
[ngModel]="password()"
(ngModelChange)="password.set($event)"
placeholder="실습용: 아무 비밀번호나 입력"
required
/>
</div>
@if (errorMessage()) {
<p class="error-text">{{ errorMessage() }}</p>
}
<button type="submit" class="btn-login">로그인</button>
</form>
</div>
</div>
`,
styles: [`
.login-wrapper { display: flex; justify-content: center; padding: 64px 16px; }
.login-card {
width: 100%; max-width: 400px; background: #fff; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 28px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
}
.expired-banner {
background: #fef2f2; color: #dc2626; border: 1px solid #fecaca;
padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
}
.return-hint { font-size: 12px; color: #64748b; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field input { padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.error-text { color: #dc2626; font-size: 13px; margin-bottom: 10px; }
.btn-login {
width: 100%; background: #2563eb; color: #fff; border: none;
padding: 10px 16px; border-radius: 6px; font-weight: 600; cursor: pointer;
}
`]
})
export class LoginPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly authStore = inject(AuthStore);
private readonly queryParamMap = toSignal(this.route.queryParamMap);
readonly username = signal('');
readonly password = signal('');
readonly errorMessage = signal('');
readonly isExpired = computed(
() => this.queryParamMap()?.get('expired') === 'true'
);
readonly targetReturnUrl = computed(() =>
getSafeReturnUrl(this.queryParamMap()?.get('returnUrl'))
);
async handleLogin(): Promise<void> {
try {
this.errorMessage.set('');
await this.authStore.login(this.username(), this.password());
// 🌟 replaceUrl: true를 사용하여 브라우저 [뒤로 가기] 시 로그인 화면으로 다시 빠지지 않게 처리!
await this.router.navigateByUrl(this.targetReturnUrl(), { replaceUrl: true });
} catch (err) {
this.errorMessage.set(
err instanceof Error ? err.message : '로그인 처리에 실패했습니다.'
);
}
}
}🔬 심화(건너뛰어도 됨): router.navigateByUrl(url, { replaceUrl: true })의 히스토리 스택 원리
만약 로그인 성공 후 기본값(replaceUrl: false, 즉 pushState)으로 /write로 이동하면 브라우저 히스토리 스택이 [/, /login?returnUrl=/write, /write]가 됩니다. 이 상태에서 사용자가 /write에서 [뒤로 가기]를 누르면 이미 로그인했는데도 다시 /login 화면이 뜨는 어색한 현상이 벌어집니다.
반면 { replaceUrl: true } 옵션을 주면 현재 히스토리의 /login 항목을 /write로 **덮어쓰기(Replace)**하므로, 뒤로 가기를 눌렀을 때 깔끔하게 원래 출발지였던 /로 돌아갑니다!
11.5 [직접 해보기] 실습 미션 💪
🎯 미션: 이미 로그인한 사용자가 /login에 접근하면 /로 돌려보내는 guestOnlyGuard 작성하기
로그인을 마친 사용자가 주소창에 /login을 직접 치고 들어오면 로그인 폼을 보여줄 필요가 없습니다. authStore.isLoggedIn()이 true이면 router.createUrlTree(['/'])를 반환하고, 아니면 true(통과)를 반환하는 guestOnlyGuard: CanActivateFn을 작성해 보세요.
💡 미션 정답 코드 확인하기
typescript
// frontend/src/app/guards/auth.guard.ts 하단에 추가
export const guestOnlyGuard: CanActivateFn = () => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.isLoggedIn()) {
// 이미 로그인된 상태라면 메인 게시판으로 리다이렉트
return router.createUrlTree(['/']);
}
return true;
};11.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 컴포넌트마다 인증 검사 코드를 복붙하지 않고 함수형 가드(
CanActivateFn)를 통해 라우터 진입점 한 곳에서 선언적으로 통제합니다. - 미인증 차단 시
router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })로 원래 목적지를 보존합니다. - 로그인 성공 후에는 오픈 리다이렉트 검증(
getSafeReturnUrl)을 거친 뒤router.navigateByUrl(url, { replaceUrl: true })로 복귀시킵니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/app/stores/auth.store.ts | JWT 토큰·사용자명·권한 신호 및 선택적 키 삭제 logout() 구현 |
src/app/guards/auth.guard.ts | authGuard, roleGuard 함수형 가드 구현 |
src/app/app.routes.ts | 지연 로딩(loadComponent) 및 canActivate 가드 장착 |
src/app/views/login-page.component.ts | 로그인 폼, getSafeReturnUrl, replaceUrl: true 복귀 구현 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
NG0203: inject() must be called from an injection context | inject(AuthStore)를 가드 함수 바깥이나 await 이후 비동기 콜백 안에서 호출함 | 가드 화살표 함수의 최상단 동기 구간에서 inject() 호출 |
주소창에 returnUrl=/login...이 끝없이 쌓이며 무한 루프 | /login 라우트에도 실수로 canActivate: [authGuard]를 붙임 | /login 라우트에는 authGuard를 붙이지 않음 |
| 로그아웃했더니 10장에서 작성 중이던 글 초안까지 사라짐 | logout()에서 localStorage.clear()를 호출함 | removeItem으로 인증 관련 키 4개만 선택적으로 삭제 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 프론트엔드에
authGuard와roleGuard를 완벽히 구현했는데도 백엔드 API에서 다시 JWT 서명과 권한을 검증해야 하는 이유는 무엇인가요? getSafeReturnUrl('//evil.com')과getSafeReturnUrl('/write?page=2')는 각각 무엇을 반환하나요?- 로그인 성공 후 페이지 이동 시
{ replaceUrl: true }옵션을 주는 이유는 무엇인가요?
셀프 체크 정답
- 프론트엔드 코드는 사용자 브라우저에서 실행되므로 개발자 도구로 가드를 우회하거나
curl/Postman으로 직접 API를 호출할 수 있기 때문입니다. 프론트엔드 가드는 UX용이고 실제 보안 경계선은 백엔드입니다. '//evil.com'은 프로토콜 상대 외부 URL(!rawUrl.startsWith('//')위반)이므로 안전한 기본값'/'를 반환하고,'/write?page=2'는 내부 경로이므로 그대로'/write?page=2'를 반환합니다.- 브라우저 히스토리에서
/login기록을 목적지 주소로 덮어써서, 로그인 직후 사용자가 [뒤로 가기]를 눌렀을 때 다시 로그인 화면으로 돌아가는 현상을 막기 위해서입니다.
다음 챕터로 이어지는 질문
로그인해서 발급받은 JWT 토큰(authStore.token())을 매번 API 호출할 때마다 수동으로 헤더에 넣어주어야 할까요? 그리고 서비스 이용 도중 토큰이 만료되어 동시에 3개의 API에서 401 Unauthorized가 터지면 어떻게 우아하게 갱신해야 할까요?
이어지는 Chapter 12에서는 Angular 22 공식 안정화된 httpResource(), PostRepository, Single-Flight 401 토큰 갱신 함수형 인터셉터(HttpInterceptorFn), 그리고 **전역 ErrorHandler**를 완성합니다.