가드 (Guards) — 요청 통과 여부를 결정하는 권한 검사기

가드 (Guards) — 요청 통과 여부를 결정하는 권한 검사기

NestJS의 가드는 특정 요청이 라우트 핸들러에 의해 처리될지 말지를 결정하는 역할을 맡아요. 권한(permission), 역할(role), ACL 같은 조건을 런타임에 확인해서 처리 흐름을 통과시키거나 막아 주죠. 전통적인 Express에서는 이런 인가(authorization) 작업을 주로 미들웨어로 처리했는데, 가드는 그보다 더 깊은 정보를 활용할 수 있어요. 미들웨어는 컨텍스트에 무감각해서 next() 이후 어떤 핸들러가 실행될지 알지 못하지만, 가드는 ExecutionContext 인스턴스에 접근해 다음에 실행될 것이 정확히 무엇인지 알 수 있어요.

가드는 @Injectable() 데코레이터로 표시된 클래스이며 CanActivate 인터페이스를 구현해요. 예외 필터, 파이프, 인터셉터처럼 가드도 요청/응답 주기에서 정확히 필요한 지점에 처리 로직을 끼워 넣도록 설계됐어요. 그 덕분에 코드를 DRY하고 선언적으로 유지할 수 있죠. 🛡️

출처: 공식문서 - Guards

본문

가드는 모든 미들웨어 다음, 그리고 인터셉터나 파이프보다는 앞에 실행돼요. 즉 요청이 핸들러에 닿기 직전에 권한을 확인하고 싶을 때 딱 맞는 위치예요.

인가 가드(Authorization guard)

인가는 가드로 처리하기 좋은 대표적인 사례예요. 특정 라우트는 호출자(보통 인증된 사용자)에게 충분한 권한이 있을 때만 열려야 하니까요. 아래 AuthGuard는 인증된 사용자(따라서 요청 헤더에 토큰이 붙어 있음)를 전제로 해요. 토큰을 추출·검증하고, 그 정보를 바탕으로 요청을 진행시킬지 말지를 결정해요.

@@filename(auth.guard)
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 validateRequest(request);
  }
}
@@switch
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthGuard {
  async canActivate(context) {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

validateRequest() 함수의 로직은 필요에 따라 단순하게도, 정교하게도 만들 수 있어요. 이 예시의 핵심은 가드가 요청/응답 주기의 어디에 들어맞는지를 보여 주는 거예요.

모든 가드는 반드시 canActivate() 함수를 구현해야 해요. 이 함수는 현재 요청을 허용할지 여부를 나타내는 불리언을 반환해요. 동기적으로, 또는 PromiseObservable을 통한 비동기적으로 반환할 수 있고요. Nest는 반환값을 보고 다음 동작을 제어해요:

  • true를 반환하면 요청이 처리돼요.
  • false를 반환하면 Nest가 요청을 거부해요.

실행 컨텍스트(Execution context)

canActivate() 함수는 단일 인자로 ExecutionContext 인스턴스를 받아요. ExecutionContextArgumentsHost를 상속하는데, ArgumentsHost는 예외 필터 챕터에서 이미 보셨던 클래스예요. 위 예시에서는 ArgumentsHost에 정의된 헬퍼 메서드를 그대로 사용해서 Request 객체를 가져왔어요. 주의할 점은 ExecutionContextArgumentsHost를 확장하면서 현재 실행 과정에 대한 추가 정보를 주는 여러 헬퍼 메서드를 더 제공한다는 거예요. 이 정보는 다양한 컨트롤러·메서드·실행 컨텍스트에서 동작하는 범용 가드를 만드는 데 유용해요.

역할 기반 인증(Role-based authentication)

특정 역할을 가진 사용자에게만 접근을 허용하는 가드를 만들어 볼게요. 먼저 기본 가드 템플릿부터 시작해요. 지금은 모든 요청을 통과시키는 단순한 버전이에요.

@@filename(roles.guard)
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}
@@switch
import { Injectable } from '@nestjs/common';

@Injectable()
export class RolesGuard {
  canActivate(context) {
    return true;
  }
}

가드 바인딩(Binding guards)

파이프와 예외 필터처럼 가드도 컨트롤러 범위, 메서드 범위, 전역 범위로 설정할 수 있어요. 아래는 @UseGuards() 데코레이터로 컨트롤러 범위 가드를 설정한 예시예요. 이 데코레이터는 단일 인자나 쉼표로 구분된 인자 목록을 받을 수 있어서, 한 번의 선언으로 여러 가드를 쉽게 적용할 수 있어요.

@@filename()
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

💡 @UseGuards() 데코레이터는 @nestjs/common 패키지에서 가져와요.

위에서는 RolesGuard 클래스(인스턴스가 아니라)를 넘겼어요. 인스턴스화의 책임을 프레임워크에 맡기고 의존성 주입을 가능하게 하려는 거예요. 파이프나 예외 필터처럼 인플레이스 인스턴스를 넘길 수도 있어요.

@@filename()
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}

이렇게 하면 이 컨트롤러가 선언한 모든 핸들러에 가드가 붙어요. 가드를 단일 메서드에만 적용하고 싶다면 @UseGuards() 데코레이터를 메서드 레벨에 붙이면 돼요.

전역 가드를 만들려면 Nest 애플리케이션 인스턴스의 useGlobalGuards() 메서드를 사용해요.

@@filename()
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

⚠️ 하이브리드 앱에서는 useGlobalGuards() 메서드가 기본적으로 게이트웨이와 마이크로서비스에 가드를 설정하지 않아요. '표준'(비하이브리드) 마이크로서비스 앱에서는 전역으로 설치돼요.

전역 가드는 모든 컨트롤러, 모든 라우트 핸들러에 걸쳐 애플리케이션 전체에서 사용돼요. 의존성 주입 측면에서 보면, 어떤 모듈 밖에서(useGlobalGuards()로) 등록한 전역 가드는 의존성을 주입할 수 없어요 — 모듈 컨텍스트 밖에서 이뤄지는 작업이기 때문이에요. 이 문제를 해결하려면 아무 모듈에서나 다음 구성을 사용해 가드를 직접 설정하면 돼요.

@@filename(app.module)
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {}

💡 가드의 의존성 주입을 위해 이 접근법을 쓸 때는, 이 구성을 어떤 모듈에 쓰든 가드는 사실상 전역이라는 점을 기억하세요. 어디에 두어야 할까요? 가드(RolesGuard)가 정의된 모듈을 고르면 돼요. 그리고 useClass가 커스텀 프로바이더 등록의 유일한 방법은 아니에요.

💡 APP_GUARD 토큰은 (같거나 다른 모듈에서) 여러 번 등록할 수 있어요 — 등록한 각 가드가 등록 순서대로 매 요청마다 실행돼요. 그리고 APP_GUARD(다른 APP_* 토큰과 마찬가지로)는 부트스트랩 중에 프레임워크가 소비하는 의사-프로바이더라서 나중에 app.get()으로 꺼내거나 다른 곳에 주입할 수 없어요.

핸들러별 역할 설정(Setting roles per handler)

RolesGuard는 동작하지만 아직 똑똑하지 않아요. 가장 중요한 가드 기능인 실행 컨텍스트를 활용하지 않고 있어요. 각 핸들러에 어떤 역할이 허용되는지도 모르고 있죠. 예를 들어 CatsController는 라우트마다 다른 권한 체계를 가질 수 있어요. 어떤 라우트는 관리자만, 어떤 라우트는 모두에게 열려 있을 수 있고요. 어떻게 하면 역할과 라우트를 유연하고 재사용 가능하게 매칭할 수 있을까요?

이때 커스텀 메타데이터(custom metadata)가 필요해져요. Nest는 Reflector.createDecorator 정적 메서드로 만든 데코레이터나 내장 @SetMetadata() 데코레이터를 통해 라우트 핸들러에 커스텀 메타데이터를 붙일 수 있는 기능을 제공해요.

Reflector.createDecorator 메서드로 핸들러에 메타데이터를 붙일 @Roles() 데코레이터를 만들어 볼게요. Reflector는 프레임워크가 기본으로 제공하며 @nestjs/core 패키지에서 노출돼요.

@@filename(roles.decorator)
import { Reflector } from '@nestjs/core';

export const Roles = Reflector.createDecorator<string[]>();

여기 Roles 데코레이터는 string[] 타입의 단일 인자를 받는 함수예요. 이제 이 데코레이터를 핸들러에 붙여서 사용하면 돼요.

@@filename(cats.controller)
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
@@switch
@Post()
@Roles(['admin'])
@Bind(Body())
async create(createCatDto) {
  this.catsService.create(createCatDto);
}

여기서는 create() 메서드에 Roles 데코레이터 메타데이터를 붙여서, admin 역할을 가진 사용자만 이 라우트에 접근할 수 있게 했어요.

Reflector.createDecorator 메서드 대신 내장 @SetMetadata() 데코레이터를 쓸 수도 있어요.

한데 모아 보기(Putting it all together)

이제 RolesGuard와 이 내용을 연결해 볼게요. 현재는 어떤 경우든 true를 반환해 모든 요청을 통과시키고 있어요. 현재 사용자에게 할당된 역할과 현재 처리 중인 라우트가 요구하는 실제 역할을 비교해서 반환값을 조건부로 만들고 싶어요. 라우트의 역할(커스텀 메타데이터)에 접근하기 위해 Reflector 헬퍼 클래스를 다시 사용할게요.

@@filename(roles.guard)
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator.js';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}
@@switch
import { Injectable, Dependencies } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator.js';

@Injectable()
@Dependencies(Reflector)
export class RolesGuard {
  constructor(reflector) {
    this.reflector = reflector;
  }

  canActivate(context) {
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}

💡 Node.js 세계에서는 인증된 사용자를 request 객체에 붙이는 것이 일반적인 관례이에요. 그래서 위 예시 코드에서 request.user가 사용자 인스턴스와 허용된 역할을 담고 있다고 가정했어요. 여러분의 앱에서는 커스텀 인증 가드(또는 미들웨어)에서 그 연관을 만들게 될 거예요.

⚠️ matchRoles() 함수의 로직은 필요에 따라 단순하거나 정교하게 만들 수 있어요. 이 예시의 핵심은 가드가 요청/응답 주기에 어떻게 들어맞는지를 보여 주는 거예요.

Reflector를 컨텍스트에 민감하게 활용하는 방법에 대한 자세한 내용은 실행 컨텍스트 챕터의 리플렉션과 메타데이터(Reflection and metadata) 섹션을 참고하세요.

충분한 권한이 없는 사용자가 엔드포인트를 요청하면 Nest는 자동으로 다음 응답을 반환해요.

{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

백그라운드에서 가드가 false를 반환하면 프레임워크가 ForbiddenException을 던진다는 점을 기억하세요. 다른 오류 응답을 반환하고 싶다면 여러분만의 특정 예외를 직접 던져야 해요. 예를 들어:

throw new UnauthorizedException();

가드가 던지는 모든 예외는 예외 레이어(전역 예외 필터와 현재 컨텍스트에 적용된 모든 예외 필터)에서 처리돼요.

더 알아보기