커스텀 데코레이터 만들기
요청 객체에서 인증 사용자를 꺼내는 매개변수 데코레이터를 만들고 가드와 결합해 반복 코드를 줄입니다.
NestJS를 사용하다 보면 @Controller(), @Get(), @Body(), @Param() 같은 다양한 데코레이터를 접하게 됩니다.
이 데코레이터는 클래스, 메서드, 매개변수에 메타데이터를 추가하거나 특정 기능을 부여하는 문법 요소입니다.
NestJS는 내장 데코레이터 외에도 개발자가 직접 커스텀 데코레이터(Custom Decorators)를 만들 수 있도록 지원합니다.
커스텀 데코레이터는 코드의 중복을 줄이고, 가독성을 높이며, 관심사를 더욱 명확하게 분리하여 애플리케이션의 유지보수성을 향상시키는 데 크게 기여합니다.
이번 절에서는 커스텀 데코레이터의 개념을 이해하고, 실제로 만들어보고, 이를 활용하는 방법을 배워보겠습니다.
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()가 추출됩니다.
커스텀 데코레이터란?
커스텀 데코레이터는 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, ctx) => { ... }): NestJS에서 매개변수 데코레이터를 생성할 때 사용하는 헬퍼 함수입니다. 인자로 콜백 함수를 받습니다.data: unknown: 이 데코레이터를 사용할 때 전달하는 인자입니다. 이 예제에서는 문자열 속성 키를 전달하거나 인자를 생략합니다. 예를 들어@User('id')의data는'id'이며,typeof data === 'string'으로 확인한 뒤 속성 키로 사용합니다.ctx: ExecutionContext: 현재 실행 컨텍스트에 대한 정보를 제공하는 객체입니다. HTTP 요청뿐만 아니라 WebSockets, gRPC 등 다양한 컨텍스트에서 작동할 수 있도록 추상화되어 있습니다.ctx.switchToHttp().getRequest():ExecutionContext를 HTTP 컨텍스트로 전환하고,Request객체를 가져옵니다. Express.js의req객체와 동일하다고 생각하시면 됩니다.request.user: 이 예시에서는 인증 과정에서req.user에 사용자 정보가 담긴다고 가정합니다. 실제 애플리케이션에서는 가드나 미들웨어에서 이 정보를request객체에 추가하는 로직이 필요합니다.typeof data === 'string' ? request.user?.[data] : request.user;: 문자열 키를 전달하면 (예:id)request.user.id를 반환하고, 인자를 생략하면 (@User())request.user전체 객체를 반환합니다.?.(Optional Chaining)을 사용해request.user가 없을 때undefined를 반환하도록 안전하게 처리합니다.
커스텀 데코레이터 사용하기
이제 @User() 데코레이터를 컨트롤러에서 사용해 사용자 정보를 간편하게 가져와 보겠습니다.
이 데코레이터가 동작하려면 요청이 컨트롤러에 도달하기 전에 request.user에 실제 사용자 정보가 채워져 있어야 합니다.
여기서는 간단한 미들웨어로 req.user를 임시 설정해 보겠습니다.
실제 환경에서는 인증 가드(Guard)에서 JWT 토큰 등을 검증해 사용자 정보를 설정하게 됩니다.
단계 1: 사용자 정보를 설정할 미들웨어 또는 가드 준비 (예시)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();
}
}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 적용
}
}@User() 데코레이터 사용
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 명령어로 애플리케이션을 실행합니다.
웹 브라우저나 Postman 등에서 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() 데코레이터는 가드와 함께 사용될 때 더욱 강력해집니다.
인증 가드가 요청을 통과시키면서 req.user에 사용자 정보를 설정하면, 컨트롤러에서는 @User() 데코레이터를 통해 이 정보를 깔끔하게 가져다 쓸 수 있기 때문입니다.
예를 들어, 역할 기반 접근 제어(RBAC)를 구현할 때, @Roles('admin')과 같은 커스텀 데코레이터를 만들고, 이 데코레이터가 설정한 메타데이터를 가드에서 읽어 사용자의 역할을 검증하는 방식으로 활용할 수 있습니다.
@Roles() 데코레이터와 RolesGuard (개념만 소개)
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);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!';
}
}@Roles()와 RolesGuard의 관계를 메타데이터 흐름으로 보면, 데코레이터와 가드의 책임 경계가 더 명확해집니다.
가드와 함께 사용할 때는 데코레이터가 값을 추출하고, 가드가 그 값의 신뢰성을 보장한다는 책임 분리를 유지해야 합니다.
이 구분에는 실제 인증 구현이라는 전제가 있습니다. 앞 절의 !!request.user 예시만으로 토큰의 서명이나 사용자의 신원을 검증한 것은 아닙니다. 현재 AuthMiddleware는 모든 요청에 같은 가짜 사용자를 넣어 추출을 연습하는 코드입니다. 헤더를 아무 값으로 바꿔도 같은 사용자가 나올 수 있으므로 로그인 보안이 완성됐다고 해석하지 않습니다.
@User('username')의 optional chaining은 사용자 객체가 없을 때 추출 결과를 undefined로 만드는 기능입니다. 그 결과를 받은 getProfile()에서 user.username을 읽는 동작까지 안전하게 만드는 것은 아닙니다. RolesGuard 역시 user.roles가 준비되었다는 전제에 의존합니다. 실제 앱에서는 인증 가드가 사용자 없는 요청을 먼저 거부하고, 역할 값의 형식을 확인한 뒤 인가해야 합니다.
추출·인증·인가를 구분하는 연습
진짜 인증 가드가 요청의 사용자를 준비한 뒤 RolesGuard가 실행되고, 해당 핸들러에는 @Roles('admin')이 있다고 가정합니다. 역할이 ['editor']인 사용자와 ['editor', 'admin']인 사용자의 요청을 비교해 보세요. 또 @User('username')만 붙이고 가드를 생략하면 admin 전용 경로가 보호되는지도 설명해 보세요.
역할 비교와 값 추출의 차이
첫 사용자는 허용 역할과 일치하는 값이 없어 가드가 false를 반환합니다. 두 번째 사용자는 admin이 있으므로 통과합니다. 예제의 some()은 하나라도 일치하면 허용하는 규칙입니다. @User()는 이미 준비된 요청에서 값을 꺼낼 뿐 접근을 차단하지 않으므로, 데코레이터만으로 관리자 경로를 보호할 수 없습니다.
이처럼 커스텀 데코레이터는 코드의 재사용성을 높이고, 특정 로직을 선언적으로 표현할 수 있게 하여 NestJS 애플리케이션의 구조를 더욱 깔끔하고 효율적으로 만들어줍니다.
이번 절에서는 NestJS 커스텀 데코레이터를 만들고 적용하는 방법을 살펴봤습니다.
요청 객체에서 반복해서 꺼내는 값은 매개변수 데코레이터로, 접근 판단은 가드로, 입력 검사와 변환은 파이프로 분리하면 컨트롤러에서 각 책임을 더 분명하게 읽을 수 있습니다.
다음 장에서는 NestJS 애플리케이션의 핵심 기능인 데이터베이스 연동에 대해 자세히 알아보겠습니다.
값 추출과 역할 정책을 조건별로 읽기
이 절의 User는 HTTP handler의 인자를 읽는 예제입니다. switchToHttp()는 현재 실행 인자에서 HTTP 요청·응답을 읽는 접근자를 선택합니다. RPC나 WebSocket 요청을 HTTP 요청으로 바꾸는 기능은 아니므로, 다른 전송 방식에서는 해당 context의 접근 방법을 사용해야 합니다.
가짜 middleware가 만든 사용자를 그대로 사용하면 아래 결과가 됩니다. 실제 사용자 객체의 키는 userId이며 id는 없습니다.
| User에 전달한 인자 | 결과 |
|---|---|
| 생략 | 전체 사용자 객체 |
'username' | 'nest_user' |
'userId' | 1 |
'id' | undefined |
'' | 빈 문자열 키가 없으므로 undefined |
이 구현은 문자열인지 검사하므로 빈 문자열도 속성 키로 처리합니다. 사용자 자체가 없을 때 값을 안전하게 추출하는 것과, 그 값을 사용하는 handler가 안전하게 실행되는 것은 별개입니다. 예를 들어 getProfile()은 user가 존재한다고 가정하고 속성을 읽습니다.
역할 판단에서는 getAllAndOverride('roles', [handler, class])가 먼저 정의된 메타데이터를 선택합니다. 두 목록을 합치지 않으며, 빈 배열도 정의된 값입니다. 이 절의 간략화된 Guard는 준비된 user.roles 배열이 있다는 전제에서 아래처럼 동작합니다.
| 선택된 requiredRoles | 사용자 역할 | 이 Guard의 반환 |
|---|---|---|
undefined | 임의 | true: 역할 조건 없이 반환 |
['admin'] | ['editor'] | false |
['admin'] | ['editor', 'admin'] | true |
[] | ['admin'] | false: 일치할 허용 역할이 없음 |
빈 배열을 “역할 제한 없음”으로 사용하려면 정책과 Guard 구현을 함께 바꾸어야 합니다. 현재 코드의 결과를 그렇게 해석하면 안 됩니다. 사용자 준비와 역할 배열 검사는 선행 인증 구현의 책임이며, User decorator를 추가하는 것만으로 인증 실패 응답이 생기지는 않습니다.
Nest 11의 기본 Express 5에서 모든 경로에 middleware를 적용하는 코드는 이름 있는 wildcard forRoutes('{*splat}')를 사용합니다. 중괄호는 루트 경로도 포함하도록 합니다. 이전 '*' 표기는 Nest의 호환 변환에 의존하므로 새 코드는 이름을 명시합니다. Nest 11 전환 안내에서 이 조건을 확인할 수 있습니다.
조건을 바꿔 확인하기
연습문제: 사용자 필드 이름과 값의 부재
가짜 사용자는 { userId: 1, username: 'nest_user', roles: ['admin'] }입니다. @User('id')로 얻은 값이 undefined이면 사용자가 없다고 결론 낼 수 있을까요? 사용자 자체가 없는 경우에 getProfile()의 인자로 그대로 넘겨도 안전할까요?
풀이: 첫 경우에는 사용자 객체가 있지만 id라는 키가 없으므로 undefined입니다. @User('userId')를 사용하면 1을 얻습니다. 두 번째 경우에는 추출 결과 자체가 undefined이고, 현재 getProfile은 user.username 등을 직접 읽으므로 안전하지 않습니다. 사용자 존재 여부는 인증 단계에서 보장하거나 handler에서 명시적으로 검사해야 합니다.
연습문제: class와 handler의 우선순위
class에 @Roles('admin'), handler에 @Roles('editor')가 있고 사용자 역할은 ['editor']입니다. 위의 getAllAndOverride 순서와 Guard를 그대로 쓰면 admin도 요구할까요?
풀이: handler의 ['editor']가 먼저 선택되므로 admin을 추가로 요구하지 않습니다. 사용자의 editor가 선택된 목록에 포함되어 true를 반환합니다. 두 목록의 병합이나 모든 역할 충족을 원한다면 메타데이터 읽기 방식과 판단 규칙을 따로 설계해야 합니다.
연습문제: 빈 메타데이터와 없는 메타데이터
class에는 @Roles('admin')이 있습니다. 첫 handler는 @Roles()를 붙였고, 두 번째 handler는 Roles를 붙이지 않았습니다. 사용자는 ['admin'] 역할을 가집니다. 첫 handler와 두 번째 handler의 결과는 각각 무엇일까요?
풀이: 첫 handler의 빈 배열은 정의된 값이라 class의 admin 목록을 덮어씁니다. if (!requiredRoles)는 빈 배열에서 참이 아니며, 빈 목록과 일치하는 역할이 없어 false입니다. 두 번째 handler는 자기 메타데이터가 없으므로 class의 ['admin']을 선택하고 true입니다. class와 handler 양쪽 모두 메타데이터가 없을 때만 이 예제는 역할 조건 없이 true를 반환합니다.