NestJS Reflect Metadata 심화

NestJS의 핵심 엔진: Reflect Metadata

NestJS의 의존성 주입, 데코레이터, 가드, 인터셉터가 모두 Reflect.metadata 위에서 동작합니다. TypeScript의 emitDecoratorMetadata 옵션이 컴파일 시 타입 정보를 메타데이터로 저장하고, NestJS는 이를 런타임에 읽어 DI 컨테이너를 구성합니다. 이 메커니즘을 이해하면 커스텀 데코레이터와 동적 모듈을 훨씬 깊이 있게 설계할 수 있습니다.

Reflect.metadata API 기본

reflect-metadata 패키지는 클래스·메서드·파라미터에 임의의 키-값을 부착하고 조회하는 API를 제공합니다.

import 'reflect-metadata';

// 메타데이터 설정
Reflect.defineMetadata('role', 'admin', MyClass);
Reflect.defineMetadata('role', 'user', MyClass.prototype, 'getProfile');

// 메타데이터 조회
Reflect.getMetadata('role', MyClass);                        // 'admin'
Reflect.getMetadata('role', MyClass.prototype, 'getProfile'); // 'user'

// TypeScript가 자동 생성하는 메타데이터 키
// 'design:type'       → 프로퍼티/파라미터 타입
// 'design:paramtypes' → 생성자/메서드 파라미터 타입 배열
// 'design:returntype' → 메서드 반환 타입

TypeScript의 emitDecoratorMetadata: true가 켜져 있으면, 데코레이터가 붙은 클래스의 생성자 파라미터 타입이 design:paramtypes로 자동 저장됩니다. NestJS DI는 바로 이 정보를 읽어 의존성을 해결합니다.

NestJS DI가 메타데이터를 읽는 과정

// 1. @Injectable() 데코레이터가 클래스에 메타데이터를 부착
@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly cacheService: CacheService,
  ) {}
}

// 2. TypeScript 컴파일러가 생성하는 코드 (개념적)
Reflect.defineMetadata(
  'design:paramtypes',
  [UserRepository, CacheService],
  UserService
);

// 3. NestJS IoC 컨테이너가 읽는 방식
const params = Reflect.getMetadata('design:paramtypes', UserService);
// → [UserRepository, CacheService]
// 각 타입에 해당하는 프로바이더를 찾아 인스턴스를 주입

이것이 NestJS에서 @Inject() 없이도 타입만으로 DI가 되는 이유입니다. 하지만 인터페이스는 런타임에 사라지므로, 인터페이스 기반 주입 시에는 @Inject('TOKEN')이 필요합니다.

SetMetadata와 커스텀 데코레이터

NestJS의 SetMetadataReflect.defineMetadata의 래퍼입니다.

// NestJS 내부 구현 (단순화)
export const SetMetadata = (key: string, value: any) =>
  (target: object, propertyKey?: string, descriptor?: any) => {
    if (descriptor) {
      Reflect.defineMetadata(key, value, descriptor.value);
    } else {
      Reflect.defineMetadata(key, value, target);
    }
  };

// 커스텀 데코레이터
export const Roles = (...roles: string[]) =>
  SetMetadata('roles', roles);

// 사용
@Controller('users')
export class UserController {
  @Get('admin')
  @Roles('admin', 'superadmin')
  getAdminPanel() {
    return { panel: 'admin' };
  }
}

Guard에서 메타데이터를 읽어 권한을 검사합니다.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // Reflector = Reflect.getMetadata의 NestJS 래퍼
    const roles = this.reflector.get<string[]>(
      'roles',
      context.getHandler()  // 메서드 레벨 메타데이터
    );
    if (!roles) return true;

    const request = context.switchToHttp().getRequest();
    return roles.includes(request.user?.role);
  }
}

Reflector 심화: getAllAndOverride·getAllAndMerge

클래스 레벨과 메서드 레벨 메타데이터가 모두 있을 때, Reflector의 고급 메서드로 병합 전략을 선택합니다.

@Roles('user')  // 클래스 레벨
@Controller('orders')
export class OrderController {
  @Roles('admin')  // 메서드 레벨
  @Delete(':id')
  remove(@Param('id') id: string) {}

  @Get()
  findAll() {}  // 메서드 레벨 없음
}

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // getAllAndOverride: 메서드 우선, 없으면 클래스 폴백
    const roles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),  // 메서드 먼저
      context.getClass(),    // 클래스 폴백
    ]);
    // DELETE /orders/:id → ['admin'] (메서드 우선)
    // GET /orders        → ['user']  (클래스 폴백)

    // getAllAndMerge: 양쪽 모두 병합
    const merged = this.reflector.getAllAndMerge<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    // DELETE /orders/:id → ['admin', 'user'] (병합)

    return roles?.some(role => request.user?.roles?.includes(role));
  }
}

DiscoveryService: 메타데이터 기반 자동 탐색

@nestjs/coreDiscoveryService를 사용하면 특정 메타데이터가 부착된 프로바이더를 자동으로 찾을 수 있습니다.

// 커스텀 데코레이터: 이벤트 핸들러 등록
export const ON_EVENT_KEY = 'ON_EVENT';
export const OnEvent = (eventName: string) =>
  SetMetadata(ON_EVENT_KEY, eventName);

// 이벤트 핸들러
@Injectable()
export class OrderHandler {
  @OnEvent('order.created')
  handleOrderCreated(payload: any) {
    console.log('주문 생성:', payload);
  }

  @OnEvent('order.cancelled')
  handleOrderCancelled(payload: any) {
    console.log('주문 취소:', payload);
  }
}

// 이벤트 디스패처: 메타데이터 기반 자동 탐색
@Injectable()
export class EventDispatcher implements OnModuleInit {
  private handlers = new Map<string, Function[]>();

  constructor(
    private discoveryService: DiscoveryService,
    private reflector: Reflector,
  ) {}

  async onModuleInit() {
    // 모든 프로바이더에서 @OnEvent 메타데이터 탐색
    const providers = this.discoveryService.getProviders();

    for (const wrapper of providers) {
      const { instance } = wrapper;
      if (!instance) continue;

      const prototype = Object.getPrototypeOf(instance);
      const methods = Object.getOwnPropertyNames(prototype)
        .filter(m => m !== 'constructor');

      for (const method of methods) {
        const eventName = this.reflector.get<string>(
          ON_EVENT_KEY,
          prototype[method],
        );
        if (eventName) {
          const handlers = this.handlers.get(eventName) || [];
          handlers.push(instance[method].bind(instance));
          this.handlers.set(eventName, handlers);
        }
      }
    }
  }

  emit(eventName: string, payload: any) {
    const handlers = this.handlers.get(eventName) || [];
    handlers.forEach(handler => handler(payload));
  }
}

이 패턴은 NestJS의 @EventEmitter2, @nestjs/schedule, @nestjs/bull 등 많은 공식 라이브러리가 내부적으로 사용하는 방식입니다.

파라미터 데코레이터와 메타데이터

커스텀 파라미터 데코레이터도 메타데이터 기반으로 동작합니다.

// @CurrentUser 파라미터 데코레이터 구현
export const CurrentUser = createParamDecorator(
  (data: keyof User | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);

// 사용
@Get('me')
getProfile(
  @CurrentUser() user: User,           // 전체 유저 객체
  @CurrentUser('email') email: string,  // 특정 필드만
) {
  return { user, email };
}

// 내부적으로 NestJS가 하는 일:
// 1. 파라미터 인덱스 + 팩토리 함수를 메타데이터로 저장
// 2. 요청 시 ExecutionContext를 팩토리에 전달
// 3. 반환값을 해당 파라미터에 주입

주의사항과 디버깅

  • tsconfig 필수 옵션: experimentalDecorators: trueemitDecoratorMetadata: true 둘 다 활성화해야 DI가 동작
  • 순환 참조: design:paramtypesundefined가 들어가면 순환 의존성 문제 → forwardRef()로 해결
  • 인터페이스 주입 불가: 인터페이스는 컴파일 후 사라져 design:paramtypesObject로 기록됨 → InjectionToken 사용
  • SWC 컴파일러: SWC는 emitDecoratorMetadata를 지원하지만 동작이 다를 수 있음 → @nestjs/cli 5.x+ 권장
// 디버깅: 클래스의 메타데이터 전체 확인
const keys = Reflect.getMetadataKeys(UserService);
console.log('메타데이터 키:', keys);
// ['design:paramtypes', '__injectable__', ...]

keys.forEach(key => {
  console.log(key, '→', Reflect.getMetadata(key, UserService));
});

관련 글

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