NestJS Custom Decorator 심화

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에 위임하는 것입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux