junyeokk
Blog
TypeScript·2025. 11. 15

reflect-metadata

TypeScript에서 클래스를 정의할 때, 런타임에는 타입 정보가 전부 사라진다. 컴파일 과정에서 인터페이스도, 제네릭도, 파라미터 타입도 모두 지워진다. 그런데 NestJS의 DI 컨테이너는 생성자 파라미터의 타입을 보고 알아서 의존성을 주입해준다. 타입이 없는 런타임에서 어떻게 이게 가능한 걸까?

답은 reflect-metadata다. TypeScript 컴파일러가 데코레이터가 붙은 클래스의 타입 정보를 메타데이터로 변환해서 런타임에 남겨두고, reflect-metadata가 이 메타데이터를 저장하고 조회하는 API를 제공한다.


메타데이터라는 개념

메타데이터는 "데이터에 대한 데이터"다. 자바의 어노테이션(@Annotation)이나 C#의 어트리뷰트([Attribute])와 같은 개념이다. 클래스나 메서드에 추가 정보를 붙여놓고, 프레임워크가 런타임에 이 정보를 읽어서 동작을 결정한다.

JavaScript에는 이런 메커니즘이 원래 없었다. reflect-metadata는 이 빈자리를 채우는 폴리필이다. ECMAScript의 Reflect 객체에 defineMetadata, getMetadata 같은 메서드를 추가해서, 객체나 프로퍼티에 임의의 키-값 쌍을 부착할 수 있게 해준다.


설치와 설정

bash
npm install reflect-metadata

앱 진입점(main.ts 등)에서 한 번만 import하면 전역으로 활성화된다.

typescript
import 'reflect-metadata';

그리고 tsconfig.json에 두 가지 옵션이 필요하다.

json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

experimentalDecorators는 데코레이터 문법을 활성화하고, emitDecoratorMetadata가 핵심이다. 이 옵션을 켜면 TypeScript 컴파일러가 데코레이터가 붙은 대상의 타입 정보를 자동으로 메타데이터로 방출한다.


핵심 API

reflect-metadataReflect 객체에 추가하는 주요 메서드는 네 가지다.

Reflect.defineMetadata

메타데이터를 정의(저장)한다.

typescript
// 클래스(또는 생성자 함수)에 메타데이터 부착
Reflect.defineMetadata('role', 'admin', User);

// 특정 프로퍼티에 메타데이터 부착
Reflect.defineMetadata('format', 'email', User.prototype, 'email');

첫 번째 인자가 메타데이터 키, 두 번째가 값, 세 번째가 대상 객체, 네 번째(선택)가 프로퍼티 키다.

Reflect.getMetadata

메타데이터를 조회한다. 프로토타입 체인을 따라 상위까지 탐색한다.

typescript
const role = Reflect.getMetadata('role', User);
// 'admin'

const format = Reflect.getMetadata('format', User.prototype, 'email');
// 'email'

Reflect.getOwnMetadata

getMetadata와 달리 프로토타입 체인을 탐색하지 않고, 해당 객체에 직접 정의된 메타데이터만 반환한다.

typescript
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으로 반환한다.

typescript
Reflect.hasMetadata('role', User);     // true
Reflect.hasOwnMetadata('role', User);  // true

Reflect.getMetadataKeys

대상에 정의된 모든 메타데이터 키 목록을 배열로 반환한다.

typescript
Reflect.getMetadataKeys(User);
// ['role', 'design:paramtypes', ...]

emitDecoratorMetadata가 하는 일

emitDecoratorMetadata: true를 설정하면 TypeScript 컴파일러가 데코레이터가 붙은 대상에 세 가지 메타데이터를 자동으로 방출한다.

메타데이터 키설명적용 대상
design:type프로퍼티의 타입프로퍼티 데코레이터
design:paramtypes메서드/생성자 파라미터 타입 배열메서드/클래스 데코레이터
design:returntype메서드 반환 타입메서드 데코레이터

실제로 어떤 코드가 생성되는지 보자.

typescript
// TypeScript 소스
class UserService {
  constructor(private repo: UserRepository, private logger: Logger) {}
}

@Injectable() 같은 데코레이터가 붙으면, 컴파일된 JavaScript는 대략 이렇게 된다:

javascript
// 컴파일된 JavaScript (개념적)
UserService = __decorate([
  Injectable(),
  __metadata("design:paramtypes", [UserRepository, Logger])
], UserService);

__metadata 헬퍼가 Reflect.metadata를 호출해서 design:paramtypes 키에 [UserRepository, Logger] 배열을 저장한다. 이제 런타임에 이 정보를 꺼낼 수 있다.

typescript
const paramTypes = Reflect.getMetadata('design:paramtypes', UserService);
// [UserRepository, Logger]

DI 컨테이너는 이 배열을 순회하면서 각 타입에 해당하는 인스턴스를 찾아 생성자에 주입한다. 이게 NestJS의 마법 같은 DI가 동작하는 원리다.

타입 매핑의 한계

컴파일러가 방출할 수 있는 타입은 런타임에 존재하는 값뿐이다. 인터페이스는 런타임에 사라지기 때문에 Object로 방출된다.

typescript
interface ILogger {
  log(message: string): void;
}

class Service {
  constructor(private logger: ILogger) {}
}

Reflect.getMetadata('design:paramtypes', Service);
// [Object] — ILogger가 아니라 Object

이것이 NestJS에서 인터페이스 대신 추상 클래스를 쓰거나, @Inject('TOKEN') 같은 커스텀 토큰을 사용하는 이유다. 인터페이스는 메타데이터로 전달할 수 없기 때문이다.

타입별 매핑 결과를 정리하면:

TypeScript 타입방출되는 런타임 값
stringString
numberNumber
booleanBoolean
클래스해당 클래스 생성자
인터페이스Object
any / unknownObject
유니온 타입Object
배열 (string[])Array
voidundefined
Promise<T>Promise

제네릭 타입 인자(<T>)도 지워진다. Promise<User>는 그냥 Promise로만 남는다.


커스텀 데코레이터에서 활용

reflect-metadata의 진짜 가치는 프레임워크를 만들거나, 선언적으로 동작을 정의할 때 드러난다. 직접 커스텀 데코레이터를 만들어 메타데이터를 활용하는 과정을 살펴보자.

예시 1: 라우팅 데코레이터

Express 스타일의 라우팅을 데코레이터로 선언하는 패턴이다.

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

컨트롤러 클래스에서 이렇게 사용한다.

typescript
class UserController {
  @Get('/users')
  getAll() { /* ... */ }

  @Get('/users/:id')
  getOne() { /* ... */ }

  @Post('/users')
  create() { /* ... */ }
}

프레임워크 초기화 시점에 메타데이터를 읽어서 라우트를 등록한다.

typescript
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가 정확히 이 방식으로 동작한다.

typescript
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 클래스에 적용하면:

typescript
class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsString()
  @MinLength(8, '비밀번호는 최소 8자 이상이어야 합니다')
  password: string;
}

검증 함수는 메타데이터를 읽어서 모든 규칙을 실행한다.

typescript
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를 읽어서 의존성을 자동 주입한다.

typescript
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;
  }
}

사용하면 이렇게 된다.

typescript
@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)를 호출하면:

  1. UserServicedesign:paramtypes를 읽어서 [UserRepository]를 얻는다
  2. UserRepository를 resolve하려면 DatabaseConnection이 필요하다
  3. DatabaseConnection은 의존성이 없으므로 바로 생성한다
  4. 역순으로 인스턴스를 조립해서 UserService를 완성한다

이 재귀적 의존성 해결이 DI 컨테이너의 핵심이다. NestJS는 여기에 스코프(싱글톤/요청/트랜지언트), 모듈 시스템, 비동기 팩토리 등을 추가한 것이다.


메타데이터 저장 구조

reflect-metadata가 내부적으로 메타데이터를 어디에 저장하는지 이해하면 동작을 더 명확히 파악할 수 있다.

내부적으로 WeakMap을 사용한다.

text
WeakMap<Object, Map<string | symbol | undefined, Map<metadataKey, metadataValue>>>

구조를 풀어보면:

  • 최외곽 WeakMap의 키는 대상 객체(클래스 생성자 또는 프로토타입)
  • 그 안에 프로퍼티 키별로 Map이 있다 (클래스 자체의 메타데이터는 키가 undefined)
  • 가장 안쪽 Map에 실제 메타데이터 키-값 쌍이 저장된다

WeakMap을 사용하기 때문에 대상 객체가 가비지 컬렉션되면 메타데이터도 함께 정리된다. 메모리 누수 걱정이 없다.

typescript
// 개념적으로 이런 구조
{
  UserService: {                    // target (WeakMap 키)
    undefined: {                    // 프로퍼티 키 없음 = 클래스 레벨
      'design:paramtypes': [UserRepository, Logger],
      'injectable': true,
    },
    'findAll': {                    // 프로퍼티 키 = 메서드 레벨
      'design:returntype': Promise,
      'design:paramtypes': [String],
    }
  }
}

Symbol 키 사용 권장

메타데이터 키로 문자열을 쓰면 다른 라이브러리와 충돌할 수 있다. Symbol을 사용하면 유일성이 보장된다.

typescript
// ❌ 문자열 키 - 충돌 가능
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에 해당하는 기능이 없다. 메타데이터 자동 방출이 표준에 포함되지 않은 것이다.

typescript
// 레거시 데코레이터 (experimentalDecorators: true)
// → emitDecoratorMetadata 사용 가능
// → reflect-metadata 동작

// TC39 Stage 3 데코레이터
// → emitDecoratorMetadata 미지원
// → 메타데이터는 데코레이터 내부에서 직접 관리해야 함

NestJS, TypeORM, class-validator 등 주요 프레임워크들이 아직 레거시 데코레이터 + reflect-metadata 조합에 의존하고 있어서, 당분간 이 패턴은 계속 사용된다. 하지만 장기적으로는 TC39 데코레이터의 metadata 필드나 별도 메커니즘으로 전환될 가능성이 높다.


주의사항

import 순서

import 'reflect-metadata'는 반드시 앱 진입점 최상단에서 호출해야 한다. 다른 모듈이 먼저 로드되면 Reflect.defineMetadata가 정의되지 않아 에러가 발생한다.

typescript
// main.ts
import 'reflect-metadata'; // 반드시 첫 줄
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

상속 시 메타데이터 병합

getMetadata는 프로토타입 체인을 따라가기 때문에, 자식 클래스에서 부모의 메타데이터가 의도치 않게 읽힐 수 있다. 자식 클래스 고유의 메타데이터만 필요하면 getOwnMetadata를 사용해야 한다.

typescript
// 부모에 라우트 정의
@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)이나 추상 클래스로 우회해야 한다

관련 문서