junyeokk
Blog
NestJS·2025. 11. 15

NestJS Guards

API를 만들다 보면 "이 요청을 처리해도 되는가?"를 판단해야 하는 순간이 온다. 로그인한 사용자인지, 관리자 권한이 있는지, 특정 조건을 만족하는지. 이런 판단 로직을 컨트롤러 메서드 안에 넣으면 어떻게 될까?

typescript
@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의 역할이 명확해진다.

text
클라이언트 요청
  → Middleware
  → Guard          ← 여기서 접근 허용/거부 결정
  → Interceptor (pre)
  → Pipe
  → Route Handler
  → Interceptor (post)
  → 응답

Guard가 false를 반환하거나 예외를 던지면, 그 뒤의 Interceptor, Pipe, Handler는 아예 실행되지 않는다. 불필요한 요청 처리를 가장 빠른 시점에 차단하는 것이다. 미들웨어보다 늦게 실행되지만, 미들웨어와 달리 "어떤 핸들러가 실행될 예정인지"를 알 수 있다는 결정적인 차이가 있다. 이것이 가능한 이유는 Guard가 ExecutionContext를 받기 때문이다.


CanActivate 인터페이스

Guard를 만들려면 CanActivate 인터페이스를 구현한다.

typescript
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)을 던진다. 다른 상태 코드가 필요하면 직접 예외를 던지면 된다.

typescript
canActivate(context: ExecutionContext): boolean {
  const isValid = this.validateRequest(context);
  if (!isValid) {
    throw new UnauthorizedException('토큰이 유효하지 않습니다');
  }
  return true;
}

ExecutionContext

Guard의 진짜 힘은 ExecutionContext에서 나온다. 이 객체는 현재 실행 중인 요청의 전체 맥락을 담고 있다.

요청 객체 접근

typescript
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()로 이 요청이 어떤 컨트롤러의 어떤 메서드에서 처리될 예정인지 알 수 있다.

typescript
const handler = context.getHandler();   // 라우트 핸들러 메서드 참조
const controller = context.getClass();  // 컨트롤러 클래스 참조

이 두 메서드가 왜 중요한지는 Reflector와 함께 쓸 때 드러난다.


Reflector와 커스텀 메타데이터

실무에서 Guard를 쓰는 가장 일반적인 패턴은 "이 엔드포인트에 어떤 권한이 필요한지"를 메타데이터로 정의하고, Guard에서 이를 읽어 판단하는 것이다.

1단계: 메타데이터 설정용 데코레이터 만들기

typescript
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단계: 컨트롤러에서 사용

typescript
@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에서 메타데이터 읽기

typescript
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는 세 가지 메서드를 제공하는데, 동작 방식이 다르다.

typescript
// 핸들러에 있으면 핸들러 것만, 없으면 클래스 것
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를 적용한다.

typescript
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {
  // AuthGuard를 통과한 요청만 도달
}

컨트롤러 레벨

해당 컨트롤러의 모든 엔드포인트에 적용된다.

typescript
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController {
  // 이 컨트롤러의 모든 라우트에 AuthGuard 적용
}

글로벌 레벨

애플리케이션의 모든 라우트에 적용된다. 두 가지 방법이 있다.

typescript
// 방법 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를 반환하면 나머지는 실행되지 않는다.

typescript
@UseGuards(AuthGuard, RolesGuard)
// 1. AuthGuard → 통과하면
// 2. RolesGuard → 통과하면 핸들러 실행

JWT 인증 Guard 구현

실무에서 가장 흔한 Guard 패턴은 JWT 토큰 기반 인증이다. 전체 흐름을 구현해보자.

Guard 구현

typescript
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를 적용하면 모든 라우트에 인증이 필요해진다. 로그인이나 회원가입처럼 인증 없이 접근해야 하는 엔드포인트는 어떻게 처리할까?

typescript
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
typescript
// 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);
    // ...
  }
}
typescript
// 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

세 가지 모두 요청 처리 파이프라인에 개입하지만, 목적과 능력이 다르다.

특성MiddlewareGuardInterceptor
실행 시점가장 먼저미들웨어 다음Guard 다음
핸들러 정보 접근❌ 불가✅ 가능✅ 가능
DI 지원✅ (클래스 미들웨어)
응답 변환✅ 가능
주요 용도로깅, CORS, 바디 파싱인증, 인가, 접근 제어로깅, 캐싱, 응답 변환

Middlewarenext()만 알고 있다. 다음에 뭐가 실행될지 모른다. 그래서 요청/응답의 일반적인 전처리(로깅, CORS 헤더 추가 등)에 적합하다.

GuardExecutionContext를 알고 있다. 어떤 컨트롤러의 어떤 메서드가 실행될 예정인지, 거기에 어떤 메타데이터가 붙어있는지 알 수 있다. 그래서 "이 요청이 이 핸들러를 실행할 자격이 있는가?"를 판단하기에 최적이다.

Interceptor는 핸들러 실행 전후 모두에 개입할 수 있다. 응답을 변환하거나, 실행 시간을 측정하거나, 캐시를 적용하는 등 양방향 로직에 적합하다.


자주 하는 실수

1. Guard에서 비즈니스 로직 수행

typescript
// ❌ 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 기대

typescript
// ❌ new로 만들면 DI 안 됨
app.useGlobalGuards(new RolesGuard());  // Reflector가 주입되지 않음

RolesGuardReflector를 주입받아야 하는데 new로 만들면 빈 객체가 된다. 반드시 APP_GUARD Provider로 등록해야 한다.

3. Guard에서 응답 직접 조작

typescript
// ❌ 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는 "회의실 앞에서 참석자 명단 확인"이다. 어떤 회의실에 들어가려는지 알기 때문에 더 정교한 접근 제어가 가능하다.


관련 문서