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의 SetMetadata는 Reflect.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/core의 DiscoveryService를 사용하면 특정 메타데이터가 부착된 프로바이더를 자동으로 찾을 수 있습니다.
// 커스텀 데코레이터: 이벤트 핸들러 등록
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: true와emitDecoratorMetadata: true둘 다 활성화해야 DI가 동작 - 순환 참조:
design:paramtypes에undefined가 들어가면 순환 의존성 문제 →forwardRef()로 해결 - 인터페이스 주입 불가: 인터페이스는 컴파일 후 사라져
design:paramtypes에Object로 기록됨 → InjectionToken 사용 - SWC 컴파일러: SWC는
emitDecoratorMetadata를 지원하지만 동작이 다를 수 있음 →@nestjs/cli5.x+ 권장
// 디버깅: 클래스의 메타데이터 전체 확인
const keys = Reflect.getMetadataKeys(UserService);
console.log('메타데이터 키:', keys);
// ['design:paramtypes', '__injectable__', ...]
keys.forEach(key => {
console.log(key, '→', Reflect.getMetadata(key, UserService));
});
관련 글
- NestJS Custom Decorator 심화 — 데코레이터 합성과 실전 패턴
- NestJS ExecutionContext 심화 — Guard·Interceptor에서 컨텍스트 활용법