NestJS Custom Decorators란? 선언적 코드의 핵심
NestJS에서 @Controller, @Injectable, @Body, @UseGuards 같은 데코레이터는 프레임워크의 근간입니다. 하지만 진짜 강력한 것은 커스텀 데코레이터를 만들어 비즈니스 로직을 선언적으로 표현하는 것입니다. @CurrentUser()로 인증된 사용자를 주입하고, @Roles('admin')으로 권한을 제어하고, @Public()으로 인증을 건너뛰는 — 이 모든 것이 커스텀 데코레이터입니다.
이 글에서는 NestJS의 4가지 데코레이터 유형(파라미터, 메서드, 클래스, 조합)의 구현 방법부터, createParamDecorator와 SetMetadata의 내부 동작, Guard/Interceptor와의 조합 패턴, 그리고 Pipe 통합과 테스트 전략까지 운영 수준에서 완전히 다룹니다.
데코레이터의 기본: TypeScript 데코레이터 이해
NestJS 데코레이터는 TypeScript(ES) 데코레이터를 기반으로 합니다. 데코레이터는 본질적으로 메타데이터를 부착하거나 동작을 변경하는 함수입니다:
// TypeScript 데코레이터의 본질
function MyDecorator(target: any, key: string, descriptor: PropertyDescriptor) {
// target: 클래스 프로토타입
// key: 메서드 이름
// descriptor: 프로퍼티 디스크립터
}
// 팩토리 패턴 (인자를 받는 데코레이터)
function MyDecorator(value: string) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
Reflect.defineMetadata('myKey', value, target, key);
};
}
유형 1: 파라미터 데코레이터 — createParamDecorator
가장 많이 사용하는 커스텀 데코레이터입니다. Controller 메서드의 파라미터에 값을 주입합니다.
@CurrentUser() — 인증된 사용자 주입
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user; // Guard에서 주입한 사용자 정보
// data가 있으면 특정 필드만 반환
return data ? user?.[data] : user;
},
);
// 사용
@Controller('orders')
export class OrderController {
@Get()
findMyOrders(@CurrentUser() user: User) {
// user 전체 객체
return this.orderService.findByUser(user.id);
}
@Get('email')
getEmail(@CurrentUser('email') email: string) {
// user.email만 추출
return { email };
}
}
@ClientIp() — 클라이언트 IP 추출
export const ClientIp = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
// 프록시/로드밸런서 뒤의 실제 IP
return (
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.headers['x-real-ip'] ||
request.socket.remoteAddress ||
'unknown'
);
},
);
// 사용
@Post('login')
login(@Body() dto: LoginDto, @ClientIp() ip: string) {
return this.authService.login(dto, ip);
}
@Pagination() — 페이지네이션 파라미터 파싱
interface PaginationParams {
page: number;
limit: number;
offset: number;
}
export const Pagination = createParamDecorator(
(data: { maxLimit?: number }, ctx: ExecutionContext): PaginationParams => {
const request = ctx.switchToHttp().getRequest();
const maxLimit = data?.maxLimit ?? 100;
const page = Math.max(1, parseInt(request.query.page) || 1);
const limit = Math.min(maxLimit, Math.max(1, parseInt(request.query.limit) || 20));
const offset = (page - 1) * limit;
return { page, limit, offset };
},
);
// 사용
@Get()
findAll(@Pagination({ maxLimit: 50 }) pagination: PaginationParams) {
return this.userService.findAll(pagination);
}
파라미터 데코레이터 + Pipe 통합
// createParamDecorator의 결과에 Pipe를 적용할 수 있음
@Get(':id')
findOne(
@CurrentUser(new ValidationPipe({ transform: true })) user: UserDto,
) { ... }
// 데코레이터 정의 시 기본 Pipe 내장
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
export const ParsedQuery = createParamDecorator(
(key: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const value = request.query[key];
if (value === undefined) {
throw new BadRequestException(`Query parameter '${key}' is required`);
}
return value;
},
);
// 사용
@Get('search')
search(@ParsedQuery('q') query: string) {
return this.searchService.search(query);
}
유형 2: 메타데이터 데코레이터 — SetMetadata + Reflector
SetMetadata는 메서드나 클래스에 메타데이터를 부착하고, Reflector로 Guard/Interceptor에서 읽습니다.
@Roles() — 역할 기반 접근 제어
// 데코레이터 정의
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// Guard에서 읽기
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) return true; // 역할 미지정 → 통과
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// 사용
@Post()
@Roles('admin', 'manager')
create(@Body() dto: CreateUserDto) { ... }
@Get()
@Roles('admin')
findAll() { ... }
@Public() — 인증 건너뛰기
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// AuthGuard에서 확인
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) return true; // @Public() → 인증 스킵
return super.canActivate(context);
}
}
// 사용
@Public()
@Get('health')
health() { return { status: 'ok' }; }
@CacheTTL() / @Throttle() — 숫자 메타데이터
// 캐시 TTL
export const CACHE_TTL_KEY = 'cacheTtl';
export const CacheTTL = (seconds: number) =>
SetMetadata(CACHE_TTL_KEY, seconds);
// 요청 제한
export const THROTTLE_KEY = 'throttle';
export const Throttle = (limit: number, ttl: number) =>
SetMetadata(THROTTLE_KEY, { limit, ttl });
// 사용
@Get('products')
@CacheTTL(60)
@Throttle(10, 60)
findAll() { ... }
유형 3: 조합 데코레이터 — applyDecorators
여러 데코레이터를 하나로 묶어 재사용성을 높입니다:
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
// 인증 + 역할 + Swagger 문서를 하나로
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: '인증 필요' }),
);
}
// 사용: 3줄이 1줄로!
@Post()
@Auth('admin')
create(@Body() dto: CreateUserDto) { ... }
// 아래와 동일:
// @SetMetadata('roles', ['admin'])
// @UseGuards(JwtAuthGuard, RolesGuard)
// @ApiBearerAuth()
// @ApiUnauthorizedResponse({ description: '인증 필요' })
@ApiPaginatedResponse() — Swagger 페이지네이션 응답 문서화
export function ApiPaginatedResponse(model: Type<any>) {
return applyDecorators(
ApiExtraModels(PaginatedDto, model),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
},
},
],
},
}),
);
}
// 사용
@Get()
@ApiPaginatedResponse(UserDto)
findAll(@Pagination() pagination: PaginationParams) { ... }
@TransactionalRoute() — 트랜잭션 + 에러 핸들링 조합
export function TransactionalRoute() {
return applyDecorators(
UseInterceptors(TransactionInterceptor),
UseFilters(DatabaseExceptionFilter),
);
}
// 사용
@Post()
@TransactionalRoute()
@Auth('admin')
create(@Body() dto: CreateOrderDto) { ... }
유형 4: 클래스 데코레이터 — Controller 레벨 기본값
// API 버전 + 공통 가드 + Swagger 태그를 묶는 클래스 데코레이터
export function ApiController(prefix: string, tag?: string) {
return applyDecorators(
Controller(`api/v1/${prefix}`),
UseGuards(JwtAuthGuard),
ApiTags(tag || prefix),
ApiBearerAuth(),
);
}
// 사용
@ApiController('orders', 'Orders')
export class OrderController {
// 모든 엔드포인트에 /api/v1/orders + JWT 인증 + Swagger 태그 적용
@Get()
findAll() { ... } // GET /api/v1/orders (JWT 필수)
@Public() // 이 엔드포인트만 인증 스킵
@Get('count')
count() { ... } // GET /api/v1/orders/count (공개)
}
고급 패턴: ExecutionContext로 멀티 프로토콜 대응
// HTTP, WebSocket, GraphQL 모두에서 사용자 추출
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
let user: any;
switch (ctx.getType<string>()) {
case 'http':
user = ctx.switchToHttp().getRequest().user;
break;
case 'ws':
user = ctx.switchToWs().getClient().user;
break;
case 'graphql':
// GraphQL context에서 사용자 추출
const gqlCtx = GqlExecutionContext.create(ctx);
user = gqlCtx.getContext().req?.user;
break;
}
return data ? user?.[data] : user;
},
);
// HTTP, WebSocket, GraphQL 모든 핸들러에서 동일하게 사용
@Get('profile')
getProfile(@CurrentUser() user: User) { ... }
@SubscribeMessage('chat')
handleChat(@CurrentUser() user: User, @MessageBody() data: any) { ... }
Reflector 심화: getAllAndOverride vs getAllAndMerge
// getAllAndOverride: 메서드 우선, 없으면 클래스
// → "이 메서드에 특별한 설정이 있으면 그것을 쓰고, 없으면 클래스 기본값"
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(), // 먼저 확인
context.getClass(), // 없으면 여기
]);
// getAllAndMerge: 메서드 + 클래스 모두 합침
// → "클래스와 메서드에 정의된 역할을 모두 합쳐서"
const roles = this.reflector.getAllAndMerge<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
// 클래스: @Roles('user') + 메서드: @Roles('admin') → ['user', 'admin']
| 메서드 | 클래스: @Roles(‘user’) 메서드: @Roles(‘admin’) |
용도 |
|---|---|---|
| getAllAndOverride | [‘admin’] (메서드 우선) | @Public, @CacheTTL (오버라이드) |
| getAllAndMerge | [‘admin’, ‘user’] (합침) | @Roles (누적) |
테스트: 커스텀 데코레이터 테스트 전략
파라미터 데코레이터 단위 테스트
// createParamDecorator의 핸들러 함수를 직접 테스트
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
function getParamDecoratorFactory(decorator: Function) {
class Test {
test(@decorator() value: any) {}
}
const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test');
return args[Object.keys(args)[0]].factory;
}
describe('CurrentUser', () => {
it('should extract user from request', () => {
const factory = getParamDecoratorFactory(CurrentUser);
const mockUser = { id: 1, email: 'test@example.com' };
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({ user: mockUser }),
}),
getType: () => 'http',
} as ExecutionContext;
const result = factory(undefined, mockContext);
expect(result).toEqual(mockUser);
});
it('should extract specific field when data is provided', () => {
const factory = getParamDecoratorFactory(CurrentUser);
const mockUser = { id: 1, email: 'test@example.com' };
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({ user: mockUser }),
}),
getType: () => 'http',
} as ExecutionContext;
const result = factory('email', mockContext);
expect(result).toBe('test@example.com');
});
});
메타데이터 데코레이터 + Guard 통합 테스트
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('should allow access when user has required role', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = createMockExecutionContext({
user: { id: 1, roles: ['admin', 'user'] },
});
expect(guard.canActivate(context)).toBe(true);
});
it('should deny access when user lacks required role', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = createMockExecutionContext({
user: { id: 2, roles: ['user'] },
});
expect(guard.canActivate(context)).toBe(false);
});
});
E2E 테스트
describe('OrderController (e2e)', () => {
it('should return orders for authenticated user', () => {
const token = generateTestToken({ id: 1, roles: ['user'] });
return request(app.getHttpServer())
.get('/api/v1/orders')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
// @CurrentUser()가 올바르게 user를 주입했는지 검증
expect(res.body).toBeInstanceOf(Array);
});
});
it('should deny access without admin role', () => {
const token = generateTestToken({ id: 1, roles: ['user'] });
return request(app.getHttpServer())
.post('/api/v1/orders') // @Roles('admin') 적용
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
it('should allow access to @Public() endpoints without token', () => {
return request(app.getHttpServer())
.get('/health') // @Public() 적용
.expect(200);
});
});
실무 안티패턴 4가지
1. 데코레이터 안에서 무거운 로직 실행
// ❌ 데코레이터에서 DB 조회
export const CurrentUser = createParamDecorator(
async (data, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
// DB 조회 → 매 요청마다 실행됨!
return await userRepository.findOne(request.userId);
},
);
// ✅ Guard에서 조회 후 request에 부착, 데코레이터는 추출만
@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
request.user = await this.authService.validateToken(request.headers.authorization);
return true;
}
}
export const CurrentUser = createParamDecorator(
(data, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user; // 추출만!
},
);
2. SetMetadata 키 충돌
// ❌ 문자열 리터럴 직접 사용 → 다른 라이브러리와 충돌 가능
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// ✅ Symbol 또는 고유 상수 사용
export const ROLES_KEY = Symbol('roles');
// 또는
export const ROLES_KEY = 'app:auth:roles'; // 네임스페이스 접두사
3. applyDecorators 순서 무시
// ❌ Guard가 메타데이터보다 먼저 등록되면 Reflector가 못 읽음
export function Auth(...roles: string[]) {
return applyDecorators(
UseGuards(RolesGuard), // Guard가 먼저
SetMetadata('roles', roles), // 메타데이터가 나중 → 문제 없음 (실제로는 OK)
);
}
// applyDecorators는 순서 무관하게 동작하지만,
// 명확성을 위해 메타데이터 → Guard 순서 권장
4. 데코레이터에서 DI 사용 시도
// ❌ createParamDecorator 안에서 DI 컨테이너 접근 불가
export const CurrentUser = createParamDecorator(
(data, ctx: ExecutionContext) => {
// this.userService 없음! DI 미지원
},
);
// ✅ DI가 필요하면 Guard/Interceptor/Pipe에서 처리
@Injectable()
export class UserResolvePipe implements PipeTransform {
constructor(private userService: UserService) {} // DI 가능!
async transform(userId: number) {
return this.userService.findById(userId);
}
}
정리: Custom Decorators 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 파라미터 데코레이터는 값 추출만 (무거운 로직 금지) | ☐ |
| SetMetadata 키는 상수/Symbol로 관리 | ☐ |
| 반복 패턴은 applyDecorators로 조합 | ☐ |
| Reflector는 getAllAndOverride vs getAllAndMerge 구분 | ☐ |
| 멀티 프로토콜(HTTP/WS/GQL) 대응 시 getType() 분기 | ☐ |
| DI 필요한 로직은 Guard/Interceptor/Pipe로 분리 | ☐ |
| 데코레이터 핸들러 함수 단위 테스트 | ☐ |
| E2E 테스트로 Guard/Interceptor 연동 검증 | ☐ |
NestJS Custom Decorators는 반복 코드를 선언적으로 추상화하는 가장 강력한 도구입니다. createParamDecorator로 값을 주입하고, SetMetadata + Reflector로 Guard와 Interceptor에 조건을 전달하며, applyDecorators로 여러 데코레이터를 조합하면, 비즈니스 로직이 깔끔하게 정리되고 코드의 의도가 명확해집니다.