Decorator와 Metadata — fluo가 reflect-metadata를 버린 이유
작성일:2026.05.07|수정일:2026.05.07|조회수:15

NestJS 프로젝트를 처음 만들면 이상하게 빠지지 않는 주문이 있다. experimentalDecorators, emitDecoratorMetadata, 그리고 엔트리 파일 어딘가의 reflect-metadata import다. 처음에는 “프레임워크가 하라는 대로 하면 되겠지” 하고 넘어가지만, DI 컨테이너를 직접 만들기 시작하면 이 주문이 꽤 큰 설계 선택이었다는 사실이 보인다. 마법은 편하지만, 마법을 디버깅하는 사람의 표정은 대체로 편하지 않다.
NestJS를 써본 적이 있다면 이런 설정이 익숙할 것이다.
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}그리고 엔트리 파일 어딘가에는 이 한 줄이 반드시 들어간다.
import 'reflect-metadata';이 두 가지가 없으면 NestJS의 DI는 동작하지 않는다. TypeScript 컴파일러가 생성자 파라미터의 타입 정보를 런타임에 심어주고, reflect-metadata가 그 정보를 읽어 주입할 클래스를 결정하기 때문이다. fluo에는 이 두 가지가 없다. tsconfig에도 없고, 엔트리 파일에도 없다. 그런데도 DI는 동작한다. 이 글은 그 빈자리를 어떻게 메웠는지, 그리고 왜 처음부터 명시적 토큰 선언을 선택했는지에 대한 이야기다.
emitDecoratorMetadata가 만드는 마법, 그리고 그 대가
emitDecoratorMetadata: true를 켜면 TypeScript는 생성자 파라미터 타입을 design:paramtypes 키로 클래스에 붙여준다. NestJS는 이 정보를 꺼내서 어떤 클래스를 주입해야 하는지 자동으로 알아낸다.
// NestJS 방식
@Injectable()
export class UsersService {
constructor(private readonly repo: UsersRepository) {}
// TypeScript가 UsersRepository 타입을 컴파일 시점에 메타데이터로 기록한다
}
// 런타임에서 읽는 방법
Reflect.getMetadata('design:paramtypes', UsersService);
// → [UsersRepository]이러한 방식은 편리하지만, 무시할 수 없는 대가가 뒤따른다. experimentalDecorators와 emitDecoratorMetadata는 ECMAScript 표준 decorator와 행동이 다른 레거시 플래그다. TypeScript 팀도 이 방식을 표준 경로로 계속 지원하겠다고 보장하지 않는다. 무엇보다, 타입이 인터페이스거나 제네릭이거나 추상 클래스면 런타임에 정보가 사라지기 때문에 결국 수동으로 토큰을 지정해야 하는 경우가 생긴다.
나는 fluo를 만들면서 이러한 암묵적 타입 추론 대신, 처음부터 명시적 토큰 선언을 선택했다. 런타임에 남을지 불확실한 타입 정보에 기대기보다, 주입 가능한 대상과 주입받는 대상을 모두 명확한 토큰으로 연결하는 방식이 더 예측 가능하다고 판단했기 때문이다.
그렇다면 fluo는 어떻게 의존성을 알아내는가
ECMAScript에서 Decorator 표준(Stage 3)이 확정되면서 decorator의 동작 방식이 달라졌다. 표준 decorator에서 context 객체는 클래스 이름, kind, 접근자 등의 정보를 제공하지만, 생성자 파라미터의 타입 정보는 포함하지 않는다. 타입은 TypeScript의 개념이지 JavaScript 런타임의 개념이 아니기 때문이다.
// 표준 decorator 시그니처
// fluo의 모든 class decorator는 이 시그니처를 따른다
type StandardClassDecoratorFn = (value: Function, context: ClassDecoratorContext) => void;fluo는 TC39 표준 decorator만 사용한다. tsconfig.json에 experimentalDecorators가 없어도 된다. 이게 없어도 되는 이유는 간단하다. 개발자가 직접 어떤 의존성이 필요한지 알려주기 때문이다.
import { Inject } from '@fluojs/core';
import { UsersRepository } from './users.repository';
// fluo는 타입 메타데이터를 자동으로 읽지 않는다.
// 생성자에 들어갈 토큰을 @Inject(...)로 명시해서 런타임 의존성을 고정한다.
@Inject(UsersRepository)
export class UsersService {
constructor(private readonly repo: UsersRepository) {}
}@Inject(UsersRepository)가 하는 일은 딱 하나다. UsersService라는 클래스에 "생성자 첫 번째 파라미터에 UsersRepository를 주입해달라"는 메타데이터를 기록하는 것. 암묵적 타입 추론이 없는 대신, 의존성 목록이 코드에 명확하게 드러난다.
WeakMap 기반 메타데이터 저장소
fluo는 WeakMap을 사용하여 메타데이터를 저장한다. reflect-metadata처럼 전역 레지스트리에 쌓는 방식이 아니다.
// packages/core/src/metadata/class-di.ts
const classDiMetadataStore = new WeakMap<Function, ClassDiMetadata>();키가 Function(클래스 자체)이기 때문에 클래스가 GC로 수거되면 메타데이터도 함께 사라진다. 전역 오염이 없고, 모듈 시스템과 독립적으로 동작한다.
모듈 메타데이터도 마찬가지 구조다.
// packages/core/src/metadata/module.ts
const moduleMetadataStore = new WeakMap<Function, ModuleMetadata>();decorator는 어떻게 합쳐지는가 — merge 전략
한 클래스에 @Inject()와 @Scope()를 같이 쓰면 어떻게 될까? 두 decorator가 각각 defineClassDiMetadata()를 호출하므로, 나중에 호출된 decorator가 앞의 것을 덮어쓰는 문제가 생길 수 있다. fluo는 이를 merge로 해결한다.
// 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 적용 순서에 관계없이 결과가 올바르다.
@Scope('singleton')
@Inject(UsersRepository, LoggerService)
export class UsersService { ... }
// inject: [UsersRepository, LoggerService], scope: 'singleton' — 순서 무관하게 정상 동작저장 결과는 Object.freeze()로 불변 처리된다. 이를 통해 메타데이터가 외부에서 변경되는 것을 원천 차단한다.
상속과 버전 기반 캐시
클래스 상속이 있을 때 메타데이터는 어떻게 동작할까? 자식 클래스가 @Inject()를 선언하지 않았다면 부모의 inject 목록을 물려받는 것이 자연스럽다. getInheritedClassDiMetadata()가 이 역할을 한다.
export function getInheritedClassDiMetadata(target: Function): ClassDiMetadata | undefined {
const cached = inheritedClassDiMetadataCache.get(target);
// metadata store가 바뀌지 않았다면 상속 체인을 다시 걷지 않는다.
if (cached?.version === classDiMetadataVersion) {
return cached.metadata ?? undefined;
}
let effective: ClassDiMetadata | undefined;
// 부모 → 자식 순서로 읽어야 자식 decorator가 부모 설정을 덮어쓸 수 있다.
for (const constructor of getClassMetadataLineage(target)) {
const metadata = classDiMetadataStore.get(constructor);
if (!metadata) {
continue;
}
// undefined인 필드는 이전 effective metadata를 유지해 decorator 조합을 보존한다.
effective = freezeClassDiMetadata({
inject: metadata.inject ?? effective?.inject,
scope: metadata.scope ?? effective?.scope,
});
}
// null도 캐시에 저장해서 "메타데이터 없음" 조회를 반복하지 않는다.
inheritedClassDiMetadataCache.set(target, {
metadata: effective ?? null,
version: classDiMetadataVersion,
});
return effective;
}프로토타입 체인을 루트부터 잎(leaf) 방향으로 순회하면서 자식이 선언한 값이 부모 값을 덮어쓰는 방식으로 merge한다. 그리고 결과를 캐시해둔다.
주목할 부분이 classDiMetadataVersion이다. defineClassDiMetadata()가 호출될 때마다 1씩 증가하는 전역 카운터다. 캐시된 결과의 version과 현재 version이 다르면 캐시를 버리고 다시 계산한다. 어떤 클래스에 메타데이터 변경이 생겨도 관련 캐시가 자동으로 무효화되는 구조다.
한편 자식 클래스가 @Inject()를 인자 없이 호출하는 경우도 있다.
@Inject()
export class ChildService extends BaseService {}이것은 실수가 아니다. 빈 inject 목록을 명시적으로 기록해서 상속된 토큰 목록을 의도적으로 지우는 escape hatch다. 부모의 의존성 목록을 따르지 않겠다는 선언이다.
Symbol.metadata — 표준과 폴백의 이중 구조
TC39 decorator 표준은 Symbol.metadata를 통해 클래스 레벨 메타데이터 bag을 공유한다. fluo는 이 표준을 우선 사용하되, 런타임이 아직 지원하지 않는 경우를 위해 폴백을 준비해뒀다.
// 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')를 쓴다. 그리고 이 심볼을 일관되게 사용하기 위한 헬퍼도 제공한다.
import { ensureMetadataSymbol } from '@fluojs/core';
// 테스트나 부트스트랩 경계에서 호출
ensureMetadataSymbol();이 구조 덕분에 Node.js, Bun, Deno, Cloudflare Workers처럼 런타임마다 Symbol.metadata 지원 시점이 다른 환경에서도 fluo는 일관되게 동작한다.
정리 — 명시성이 가져다주는 것
fluo가 emitDecoratorMetadata와 reflect-metadata를 버린 이유는 단순하다. 런타임에 남을지 확실하지 않은 타입 추론보다, 개발자가 직접 선언한 토큰이 더 신뢰할 수 있기 때문이다. 자동 추론은 데모에서는 매끄럽지만, 인터페이스와 제네릭과 추상화가 섞이는 실제 코드에서는 어느 순간 “왜 이게 이 타입으로 들어오지?”라는 질문을 남긴다.
그 결과 fluo에서 의존성 목록은 항상 코드에 보인다. 인터페이스든 심볼 토큰이든 추상 클래스든, 모두 동일하게 @Inject(TOKEN) 한 줄로 표현된다. 컴파일러가 남겨준 흔적을 뒤지는 대신, 컨테이너가 읽어야 할 런타임 계약을 직접 남긴다. 조금 더 말해야 하지만, 나중에 덜 추리하게 된다.
다음 글에서는 이 @Inject()에 전달하는 토큰이 실제로 어떤 타입인지 살펴본다. forwardRef와 optional 같은 특수 토큰이 왜 필요한지, 그리고 provider가 어떤 형태로 컨테이너의 등록 단위가 되는지도 이어서 정리한다.
댓글
댓글을 불러오는 중...