앵귤러 중급 강좌 #6 Guards와 Resolver
기초 #6 Router에서는 라우트를 정의하고 <router-outlet>에 화면을 그리는 법을 다뤘습니다. 거기까지가 “어떤 URL일 때 무엇을 그릴까"의 단계였다면, 그 한 칸 앞에는 늘 두 가지 질문이 따라옵니다.
- “이 사용자가 이 페이지에 들어가도 되나?”
- “화면을 그리기 전에 데이터를 미리 받아두는 게 낫지 않나?”
전자를 다루는 도구가 Guards, 후자를 다루는 도구가 Resolver입니다. 둘 다 라우터의 흐름을 살짝 가로채서 결정을 내리거나 준비를 하는 역할을 합니다. 이번 글에서는 모던 앵귤러의 함수형 스타일을 중심으로 Guards와 Resolver를 정리하겠습니다.
Guard란 무엇인가 #
Guard는 단순합니다. 라우트의 흐름을 가로채는 함수입니다. 사용자가 어떤 URL로 이동하려고 할 때 “통과시킬지 말지"를 결정하는 검문소입니다. 반환값은 셋 중 하나입니다.
true— 통과. 원래 가려던 라우트로 이동false— 차단. 이동을 멈추고 현재 위치에 머무름UrlTree— 다른 경로로 리다이렉트.router.createUrlTree(['/login'])같은 식
동기로 즉시 결정할 수도 있고, Promise나 Observable로 비동기 결정도 가능합니다. 토큰 검증을 서버에 물어보고 그 결과로 통과 여부를 정하는 식입니다.
앵귤러는 검사 시점에 따라 여러 종류의 Guard를 제공합니다. 가장 자주 쓰는 셋은 canActivate, canMatch, canDeactivate입니다.
canActivate — 가장 자주 쓰는 가드 #
canActivate는 사용자가 특정 라우트에 들어오려는 순간 실행됩니다. 인증 가드의 99%는 이 단계에 들어갑니다.
기초 #5 Service와 DI에서 다뤘던 패턴 그대로, 인증 상태를 들고 있는 AuthService가 있다고 가정합니다 — isLoggedIn()과 user() 시그널을 노출하는 평범한 싱글톤 Service입니다. 이걸 가드 안에서 끌어다 쓸 겁니다.
함수형 가드 (Modern style) #
모던 앵귤러는 가드를 함수로 작성하는 것을 권장합니다. 타입은 CanActivateFn이고, 의존성은 inject()로 가져옵니다.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// 원래 가려던 URL을 쿼리에 보존 — 로그인 후 그 위치로 돌려보내기 위해
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};라우트 정의에는 canActivate 배열에 함수를 그대로 넣습니다.
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'mypage', canActivate: [authGuard], component: MyPageComponent },
{ path: 'login', component: LoginComponent },
];canActivate는 배열입니다. 가드를 여러 개 나열할 수 있고, 하나라도 false/UrlTree를 반환하면 거기서 흐름이 멈춥니다. 인증 가드 + 권한 가드를 같이 두는 패턴은 잠시 후에 다시 보겠습니다.
CanActivate 인터페이스)는 v15.2부터 deprecated 상태입니다. 새 프로젝트에서는 함수형으로 작성하시고, 기존 클래스 가드를 유지보수하는 입장이라면 다음 절을 참고하세요.클래스 기반 가드 (legacy) #
함수형이 표준이 되기 전에는 가드를 클래스로 작성했습니다. 기존 코드베이스에서는 여전히 자주 보입니다.
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) {}
canActivate(): boolean | UrlTree {
return this.auth.isLoggedIn() ? true : this.router.createUrlTree(['/login']);
}
}라우트에서는 canActivate: [AuthGuard]로 클래스 자체를 넘기는 식이었습니다. 동작은 함수형과 거의 동일하지만, 클래스를 정의하고 DI로 받는 보일러플레이트가 한 겹 더 있습니다. 또한 함수형과 달리 라우트 외부(예: 다른 가드 함수 안)에서 재사용하기 까다롭다는 단점이 있습니다.
새 프로젝트에서는 함수형, 기존 코드 유지보수에서는 클래스 가드 — 이렇게 갈라서 보시면 됩니다.
canMatch — 매칭 자체를 막는 가드 #
canActivate는 “이 라우트가 매칭됐다, 들어가도 되나?“를 묻는 단계입니다. 그래서 loadComponent로 lazy-loaded인 라우트라도 이미 청크는 다운로드된 다음에야 가드가 돌아갑니다.
canMatch는 한 단계 더 앞입니다. 라우트가 매칭되기 전에 검사하기 때문에, 매칭에 실패하면 청크를 받지 않고 다음 라우트로 넘어갑니다.
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const adminMatch: CanMatchFn = () => {
const auth = inject(AuthService);
const user = auth.user();
return user?.name === 'admin' ? true : inject(Router).createUrlTree(['/']);
};export const routes: Routes = [
{
path: 'admin',
canMatch: [adminMatch],
loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent),
},
// 같은 path를 권한별로 다르게 매칭시키는 패턴
{ path: 'admin', component: AccessDeniedComponent },
];canMatch가 false를 반환하면 그 라우트는 아예 없는 셈 치고 다음 라우트 매칭으로 넘어갑니다. 권한이 없는 사용자에게 admin 청크를 다운로드시키지 않을 수 있다는 점이 핵심입니다. lazy loading과 함께 쓸 때 진가가 나옵니다.
canActivate와 canMatch 중 무엇을 쓸지 헷갈린다면 단순한 기준이 있습니다.
- 들어가도 되는지 묻는다 + 안 되면 리다이렉트 →
canActivate - lazy 청크조차 받기 싫다 / 같은 path를 권한별로 다르게 매칭 →
canMatch
대부분의 인증,권한 체크는 canActivate로 충분합니다.
canDeactivate — 떠날 때 검사 #
canDeactivate는 사용자가 페이지를 떠나려 할 때 실행됩니다. “변경사항이 저장되지 않았습니다. 정말 나가시겠습니까?” 같은 패턴이 여기서 나옵니다.
먼저 컴포넌트 쪽에 “지금 떠나도 되는지"를 알려주는 메서드 하나를 둡니다.
export class EditPostComponent {
private dirty = signal(false);
markDirty() { this.dirty.set(true); }
canLeave(): boolean {
if (!this.dirty()) return true;
return confirm('저장되지 않은 변경사항이 있습니다. 정말 나가시겠습니까?');
}
}가드는 라우터가 떠나려는 컴포넌트의 인스턴스를 첫 번째 인자로 넘겨줍니다.
import { CanDeactivateFn } from '@angular/router';
interface HasCanLeave {
canLeave: () => boolean | Promise<boolean>;
}
export const canLeaveGuard: CanDeactivateFn<HasCanLeave> = (component) => {
return component.canLeave();
};export const routes: Routes = [
{
path: 'posts/:id/edit',
canDeactivate: [canLeaveGuard],
component: EditPostComponent,
},
];가드를 컴포넌트가 아니라 라우트에 두는 이유는, 컴포넌트가 언마운트되기 직전에 라우터가 결정을 내려야 하기 때문입니다. 또 한 가지 가드를 여러 편집 화면에서 재사용할 수 있다는 장점도 있습니다 — HasCanLeave 인터페이스를 따르는 컴포넌트라면 어디든 끼워 쓸 수 있습니다.
Resolver — 화면 진입 전에 데이터 미리 받기 #
여기서부터는 시선을 바꿉니다. “들어가도 되나"가 아니라 “들어가기 전에 데이터를 미리 받아두자” 입니다.
기본 패턴은 이렇습니다. 사용자가 /posts/42로 이동하면, 화면이 그려지기 전에 라우터가 먼저 글 데이터를 받아옵니다. 컴포넌트는 처음 렌더링되는 그 순간부터 데이터를 손에 쥐고 시작하므로, 로딩 스피너 분기를 깔끔하게 줄일 수 있습니다.
getPost(id) 메서드를 가진 평범한 PostService가 Observable<Post>를 돌려준다고 합시다. Resolver는 ResolveFn 타입의 함수입니다.
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { PostService, Post } from './post.service';
export const postResolver: ResolveFn<Post> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(PostService).getPost(id);
};라우트에 등록할 때는 resolve 객체에 키-값 쌍으로 넘깁니다. 키 이름이 컴포넌트가 데이터를 꺼낼 때 쓰는 이름이 됩니다.
export const routes: Routes = [
{
path: 'posts/:id',
resolve: { post: postResolver },
component: PostDetailComponent,
},
];컴포넌트에서는 ActivatedRoute의 data로 결과를 읽습니다.
@Component({
selector: 'app-post-detail',
standalone: true,
template: `
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</article>
`,
})
export class PostDetailComponent {
private route = inject(ActivatedRoute);
protected readonly post = this.route.snapshot.data['post'] as Post;
}화면이 그려지는 순간 post는 이미 채워져 있습니다. 컴포넌트 안에서 “로딩 중” 분기를 둘 필요가 없습니다. Resolver가 작업 중일 때는 라우터가 아직 라우트 전환을 시작하지 않은 상태이므로, 사용자에게는 이전 페이지가 그대로 보입니다. 전환 중 스피너가 필요하면 Router의 events를 구독해 NavigationStart/NavigationEnd로 별도 표시를 합니다.
@if (loading()) { ... } 분기를 두는 편이 보통은 더 단순합니다. Resolver가 빛을 발하는 경우는 — (1) 데이터가 없는 화면을 절대 보여주기 싫을 때, (2) SEO,소셜 미리보기 때문에 첫 렌더에 콘텐츠가 차 있어야 할 때, (3) 같은 데이터를 부모/자식 라우트가 모두 봐야 할 때 — 정도로 좁게 쓰는 게 실용적입니다.가드 vs Resolver #
둘 다 라우트의 흐름을 가로채지만, 목적이 다릅니다.
| 구분 | Guard | Resolver |
|---|---|---|
| 묻는 것 | “통과시켜도 되나?” | “데이터를 미리 받아둔다” |
| 반환값 | boolean / UrlTree | 데이터(또는 그 Observable/Promise) |
| 실패 시 | 라우트 차단,리다이렉트 | 라우트 자체가 진행되지 않음 |
| 자주 쓰는 곳 | 인증,권한 | 상세 페이지의 메인 데이터 |
실행 순서도 정해져 있습니다. canMatch → canActivate(부모→자식) → resolve → 컴포넌트 생성 순으로 흐릅니다. 즉 Resolver가 도는 시점에는 이미 모든 가드가 통과한 뒤입니다. 권한이 없는 사용자에게 데이터를 받아오는 일은 발생하지 않습니다.
실무 패턴 — 한 라우트에 여러 가드 + Resolver #
실무에서는 가드와 Resolver를 한 라우트에 함께 거는 일이 많습니다. 예를 들어 “관리자 권한이 있고, 글이 존재할 때만 수정 페이지로 진입한다"는 시나리오입니다.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const roleGuard = (role: string): CanActivateFn => {
return () => {
const user = inject(AuthService).user();
return user?.name === role
? true
: inject(Router).createUrlTree(['/forbidden']);
};
};가드 함수를 팩토리(factory) 형태로 만들어두면 라우트마다 다른 권한을 손쉽게 끼워넣을 수 있습니다. 라우트 정의는 이렇게 됩니다.
export const routes: Routes = [
{
path: 'admin/posts/:id/edit',
canActivate: [authGuard, roleGuard('admin')],
canDeactivate: [canLeaveGuard],
resolve: { post: postResolver },
component: EditPostComponent,
},
];읽는 순서가 그대로 시나리오가 됩니다.
authGuard— 로그인 안 됐으면/login으로roleGuard('admin')— admin이 아니면/forbidden으로postResolver— 글 데이터를 미리 받아두기- 컴포넌트 진입 — 첫 렌더부터 글 데이터가 손에 있음
- 떠날 때
canLeaveGuard— 미저장 변경 확인
라우트 객체 하나만 보고도 “이 페이지의 진입 정책 + 데이터 정책 + 이탈 정책"이 한눈에 들어옵니다. 이게 가드와 Resolver를 라우트 레벨에 두는 가장 큰 이점입니다 — 정책이 컴포넌트 안에 흩어지지 않습니다.
마무리 #
이번 글에서는 라우트의 흐름을 가로채는 두 도구를 살펴봤습니다. 정리하면:
- Guard는 “통과시킬지” 묻는 함수.
true/false/UrlTree반환 canActivate— 가장 자주 쓰는 인증,권한 가드- **함수형 가드 +
inject()**가 모던 앵귤러의 표준. 클래스 가드는 legacy canMatch— 라우트 매칭 자체를 막아 lazy 청크 다운로드까지 절약canDeactivate— 떠날 때 “저장 안 됐는데 나갈래요?” 패턴- Resolver는 화면 진입 전에 데이터를 미리 받아두는 함수.
ResolveFn+inject() - 한 라우트에 여러 가드와 Resolver를 함께 걸어 진입,데이터,이탈 정책을 라우트 레벨에 모은다
라우트 정책을 다루는 도구가 갖춰지면, 다음 단계는 **“이 가드가 정말 의도대로 동작하는가, Resolver가 올바른 데이터를 넘기는가”**를 검증하는 일입니다. 다음 글인 “앵귤러 중급 강좌 #7 테스트"에서는 컴포넌트, Service, 그리고 가드/Resolver를 어떻게 단위 테스트로 묶어 두는지 — TestBed와 HttpTestingController를 중심으로 살펴보겠습니다.