reflect-metadata
TypeScript에서 클래스를 정의할 때, 런타임에는 타입 정보가 전부 사라진다. 컴파일 과정에서 인터페이스도, 제네릭도, 파라미터 타입도 모두 지워진다. 그런데 NestJS의 DI 컨테이너는 생성자 파라미터의 타입을 보고 알아서 의존성을 주입해준다. 타입이 없는 런타임에서 어떻게 이게 가능한 걸까?
답은 reflect-metadata다. TypeScript 컴파일러가 데코레이터가 붙은 클래스의 타입 정보를 메타데이터로 변환해서 런타임에 남겨두고, reflect-metadata가 이 메타데이터를 저장하고 조회하는 API를 제공한다.
메타데이터라는 개념
메타데이터는 "데이터에 대한 데이터"다. 자바의 어노테이션(@Annotation)이나 C#의 어트리뷰트([Attribute])와 같은 개념이다. 클래스나 메서드에 추가 정보를 붙여놓고, 프레임워크가 런타임에 이 정보를 읽어서 동작을 결정한다.
JavaScript에는 이런 메커니즘이 원래 없었다. reflect-metadata는 이 빈자리를 채우는 폴리필이다. ECMAScript의 Reflect 객체에 defineMetadata, getMetadata 같은 메서드를 추가해서, 객체나 프로퍼티에 임의의 키-값 쌍을 부착할 수 있게 해준다.
설치와 설정
npm install reflect-metadata
앱 진입점(main.ts 등)에서 한 번만 import하면 전역으로 활성화된다.
import 'reflect-metadata';
그리고 tsconfig.json에 두 가지 옵션이 필요하다.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
experimentalDecorators는 데코레이터 문법을 활성화하고, emitDecoratorMetadata가 핵심이다. 이 옵션을 켜면 TypeScript 컴파일러가 데코레이터가 붙은 대상의 타입 정보를 자동으로 메타데이터로 방출한다.
핵심 API
reflect-metadata가 Reflect 객체에 추가하는 주요 메서드는 네 가지다.
Reflect.defineMetadata
메타데이터를 정의(저장)한다.
// 클래스(또는 생성자 함수)에 메타데이터 부착
Reflect.defineMetadata('role', 'admin', User);
// 특정 프로퍼티에 메타데이터 부착
Reflect.defineMetadata('format', 'email', User.prototype, 'email');
첫 번째 인자가 메타데이터 키, 두 번째가 값, 세 번째가 대상 객체, 네 번째(선택)가 프로퍼티 키다.
Reflect.getMetadata
메타데이터를 조회한다. 프로토타입 체인을 따라 상위까지 탐색한다.
const role = Reflect.getMetadata('role', User);
// 'admin'
const format = Reflect.getMetadata('format', User.prototype, 'email');
// 'email'
Reflect.getOwnMetadata
getMetadata와 달리 프로토타입 체인을 탐색하지 않고, 해당 객체에 직접 정의된 메타데이터만 반환한다.
class Base {}
Reflect.defineMetadata('key', 'base-value', Base);
class Child extends Base {}
Reflect.getMetadata('key', Child); // 'base-value' (상속됨)
Reflect.getOwnMetadata('key', Child); // undefined (직접 정의 안 함)
이 차이는 프레임워크에서 상속 관계의 클래스를 다룰 때 중요하다. 부모 클래스의 메타데이터를 자식이 의도치 않게 물려받는 걸 방지할 수 있다.
Reflect.hasMetadata / Reflect.hasOwnMetadata
메타데이터 존재 여부를 boolean으로 반환한다.
Reflect.hasMetadata('role', User); // true
Reflect.hasOwnMetadata('role', User); // true
Reflect.getMetadataKeys
대상에 정의된 모든 메타데이터 키 목록을 배열로 반환한다.
Reflect.getMetadataKeys(User);
// ['role', 'design:paramtypes', ...]
emitDecoratorMetadata가 하는 일
emitDecoratorMetadata: true를 설정하면 TypeScript 컴파일러가 데코레이터가 붙은 대상에 세 가지 메타데이터를 자동으로 방출한다.
| 메타데이터 키 | 설명 | 적용 대상 |
|---|---|---|
design:type | 프로퍼티의 타입 | 프로퍼티 데코레이터 |
design:paramtypes | 메서드/생성자 파라미터 타입 배열 | 메서드/클래스 데코레이터 |
design:returntype | 메서드 반환 타입 | 메서드 데코레이터 |
실제로 어떤 코드가 생성되는지 보자.
// TypeScript 소스
class UserService {
constructor(private repo: UserRepository, private logger: Logger) {}
}
@Injectable() 같은 데코레이터가 붙으면, 컴파일된 JavaScript는 대략 이렇게 된다:
// 컴파일된 JavaScript (개념적)
UserService = __decorate([
Injectable(),
__metadata("design:paramtypes", [UserRepository, Logger])
], UserService);
__metadata 헬퍼가 Reflect.metadata를 호출해서 design:paramtypes 키에 [UserRepository, Logger] 배열을 저장한다. 이제 런타임에 이 정보를 꺼낼 수 있다.
const paramTypes = Reflect.getMetadata('design:paramtypes', UserService);
// [UserRepository, Logger]
DI 컨테이너는 이 배열을 순회하면서 각 타입에 해당하는 인스턴스를 찾아 생성자에 주입한다. 이게 NestJS의 마법 같은 DI가 동작하는 원리다.
타입 매핑의 한계
컴파일러가 방출할 수 있는 타입은 런타임에 존재하는 값뿐이다. 인터페이스는 런타임에 사라지기 때문에 Object로 방출된다.
interface ILogger {
log(message: string): void;
}
class Service {
constructor(private logger: ILogger) {}
}
Reflect.getMetadata('design:paramtypes', Service);
// [Object] — ILogger가 아니라 Object
이것이 NestJS에서 인터페이스 대신 추상 클래스를 쓰거나, @Inject('TOKEN') 같은 커스텀 토큰을 사용하는 이유다. 인터페이스는 메타데이터로 전달할 수 없기 때문이다.
타입별 매핑 결과를 정리하면:
| TypeScript 타입 | 방출되는 런타임 값 |
|---|---|
string | String |
number | Number |
boolean | Boolean |
| 클래스 | 해당 클래스 생성자 |
| 인터페이스 | Object |
any / unknown | Object |
| 유니온 타입 | Object |
배열 (string[]) | Array |
void | undefined |
Promise<T> | Promise |
제네릭 타입 인자(<T>)도 지워진다. Promise<User>는 그냥 Promise로만 남는다.
커스텀 데코레이터에서 활용
reflect-metadata의 진짜 가치는 프레임워크를 만들거나, 선언적으로 동작을 정의할 때 드러난다. 직접 커스텀 데코레이터를 만들어 메타데이터를 활용하는 과정을 살펴보자.
예시 1: 라우팅 데코레이터
Express 스타일의 라우팅을 데코레이터로 선언하는 패턴이다.
const ROUTES_KEY = Symbol('routes');
interface RouteDefinition {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
handlerName: string;
}
function Get(path: string): MethodDecorator {
return (target, propertyKey) => {
const routes: RouteDefinition[] =
Reflect.getOwnMetadata(ROUTES_KEY, target.constructor) || [];
routes.push({
method: 'get',
path,
handlerName: String(propertyKey),
});
Reflect.defineMetadata(ROUTES_KEY, routes, target.constructor);
};
}
function Post(path: string): MethodDecorator {
return (target, propertyKey) => {
const routes: RouteDefinition[] =
Reflect.getOwnMetadata(ROUTES_KEY, target.constructor) || [];
routes.push({
method: 'post',
path,
handlerName: String(propertyKey),
});
Reflect.defineMetadata(ROUTES_KEY, routes, target.constructor);
};
}
컨트롤러 클래스에서 이렇게 사용한다.
class UserController {
@Get('/users')
getAll() { /* ... */ }
@Get('/users/:id')
getOne() { /* ... */ }
@Post('/users')
create() { /* ... */ }
}
프레임워크 초기화 시점에 메타데이터를 읽어서 라우트를 등록한다.
function registerRoutes(app: Express, controller: any) {
const routes: RouteDefinition[] =
Reflect.getOwnMetadata(ROUTES_KEY, controller.constructor) || [];
for (const route of routes) {
app[route.method](route.path, (req, res) => {
controller[route.handlerName](req, res);
});
}
}
데코레이터가 메타데이터를 저장하고, 프레임워크가 나중에 읽어서 동작을 구성한다. 이 패턴이 NestJS의 @Get(), @Post(), @Controller() 등이 동작하는 기본 원리다.
예시 2: 유효성 검증 데코레이터
프로퍼티에 검증 규칙을 메타데이터로 부착하고, 런타임에 검증하는 패턴이다. class-validator가 정확히 이 방식으로 동작한다.
const VALIDATIONS_KEY = Symbol('validations');
interface ValidationRule {
property: string;
validator: (value: any) => boolean;
message: string;
}
function IsString(message = '문자열이어야 합니다'): PropertyDecorator {
return (target, propertyKey) => {
const rules: ValidationRule[] =
Reflect.getOwnMetadata(VALIDATIONS_KEY, target.constructor) || [];
rules.push({
property: String(propertyKey),
validator: (value) => typeof value === 'string',
message,
});
Reflect.defineMetadata(VALIDATIONS_KEY, rules, target.constructor);
};
}
function MinLength(min: number, message?: string): PropertyDecorator {
return (target, propertyKey) => {
const rules: ValidationRule[] =
Reflect.getOwnMetadata(VALIDATIONS_KEY, target.constructor) || [];
rules.push({
property: String(propertyKey),
validator: (value) =>
typeof value === 'string' && value.length >= min,
message: message || `최소 ${min}자 이상이어야 합니다`,
});
Reflect.defineMetadata(VALIDATIONS_KEY, rules, target.constructor);
};
}
DTO 클래스에 적용하면:
class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsString()
@MinLength(8, '비밀번호는 최소 8자 이상이어야 합니다')
password: string;
}
검증 함수는 메타데이터를 읽어서 모든 규칙을 실행한다.
function validate(instance: object): string[] {
const rules: ValidationRule[] =
Reflect.getOwnMetadata(VALIDATIONS_KEY, instance.constructor) || [];
const errors: string[] = [];
for (const rule of rules) {
const value = (instance as any)[rule.property];
if (!rule.validator(value)) {
errors.push(`${rule.property}: ${rule.message}`);
}
}
return errors;
}
// 사용
const dto = new CreateUserDto();
dto.name = 'J';
dto.password = '123';
const errors = validate(dto);
// ['name: 최소 2자 이상이어야 합니다', 'password: 비밀번호는 최소 8자 이상이어야 합니다']
예시 3: 간단한 DI 컨테이너
NestJS DI의 핵심 원리를 축소한 버전이다. design:paramtypes를 읽어서 의존성을 자동 주입한다.
const INJECTABLE_KEY = Symbol('injectable');
function Injectable(): ClassDecorator {
return (target) => {
Reflect.defineMetadata(INJECTABLE_KEY, true, target);
};
}
class Container {
private instances = new Map<Function, any>();
resolve<T>(target: new (...args: any[]) => T): T {
// 이미 생성된 인스턴스가 있으면 반환 (싱글톤)
if (this.instances.has(target)) {
return this.instances.get(target);
}
// 생성자 파라미터 타입을 메타데이터에서 읽기
const paramTypes: Function[] =
Reflect.getMetadata('design:paramtypes', target) || [];
// 각 파라미터 타입을 재귀적으로 resolve
const dependencies = paramTypes.map((type) => this.resolve(type));
// 인스턴스 생성 및 캐싱
const instance = new target(...dependencies);
this.instances.set(target, instance);
return instance;
}
}
사용하면 이렇게 된다.
@Injectable()
class DatabaseConnection {
query(sql: string) {
return `결과: ${sql}`;
}
}
@Injectable()
class UserRepository {
constructor(private db: DatabaseConnection) {}
findAll() {
return this.db.query('SELECT * FROM users');
}
}
@Injectable()
class UserService {
constructor(private repo: UserRepository) {}
getUsers() {
return this.repo.findAll();
}
}
const container = new Container();
const userService = container.resolve(UserService);
userService.getUsers();
// '결과: SELECT * FROM users'
container.resolve(UserService)를 호출하면:
UserService의design:paramtypes를 읽어서[UserRepository]를 얻는다UserRepository를 resolve하려면DatabaseConnection이 필요하다DatabaseConnection은 의존성이 없으므로 바로 생성한다- 역순으로 인스턴스를 조립해서
UserService를 완성한다
이 재귀적 의존성 해결이 DI 컨테이너의 핵심이다. NestJS는 여기에 스코프(싱글톤/요청/트랜지언트), 모듈 시스템, 비동기 팩토리 등을 추가한 것이다.
메타데이터 저장 구조
reflect-metadata가 내부적으로 메타데이터를 어디에 저장하는지 이해하면 동작을 더 명확히 파악할 수 있다.
내부적으로 WeakMap을 사용한다.
WeakMap<Object, Map<string | symbol | undefined, Map<metadataKey, metadataValue>>>
구조를 풀어보면:
- 최외곽
WeakMap의 키는 대상 객체(클래스 생성자 또는 프로토타입) - 그 안에 프로퍼티 키별로
Map이 있다 (클래스 자체의 메타데이터는 키가undefined) - 가장 안쪽
Map에 실제 메타데이터 키-값 쌍이 저장된다
WeakMap을 사용하기 때문에 대상 객체가 가비지 컬렉션되면 메타데이터도 함께 정리된다. 메모리 누수 걱정이 없다.
// 개념적으로 이런 구조
{
UserService: { // target (WeakMap 키)
undefined: { // 프로퍼티 키 없음 = 클래스 레벨
'design:paramtypes': [UserRepository, Logger],
'injectable': true,
},
'findAll': { // 프로퍼티 키 = 메서드 레벨
'design:returntype': Promise,
'design:paramtypes': [String],
}
}
}
Symbol 키 사용 권장
메타데이터 키로 문자열을 쓰면 다른 라이브러리와 충돌할 수 있다. Symbol을 사용하면 유일성이 보장된다.
// ❌ 문자열 키 - 충돌 가능
Reflect.defineMetadata('validate', rules, target);
// ✅ Symbol 키 - 충돌 불가
const VALIDATE_KEY = Symbol('validate');
Reflect.defineMetadata(VALIDATE_KEY, rules, target);
NestJS 소스 코드를 보면 내부적으로 문자열 상수를 사용하지만, 접두사를 붙여서 충돌을 방지한다 (__guards__, __interceptors__ 등). 직접 만드는 라이브러리에서는 Symbol이 더 안전하다.
TC39 데코레이터와의 관계
현재 reflect-metadata는 Stage 2에서 멈춘 TC39 제안의 폴리필이다. 한편 데코레이터 자체는 Stage 3까지 올라갔고 TypeScript 5.0부터 experimentalDecorators 없이 사용할 수 있다.
하지만 새로운 TC39 데코레이터(Stage 3)에는 emitDecoratorMetadata에 해당하는 기능이 없다. 메타데이터 자동 방출이 표준에 포함되지 않은 것이다.
// 레거시 데코레이터 (experimentalDecorators: true)
// → emitDecoratorMetadata 사용 가능
// → reflect-metadata 동작
// TC39 Stage 3 데코레이터
// → emitDecoratorMetadata 미지원
// → 메타데이터는 데코레이터 내부에서 직접 관리해야 함
NestJS, TypeORM, class-validator 등 주요 프레임워크들이 아직 레거시 데코레이터 + reflect-metadata 조합에 의존하고 있어서, 당분간 이 패턴은 계속 사용된다. 하지만 장기적으로는 TC39 데코레이터의 metadata 필드나 별도 메커니즘으로 전환될 가능성이 높다.
주의사항
import 순서
import 'reflect-metadata'는 반드시 앱 진입점 최상단에서 호출해야 한다. 다른 모듈이 먼저 로드되면 Reflect.defineMetadata가 정의되지 않아 에러가 발생한다.
// main.ts
import 'reflect-metadata'; // 반드시 첫 줄
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
상속 시 메타데이터 병합
getMetadata는 프로토타입 체인을 따라가기 때문에, 자식 클래스에서 부모의 메타데이터가 의도치 않게 읽힐 수 있다. 자식 클래스 고유의 메타데이터만 필요하면 getOwnMetadata를 사용해야 한다.
// 부모에 라우트 정의
@Controller('/base')
class BaseController {
@Get('/health')
health() {}
}
// 자식이 부모의 라우트를 의도치 않게 상속
class ChildController extends BaseController {
@Get('/status')
status() {}
}
// getMetadata → 부모 + 자식 라우트 모두 반환
// getOwnMetadata → 자식 라우트만 반환
순환 의존성
DI 컨테이너에서 A → B → A 형태의 순환 참조가 발생하면 무한 루프에 빠진다. NestJS는 forwardRef()로 이를 해결하지만, 근본적으로는 설계를 수정해서 순환을 끊는 게 좋다.
정리
Reflect.defineMetadata/getMetadata로 클래스·메서드·프로퍼티에 임의 키-값을 부착하고 런타임에 조회한다emitDecoratorMetadata가 데코레이터 대상의design:paramtypes등을 자동 방출하며, 이것이 NestJS DI의 핵심 원리다- 인터페이스·제네릭·유니온은 런타임에
Object로 떨어지므로, 토큰 기반 주입(@Inject)이나 추상 클래스로 우회해야 한다
관련 문서
- class-transformer - plainToInstance, 런타임 타입 변환
- class-validator - 데코레이터 기반 런타임 검증
- TypeScript 타입 가드 - 런타임 타입 좁히기