커스텀 데코레이터 만들기
요청 객체에서 인증 사용자를 꺼내는 매개변수 데코레이터를 만들고 가드와 결합해 반복 코드를 줄입니다.
NestJS를 쓰다 보면 @Controller(), @Get(), @Body(), @Param() 같은 데코레이터를 많이 만납니다. 데코레이터는 클래스, 메서드, 매개변수에 메타데이터를 붙이거나 기능을 더하는 문법입니다. NestJS는 내장 데코레이터 말고도 개발자가 직접 사용자 정의 데코레이터(custom decorator)를 만들 수 있게 해 줍니다.
사용자 정의 데코레이터는 반복되는 코드를 줄이고, 컨트롤러가 무엇을 하는지 읽기 쉽게 만들어 줍니다. 이 글에서는 요청에서 사용자 정보를 꺼내는 매개변수 데코레이터를 만들고, 역할 메타데이터를 붙이는 데코레이터와 가드를 결합하는 방법을 살펴봅니다.
Nest 데코레이터 · 추출과 인가
User는 사용자 값을 추출하고, Roles와 Guard는 접근 정책을 판단합니다
첫 예제의 middleware는 가짜 사용자를 넣으며 실제 인증은 따로 구현해야 합니다. RolesGuard는 먼저 준비된 user.roles와 route/class 메타데이터를 비교하고, 허용된 핸들러의 User 데코레이터가 값을 추출합니다.
사용자 준비·추출 트랙
첫 예제는 AuthMiddleware가 임시 사용자를 설정하고 @User()가 전체 사용자나 선택 필드를 추출합니다. 실제 인증은 별도 구현해야 합니다.
메타데이터·인가 트랙
@Roles()가 정책을 선언하고 Reflector가 읽습니다. 예제 RolesGuard는 user.roles를 바로 사용하므로 선행 인증 가드가 user를 준비해야 합니다.
@User()는 신원을 검증하지 않습니다. 예제 getAllAndOverride는 handler 메타데이터를 class보다 우선하며 두 목록을 합치지 않습니다. user.roles의 하나라도 허용 역할에 있으면 통과합니다. RolesGuard가 먼저 실행되고, 통과한 요청의 handler 인자에서 @User()가 추출됩니다.
그림의 위쪽은 사용자를 준비하고 값을 꺼내는 흐름이고, 아래쪽은 메타데이터로 접근을 판단하는 흐름입니다. 인증이나 예제의 임시 사용자 준비가 request.user를 채우면, @Roles()가 선언한 정책을 Reflector로 읽은 RolesGuard가 사용자의 역할과 비교합니다. 허용된 요청에서만 @User()가 값을 꺼내 핸들러로 넘기고, 거부된 요청에서는 값 추출과 핸들러가 실행되지 않습니다. 맨 아래 줄은 @User()가 신원을 검증하지 않으며, 인증이 인가보다 먼저라는 점을 짚습니다.
사용자 정의 매개변수 데코레이터
매개변수 데코레이터는 NestJS의 createParamDecorator 함수로 만듭니다. 이 함수는 실행 문맥(ExecutionContext)에서 데이터를 꺼내거나 바꿔 라우트 핸들러의 매개변수로 넘겨줍니다. 파이프가 주로 매개변수의 유효성 검사와 타입 변환을 맡는다면, 사용자 정의 데코레이터는 요청에서 특정 데이터를 꺼내는 데 더 알맞습니다.
자주 쓰는 경우로는 인증된 사용자의 정보(req.user) 꺼내기, Authorization 같은 특정 헤더 값 읽기, 세션이나 쿠키의 데이터 읽기, req.query·req.params·req.body 가운데 원하는 부분만 뽑아내기가 있습니다.
@User() 만들기
가장 흔히 쓰는 사용자 정의 데코레이터는 현재 인증된 사용자의 정보를 가져오는 것입니다. JWT 인증 같은 과정에서 사용자 정보가 req.user에 저장된다고 해 봅시다. 매번 req.user를 직접 꺼내는 대신 @User()로 간단히 가져오도록 만들어 봅니다. src/common/decorators 디렉터리에 user.decorator.ts를 만듭니다.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
// ExecutionContext는 Http, RPC, Websockets 컨텍스트를 추상화합니다.
// 여기서는 현재 HTTP handler의 요청 인자를 읽습니다.
const request = ctx.switchToHttp().getRequest();
// data 인자는 데코레이터에 전달된 인자입니다 (예: @User('firstName')).
// 문자열 속성 키를 전달하면 해당 속성을, 생략하면 전체 user 객체를 반환합니다.
return typeof data === 'string' ? request.user?.[data] : request.user;
},
);createParamDecorator는 콜백 함수를 받아 매개변수 데코레이터를 만듭니다. 콜백의 data는 데코레이터를 쓸 때 넘긴 인자로, @User('id')라면 'id'이며, 이 예제는 typeof data === 'string'으로 확인한 뒤 속성 키로 씁니다. ctx는 HTTP뿐 아니라 WebSocket이나 gRPC 같은 여러 컨텍스트를 추상화한 실행 문맥이고, ctx.switchToHttp().getRequest()는 HTTP 요청 객체, 곧 Express의 req를 가져옵니다.
이 예제는 인증 과정에서 req.user에 사용자 정보가 담긴다고 가정하므로, 실제 애플리케이션에서는 가드나 미들웨어가 이 정보를 요청 객체에 넣는 로직이 따로 있어야 합니다. 마지막 줄은 문자열 키를 넘기면 request.user의 그 속성을, 인자를 생략하면 사용자 객체 전체를 돌려줍니다. 선택적 체이닝(?.)을 썼으므로 request.user가 없으면 undefined가 됩니다.
switchToHttp()는 현재 실행 인자에서 HTTP 요청과 응답을 읽는 접근자를 고를 뿐, RPC나 WebSocket 요청을 HTTP 요청으로 바꿔 주지는 않습니다. 다른 전송 방식에서는 그 컨텍스트에 맞는 접근 방법을 써야 합니다.
@User() 사용하기
이 데코레이터가 동작하려면 요청이 컨트롤러에 닿기 전에 request.user가 채워져 있어야 합니다. 실제 환경에서는 인증 가드가 JWT 토큰 등을 검증해 사용자를 설정하지만, 여기서는 간단한 미들웨어로 req.user를 임시로 넣습니다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 실제 인증 로직 (DB 조회, JWT 검증 등)
// 여기서는 임시로 가짜 사용자 정보를 req.user에 할당합니다.
(req as any).user = { userId: 1, username: 'nest_user', roles: ['admin'] };
console.log('User mocked by AuthMiddleware:', (req as any).user);
next();
}
}미들웨어는 루트를 포함한 모든 경로에 적용합니다. NestJS 11부터 기본 Express 5의 경로 규칙을 따르므로, 모든 경로를 뜻하는 와일드카드에도 {*splat}처럼 이름을 붙입니다. 중괄호는 루트 경로까지 포함하게 하며, 예전의 '*' 표기는 Nest의 호환 변환에 기대므로 새 코드에서는 이름을 명시합니다. 자세한 규칙은 미들웨어 문서에 있습니다.
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthMiddleware } from './auth/auth.middleware'; // 미들웨어 임포트
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.forRoutes('{*splat}'); // 모든 경로에 AuthMiddleware 적용
}
}미들웨어는 가드와 파이프보다 먼저 실행되므로, 이렇게 등록하면 모든 요청이 컨트롤러에 닿을 때 req.user에 임시 사용자가 들어 있습니다. 미들웨어의 console.log도 요청마다 한 번씩 실행됩니다. 이제 컨트롤러에서 @User()로 사용자 정보를 받습니다. 아래 코드에서는 인자 없이 쓴 @User()와 속성 이름을 넘긴 @User('username')가 각각 무엇을 받는지 비교합니다.
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { User } from './common/decorators/user.decorator'; // 커스텀 데코레이터 임포트
// 사용자 정보의 타입을 정의해두면 타입 안정성을 높일 수 있습니다.
interface CurrentUser {
userId: number;
username: string;
roles: string[];
}
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('profile')
// @User() 데코레이터를 사용하여 요청 객체에서 user 정보를 가져옵니다.
getProfile(@User() user: CurrentUser) {
console.log('Current User:', user);
return `Hello, ${user.username}! Your ID is ${user.userId} and roles are ${user.roles.join(', ')}.`;
}
@Get('username')
// @User('username') 데코레이터를 사용하여 user 객체에서 특정 속성만 가져옵니다.
getUsername(@User('username') username: string) {
console.log('Current Username:', username);
return `Your username is: ${username}`;
}
}npm run start:dev로 애플리케이션을 실행하고, http://localhost:3000/profile이나 http://localhost:3000/username으로 GET 요청을 보냅니다. 앞 장의 api 전역 접두사를 유지했다면 http://localhost:3000/api/profile과 http://localhost:3000/api/username을 씁니다. 콘솔에 Current User: { userId: 1, username: 'nest_user', roles: [ 'admin' ] }처럼 출력되고, 응답으로 사용자 정보가 담긴 문자열이 나옵니다.
임시 사용자를 그대로 쓰면, @User()에 넘긴 인자에 따라 결과가 다음과 같습니다. 실제 사용자 객체의 키는 userId이고 id는 없습니다.
@User()에 넘긴 인자 | 결과 |
|---|---|
| 생략 | 사용자 객체 전체 |
'username' | 'nest_user' |
'userId' | 1 |
'id' | undefined |
'' | 빈 문자열 키가 없으므로 undefined |
이 구현은 문자열인지 검사하므로 빈 문자열도 속성 키로 다룹니다. 사용자가 없을 때 값을 안전하게 꺼내는 것과, 그 값을 쓰는 핸들러가 안전하게 실행되는 것은 다른 문제입니다. 예를 들어 getProfile()은 사용자가 있다고 가정하고 속성을 읽습니다.
더 깊이: 사용자 정의 데코레이터의 값도 파이프를 거칠까
Nest는 사용자 정의 매개변수 데코레이터를 @Body(), @Param(), @Query() 같은 내장 데코레이터와 똑같이 다룹니다. 그래서 @User()가 꺼낸 값도 전역 파이프를 포함한 파이프를 거친 뒤 핸들러에 들어가며, 파이프가 받는 ArgumentMetadata의 type은 'body', 'query', 'param'이 아니라 'custom'이 됩니다. 모든 인자를 변환하는 전역 파이프를 직접 만든다면, 이 값으로 사용자 정의 데코레이터의 인자를 걸러 내지 않으면 서버가 준비한 사용자 객체까지 바꾸려 들 수 있습니다.
반대로 ValidationPipe는 기본적으로 사용자 정의 데코레이터가 붙은 인자를 검증하지 않고 건너뜁니다. 그 값도 검증하고 싶다면 사용자 정의 데코레이터 문서처럼 @User(new ValidationPipe({ validateCustomDecorators: true }))로 데코레이터에 파이프를 직접 넘기거나, 전역 ValidationPipe에 같은 옵션을 켭니다. 전역 ValidationPipe를 등록했으니 @User()의 값도 검사되었으리라 생각하면 틀리는 이유가 이것입니다.
데코레이터와 가드의 결합
인증 가드가 요청을 통과시키면서 req.user에 사용자 정보를 넣으면, 컨트롤러는 @User()로 그 정보를 깔끔하게 가져다 쓸 수 있습니다. 역할 기반 접근 제어(RBAC)를 구현할 때도 @Roles('admin') 같은 데코레이터로 메타데이터를 붙이고, 가드가 그 메타데이터를 읽어 사용자의 역할을 검증할 수 있습니다.
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);SetMetadata()는 메타데이터 키와 값을 붙이는 저수준 방식입니다. NestJS 문서는 export const Roles = Reflector.createDecorator<string[]>();처럼 타입이 있는 데코레이터를 만드는 방식도 안내하며, NestJS 12의 CLI가 만드는 데코레이터도 이 형식입니다. 그 방식에서는 @Roles(['admin'])처럼 배열을 넘기고, 가드에서는 문자열 키 대신 Roles 데코레이터 자체로 메타데이터를 읽습니다. 이 글은 키 이름이 드러나는 SetMetadata()로 설명합니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; // Reflector를 사용하여 메타데이터를 읽습니다.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true; // 역할 제한이 없으면 접근 허용
}
const { user } = context.switchToHttp().getRequest();
// 실제 로직: user의 roles가 requiredRoles에 포함되는지 확인
return user.roles.some((role: string) => requiredRoles.includes(role));
}
}import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/guards/auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@Controller('admin')
@UseGuards(AuthGuard, RolesGuard) // AuthGuard 먼저, RolesGuard 나중에 실행
export class AdminController {
@Get('dashboard')
@Roles('admin') // 이 메서드는 'admin' 역할만 접근 가능
getAdminDashboard() {
return 'Welcome to the admin dashboard!';
}
}getAllAndOverride('roles', [handler, class])는 핸들러와 클래스 가운데 먼저 정의된 메타데이터를 고르며, 두 목록을 합치지 않습니다. 빈 배열도 정의된 값으로 봅니다. 이 간략화된 가드는 user.roles 배열이 준비되어 있다는 전제에서 다음처럼 동작합니다.
선택된 requiredRoles | 사용자의 역할 | 이 가드의 반환 |
|---|---|---|
undefined | 아무거나 | true, 역할 조건 없이 통과 |
['admin'] | ['editor'] | false |
['admin'] | ['editor', 'admin'] | true |
[] | ['admin'] | false, 일치할 허용 역할이 없음 |
빈 배열을 역할 제한 없음으로 쓰고 싶다면 정책과 가드 구현을 함께 바꿔야 하며, 지금의 코드를 그렇게 해석하면 안 됩니다.
가드와 함께 쓸 때는 데코레이터가 값을 꺼내고, 가드가 그 값을 믿을 수 있는지 확인한다는 역할 분리를 지킵니다. 이 구분은 실제 인증 구현을 전제로 합니다. 미들웨어, 인터셉터, 파이프, 가드의 !!request.user 예시만으로 토큰의 서명이나 사용자의 신원을 검증한 것이 아니며, 지금의 AuthMiddleware는 모든 요청에 같은 가짜 사용자를 넣어 추출을 연습하는 코드입니다. 헤더를 아무렇게나 바꿔도 같은 사용자가 나오므로, 로그인 보안이 완성됐다고 해석하지 않습니다.
@User('username')의 선택적 체이닝은 사용자 객체가 없을 때 추출 결과를 undefined로 만들 뿐, 그 결과를 받은 getProfile()이 user.username을 읽는 동작까지 안전하게 만들지는 않습니다. RolesGuard도 user.roles가 준비되어 있다고 전제합니다. 실제 앱에서는 인증 가드가 사용자 없는 요청을 먼저 거부하고, 역할 값의 형식을 확인한 뒤 인가해야 합니다.
정리
createParamDecorator로 만든 매개변수 데코레이터는 실행 문맥에서 값을 꺼내 핸들러 인자로 넘기며, 신원을 검증하지는 않습니다.- 역할 같은 정책은
SetMetadata()나Reflector.createDecorator()로 메타데이터를 붙이고, 가드가Reflector로 읽어 판단합니다. getAllAndOverride는 핸들러의 메타데이터를 클래스보다 먼저 고르고 두 목록을 합치지 않으며, 빈 배열도 정의된 값입니다.- 요청에서 반복해 꺼내는 값은 데코레이터로, 접근 판단은 가드로, 입력 검사와 변환은 파이프로 나눕니다.
연습 문제
1. 추출, 인증, 인가
진짜 인증 가드가 요청의 사용자를 준비한 뒤 RolesGuard가 실행되고, 핸들러에는 @Roles('admin')이 있습니다. 역할이 ['editor']인 사용자와 ['editor', 'admin']인 사용자의 요청을 비교하세요. 또 @User('username')만 붙이고 가드를 생략하면 admin 전용 경로가 보호되나요?
해설 보기
첫 사용자는 허용 역할과 일치하는 값이 없어 가드가 false를 돌려주고, 두 번째 사용자는 admin이 있으므로 통과합니다. 예제의 some()은 하나라도 일치하면 허용하는 규칙입니다.
@User()는 이미 준비된 요청에서 값을 꺼낼 뿐 접근을 막지 않으므로, 데코레이터만으로는 관리자 경로를 보호할 수 없습니다.
2. 필드 이름과 값의 부재
임시 사용자는 { userId: 1, username: 'nest_user', roles: ['admin'] }입니다. @User('id')로 얻은 값이 undefined라면 사용자가 없다고 결론 낼 수 있나요? 사용자 자체가 없는 경우에 추출 결과를 getProfile()의 인자로 그대로 넘겨도 안전한가요?
해설 보기
첫 경우에는 사용자 객체가 있지만 id라는 키가 없어서 undefined입니다. @User('userId')를 쓰면 1을 얻습니다.
두 번째 경우에는 추출 결과 자체가 undefined이고, 지금의 getProfile은 user.username 등을 바로 읽으므로 TypeError가 납니다. 사용자의 존재는 인증 단계에서 보장하거나, 핸들러에서 명시적으로 검사해야 합니다.
3. 클래스와 핸들러의 우선순위
클래스에 @Roles('admin'), 핸들러에 @Roles('editor')가 있고, 사용자의 역할은 ['editor']입니다. 위의 getAllAndOverride 순서와 가드를 그대로 쓰면 admin도 요구하나요?
해설 보기
핸들러의 ['editor']가 먼저 선택되므로 admin을 추가로 요구하지 않습니다. 사용자의 editor가 선택된 목록에 들어 있으므로 true를 돌려줍니다.
두 목록을 합치거나, 모든 역할을 갖춰야 통과시키려면 메타데이터를 읽는 방식과 판단 규칙을 따로 설계해야 합니다. 예를 들어 getAllAndMerge는 두 목록을 합쳐 줍니다.
4. 빈 메타데이터와 없는 메타데이터
클래스에는 @Roles('admin')이 있습니다. 첫 핸들러에는 인자 없이 @Roles()를 붙였고, 두 번째 핸들러에는 @Roles를 붙이지 않았습니다. 사용자의 역할은 ['admin']입니다. 두 핸들러의 결과는 각각 무엇인가요?
해설 보기
첫 핸들러의 빈 배열은 정의된 값이라 클래스의 admin 목록을 덮어씁니다. if (!requiredRoles)는 빈 배열에서 참이 아니므로 다음 줄로 가고, 빈 목록과 일치하는 역할이 없어 false입니다.
두 번째 핸들러는 자기 메타데이터가 없으므로 클래스의 ['admin']을 골라 true입니다. 이 예제가 역할 조건 없이 true를 돌려주는 것은 클래스와 핸들러 모두 메타데이터가 없을 때뿐입니다.
이것으로 2장을 마칩니다. 2장에서는 의존성 주입과 IoC 컨테이너의 등록·범위·토큰에서 시작해, 미들웨어·가드·파이프·인터셉터의 실행 순서, 예외 필터의 적용 범위와 오류 응답, 사용자 정의 데코레이터와 가드의 결합까지 살펴봤습니다. 다음 장의 TypeORM을 사용한 데이터베이스 연동부터는 메모리 배열 대신 실제 데이터베이스에 데이터를 저장하는 방법을 다룹니다.