Node
Nest.js
Nest AOP

NestJS createParamDecorator 내부 동작 원리

결론

소스 코드에서 Reflect.defineMetadata(ROUTE_ARGS_METADATA, assignCustomParameterMetadata(args, paramtype, index, factory, paramData, ...(paramPipes as PipeTransform[])), target.constructor, key!) 라고 구현되어 있음.

NestJS 공식 문서에서 "Nest runs globally bound middleware (such as middleware bound with app.use) and then it runs module bound middleware... Guards are executed after all middleware but before any interceptor or pipe" 라고 명시되어 있음.


1. createParamDecorator 내부 구현

1.1 컴파일 타임 동작

createParamDecorator는 factory 함수를 받아 ParameterDecorator를 반환합니다.

// 실제 NestJS 소스 코드 구조
export function createParamDecorator<T = any>(
  factory: CustomParamFactory<T>,
): (
  ...dataOrPipes: (Type<PipeTransform> | PipeTransform | T)[]
) => ParameterDecorator {
  return (...dataOrPipes) => (target, key, index) => {
    // 1. 고유 paramtype 생성
    const paramtype = uid(21);
 
    // 2. 기존 메타데이터 조회
    const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) || {};
 
    // 3. 새 메타데이터 병합 및 저장
    Reflect.defineMetadata(
      ROUTE_ARGS_METADATA,
      assignCustomParameterMetadata(args, paramtype, index, factory, paramData, ...pipes),
      target.constructor,
      key
    );
  };
}

NestJS GitHub - create-route-param-metadata.decorator.ts (opens in a new tab)

소스 코드에서 uid(21)로 고유 식별자를 생성하고, Reflect.getMetadataReflect.defineMetadata를 사용하여 메타데이터를 관리함.

1.2 메타데이터 구조

ROUTE_ARGS_METADATA에 저장되는 구조는 다음과 같습니다:

{
  [paramtype:index]: {
    index: number,      // 파라미터 인덱스
    factory: Function,  // 값을 추출하는 factory 함수
    data: any,          // 데코레이터에 전달된 데이터
    pipes: PipeTransform[]  // 적용할 파이프들
  }
}

NestJS GitHub - create-param-decorator.spec.ts (opens in a new tab)

테스트 코드에서 const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); return args[Object.keys(args)[0]].factory; 방식으로 factory를 조회할 수 있음을 보여줌.

1.3 런타임 동작

RouteParamsFactory가 요청 처리 시 메타데이터를 조회하여 실행합니다:

  1. ROUTE_ARGS_METADATA에서 해당 메서드의 파라미터 정보 조회
  2. 각 파라미터에 대해 factory 함수 실행 (ExecutionContext, data 전달)
  3. factory 반환값에 pipes 적용
  4. 최종값을 컨트롤러 메서드 파라미터로 주입

Stack Overflow - Nestjs Retrieve the request context from a Decorator (opens in a new tab)

"The @Req decorator only creates a special parameter decorator during startup which is processed by RouteParamsFactory before calling a method" 라고 설명되어 있음.


2. AOP(Aspect-Oriented Programming)와 Weaving

2.1 AOP 개념과 NestJS 매핑

NestJS는 AOP 패러다임을 기반으로 설계되어 cross-cutting concerns를 분리합니다.

AOP 개념NestJS 구현설명
AspectGuard, Interceptor, Filter횡단 관심사를 캡슐화한 모듈
Advice각 컴포넌트의 실행 로직특정 시점에 실행되는 코드
Joinpoint요청 처리 파이프라인의 각 단계Advice가 적용될 수 있는 지점
Pointcut라우트 핸들러 호출 (handle())실제로 Advice가 적용되는 지점
Weaving런타임 메타데이터 기반 조합Aspect를 코드에 적용하는 과정

NestJS 공식 문서 - Interceptors (opens in a new tab)

"Using Aspect Oriented Programming terminology, the invocation of the route handler (i.e., calling handle()) is called a Pointcut, indicating that it's the point at which our additional logic is inserted" 라고 명시되어 있음.

2.2 Advice 유형별 NestJS 구현

// Before Advice - Guard
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    // 핸들러 실행 전에 호출
    return true;
  }
}
 
// Around Advice - Interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');  // pre-processing
 
    return next.handle().pipe(
      tap(() => console.log('After...'))  // post-processing
    );
  }
}
 
// After Throwing Advice - Exception Filter
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    // 예외 발생 시 호출
  }
}

NestJS 공식 문서 - Interceptors (opens in a new tab)

"This approach means that the intercept() method effectively wraps the request/response stream. As a result, you may implement custom logic both before and after the execution of the final route handler" 라고 명시되어 있음.

2.3 Weaving 메커니즘

NestJS는 전통적인 Runtime Weaving(프록시 생성)이 아닌 메타데이터 기반 런타임 해석(Metadata-driven Runtime Resolution) 방식을 사용합니다:

  1. 데코레이터 적용 시점: reflect-metadata를 사용해 메타데이터 저장 (한 번만 저장, 이후 변경 없음)
  2. 애플리케이션 부트스트랩: Reflector가 메타데이터 조회 및 의존성 그래프 구성
  3. 요청 처리 시점: 저장된 메타데이터를 읽어서 Guard, Interceptor, Pipe 실행 여부 및 순서 결정

Spring AOP는 프록시 객체를 동적으로 생성하지만, NestJS는 메타데이터를 읽어서 실행 흐름을 제어합니다.

// 메타데이터 저장 (데코레이터 - 클래스 정의 시점에 1회)
Reflect.defineMetadata('roles', ['admin'], target);
 
// 메타데이터 조회 (런타임 - 요청마다 읽기만 함)
const roles = this.reflector.get<string[]>('roles', context.getHandler());

DEV Community - Deep Dive into NestJS Decorators (opens in a new tab)

"Reflect.defineMetadata stores metadata on the target... which NestJS retrieves via the Reflector class during runtime to build dependency graphs and configure routing" 라고 설명되어 있음.


3. 실행 순서 (Request Lifecycle)

3.1 전체 실행 순서

요청 → Middleware → Guards → Interceptors(pre) → Pipes → Handler → Interceptors(post) → Exception Filters → 응답

NestJS 공식 문서 - Request Lifecycle (opens in a new tab)

"First, Nest runs globally bound middleware... Guards are executed after all middleware but before any interceptor or pipe... Pipes follow the standard sequence from global to controller to route" 라고 명시되어 있음.

3.2 상세 실행 순서

1) Middleware
  • Global middleware (app.use())
  • Module-bound middleware (경로 기반)
2) Guards
  • Global guards → Controller guards → Route guards
  • canActivate() 반환값이 false면 즉시 종료
3) Interceptors (Pre-controller)
  • Global → Controller → Route 순
  • intercept() 메서드의 next.handle() 호출 전 로직
4) Pipes (Parameter Decorator Factory 포함)
  • Global → Controller → Route → Parameter 순
  • 중요: 파라미터는 역순으로 처리 (마지막 파라미터 → 첫 번째 파라미터)
// 파라미터 처리 순서 예시
async handler(
  @Param('id') id: string,      // 3번째로 처리
  @Body() body: CreateDto,       // 2번째로 처리
  @User() user: UserEntity       // 1번째로 처리 (마지막 파라미터)
) {}

NestJS 공식 문서 - Request Lifecycle (opens in a new tab)

"Pipes follow the standard sequence from global to controller to route... However, at the route parameter level, if there are multiple pipes in operation, they will execute in the order from the last parameter with a pipe to the first" 라고 명시되어 있음.

5) Route Handler (Controller)
6) Interceptors (Post-controller)
  • Route → Controller → Global 순 (역순)
  • next.handle()이 반환하는 Observable 처리

NestJS 공식 문서 - Request Lifecycle (opens in a new tab)

"Interceptors, for the most part, follow the same pattern as guards, with one catch: as interceptors return RxJS Observables, the observables will be resolved in a first in last out manner" 라고 명시되어 있음.

7) Exception Filters
  • Route → Controller → Global 순 (역순)
  • 가장 가까운 레벨에서 먼저 처리

3.3 Parameter Decorator와 Pipe의 상호작용

// 커스텀 파라미터 데코레이터
export const User = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;  // factory 반환값
  },
);
 
// 사용 예시 - Pipe와 함께
@Get()
async findOne(@User('id', ParseIntPipe) userId: number) {
  // 1. Guard 통과
  // 2. Interceptor pre-processing
  // 3. User decorator factory 실행 → user.id 반환
  // 4. ParseIntPipe가 반환값을 number로 변환
  // 5. 변환된 값이 userId에 주입
  return this.userService.findOne(userId);
}

NestJS 공식 문서 - Custom Decorators (opens in a new tab)

"Nest treats custom param decorators in the same fashion as the built-in ones (@Body(), @Param() and @Query()). This means that pipes are executed for the custom annotated parameters as well" 라고 명시되어 있음.


4. 실제 사용 예시

4.1 기본 사용법

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
 
export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);
 
// Controller에서 사용
@Controller('users')
export class UsersController {
  @Get('profile')
  getProfile(@CurrentUser() user: User) {
    return user;
  }
}

4.2 데이터 전달

export const CurrentUser = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);
 
// 특정 속성만 추출
@Get('profile')
getProfile(@CurrentUser('email') email: string) {
  return { email };
}

4.3 Pipe와 함께 사용

@Get('profile')
getProfile(
  @CurrentUser('id', ParseIntPipe) userId: number,
  @CurrentUser(new ValidationPipe({ validateCustomDecorators: true })) user: UserDto
) {
  return this.userService.findOne(userId);
}

NestJS 공식 문서 - Custom Decorators (opens in a new tab)

"Working with pipes... you can apply pipes directly to the custom decorator" 라고 설명되어 있음.


5. 주의사항

5.1 ValidationPipe 사용 시

커스텀 데코레이터에 ValidationPipe를 적용하려면 validateCustomDecorators: true 옵션이 필요합니다.

@Get()
async findOne(
  @User(new ValidationPipe({ validateCustomDecorators: true })) user: UserDto
) {}

5.2 서비스 주입 불가

Parameter decorator 내에서는 서비스를 직접 주입할 수 없습니다. 대신 Guard나 Interceptor를 사용해야 합니다.

Stack Overflow - Is it possible to get service instance inside a param decorator? (opens in a new tab)

"It is not possible to inject a service into your custom decorator. Instead, you can create an AuthGuard that has access to your service" 라고 설명되어 있음.

5.3 테스트 방법

// 테스트에서 factory 함수 추출
const getParamDecoratorFactory = (decorator: Function) => {
  class Test {
    public test(@decorator() value) {}
  }
  const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test');
  return args[Object.keys(args)[0]].factory;
};
 
// 사용
const factory = getParamDecoratorFactory(CurrentUser);
const result = factory(null, mockExecutionContext);

참고 자료