NestJS Custom Decorator 심화

NestJS 커스텀 데코레이터란?

NestJS에서 커스텀 데코레이터는 반복 로직을 선언적으로 추상화하는 강력한 도구입니다. TypeScript 데코레이터와 NestJS의 메타데이터 시스템을 결합하면, @CurrentUser(), @Roles('admin'), @Public() 같은 직관적인 API를 만들 수 있습니다. Guard, Interceptor, Pipe와 결합하면 횡단 관심사를 깔끔하게 분리할 수 있습니다.

파라미터 데코레이터: createParamDecorator

가장 많이 사용하는 패턴으로, 요청 객체에서 특정 데이터를 추출합니다.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

// 현재 인증된 사용자 추출
export const CurrentUser = createParamDecorator(
  (data: keyof User | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    // @CurrentUser() → 전체 user 객체
    // @CurrentUser('email') → user.email만
    return data ? user?.[data] : user;
  },
);

// 클라이언트 IP 추출
export const ClientIp = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest();
    return (
      request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
      request.ip
    );
  },
);

// 사용
@Get('profile')
getProfile(
  @CurrentUser() user: User,           // 전체 객체
  @CurrentUser('email') email: string, // 특정 필드
  @ClientIp() ip: string,             // 클라이언트 IP
) {
  return { user, email, ip };
}

createParamDecorator의 첫 번째 인자 data는 데코레이터에 전달한 값, 두 번째 ctx는 ExecutionContext입니다. HTTP뿐 아니라 WebSocket, GraphQL 컨텍스트도 처리할 수 있습니다.

메타데이터 데코레이터: SetMetadata

Guard나 Interceptor에서 읽을 수 있는 메타데이터를 설정합니다.

import { SetMetadata } from '@nestjs/common';

// 역할 기반 접근 제어
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

// 공개 엔드포인트 표시
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// 캐시 TTL 설정
export const CACHE_TTL_KEY = 'cacheTtl';
export const CacheTTL = (seconds: number) => SetMetadata(CACHE_TTL_KEY, seconds);

// 사용
@Controller('users')
export class UserController {
  @Public()                    // 인증 불필요
  @Get('health')
  health() { return 'ok'; }

  @Roles('admin', 'manager')  // admin 또는 manager만
  @CacheTTL(300)               // 5분 캐시
  @Get('stats')
  getStats() { ... }
}

메타데이터는 Reflector를 통해 Guard/Interceptor에서 읽습니다:

@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));
  }
}

데코레이터 합성: applyDecorators

여러 데코레이터를 하나로 합성하면 반복을 줄일 수 있습니다.

import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';

// 인증 필요 엔드포인트 데코레이터
export function Auth(...roles: string[]) {
  return applyDecorators(
    Roles(...roles),
    UseGuards(JwtAuthGuard, RolesGuard),
    ApiBearerAuth(),
    ApiUnauthorizedResponse({ description: '인증 필요' }),
  );
}

// API 응답 캐싱 데코레이터
export function Cached(ttlSeconds: number = 60) {
  return applyDecorators(
    CacheTTL(ttlSeconds),
    UseInterceptors(CacheInterceptor),
  );
}

// Paginated 응답 데코레이터
export function Paginated(dto: Type) {
  return applyDecorators(
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(PaginatedResponseDto) },
          { properties: { data: { type: 'array', items: { $ref: getSchemaPath(dto) } } } },
        ],
      },
    }),
  );
}

// 사용 — 데코레이터 1개로 5개 역할
@Controller('admin')
export class AdminController {
  @Auth('admin')          // Guard + Swagger + Role 한 번에
  @Cached(300)            // 캐시 + Interceptor 한 번에
  @Paginated(UserDto)     // Swagger 페이지네이션 응답
  @Get('users')
  findAll(@Query() query: PaginateQuery) { ... }
}

applyDecorators는 데코레이터 배열을 하나의 데코레이터로 합성합니다. 팀 전체에서 일관된 패턴을 강제할 수 있어, 코드 리뷰 부담을 크게 줄여줍니다.

Pipe 연동 파라미터 데코레이터

커스텀 파라미터 데코레이터에 Pipe를 연동하면 추출과 검증을 동시에 처리합니다.

// UUID 파라미터 + 검증
export const UUIDParam = (paramName: string = 'id') =>
  Param(paramName, new ParseUUIDPipe({ version: '4' }));

// 페이지네이션 쿼리 추출 + 변환
export const Pagination = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const page = Math.max(1, parseInt(request.query.page) || 1);
    const limit = Math.min(100, Math.max(1, parseInt(request.query.limit) || 20));
    const sort = request.query.sort || 'createdAt';
    const order = request.query.order?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';

    return { page, limit, sort, order, skip: (page - 1) * limit };
  },
);

// 파일 업로드 + 검증
export const UploadImage = () =>
  applyDecorators(
    UseInterceptors(FileInterceptor('file')),
    UploadedFile(
      new ParseFilePipeBuilder()
        .addFileTypeValidator({ fileType: /(jpg|jpeg|png|webp)$/ })
        .addMaxSizeValidator({ maxSize: 5 * 1024 * 1024 })
        .build(),
    ),
  );

// 사용
@Get(':id/orders')
findOrders(
  @UUIDParam() id: string,
  @Pagination() pagination: PaginationDto,
) { ... }

@Post('avatar')
uploadAvatar(@UploadImage() file: Express.Multer.File) { ... }

GraphQL·WebSocket 호환 데코레이터

HTTP 외에 다른 전송 계층도 지원하는 범용 데코레이터 패턴입니다.

export const CurrentUser = createParamDecorator(
  (data: string | undefined, ctx: ExecutionContext) => {
    const type = ctx.getType<string>();
    let user: User;

    if (type === 'http') {
      user = ctx.switchToHttp().getRequest().user;
    } else if (type === 'ws') {
      user = ctx.switchToWs().getClient().user;
    } else if (type === 'graphql') {
      // GqlExecutionContext로 변환
      const gqlCtx = GqlExecutionContext.create(ctx);
      user = gqlCtx.getContext().req.user;
    }

    return data ? user?.[data] : user;
  },
);

// HTTP, WebSocket, GraphQL 모두에서 동일하게 동작
@Query(() => UserType)
me(@CurrentUser() user: User) { return user; }

@SubscribeMessage('chat')
handleChat(@CurrentUser() user: User, @MessageBody() msg: string) { ... }

ctx.getType()으로 현재 실행 컨텍스트를 판별하면 하나의 데코레이터로 모든 전송 계층을 지원할 수 있습니다. NestJS ExecutionContext의 핵심 활용 패턴입니다.

클래스 데코레이터 패턴

컨트롤러 전체에 공통 설정을 적용하는 클래스 데코레이터입니다.

// API 버전 + 접두사 + Swagger 태그
export function ApiController(prefix: string, version: string = '1') {
  return applyDecorators(
    Controller(`api/v${version}/${prefix}`),
    ApiTags(prefix),
    UseGuards(ThrottlerGuard),
    UseInterceptors(LoggingInterceptor),
  );
}

// Crud 컨트롤러 기본 설정
export function CrudController(entity: string) {
  return applyDecorators(
    ApiController(entity),
    Auth('admin'),
    UseInterceptors(ClassSerializerInterceptor),
  );
}

// 사용
@CrudController('users')
export class UserController {
  // 자동으로: /api/v1/users, Swagger 태그, 인증, 직렬화 적용
  @Get()
  findAll() { ... }
}

이 패턴은 마이크로서비스에서 수십 개의 CRUD 컨트롤러를 생성할 때 보일러플레이트를 극적으로 줄여줍니다. NestJS Middleware와 함께 레이어별 관심사를 분리하면 유지보수성이 크게 향상됩니다.

테스트 전략

describe('CurrentUser decorator', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      controllers: [TestController],
    }).compile();
    app = module.createNestApplication();
    await app.init();
  });

  it('should extract user from request', () => {
    return request(app.getHttpServer())
      .get('/test/me')
      .set('Authorization', 'Bearer valid-token')
      .expect(200)
      .expect((res) => {
        expect(res.body.email).toBe('test@example.com');
      });
  });

  it('should extract specific field', () => {
    return request(app.getHttpServer())
      .get('/test/email')  // @CurrentUser('email')
      .set('Authorization', 'Bearer valid-token')
      .expect(200)
      .expect((res) => {
        expect(res.body).toBe('test@example.com');
      });
  });
});

정리

NestJS 커스텀 데코레이터는 3가지 핵심 패턴으로 나뉩니다: createParamDecorator로 파라미터 추출, SetMetadata로 Guard/Interceptor 메타데이터 설정, applyDecorators로 여러 데코레이터 합성. 이 3가지를 조합하면 @Auth('admin'), @Cached(300), @CrudController('users') 같은 선언적 API를 만들어 코드 중복을 제거하고 팀 전체의 일관성을 유지할 수 있습니다.

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