NestJS Custom Decorators

NestJS Custom Decorators란? 선언적 코드의 핵심

NestJS에서 @Controller, @Injectable, @Body, @UseGuards 같은 데코레이터는 프레임워크의 근간입니다. 하지만 진짜 강력한 것은 커스텀 데코레이터를 만들어 비즈니스 로직을 선언적으로 표현하는 것입니다. @CurrentUser()로 인증된 사용자를 주입하고, @Roles('admin')으로 권한을 제어하고, @Public()으로 인증을 건너뛰는 — 이 모든 것이 커스텀 데코레이터입니다.

이 글에서는 NestJS의 4가지 데코레이터 유형(파라미터, 메서드, 클래스, 조합)의 구현 방법부터, createParamDecoratorSetMetadata의 내부 동작, 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 + ReflectorGuardInterceptor에 조건을 전달하며, applyDecorators로 여러 데코레이터를 조합하면, 비즈니스 로직이 깔끔하게 정리되고 코드의 의도가 명확해집니다.

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