Guard가 하는 일: 요청의 실행 여부를 결정하는 단일 책임
NestJS에서 Guard는 요청이 라우트 핸들러에 도달하기 전에 “이 요청을 처리해도 되는가?”를 판단하는 계층이다. NestJS 공식 문서는 Guard를 “단일 책임(single responsibility)을 가진 인가(authorization) 메커니즘”으로 정의한다. Express의 미들웨어와 달리, Guard는 ExecutionContext에 접근할 수 있어 다음에 어떤 핸들러가 실행될지를 알고 있다.
Guard는 CanActivate 인터페이스를 구현하며, canActivate() 메서드가 true를 반환하면 요청이 계속 진행되고, false를 반환하면 NestJS가 자동으로 ForbiddenException(403)을 던진다.
NestJS 요청 파이프라인에서 Guard의 위치
NestJS 공식 문서에 명시된 실행 순서는 다음과 같다.
| 순서 | 계층 | 역할 | 실패 시 동작 |
|---|---|---|---|
| 1 | Middleware | req/res 변환, 로깅 | next() 미호출 시 중단 |
| 2 | Guard | 인가/인증 판단 | ForbiddenException (403) |
| 3 | Interceptor (before) | 요청 전처리, 타임아웃 | 예외 전파 |
| 4 | Pipe | 유효성 검사, 변환 | BadRequestException (400) |
| 5 | Handler | 비즈니스 로직 | — |
| 6 | Interceptor (after) | 응답 후처리, 캐싱 | 예외 전파 |
| 7 | Exception Filter | 예외 포맷팅 | 최종 응답 |
핵심: Guard는 Pipe보다 먼저 실행된다. 즉, Guard 시점에는 DTO 유효성 검사가 아직 수행되지 않았다. Guard에서 body를 직접 읽어 판단하는 것은 안전하지 않다.
기본 Guard 구현: CanActivate 인터페이스
// auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return this.validateRequest(request);
}
private validateRequest(request: any): boolean {
// 토큰 검증 로직
const token = request.headers.authorization?.split(' ')[1];
if (!token) return false;
// ... JWT 검증
return true;
}
}
canActivate()는 동기(boolean), Promise, Observable 중 어떤 것이든 반환할 수 있다. 비동기 토큰 검증이나 DB 조회가 필요한 경우 async/await를 자연스럽게 사용할 수 있다.
Guard 바인딩: 핸들러 → 컨트롤러 → 글로벌, 세 가지 스코프
// 1. 핸들러 스코프: 특정 라우트에만 적용
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {
return this.userService.getProfile();
}
// 2. 컨트롤러 스코프: 컨트롤러 내 모든 라우트에 적용
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController { /* ... */ }
// 3. 글로벌 스코프: 모든 라우트에 적용
// main.ts
app.useGlobalGuards(new AuthGuard());
// 또는 DI를 사용하려면 모듈에서 등록 (공식 문서 권장)
// app.module.ts
@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}
| 바인딩 방식 | DI 지원 | 적용 범위 | 사용 시점 |
|---|---|---|---|
@UseGuards() 핸들러 |
✅ (클래스 참조 시) | 단일 라우트 | 특정 엔드포인트만 보호 |
@UseGuards() 컨트롤러 |
✅ | 컨트롤러 전체 | 컨트롤러 단위 인가 |
app.useGlobalGuards() |
❌ | 전체 애플리케이션 | DI 불필요한 간단한 Guard |
APP_GUARD 프로바이더 |
✅ | 전체 애플리케이션 | 글로벌 + DI 필요 시 (권장) |
ExecutionContext와 Reflector: 메타데이터 기반 역할(Role) Guard
Guard의 진정한 힘은 ExecutionContext와 Reflector를 조합해 메타데이터 기반 인가를 구현할 때 나온다. 공식 문서의 역할 기반 Guard 패턴을 보자.
// roles.decorator.ts — 커스텀 데코레이터로 메타데이터 설정
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts — Reflector로 메타데이터 읽기
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// getAllAndOverride: 핸들러 메타데이터 우선, 없으면 컨트롤러 메타데이터
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
// 메타데이터가 없으면 제한 없음 → 통과
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// 사용: 컨트롤러에서 역할 지정
@Controller('admin')
@UseGuards(AuthGuard, RolesGuard) // AuthGuard가 먼저 실행되어 user를 req에 세팅
export class AdminController {
@Roles('admin')
@Get('dashboard')
getDashboard() {
return this.adminService.getDashboard();
}
@Roles('admin', 'moderator') // 핸들러 레벨 메타데이터가 우선
@Delete(':id')
deleteUser(@Param('id') id: string) {
return this.adminService.deleteUser(id);
}
}
Reflector의 세 가지 읽기 메서드
| 메서드 | 동작 | 사용 시나리오 |
|---|---|---|
get(key, target) |
단일 대상에서 메타데이터 읽기 | 핸들러 또는 클래스 하나만 확인 |
getAllAndOverride(key, targets[]) |
첫 번째로 찾은 값 반환 (핸들러 우선) | 핸들러가 컨트롤러 설정을 덮어쓰기 |
getAllAndMerge(key, targets[]) |
모든 대상의 값을 병합(배열 concat) | 핸들러와 컨트롤러 역할을 합산 |
getAllAndOverride vs getAllAndMerge는 Guard 동작을 완전히 바꾸는 선택이다. Override를 쓰면 핸들러에 @Roles('admin')을 붙이면 컨트롤러의 @Roles('user')는 무시된다. Merge를 쓰면 ['admin', 'user']가 모두 허용된다.
Public 라우트 패턴: 글로벌 Guard에서 특정 엔드포인트 제외
APP_GUARD로 글로벌 Guard를 등록하면 모든 라우트가 보호된다. 로그인, 회원가입, 헬스체크 같은 공개 엔드포인트는 제외해야 한다. 공식 문서에서 권장하는 패턴이다.
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// auth.guard.ts — Public 메타데이터 확인
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) {
return true; // Public 라우트는 인증 없이 통과
}
// 일반 인증 로직
const request = context.switchToHttp().getRequest();
return this.validateToken(request);
}
private validateToken(request: any): boolean {
// JWT 검증 등
return !!request.headers.authorization;
}
}
// 사용
@Controller('auth')
export class AuthController {
@Public() // Guard 건너뜀
@Post('login')
login(@Body() dto: LoginDto) { /* ... */ }
@Public()
@Post('register')
register(@Body() dto: RegisterDto) { /* ... */ }
}
@Controller('health')
@Public() // 컨트롤러 전체를 Public으로
export class HealthController {
@Get()
check() { return { status: 'ok' }; }
}
복수 Guard 조합: AND 논리와 실행 순서
@UseGuards(GuardA, GuardB)로 여러 Guard를 지정하면 왼쪽에서 오른쪽으로 순서대로 실행되며, 하나라도 false를 반환하면 즉시 중단된다 (AND 논리). 공식 문서에 명시된 동작이다.
// 실행 순서: AuthGuard → RolesGuard → ThrottlerGuard
@UseGuards(AuthGuard, RolesGuard, ThrottlerGuard)
@Get('sensitive-data')
getSensitiveData() { /* ... */ }
// AuthGuard가 false → RolesGuard는 실행되지 않음
// AuthGuard가 true, RolesGuard가 false → ThrottlerGuard는 실행되지 않음
OR 논리가 필요한 경우: NestJS Guard는 기본적으로 AND만 지원한다. “API 키 또는 JWT 중 하나만 있으면 통과”와 같은 OR 논리는 단일 Guard 내부에서 구현해야 한다.
// or-auth.guard.ts — OR 논리를 단일 Guard에서 구현
@Injectable()
export class OrAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private apiKeyService: ApiKeyService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// JWT 시도
const jwtResult = await this.tryJwt(request);
if (jwtResult) return true;
// API Key 시도
const apiKeyResult = await this.tryApiKey(request);
if (apiKeyResult) return true;
return false; // 둘 다 실패
}
private async tryJwt(request: any): Promise<boolean> {
try {
const token = request.headers.authorization?.split(' ')[1];
if (!token) return false;
request.user = await this.jwtService.verifyAsync(token);
return true;
} catch {
return false;
}
}
private async tryApiKey(request: any): Promise<boolean> {
const apiKey = request.headers['x-api-key'];
if (!apiKey) return false;
const valid = await this.apiKeyService.validate(apiKey);
return valid;
}
}
Guard에서 예외 커스터마이징: ForbiddenException 대신 UnauthorizedException
Guard가 false를 반환하면 NestJS는 기본적으로 ForbiddenException(403)을 던진다. 인증 실패는 401이 더 적절한 경우가 많다. Guard 내부에서 직접 예외를 던져 이를 제어할 수 있다.
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
// false 대신 직접 예외를 던지면 상태 코드/메시지 제어 가능
throw new UnauthorizedException('토큰이 필요합니다');
}
try {
// JWT 검증
request.user = this.jwtService.verify(token);
return true;
} catch (error) {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
}
}
WebSocket·GraphQL Guard: ExecutionContext의 프로토콜 전환
NestJS Guard는 HTTP뿐 아니라 WebSocket, GraphQL에서도 동일하게 동작한다. ExecutionContext의 getType()으로 프로토콜을 구분하고, switchToWs()나 GqlExecutionContext로 전환한다.
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// HTTP와 GraphQL 모두 지원하는 Guard
if (context.getType() === 'http') {
const request = context.switchToHttp().getRequest();
return this.validateToken(request.headers.authorization);
}
// GraphQL
const gqlContext = GqlExecutionContext.create(context);
const { req } = gqlContext.getContext();
return this.validateToken(req.headers.authorization);
}
private validateToken(authHeader?: string): boolean {
if (!authHeader) return false;
// ... 검증 로직
return true;
}
}
실전 체크리스트: Guard 설계 6단계
- 인증(Authentication)과 인가(Authorization) 분리 — 인증 Guard(JWT 검증)와 인가 Guard(역할 확인)를 별도로 만들어 조합한다
- 글로벌 Guard + Public 데코레이터 패턴 채택 —
APP_GUARD로 등록하고 공개 엔드포인트만@Public()로 제외 (화이트리스트 방식이 블랙리스트보다 안전) - Reflector 메서드 선택 — Override(덮어쓰기)와 Merge(합산) 중 비즈니스 요구에 맞는 것 선택
- Guard에서 request에 데이터 주입 — 인증 Guard가
request.user를 세팅하면 이후 인가 Guard와 핸들러가 이를 사용 - 예외 직접 던지기 — 401과 403을 구분해야 하면
false대신 명시적 예외를 던진다 - 테스트: Guard 단독 + e2e — Guard의
canActivate()를 단위 테스트하고, e2e에서 실제 HTTP 응답 코드를 검증한다
흔한 실수 4가지와 방지법
실수 1: app.useGlobalGuards()에서 DI를 기대
증상: Guard 생성자에 서비스를 주입했는데 undefined. app.useGlobalGuards(new MyGuard())는 NestJS IoC 컨테이너 바깥에서 인스턴스를 생성하므로 DI가 동작하지 않는다.
방지: 글로벌 Guard에 DI가 필요하면 반드시 APP_GUARD 프로바이더로 등록한다. 공식 문서에 명시된 패턴이다.
실수 2: Guard에서 request body를 검증
증상: Guard에서 body 필드를 읽어 판단하는데, Pipe 유효성 검사 전이라 예상과 다른 값이 들어온다.
방지: Guard는 인가(authorization) 전용이다. body 유효성 검사는 Pipe의 책임이므로 Guard에서는 헤더, 쿼리 파라미터, 메타데이터만 사용한다.
실수 3: getAllAndOverride와 getAllAndMerge 혼동
증상: 핸들러에 @Roles('editor')를 붙였는데 컨트롤러의 @Roles('admin')도 허용된다 (Merge를 사용한 경우). 또는 반대로, 핸들러에서 역할을 추가하고 싶은데 컨트롤러 역할이 사라진다 (Override를 사용한 경우).
방지: Override는 “핸들러가 이긴다”, Merge는 “둘 다 합친다”. 비즈니스 규칙을 먼저 정하고 메서드를 선택한다.
실수 4: Guard 실행 순서를 고려하지 않음
증상: RolesGuard가 request.user.roles를 읽는데 undefined. AuthGuard가 request.user를 세팅해야 하는데 RolesGuard가 먼저 실행됐다.
방지: @UseGuards(AuthGuard, RolesGuard) 순서로 인증 → 인가를 보장한다. 글로벌 Guard는 로컬 Guard보다 먼저 실행된다는 점도 기억한다.
마무리
NestJS Guard는 “이 요청을 처리해도 되는가?”라는 단 하나의 질문에 집중하는 계층이다. ExecutionContext로 다음 핸들러를 알 수 있고, Reflector로 메타데이터를 읽을 수 있으며, APP_GUARD로 글로벌 적용 + DI를 동시에 달성할 수 있다. 인증과 인가를 분리하고, Public 데코레이터로 화이트리스트 패턴을 적용하면 보안과 유지보수 모두를 잡을 수 있다. 이 글의 모든 내용은 NestJS 공식 문서(Guards, Execution Context)를 근거로 한다.