NestJS Custom Decorator란?
NestJS에서 커스텀 데코레이터(Custom Decorator)는 반복되는 로직을 선언적으로 추출하여 컨트롤러와 서비스의 코드를 극적으로 간결하게 만드는 핵심 기법입니다. @CurrentUser(), @Roles(), @Cacheable() 같은 프로젝트 전용 데코레이터를 만들면 비즈니스 의도가 코드에 명확하게 드러납니다.
이 글에서는 파라미터 데코레이터, 메서드 데코레이터, 클래스 데코레이터, 데코레이터 합성(Composition), 메타데이터 기반 Guard/Interceptor 연동까지 실무 패턴을 심화 정리합니다.
파라미터 데코레이터: createParamDecorator
가장 흔한 패턴은 요청 컨텍스트에서 특정 데이터를 추출하는 파라미터 데코레이터입니다.
기본: @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;
// data가 있으면 특정 필드만 반환
// @CurrentUser('email') → user.email
return data ? user?.[data] : user;
},
);
// 사용
@Get('profile')
getProfile(@CurrentUser() user: UserEntity) {
return user;
}
@Get('email')
getEmail(@CurrentUser('email') email: string) {
return { email };
}
심화: 타입 안전 + Validation 적용
// 요청에서 pagination 파라미터 추출 + 기본값 적용
export const Pagination = createParamDecorator(
(defaults: Partial<PaginationDto> = {}, ctx: ExecutionContext): PaginationDto => {
const request = ctx.switchToHttp().getRequest();
const query = request.query;
const page = Math.max(1, parseInt(query.page, 10) || defaults.page || 1);
const limit = Math.min(
100,
Math.max(1, parseInt(query.limit, 10) || defaults.limit || 20),
);
const sort = query.sort || defaults.sort || 'createdAt';
const order = ['ASC', 'DESC'].includes(query.order?.toUpperCase())
? query.order.toUpperCase()
: defaults.order || 'DESC';
return { page, limit, sort, order, skip: (page - 1) * limit };
},
);
// 사용: 기본값 커스터마이징
@Get()
findAll(@Pagination({ limit: 10, sort: 'name' }) pagination: PaginationDto) {
return this.service.findAll(pagination);
}
WebSocket/GraphQL 대응 파라미터 데코레이터
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const GqlCurrentUser = createParamDecorator(
(data: string | undefined, context: ExecutionContext) => {
// HTTP vs GraphQL vs WebSocket 자동 감지
const type = context.getType<string>();
let user: any;
if (type === 'graphql') {
const gqlCtx = GqlExecutionContext.create(context);
user = gqlCtx.getContext().req?.user;
} else if (type === 'ws') {
const client = context.switchToWs().getClient();
user = client.data?.user;
} else {
user = context.switchToHttp().getRequest().user;
}
return data ? user?.[data] : user;
},
);
메타데이터 데코레이터: SetMetadata와 Reflector
Guard, Interceptor와 연동하는 패턴의 핵심은 메타데이터입니다. SetMetadata로 메타데이터를 설정하고, Reflector로 읽어옵니다.
import { SetMetadata, applyDecorators } from '@nestjs/common';
// 기본 패턴
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// Guard에서 읽기
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 핸들러 + 클래스 레벨 메타데이터 병합
const requiredRoles = this.reflector.getAllAndMerge<Role[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles.length) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
복합 메타데이터 데코레이터
// 캐시 설정 데코레이터
export const CACHE_OPTIONS_KEY = 'cache_options';
export interface CacheOptions {
ttl?: number; // 초 단위
key?: string; // 캐시 키 패턴
unless?: string; // 캐시 제외 조건 SpEL
evict?: string[]; // 이 메서드 호출 시 무효화할 키
}
export const Cacheable = (options: CacheOptions = {}) =>
SetMetadata(CACHE_OPTIONS_KEY, { ttl: 300, ...options });
export const CacheEvict = (...keys: string[]) =>
SetMetadata(CACHE_OPTIONS_KEY, { evict: keys });
// Interceptor에서 메타데이터 활용
@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
private cacheManager: Cache,
) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const options = this.reflector.get<CacheOptions>(
CACHE_OPTIONS_KEY,
context.getHandler(),
);
if (!options) return next.handle();
// evict 모드
if (options.evict?.length) {
return next.handle().pipe(
tap(async () => {
for (const key of options.evict) {
await this.cacheManager.del(key);
}
}),
);
}
// 캐시 읽기/쓰기
const request = context.switchToHttp().getRequest();
const cacheKey = options.key || `${request.url}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) return of(cached);
return next.handle().pipe(
tap((data) => this.cacheManager.set(cacheKey, data, options.ttl * 1000)),
);
}
}
// 사용
@Get(':id')
@Cacheable({ ttl: 600, key: 'product:{id}' })
findOne(@Param('id') id: string) { ... }
@Put(':id')
@CacheEvict('product:{id}', 'products:list')
update(@Param('id') id: string) { ... }
데코레이터 합성: applyDecorators
여러 데코레이터를 하나로 합성하면 코드 중복을 획기적으로 줄일 수 있습니다. NestJS Interceptor 실전 패턴에서 다뤘던 인터셉터와 결합하면 더욱 강력합니다.
import { applyDecorators, UseGuards, UseInterceptors, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse } from '@nestjs/swagger';
// 인증 + 역할 + Swagger 문서를 하나로
export function Auth(...roles: Role[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: '인증 필요' }),
ApiForbiddenResponse({ description: '권한 부족' }),
);
}
// 사용: 한 줄로 인증+인가+문서화
@Post()
@Auth(Role.ADMIN)
create(@Body() dto: CreateProductDto) { ... }
// API 엔드포인트 합성
export function ApiPaginatedEndpoint(summary: string, type: Type) {
return applyDecorators(
Get(),
Auth(),
ApiOperation({ summary }),
ApiPaginatedResponse(type),
UseInterceptors(PaginationInterceptor),
);
}
@ApiPaginatedEndpoint('상품 목록 조회', ProductDto)
findAll(@Pagination() pagination: PaginationDto) { ... }
조건부 데코레이터
// 환경에 따라 다른 데코레이터 적용
export function ConditionalAuth() {
if (process.env.NODE_ENV === 'test') {
// 테스트 환경: 인증 스킵
return applyDecorators(SetMetadata('isPublic', true));
}
return applyDecorators(
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
);
}
// 기능 플래그 기반
export function FeatureFlag(flag: string) {
return applyDecorators(
SetMetadata('feature_flag', flag),
UseGuards(FeatureFlagGuard),
);
}
@Post('v2/checkout')
@FeatureFlag('new-checkout')
checkoutV2(@Body() dto: CheckoutDto) { ... }
클래스 데코레이터와 동적 모듈 패턴
// 감사 로그 자동 적용 클래스 데코레이터
export function Auditable(resourceType: string) {
return applyDecorators(
SetMetadata('audit_resource', resourceType),
UseInterceptors(AuditLogInterceptor),
Controller(), // 컨트롤러 데코레이터도 합성 가능
);
}
@Auditable('order')
@Controller('orders')
export class OrderController {
// 모든 핸들러에 감사 로그 자동 적용
}
// 커스텀 Injectable 데코레이터
export function EventHandler(eventName: string) {
return (target: Function) => {
Injectable()(target);
Reflect.defineMetadata('event_handler', eventName, target);
};
}
@EventHandler('order.created')
export class OrderCreatedHandler {
handle(payload: OrderCreatedEvent) { ... }
}
실전 팁: 데코레이터 테스트
NestJS Vitest 테스트 전환 글에서 다뤘듯이, 커스텀 데코레이터도 단위 테스트가 가능합니다.
// 파라미터 데코레이터 테스트
describe('CurrentUser', () => {
it('should extract user from request', () => {
const mockUser = { id: 1, email: 'test@test.com' };
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({ user: mockUser }),
}),
getType: () => 'http',
} as unknown as ExecutionContext;
// createParamDecorator의 팩토리 함수 직접 호출
const factory = CurrentUser[Symbol.for('CUSTOM_PARAM_DECORATOR')];
// 또는 ROUTE_ARGS_METADATA에서 추출
const result = currentUserFactory(undefined, mockContext);
expect(result).toEqual(mockUser);
});
it('should return specific field when data provided', () => {
const mockContext = createMockContext({ user: { email: 'a@b.com' } });
const result = currentUserFactory('email', mockContext);
expect(result).toBe('a@b.com');
});
});
// 메타데이터 데코레이터 테스트
describe('Roles', () => {
it('should set roles metadata', () => {
@Roles(Role.ADMIN, Role.USER)
class TestClass {}
const roles = Reflect.getMetadata(ROLES_KEY, TestClass);
expect(roles).toEqual([Role.ADMIN, Role.USER]);
});
});
주의사항과 안티패턴
| 패턴 | 문제 | 해결 |
|---|---|---|
| 데코레이터에서 DI 사용 | 데코레이터는 클래스 정의 시점에 실행되어 DI 불가 | Guard/Interceptor에서 DI 후 메타데이터로 연동 |
| 비동기 파라미터 데코레이터 | createParamDecorator는 async 미지원 | Pipe에서 비동기 변환 처리 |
| 과도한 합성 | 5개 이상 합성 시 디버깅 어려움 | 2~3개 수준으로 유지, 문서화 필수 |
| 메타데이터 키 충돌 | 문자열 키가 다른 라이브러리와 겹침 | Symbol 또는 네임스페이스 접두사 사용 |
마무리
NestJS 커스텀 데코레이터는 파라미터 추출, 메타데이터 기반 인가, 데코레이터 합성, 클래스 레벨 자동화까지 확장하면 프레임워크 수준의 선언적 코드를 구현할 수 있습니다. 핵심은 데코레이터 자체는 가볍게 유지하고, 실제 로직은 Guard·Interceptor·Pipe에 위임하는 것입니다.