Decorator와 Metadata — fluo가 reflect-metadata를 버린 이유

작성일:2026.05.07|조회수:7

Decorator와 Metadata — fluo가 reflect-metadata를 버린 이유

들어가며

NestJS를 써본 적이 있다면 이런 설정이 익숙할 것이다.

JSON
// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

그리고 엔트리 파일 어딘가에는 이 한 줄이 반드시 들어간다.

TS
import 'reflect-metadata';

이 두 가지가 없으면 NestJS의 DI는 동작하지 않는다. TypeScript 컴파일러가 생성자 파라미터의 타입 정보를 런타임에 심어주고(emitDecoratorMetadata), reflect-metadata가 그 정보를 읽어서 주입할 클래스를 결정하기 때문이다.

fluo에는 이 두 가지가 없다. tsconfig에도, 엔트리 파일에도. 그런데도 DI가 동작한다. 이 글에서는 내가 왜 이런 결정을 내렸고, 이 결정을 위해 어떤 설계가 뒤따랐는지 이야기해보려 한다.

emitDecoratorMetadata가 만드는 마법, 그리고 그 대가

emitDecoratorMetadata: true를 켜면 TypeScript는 생성자 파라미터 타입을 design:paramtypes 키로 클래스에 붙여준다. NestJS는 이 정보를 꺼내서 어떤 클래스를 주입해야 하는지 자동으로 알아낸다.

TS
// NestJS 방식
@Injectable()
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
  // TypeScript가 UsersRepository 타입을 컴파일 시점에 메타데이터로 기록한다
}

// 런타임에서 읽는 방법
Reflect.getMetadata('design:paramtypes', UsersService);
// → [UsersRepository]

이러한 방식은 편리하지만, 무시할 수 없는 대가가 뒤따른다. experimentalDecoratorsemitDecoratorMetadata는 ECMAScript 표준 decorator와 행동이 다른 레거시 플래그다. TypeScript 팀도 이 방식을 표준 경로로 계속 지원하겠다고 보장하지 않는다. 무엇보다, 타입이 인터페이스거나 제네릭이거나 추상 클래스면 런타임에 정보가 사라지기 때문에 결국 수동으로 토큰을 지정해야 하는 경우가 생긴다.

나는 fluo를 만들면서 이러한 암묵적 타입 추론 대신, 처음부터 명시적 토큰 선언을 선택했다. 런타임에 남을지 불확실한 타입 정보에 기대기보다, 주입 가능한 대상과 주입받는 대상을 모두 명확한 토큰으로 연결하는 방식이 더 예측 가능하다고 판단했기 때문이다.

그렇다면 fluo는 어떻게 의존성을 알아내는가

ECMAScript에서 Decorator 표준(Stage 3)이 확정되면서 decorator의 동작 방식이 달라졌다. 표준 decorator에서 context 객체는 클래스 이름, kind, 접근자 등의 정보를 제공하지만, 생성자 파라미터의 타입 정보는 포함하지 않는다. 타입은 TypeScript의 개념이지 JavaScript 런타임의 개념이 아니기 때문이다.

TS
// 표준 decorator 시그니처
// fluo의 모든 class decorator는 이 시그니처를 따른다
type StandardClassDecoratorFn = (value: Function, context: ClassDecoratorContext) => void;

fluo는 TC39 표준 decorator만 사용한다. tsconfig.jsonexperimentalDecorators가 없어도 된다. 이게 없어도 되는 이유는 간단하다. 개발자가 직접 어떤 의존성이 필요한지 알려주기 때문이다.

TS
import { Inject } from '@fluojs/core';
import { UsersRepository } from './users.repository';

@Inject(UsersRepository)
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
}

@Inject(UsersRepository)가 하는 일은 딱 하나다. UsersService라는 클래스에 "생성자 첫 번째 파라미터에 UsersRepository를 주입해달라"는 메타데이터를 기록하는 것. 암묵적 타입 추론이 없는 대신, 의존성 목록이 코드에 명확하게 드러난다.

WeakMap 기반 메타데이터 저장소

fluo는 WeakMap을 사용하여 메타데이터를 저장한다. reflect-metadata처럼 전역 레지스트리에 쌓는 방식이 아니다.

TS
// packages/core/src/metadata/class-di.ts
const classDiMetadataStore = new WeakMap<Function, ClassDiMetadata>();

키가 Function(클래스 자체)이기 때문에 클래스가 GC로 수거되면 메타데이터도 함께 사라진다. 전역 오염이 없고, 모듈 시스템과 독립적으로 동작한다.

모듈 메타데이터도 마찬가지 구조다.

TS
// packages/core/src/metadata/module.ts
const moduleMetadataStore = new WeakMap<Function, ModuleMetadata>();

decorator는 어떻게 합쳐지는가 — merge 전략

한 클래스에 @Inject()@Scope()를 같이 쓰면 어떻게 될까? 두 decorator가 각각 defineClassDiMetadata()를 호출하므로, 나중에 호출된 decorator가 앞의 것을 덮어쓰는 문제가 생길 수 있다. fluo는 이를 merge로 해결한다.

TS
// packages/core/src/metadata/class-di.ts
export function defineClassDiMetadata(target: Function, metadata: ClassDiMetadata): void {
  const existing = classDiMetadataStore.get(target);

  classDiMetadataStore.set(target, freezeClassDiMetadata({
    inject: metadata.inject !== undefined ? metadata.inject : existing?.inject,
    scope: metadata.scope ?? existing?.scope,
  }));
  classDiMetadataVersion += 1;
}

새로 쓰는 필드만 갱신하고, 나머지는 기존 값을 보존한다. 덕분에 decorator 적용 순서에 관계없이 결과가 올바르다.

TS
@Scope('singleton')
@Inject(UsersRepository, LoggerService)
export class UsersService { ... }
// inject: [UsersRepository, LoggerService], scope: 'singleton' — 순서 무관하게 정상 동작

저장 결과는 Object.freeze()로 불변 처리된다. 이를 통해 메타데이터가 외부에서 변경되는 것을 원천 차단한다.

상속과 버전 기반 캐시

클래스 상속이 있을 때 메타데이터는 어떻게 동작할까? 자식 클래스가 @Inject()를 선언하지 않았다면 부모의 inject 목록을 물려받는 것이 자연스럽다. getInheritedClassDiMetadata()가 이 역할을 한다.

TS
export function getInheritedClassDiMetadata(target: Function): ClassDiMetadata | undefined {
  const cached = inheritedClassDiMetadataCache.get(target);

  if (cached?.version === classDiMetadataVersion) {
    return cached.metadata ?? undefined;
  }

  let effective: ClassDiMetadata | undefined;

  for (const constructor of getClassMetadataLineage(target)) {
    const metadata = classDiMetadataStore.get(constructor);

    if (!metadata) {
      continue;
    }

    effective = freezeClassDiMetadata({
      inject: metadata.inject ?? effective?.inject,
      scope: metadata.scope ?? effective?.scope,
    });
  }

  inheritedClassDiMetadataCache.set(target, {
    metadata: effective ?? null,
    version: classDiMetadataVersion,
  });

  return effective;
}

프로토타입 체인을 루트부터 잎(leaf) 방향으로 순회하면서 자식이 선언한 값이 부모 값을 덮어쓰는 방식으로 merge한다. 그리고 결과를 캐시해둔다.

주목할 부분이 classDiMetadataVersion이다. defineClassDiMetadata()가 호출될 때마다 1씩 증가하는 전역 카운터다. 캐시된 결과의 version과 현재 version이 다르면 캐시를 버리고 다시 계산한다. 어떤 클래스에 메타데이터 변경이 생겨도 관련 캐시가 자동으로 무효화되는 구조다.

한편 자식 클래스가 @Inject()를 인자 없이 호출하는 경우도 있다.

TS
@Inject()
export class ChildService extends BaseService {}

이것은 실수가 아니다. 빈 inject 목록을 명시적으로 기록해서 상속된 토큰 목록을 의도적으로 지우는 escape hatch다. 부모의 의존성 목록을 따르지 않겠다는 선언이다.

Symbol.metadata — 표준과 폴백의 이중 구조

TC39 decorator 표준은 Symbol.metadata를 통해 클래스 레벨 메타데이터 bag을 공유한다. fluo는 이 표준을 우선 사용하되, 런타임이 아직 지원하지 않는 경우를 위해 폴백을 준비해뒀다.

TS
// packages/core/src/metadata/shared.ts
const fallbackMetadataSymbol = Symbol.for('fluo.symbol.metadata');
export let metadataSymbol = symbolWithMetadata.metadata ?? fallbackMetadataSymbol;

Symbol.metadata가 있으면 그걸 쓰고, 없으면 Symbol.for('fluo.symbol.metadata')를 쓴다. 그리고 이 심볼을 일관되게 사용하기 위한 헬퍼도 제공한다.

TS
import { ensureMetadataSymbol } from '@fluojs/core';

// 테스트나 부트스트랩 경계에서 호출
ensureMetadataSymbol();

이 구조 덕분에 Node.js, Bun, Deno, Cloudflare Workers처럼 런타임마다 Symbol.metadata 지원 시점이 다른 환경에서도 fluo는 일관되게 동작한다.

정리 — 명시성이 가져다주는 것

fluo가 emitDecoratorMetadatareflect-metadata를 버린 이유는 단순하다. 암묵적 타입 추론에 기대는 것보다, 개발자가 직접 선언한 명시적 정보가 더 신뢰할 수 있기 때문이다.

그 결과 fluo에서의 의존성 목록은 항상 코드에 보인다. 인터페이스든 심볼 토큰이든 추상 클래스든, 모두 동일하게 @Inject(TOKEN) 한 줄로 표현된다. 컴파일러 마법에 의존하지 않기 때문에 어떤 런타임에서도 동일하게 동작한다.

다음 편에서는 이 @Inject()에 전달하는 토큰이 실제로 어떤 타입이고, forwardRefoptional 같은 특수 토큰이 왜 필요한지, 그리고 provider가 어떤 다섯 가지 형태로 선언되는지를 이야기해보려 한다.

더 읽어보기

추천 포스트가 아직 없어요.

댓글

댓글을 불러오는 중...