NestJS Guard란?
NestJS Guard는 CanActivate 인터페이스를 구현해 요청의 인가(Authorization)를 제어하는 핵심 컴포넌트다. Express의 미들웨어와 달리, Guard는 ExecutionContext에 접근할 수 있어 어떤 핸들러가 실행될지 정확히 알고 있다. 이 차이가 Guard를 미들웨어보다 강력하게 만든다.
Guard는 NestJS 요청 라이프사이클에서 미들웨어 → Guard → Interceptor(before) → Pipe → Handler → Interceptor(after) → Exception Filter 순서로 실행된다. 즉, Pipe나 Handler에 도달하기 전에 요청을 차단할 수 있다.
CanActivate 기본 구현
Guard의 핵심은 canActivate() 메서드다. true를 반환하면 요청이 계속 진행되고, false를 반환하면 ForbiddenException이 발생한다.
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();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) return false;
// 토큰 검증 로직
return this.validateToken(token);
}
private async validateToken(token: string): Promise<boolean> {
// JWT 검증 등
return true;
}
}
반환 타입이 boolean | Promise<boolean> | Observable<boolean>이므로 동기·비동기 모두 지원한다. DB 조회나 외부 API 호출이 필요한 인가 로직도 자연스럽게 처리할 수 있다.
ExecutionContext 활용
ExecutionContext는 ArgumentsHost를 확장한 클래스로, 현재 실행 중인 클래스와 핸들러 정보를 제공한다. 이를 통해 메타데이터 기반 인가가 가능하다.
// ExecutionContext 핵심 메서드
const controller = context.getClass(); // 컨트롤러 클래스
const handler = context.getHandler(); // 핸들러 메서드
const type = context.getType(); // 'http' | 'ws' | 'rpc'
// HTTP 요청 객체 접근
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// WebSocket 컨텍스트
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
getClass()와 getHandler()를 Reflector와 함께 사용하면, 커스텀 데코레이터로 설정한 메타데이터를 읽어 유연한 인가 로직을 구현할 수 있다.
역할 기반 접근 제어 (RBAC)
실무에서 가장 흔한 패턴인 Role-Based Access Control을 Guard로 구현해보자. 커스텀 데코레이터와 Reflector를 조합한다.
1단계: 역할 데코레이터 정의
import { SetMetadata } from '@nestjs/common';
export enum Role {
USER = 'user',
ADMIN = 'admin',
SUPER_ADMIN = 'super_admin',
}
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
2단계: RolesGuard 구현
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role, ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 핸들러와 클래스 양쪽에서 메타데이터 병합
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
// 역할 메타데이터가 없으면 공개 엔드포인트
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
3단계: 컨트롤러에 적용
@Controller('users')
@UseGuards(AuthGuard, RolesGuard)
export class UsersController {
@Get()
@Roles(Role.ADMIN)
findAll() {
return this.usersService.findAll();
}
@Get(':id')
@Roles(Role.USER, Role.ADMIN)
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Delete(':id')
@Roles(Role.SUPER_ADMIN)
remove(@Param('id') id: string) {
return this.usersService.remove(id);
}
}
Reflector.getAllAndOverride()는 핸들러 → 클래스 순서로 메타데이터를 찾아 첫 번째 값을 반환한다. 반면 getAllAndMerge()는 양쪽 메타데이터를 배열로 합친다. 상황에 따라 적절한 메서드를 선택하자.
Guard 적용 범위: 메서드 → 컨트롤러 → 글로벌
Guard는 세 가지 범위로 적용할 수 있다.
// 1. 메서드 레벨
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {}
// 2. 컨트롤러 레벨 — 모든 핸들러에 적용
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController {}
// 3. 글로벌 레벨 — 모든 라우트에 적용
// main.ts
app.useGlobalGuards(new AuthGuard());
// 또는 모듈에서 (DI 지원)
@Module({
providers: [
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}
주의: app.useGlobalGuards()는 DI 컨테이너 외부에서 등록되므로 의존성 주입이 불가능하다. APP_GUARD 토큰으로 모듈에 등록하면 Reflector 등 다른 서비스를 주입받을 수 있다.
Public 엔드포인트 처리 패턴
글로벌 Guard를 사용할 때 특정 라우트를 인증 없이 공개하려면 @Public() 데코레이터 패턴을 사용한다.
// 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
@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;
// 인증 로직 실행
return this.validateRequest(context);
}
}
// controller
@Public()
@Get('health')
healthCheck() { return { status: 'ok' }; }
이 패턴은 NestJS 공식 문서에서도 권장하는 방식이며, Pipe 검증과 함께 사용하면 인증·인가·검증을 계층적으로 분리할 수 있다.
다중 Guard 실행 순서와 조합
여러 Guard를 동시에 적용하면 배열 순서대로 실행되며, 하나라도 false를 반환하면 즉시 중단된다.
// 실행 순서: AuthGuard → RolesGuard → ThrottleGuard
@UseGuards(AuthGuard, RolesGuard, ThrottleGuard)
@Controller('admin')
export class AdminController {}
// 글로벌 + 컨트롤러 + 메서드 조합 시
// 글로벌 Guard → 컨트롤러 Guard → 메서드 Guard 순서
이 특성을 활용하면 인증 → 인가 → 속도 제한 같은 파이프라인을 자연스럽게 구성할 수 있다.
커스텀 예외 던지기
Guard에서 false를 반환하면 기본적으로 ForbiddenException이 발생한다. 더 구체적인 에러를 반환하려면 직접 예외를 던진다.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
if (!token) {
throw new UnauthorizedException('토큰이 필요합니다');
}
try {
const payload = this.jwtService.verify(token.replace('Bearer ', ''));
request.user = payload;
return true;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new UnauthorizedException('토큰이 만료되었습니다');
}
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
}
}
WebSocket·GraphQL Guard
Guard는 HTTP뿐 아니라 WebSocket과 GraphQL에서도 동일하게 작동한다. ExecutionContext.getType()으로 프로토콜을 구분한다.
@Injectable()
export class WsAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (context.getType() === 'ws') {
const client = context.switchToWs().getClient();
const token = client.handshake?.auth?.token;
return this.validateToken(token);
}
if (context.getType() === 'http') {
const request = context.switchToHttp().getRequest();
return this.validateHttpRequest(request);
}
// GraphQL
const gqlContext = GqlExecutionContext.create(context);
const { req } = gqlContext.getContext();
return this.validateHttpRequest(req);
}
}
실전 패턴: 소유권 검증 Guard
RBAC만으로는 부족한 경우가 많다. “자신의 리소스만 수정 가능”한 소유권 검증은 별도 Guard로 분리한다.
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(
private reflector: Reflector,
private moduleRef: ModuleRef,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ownerCheck = this.reflector.get<OwnerCheckConfig>(
'ownerCheck',
context.getHandler(),
);
if (!ownerCheck) return true;
const request = context.switchToHttp().getRequest();
const userId = request.user.id;
const resourceId = request.params[ownerCheck.paramKey || 'id'];
// 동적으로 서비스 가져오기
const service = this.moduleRef.get(ownerCheck.service, { strict: false });
const resource = await service.findOne(resourceId);
if (!resource) throw new NotFoundException();
if (resource[ownerCheck.ownerField || 'userId'] !== userId) {
throw new ForbiddenException('소유자만 접근할 수 있습니다');
}
// 핸들러에서 재조회 방지
request.resource = resource;
return true;
}
}
// 사용
@OwnerCheck({ service: PostsService, ownerField: 'authorId' })
@UseGuards(AuthGuard, OwnershipGuard)
@Put(':id')
update(@Req() req, @Body() dto: UpdatePostDto) {
return this.postsService.update(req.resource, dto);
}
Guard 테스트 전략
Guard는 ExecutionContext를 모킹하여 단위 테스트할 수 있다.
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile();
guard = module.get(RolesGuard);
reflector = module.get(Reflector);
});
it('역할 메타데이터가 없으면 통과', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockExecutionContext({ user: { roles: [] } });
expect(guard.canActivate(context)).toBe(true);
});
it('필요한 역할이 없으면 거부', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
const context = createMockExecutionContext({
user: { roles: [Role.USER] },
});
expect(guard.canActivate(context)).toBe(false);
});
});
function createMockExecutionContext(request: any): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => request }),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as unknown as ExecutionContext;
}
정리
| 패턴 | 용도 | 핵심 포인트 |
|---|---|---|
| AuthGuard | 인증 확인 | JWT 검증, request.user 주입 |
| RolesGuard | 역할 기반 인가 | Reflector + SetMetadata 조합 |
| @Public() | 공개 엔드포인트 | 글로벌 Guard 예외 처리 |
| OwnershipGuard | 소유권 검증 | ModuleRef로 동적 서비스 조회 |
| APP_GUARD | 글로벌 등록 | DI 지원, 다중 Guard 순서 보장 |
Guard는 NestJS에서 인증과 인가를 비즈니스 로직과 완전히 분리하는 핵심 도구다. ExecutionContext와 Reflector를 활용한 메타데이터 기반 설계를 익히면, 선언적이고 재사용 가능한 인가 시스템을 구축할 수 있다.