NestJS Guards
API를 만들다 보면 "이 요청을 처리해도 되는가?"를 판단해야 하는 순간이 온다. 로그인한 사용자인지, 관리자 권한이 있는지, 특정 조건을 만족하는지. 이런 판단 로직을 컨트롤러 메서드 안에 넣으면 어떻게 될까?
@Post('admin/users')
createUser(@Req() req: Request) {
const token = req.headers.authorization;
const user = this.jwtService.verify(token);
if (user.role !== 'admin') {
throw new ForbiddenException();
}
// 실제 로직...
}
모든 관리자 전용 엔드포인트마다 이 코드가 반복된다. 권한 체크 로직이 비즈니스 로직과 뒤섞이고, 한 곳을 수정하면 다른 곳도 전부 수정해야 한다. Express에서는 미들웨어로 이 문제를 해결했지만, 미들웨어는 next() 이후에 어떤 핸들러가 실행될지 알 수 없다. 즉, 라우트별로 다른 권한 정책을 적용하기가 어렵다.
NestJS의 Guard는 이 문제를 위해 설계된 전용 레이어다. "이 요청을 이 핸들러가 처리해도 되는가?"라는 단일 책임을 가지며, 실행 파이프라인에서 미들웨어 다음, 인터셉터와 파이프 이전에 위치한다.
실행 파이프라인에서의 위치
NestJS가 요청을 처리하는 순서를 이해하면 Guard의 역할이 명확해진다.
클라이언트 요청
→ Middleware
→ Guard ← 여기서 접근 허용/거부 결정
→ Interceptor (pre)
→ Pipe
→ Route Handler
→ Interceptor (post)
→ 응답
Guard가 false를 반환하거나 예외를 던지면, 그 뒤의 Interceptor, Pipe, Handler는 아예 실행되지 않는다. 불필요한 요청 처리를 가장 빠른 시점에 차단하는 것이다. 미들웨어보다 늦게 실행되지만, 미들웨어와 달리 "어떤 핸들러가 실행될 예정인지"를 알 수 있다는 결정적인 차이가 있다. 이것이 가능한 이유는 Guard가 ExecutionContext를 받기 때문이다.
CanActivate 인터페이스
Guard를 만들려면 CanActivate 인터페이스를 구현한다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
// true → 요청 통과
// false → ForbiddenException 자동 발생
}
}
canActivate 메서드의 반환값은 세 가지 형태를 지원한다.
boolean— 동기적으로 즉시 결정Promise<boolean>— DB 조회나 외부 API 호출이 필요할 때Observable<boolean>— RxJS 스트림 기반 판단이 필요할 때
false를 반환하면 NestJS가 자동으로 ForbiddenException (403)을 던진다. 다른 상태 코드가 필요하면 직접 예외를 던지면 된다.
canActivate(context: ExecutionContext): boolean {
const isValid = this.validateRequest(context);
if (!isValid) {
throw new UnauthorizedException('토큰이 유효하지 않습니다');
}
return true;
}
ExecutionContext
Guard의 진짜 힘은 ExecutionContext에서 나온다. 이 객체는 현재 실행 중인 요청의 전체 맥락을 담고 있다.
요청 객체 접근
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
const user = request.user;
// ...
}
switchToHttp()는 HTTP 컨텍스트를 반환하고, getRequest()와 getResponse()로 Express(또는 Fastify)의 요청/응답 객체에 접근한다. WebSocket이나 GraphQL 같은 다른 전송 계층에서는 switchToWs()나 switchToRpc()를 사용한다.
핸들러 메타데이터 접근
ExecutionContext가 미들웨어와 차별화되는 핵심 기능이다. getHandler()와 getClass()로 이 요청이 어떤 컨트롤러의 어떤 메서드에서 처리될 예정인지 알 수 있다.
const handler = context.getHandler(); // 라우트 핸들러 메서드 참조
const controller = context.getClass(); // 컨트롤러 클래스 참조
이 두 메서드가 왜 중요한지는 Reflector와 함께 쓸 때 드러난다.
Reflector와 커스텀 메타데이터
실무에서 Guard를 쓰는 가장 일반적인 패턴은 "이 엔드포인트에 어떤 권한이 필요한지"를 메타데이터로 정의하고, Guard에서 이를 읽어 판단하는 것이다.
1단계: 메타데이터 설정용 데코레이터 만들기
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
SetMetadata는 핸들러나 클래스에 키-값 쌍의 메타데이터를 부착하는 데코레이터를 만든다. 위 코드에서 Roles('admin', 'manager')를 사용하면 해당 핸들러에 { roles: ['admin', 'manager'] } 메타데이터가 설정된다.
2단계: 컨트롤러에서 사용
@Controller('users')
export class UsersController {
@Post()
@Roles('admin')
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
@Roles('admin', 'manager')
findAll() {
return this.usersService.findAll();
}
@Get(':id')
// @Roles 없음 → 누구나 접근 가능
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}
3단계: Guard에서 메타데이터 읽기
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 {
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));
}
}
Reflector는 NestJS의 메타데이터 유틸리티다. DI로 주입받아 사용한다. getAllAndOverride는 핸들러 → 클래스 순서로 메타데이터를 찾고, 핸들러에 있으면 그걸 우선 사용한다. 클래스에만 있으면 클래스의 메타데이터를 사용한다.
Reflector 메서드 비교
Reflector는 세 가지 메서드를 제공하는데, 동작 방식이 다르다.
// 핸들러에 있으면 핸들러 것만, 없으면 클래스 것
reflector.getAllAndOverride(ROLES_KEY, [handler, controller]);
// 핸들러와 클래스의 메타데이터를 합침 (배열이면 concat)
reflector.getAllAndMerge(ROLES_KEY, [handler, controller]);
// 특정 대상 하나에서만 읽기
reflector.get(ROLES_KEY, handler);
| 메서드 | 핸들러 메타데이터 | 클래스 메타데이터 | 결과 |
|---|---|---|---|
getAllAndOverride | ['admin'] | ['manager'] | ['admin'] |
getAllAndMerge | ['admin'] | ['manager'] | ['admin', 'manager'] |
get (handler) | ['admin'] | ['manager'] | ['admin'] |
getAllAndOverride는 "핸들러 설정이 클래스 설정을 덮어쓴다"는 직관적인 동작이라 가장 많이 사용된다. getAllAndMerge는 "클래스 레벨 기본 권한 + 핸들러 레벨 추가 권한"을 합쳐야 할 때 유용하다.
Guard 바인딩
Guard를 적용하는 방법은 세 가지 범위로 나뉜다.
메서드 레벨
특정 엔드포인트에만 Guard를 적용한다.
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {
// AuthGuard를 통과한 요청만 도달
}
컨트롤러 레벨
해당 컨트롤러의 모든 엔드포인트에 적용된다.
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController {
// 이 컨트롤러의 모든 라우트에 AuthGuard 적용
}
글로벌 레벨
애플리케이션의 모든 라우트에 적용된다. 두 가지 방법이 있다.
// 방법 1: main.ts에서 직접 설정 (DI 불가)
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard());
// 방법 2: 모듈에서 Provider로 등록 (DI 가능 ← 추천)
@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}
방법 1은 new로 직접 인스턴스를 만들기 때문에 DI가 동작하지 않는다. Reflector나 다른 서비스를 주입받아야 하는 Guard라면 반드시 방법 2를 사용해야 한다. APP_GUARD 토큰으로 등록하면 NestJS가 자동으로 글로벌 Guard로 인식한다.
여러 Guard를 등록하면 선언 순서대로 실행된다. 하나라도 false를 반환하면 나머지는 실행되지 않는다.
@UseGuards(AuthGuard, RolesGuard)
// 1. AuthGuard → 통과하면
// 2. RolesGuard → 통과하면 핸들러 실행
JWT 인증 Guard 구현
실무에서 가장 흔한 Guard 패턴은 JWT 토큰 기반 인증이다. 전체 흐름을 구현해보자.
Guard 구현
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
try {
const payload = await this.jwtService.verifyAsync(token);
// request에 user 정보를 부착 → 이후 핸들러에서 사용 가능
request['user'] = payload;
} catch {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
return true;
}
private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
핵심 포인트는 request['user'] = payload 부분이다. Guard에서 검증한 사용자 정보를 request 객체에 부착하면, 이후 실행되는 Interceptor, Pipe, Handler에서 모두 이 정보를 사용할 수 있다. Guard가 파이프라인 초기에 실행되기 때문에 가능한 패턴이다.
Public 엔드포인트 처리
글로벌 Guard를 적용하면 모든 라우트에 인증이 필요해진다. 로그인이나 회원가입처럼 인증 없이 접근해야 하는 엔드포인트는 어떻게 처리할까?
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// @Public() 데코레이터가 있으면 인증 건너뛰기
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// 기존 JWT 검증 로직...
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
// ...
}
}
// auth.controller.ts
@Controller('auth')
export class AuthController {
@Public()
@Post('login')
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Public()
@Post('register')
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
}
이 패턴의 아름다운 점은 기본값이 "보호"라는 것이다. 새 엔드포인트를 추가하면 자동으로 인증이 적용되고, 명시적으로 @Public()을 붙여야만 열린다. 보안에서 가장 중요한 "기본 거부(deny by default)" 원칙을 자연스럽게 따르게 된다.
Guard vs Middleware vs Interceptor
세 가지 모두 요청 처리 파이프라인에 개입하지만, 목적과 능력이 다르다.
| 특성 | Middleware | Guard | Interceptor |
|---|---|---|---|
| 실행 시점 | 가장 먼저 | 미들웨어 다음 | Guard 다음 |
| 핸들러 정보 접근 | ❌ 불가 | ✅ 가능 | ✅ 가능 |
| DI 지원 | ✅ (클래스 미들웨어) | ✅ | ✅ |
| 응답 변환 | ❌ | ❌ | ✅ 가능 |
| 주요 용도 | 로깅, CORS, 바디 파싱 | 인증, 인가, 접근 제어 | 로깅, 캐싱, 응답 변환 |
Middleware는 next()만 알고 있다. 다음에 뭐가 실행될지 모른다. 그래서 요청/응답의 일반적인 전처리(로깅, CORS 헤더 추가 등)에 적합하다.
Guard는 ExecutionContext를 알고 있다. 어떤 컨트롤러의 어떤 메서드가 실행될 예정인지, 거기에 어떤 메타데이터가 붙어있는지 알 수 있다. 그래서 "이 요청이 이 핸들러를 실행할 자격이 있는가?"를 판단하기에 최적이다.
Interceptor는 핸들러 실행 전후 모두에 개입할 수 있다. 응답을 변환하거나, 실행 시간을 측정하거나, 캐시를 적용하는 등 양방향 로직에 적합하다.
자주 하는 실수
1. Guard에서 비즈니스 로직 수행
// ❌ Guard가 비즈니스 로직까지 처리
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = this.usersService.findById(request.userId);
user.lastAccessedAt = new Date();
this.usersService.save(user); // 이건 Guard의 역할이 아님
return true;
}
Guard의 역할은 "통과/거부" 결정이 전부다. 사용자 정보 갱신 같은 부수 효과는 Interceptor나 Handler에서 처리해야 한다.
2. 인스턴스로 글로벌 등록하면서 DI 기대
// ❌ new로 만들면 DI 안 됨
app.useGlobalGuards(new RolesGuard()); // Reflector가 주입되지 않음
RolesGuard가 Reflector를 주입받아야 하는데 new로 만들면 빈 객체가 된다. 반드시 APP_GUARD Provider로 등록해야 한다.
3. Guard에서 응답 직접 조작
// ❌ Guard는 응답을 변환하는 레이어가 아님
canActivate(context: ExecutionContext): boolean {
const response = context.switchToHttp().getResponse();
response.header('X-Custom', 'value'); // 여기서 하지 말 것
return true;
}
응답 헤더 추가나 변환은 Interceptor의 영역이다. Guard는 순수하게 "접근 가능 여부"만 판단해야 한다.
정리
Guard는 NestJS 파이프라인에서 접근 제어라는 명확한 책임을 가진 레이어다.
CanActivate인터페이스의canActivate메서드를 구현한다ExecutionContext로 요청 객체와 핸들러 메타데이터에 접근한다Reflector+SetMetadata조합으로 선언적 권한 체계를 만든다- 글로벌 Guard +
@Public()패턴으로 "기본 거부" 보안을 구현한다 - 메서드/컨트롤러/글로벌 세 범위로 적용할 수 있다
미들웨어가 "문 앞에서 신분증 확인"이라면, Guard는 "회의실 앞에서 참석자 명단 확인"이다. 어떤 회의실에 들어가려는지 알기 때문에 더 정교한 접근 제어가 가능하다.