개요
회사에서 NestJS 기술을 활용하면서 Guard라는 기능을 접했을 때, 추상적으로 'Spring Security와 비슷하구나'라는 생각으로만 접근했습니다. 하지만 실제 동작 원리와 인가된 유저 주입 메커니즘을 깊이 이해하지 못한 채, AI가 생성한 코드를 검증 없이 사용하다가 기존 인증/인가 로직에 예기치 않은 영향을 주는 문제를 발생시켰습니다. 이로 인해 팀원들에게 불편을 끼치는 상황이 발생했고, 이는 근본적인 이해 부족에서 비롯된 것임을 깨달았습니다. 같은 실수를 반복하지 않기 위해, 그리고 Guard를 제대로 이해하기 위해 이 글을 작성합니다.
이 글을 읽으면 좋은 사람들
- Spring 백그라운드를 가진 개발자가 NestJS로 전환하는 경우
- 두 프레임워크를 함께 사용하는 팀에서 일하는 개발자
- 인증/인가 로직의 프레임워크별 구현 차이를 이해하고자 하는 개발자
Guard란 무엇인가?
Guard는 NestJS에서 인증/인가를 처리하는 핵심 모듈입니다. Express의 미들웨어가 가진 한계를 극복하기 위해 설계되었으며, ExecutionContext
를 통해 다음에 실행될 핸들러의 정보를 알 수 있고, 메타데이터와 긴밀하게 연동됩니다.
Express 미들웨어의 한계
먼저 Express에서 미들웨어로 인증/인가를 구현할 때의 문제점을 살펴보겠습니다.
// auth.middleware.js
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '토큰이 없습니다' });
}
const isValid = validateToken(token);
if (!isValid) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다' });
}
req.user = { id: 1, username: 'john', role: 'admin' };
next(); // ⚠️ 다음에 어떤 핸들러가 실행될지 모름
}
// role.middleware.js
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(403).json({ message: '사용자 정보가 없습니다' });
}
const hasRole = roles.includes(req.user.role);
if (!hasRole) {
return res.status(403).json({
message: `필요한 권한: ${roles.join(', ')}`
});
}
next();
};
}
Express 라우터에서의 사용
javascriptconst express = require('express');
const app = express();
// ❌ 문제점 1: 반복적이고 명령형 코드
app.get('/users/profile', authMiddleware, (req, res) => {
res.json({ message: '인증된 사용자만 접근 가능' });
});
// ❌ 문제점 2: 미들웨어 순서를 수동으로 관리해야 함
app.post('/users/admin',
authMiddleware,
requireRole('admin'),
(req, res) => {
res.json({ message: '관리자만 접근 가능' });
}
);
// ❌ 문제점 3: 라우트 메타데이터에 접근하기 어려움
app.get('/users/moderator-area',
authMiddleware,
requireRole('admin', 'moderator'),
(req, res) => {
res.json({ message: '관리자 또는 모더레이터 접근 가능' });
}
);
// ❌ 문제점 4: 전역 적용 시 예외 처리가 복잡함
app.use(authMiddleware); // public 엔드포인트도 막아버림
app.get('/public', (req, res) => {
res.json({ message: '누구나 접근 가능해야 하는데...' });
});
이처럼 미들웨어는 next() 호출 후 어떤 핸들러가 실행될지 알 수 없기 때문에, 복잡한 인증/인가 요구사항이 추가될수록 관리하기 어려워집니다.
NestJS Guard의 해결책
Guard는 이러한 문제를 해결하기 위해 다음과 같은 특징을 제공합니다:
- ExecutionContext: 다음에 실행될 핸들러 정보에 접근
- 메타데이터 통합: Reflector를 통해 데코레이터와 긴밀하게 연동
- 선언적 사용: 깔끔하고 직관적인 코드 작성
- 유연한 적용: 전역/컨트롤러/메서드 레벨에서 선택적 적용
Guard 구현 예시
AuthGuard
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// ✅ ExecutionContext를 통해 다음 핸들러 정보를 알 수 있음
const request = context.switchToHttp().getRequest();
const handler = context.getHandler();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
const isValid = this.validateToken(token);
if (!isValid) {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
request.user = { id: 1, username: 'john', role: 'admin' };
return true;
}
private validateToken(token: string): boolean {
return token === 'valid-token';
}
}
RolesGuard
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// ✅ 메타데이터를 통해 핸들러에 필요한 역할 정보를 읽을 수 있음
const requiredRoles = this.reflector.get<string[]>(
'roles',
context.getHandler()
);
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new UnauthorizedException('사용자 정보가 없습니다');
}
const hasRole = requiredRoles.some(role => user.role === role);
if (!hasRole) {
throw new UnauthorizedException(
`필요한 권한: ${requiredRoles.join(', ')}`
);
}
return true;
}
}
Controller에서의 사용
@Controller('users')
export class UsersController {
// ✅ 선언적이고 가독성이 좋음
@Get('profile')
@UseGuards(AuthGuard)
getProfile() {
return { message: '인증된 사용자만 접근 가능' };
}
// ✅ 메타데이터와 가드가 긴밀하게 연결됨
@Post('admin')
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
adminOnly() {
return { message: '관리자만 접근 가능' };
}
// ✅ DRY 원칙 - 역할만 변경하면 됨
@Get('moderator-area')
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin', 'moderator')
moderatorArea() {
return { message: '관리자 또는 모더레이터 접근 가능' };
}
// ✅ 가드가 없는 라우트는 자유롭게 설정
@Get('public')
publicEndpoint() {
return { message: '누구나 접근 가능' };
}
}
전역 Guard 설정
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
Express 미들웨어 vs NestJS Guard 비교
특징 | Express 미들웨어 | NestJS Guard |
다음 핸들러 인식 | ❌ next() 후 알 수 없음 |
✅ ExecutionContext로 명확히 알 수 있음 |
메타데이터 접근 | ❌ 라우트 정보와 분리됨 | ✅ Reflector로 쉽게 접근 |
코드 스타일 | ❌ 명령형, 반복적 | ✅ 선언적, DRY |
전역 적용 | ❌ public 엔드포인트 예외 처리 복잡 | ✅ 데코레이터로 유연하게 적용/제외 |
테스트 | ❌ 의존성 주입 없음 | ✅ DI로 테스트 용이 |
Guard 실행 흐름
1. 클라이언트 요청
↓
2. AuthGuard.canActivate() 실행
- ExecutionContext로 핸들러 정보 확인
- 토큰 검증
- request.user에 사용자 정보 추가
↓
3. RolesGuard.canActivate() 실행
- Reflector로 메타데이터에서 필요한 역할 확인
- request.user의 역할과 비교
↓
4. 모든 가드 통과 시 핸들러 실행
주요 장점
1. ExecutionContext의 사용법
const context: ExecutionContext
const handler = context.getHandler(); // 핸들러 메서드 정보
const controller = context.getClass(); // 컨트롤러 클래스 정보
const request = context.switchToHttp().getRequest();
2. 메타데이터와의 긴밀한 통합
// 메타데이터 정의
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// 가드에서 읽기
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
3. 선언적 사용
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
adminOnly() {
// 간결하고 명확한 코드
}
결론
NestJS Guard는 Express 미들웨어의 한계를 극복하고, ExecutionContext와 메타데이터를 활용하여 더 선언적이고 유지보수하기 쉬운 인증/인가 시스템을 제공합니다.
Spring Security와의 차이점
1. 아키텍처 차이점
- Spring Security: 필터 체인 기반의 중앙 집중식 보안 처리
- SecurityFilterChain으로 모든 요청을 가로채서 처리
- SecurityContext를 통한 전역 인증 정보 관리
- NestJS Guard: 데코레이터 기반의 명시적 선언
- 각 라우트/컨트롤러에 명시적으로 가드 선언
- ExecutionContext를 통해 요청별 실행 컨텍스트에 직접 접근
- Request 객체에 인증 정보 추가
2. 권한 제어 방식 (가장 큰 차이)
- Spring Security: SpEL 표현식으로 선언적 권한 제어
@PreAuthorize("hasRole('ADMIN') or @service.isOwner(#id)") public Document getDocument(Long id) { }
- NestJS Guard: 데코레이터를 활용한 명시적으로 선언하여 권한을 제어
@UseGuards(AuthGuard, RolesGuard) @Roles('admin') getDocument() { }
3. Nest.js Guard의 장단점
Spring Security
- ✅ CSRF, Remember-me, Session 관리 등 풍부한 기본 기능
- ✅ @PostAuthorize, @PostFilter 등 강력한 메서드 보안
- ❌ 높은 러닝 커브와 복잡한 필터 체인 구조
NestJS Guard
- ✅ 낮은 러닝 커브, 직관적이고 이해하기 쉬운 구조
- ✅ TypeScript 기반 타입 안전성
- ✅ 필요한 기능만 선택적으로 추가 가능 (가벼움)
- ❌ Spring Security의 풍부한 기본 기능들을 직접 구현해야 함
- ❌ 복잡한 권한 로직을 SpEL처럼 간결하게 표현하기 어려움
NestJS Guard 사용법
Authorization Guard 선언
NestJS Authorization guard는 스프링 시큐리티에서 인증 인가 서비스를 처리하는 AuthorizationManager
와 같습니다. 기본적으로 CanActivate 인터페이스를 기반으로 구현해야합니다. 응답을 boolean 뿐만 아니라 Promise와 Observable로 래핑하여 비동기로 처리할 수 있습니다.
- true 리턴은 요청이 승인하여 요청을 처리된다는 의미
- false 리턴은 요청을 거부하여 요청을 처리하지 않음
// AuthGuard
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// ✅ ExecutionContext를 통해 다음 핸들러 정보를 알 수 있음
const request = context.switchToHttp().getRequest();
const handler = context.getHandler();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
const isValid = this.validateToken(token);
if (!isValid) {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
request.user = { id: 1, username: 'john', role: 'admin' };
return true;
}
private validateToken(token: string): boolean {
return token === 'valid-token';
}
}
Execution context
AuthGuard
를 보면 ExecutionContext를 파라미터로 받는 것을 알 수 있습니다. ExecutionContext는 ArgumentsHost를 상속받아, Spring의 HttpServletRequest처럼 현재 요청의 모든 정보(헤더, 파라미터, 바디 등)에 접근할 수 있게 해줍니다.
더 나아가 ExecutionContext는 Spring AOP의 MethodInvocation이나 Spring MVC의 HandlerMethod처럼 실행될 핸들러의 메타데이터(메서드, 클래스 정보)에도 접근할 수 있어, Guard가 "다음에 무엇이 실행될지" 정확히 알 수 있습니다.
메타데이터 접근 방식 비교
NestJS - Reflector
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 메타데이터 읽기
const roles = this.reflector.get<string[]>('roles', context.getHandler());
return checkRoles(roles);
}
}
Spring - AnnotationUtils
public class RolesInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 메타데이터(애노테이션) 읽기
RolesAllowed annotation = AnnotationUtils
.findAnnotation(handlerMethod.getMethod(), RolesAllowed.class);
return checkRoles(annotation.value());
}
return true;
}
}
Role 기반 Auth Guard 사용법
단순히 인증, 인가를 권한을 체크하는 것 뿐만 아니라 권한을 어드민, 사용자, 비회원, 내부 직원 등 다양하게 권한을 분리하여 관리하려고 한다면 위의 예시와 같은 AuthGuard
로는 핸들링하기 어려울 것입니다. 그래서 NestJS도 Role기반으로 인증, 인가를 처리할 수 있도록 지원해줍니다.
1. Role 열거형 정의
먼저 애플리케이션에서 사용할 역할들을 정의합니다.
// role.enum.ts
export enum Role {
ADMIN = 'admin',
USER = 'user',
MODERATOR = 'moderator',
GUEST = 'guest',
INTERNAL_STAFF = 'internal_staff',
}
2. Role 데코레이터 생성
메타데이터를 설정하기 위한 커스텀 데코레이터를 만듭니다.
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
이 데코레이터는 @Roles(Role.ADMIN) 형태로 사용되며, 내부적으로 해당 핸들러에 필요한 역할 정보를 메타데이터로 저장합니다.
3. RolesGuard 구현
Reflector를 사용하여 메타데이터를 읽고 권한을 체크하는 가드를 구현합니다.
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. 핸들러와 클래스에서 필요한 역할 메타데이터 가져오기
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), // 메서드 레벨 메타데이터
context.getClass(), // 클래스 레벨 메타데이터
]);
// 2. 역할이 지정되지 않았다면 접근 허용
if (!requiredRoles) {
return true;
}
// 3. Request 객체에서 사용자 정보 가져오기
const { user } = context.switchToHttp().getRequest();
// 4. 사용자가 없거나 역할이 없으면 거부
if (!user || !user.roles) {
return false;
}
// 5. 사용자의 역할이 필요한 역할 중 하나라도 포함되는지 확인
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
Reflector란?
Reflector는 NestJS에서 메타데이터를 읽어오는 유틸리티 클래스입니다. 데코레이터로 설정한 메타데이터를 Guard, Interceptor, Filter 등에서 읽을 수 있게 해줍니다.
4. Controller에서 사용하기
이제 컨트롤러에서 역할 기반 권한 제어를 적용할 수 있습니다.
// users.controller.ts
import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './roles.guard';
import { Roles } from './roles.decorator';
import { Role } from './role.enum';
@Controller('users')
@UseGuards(AuthGuard, RolesGuard) // 인증 먼저, 역할 체크는 그 다음
export class UsersController {
// 인증된 모든 사용자 접근 가능 (역할 제한 없음)
@Get('profile')
getProfile() {
return { message: '자신의 프로필 조회' };
}
// 관리자만 접근 가능
@Get('admin')
@Roles(Role.ADMIN)
getAdminData() {
return { message: '관리자 전용 데이터' };
}
// 관리자 또는 모더레이터 접근 가능
@Get('moderation')
@Roles(Role.ADMIN, Role.MODERATOR)
getModerationPanel() {
return { message: '중재 패널' };
}
// 내부 직원만 접근 가능
@Post('internal-report')
@Roles(Role.INTERNAL_STAFF)
createInternalReport() {
return { message: '내부 리포트 생성' };
}
// 관리자만 사용자 삭제 가능
@Delete(':id')
@Roles(Role.ADMIN)
deleteUser() {
return { message: '사용자 삭제됨' };
}
}
5. 클래스 레벨 적용
컨트롤러 전체에 역할을 적용하고, 특정 메서드만 예외 처리할 수도 있습니다.
@Controller('admin')
@UseGuards(AuthGuard, RolesGuard)
@Roles(Role.ADMIN) // 컨트롤러 전체에 관리자 권한 필요
export class AdminController {
@Get('dashboard')
getDashboard() {
// @Roles 없어도 클래스 레벨 권한 적용
return { message: '관리자 대시보드' };
}
@Get('users')
getAllUsers() {
return { message: '모든 사용자 목록' };
}
@Get('settings')
@Roles(Role.ADMIN, Role.INTERNAL_STAFF) // 메서드 레벨이 클래스 레벨 오버라이드
getSettings() {
return { message: '내부 직원도 설정 접근 가능' };
}
}
6. 사용자 객체에 역할 추가하기
AuthGuard에서 사용자 정보를 request에 추가할 때 역할 정보도 함께 넣어줍니다.
// auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
try {
const payload = this.jwtService.verify(token);
// ✅ 역할 정보를 포함한 사용자 객체 추가
request.user = {
id: payload.sub,
username: payload.username,
roles: payload.roles, // ['admin', 'moderator'] 같은 배열
};
return true;
} catch {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
7. 전역 Guard 설정
모든 라우트에 RolesGuard를 적용하려면 AppModule에서 전역으로 등록합니다.
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
전역으로 설정하면 @UseGuards(RolesGuard)를 매번 작성하지 않아도 되지만, 모든 라우트에 적용되므로 public 엔드포인트는 @Roles() 없이 두어야 합니다.
Public Guard 지정하는 법
전역 Guard를 사용할 때 public 엔드포인트를 위한 데코레이터를 만듭니다.
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
RolesGuard를 수정하여 public 엔드포인트는 체크하지 않도록 합니다.
// roles.guard.ts (수정)
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Public 데코레이터가 있으면 통과
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
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));
}
}
사용 예시:
@Controller('auth')
export class AuthController {
@Public() // 전역 Guard 무시
@Post('login')
login() {
return { token: 'jwt-token' };
}
@Public()
@Post('register')
register() {
return { message: '회원가입 완료' };
}
}
더 복잡한 권한을 관리하는 법 - 실무편
실무에서는 사용자의 Role 뿐만 아니라 기능 단위로 접근을 제어해야되는 더 복잡한 권한 체크가 필요할 수 있습니다. 그럴 경우 아래와 같이 예시를 활용하여 구성하게 된다면 더 넓은 범위의 인증과 인가를 처리할 수 있을 것입니다.
// advanced-roles.guard.ts
@Injectable()
export class AdvancedRolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private userService: UserService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.get<Role[]>(
ROLES_KEY,
context.getHandler(),
);
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// 1. 기본 역할 체크
const hasRole = requiredRoles.some((role) => user.roles?.includes(role));
if (!hasRole) {
return false;
}
// 2. 추가 조건: 리소스 소유권 체크
const resourceId = request.params.id;
if (resourceId) {
const isOwner = await this.userService.isResourceOwner(
user.id,
resourceId,
);
// 관리자거나 소유자면 허용
return user.roles.includes(Role.ADMIN) || isOwner;
}
return true;
}
}
정리
지금까지 NestJS의 Guard를 살펴보았습니다. Express 미들웨어부터 Spring Security, 그리고 NestJS Guard까지 비교하며 살펴본 결과, 모든 프레임워크가 비슷한 방향으로 발전해왔다는 것을 알 수 있었습니다.
공통적인 진화 방향
1. 요청 컨텍스트 추상화
- Express: req, res, next
- Spring: HttpServletRequest, HandlerMethod
- NestJS: ExecutionContext, ArgumentsHost
2. 메타데이터 기반 선언적 처리
- Spring: @PreAuthorize, @RolesAllowed 애노테이션
- NestJS: @Roles, @UseGuards 데코레이터
3. 관심사의 분리
- 인증/인가 로직을 비즈니스 로직에서 분리
- 재사용 가능하고 테스트하기 쉬운 구조
이러한 패턴들은 과거부터 수많은 시행착오를 통해 발전하면서 현재의 구조로 정립되었습니다. 각 프레임워크는 생태계와 언어의 특성에 맞게 구현 방식은 다르지만, 핵심 철학은 동일합니다.
NestJS Guard의 동작 흐름
Role 기반 Guard는 다음과 같은 흐름으로 동작합니다:
1. @Roles 데코레이터로 메타데이터 설정
↓
2. RolesGuard가 Reflector로 메타데이터 읽기
↓
3. ExecutionContext를 통해 Request 객체 접근
↓
4. Request 객체에서 사용자 역할 확인
↓
5. 필요한 역할과 비교하여 접근 허용/거부
마치며
처음 개요에서 언급했듯이, Guard의 동작 원리를 제대로 이해하지 못한 채 사용하면 예기치 않은 문제가 발생할 수 있습니다. 하지만 이제는 다음을 명확히 이해하게 되었습니다.
- Guard가 무엇인지: 인증/인가를 처리하는 선언적 모듈
- 왜 미들웨어 대신 Guard를 사용하는지: ExecutionContext로 다음 핸들러를 알 수 있고, 메타데이터와 긴밀하게 연동되기 때문
- 어떻게 동작하는지: Reflector로 메타데이터를 읽고, ExecutionContext로 요청 정보에 접근
- Spring Security와 어떻게 다른지: 선언적 접근은 유사하지만, NestJS가 더 명시적이고 단순한 구조
Guard를 제대로 이해하고 사용한다면, 유지보수하기 쉽고 확장 가능한 인증/인가 시스템을 구축할 수 있을 것입니다.
참고 자료
With Claude