@Injectable()의 실체 — Reflect.metadata와 데코레이터가 하는 일 (2편)
들어가며 — 데코레이터 하나로 DI가 된다?
1편에서 NestFactory.create()가 모듈을 스캔하고 인스턴스를 생성하는 과정을 봤습니다. 그 과정에서 핵심적인 코드가 하나 있었습니다.
// packages/core/injector/injector.ts
const paramTypes = Reflect.getMetadata('design:paramtypes', type);
Injector가 Provider의 생성자 파라미터 타입을 읽어서 의존성을 자동으로 주입하는 부분이었습니다. 그런데 의문이 남았습니다. 이 'design:paramtypes' 메타데이터는 누가, 언제 저장하는 걸까요? 그리고 @Injectable() 데코레이터는 정확히 어떤 역할을 하는 걸까요?
Node.js 시리즈에서 V8의 히든 클래스를 추적했던 것처럼, 이번에도 추상화 뒤의 실체를 직접 확인해보겠습니다.
1. TypeScript 데코레이터의 기초 — 데코레이터는 함수입니다
NestJS를 사용하면서 데코레이터를 매일 쓰지만, 데코레이터의 본질을 정확히 이해하고 있는 사람은 의외로 많지 않습니다. 데코레이터는 특별한 문법이 아닙니다. 그냥 함수입니다.
// 데코레이터는 이렇게 생긴 함수입니다
function MyDecorator(target: Function) {
console.log('데코레이터 실행:', target.name);
}
@MyDecorator
class MyService {}
// 출력: "데코레이터 실행: MyService"
@MyDecorator라고 쓰면, TypeScript 컴파일러가 이것을 MyDecorator(MyService) 함수 호출로 변환합니다. 클래스가 정의되는 시점에 함수가 실행되는 것입니다. 런타임에 인스턴스를 만들 때가 아니라, 클래스가 로드되는 시점에 실행됩니다.
1-1. 데코레이터의 종류
TypeScript에는 5종류의 데코레이터가 있고, 각각 받는 인자가 다릅니다.
종류 | 적용 대상 | 받는 인자 | NestJS 예시 |
|---|---|---|---|
클래스 데코레이터 | class |
|
|
메서드 데코레이터 | method |
|
|
프로퍼티 데코레이터 | property |
|
|
파라미터 데코레이터 | parameter |
|
|
접근자 데코레이터 | accessor |
| (NestJS에서 거의 안 씀) |
NestJS의 DI에서 가장 중요한 것은 클래스 데코레이터입니다. @Injectable(), @Controller(), @Module()이 모두 클래스 데코레이터이고, 이들이 하는 일은 본질적으로 같습니다 — 메타데이터를 저장하는 것입니다.
1-2. 데코레이터 팩토리 패턴
한 가지 주의할 점이 있습니다. @Injectable이 아니라 @Injectable()입니다. 괄호가 있습니다. 이것은 데코레이터 팩토리(Decorator Factory) 패턴입니다.
// 데코레이터 팩토리 — 함수를 반환하는 함수
function Injectable(options?: InjectableOptions): ClassDecorator {
// 이 함수가 먼저 실행되고 (팩토리)
return (target: object) => {
// 이 함수가 클래스에 적용됩니다 (실제 데코레이터)
};
}
// 사용: @Injectable() — 괄호가 팩토리를 호출
// 결과: 반환된 함수가 클래스 데코레이터로 적용됨
@Injectable()은 Injectable()을 먼저 호출하고, 그 반환값(함수)을 클래스에 적용하는 2단계 과정입니다. 이 패턴 덕분에 @Injectable({ scope: Scope.REQUEST })처럼 옵션을 전달할 수 있습니다.
2. @Injectable() 소스코드 — 놀라울 정도로 단순합니다
📎 packages/common/decorators/core/injectable.decorator.ts
이제 실제 NestJS 소스코드를 열어보겠습니다. @Injectable()의 전체 구현은 이것이 전부입니다.
// packages/common/decorators/core/injectable.decorator.ts
import { INJECTABLE_WATERMARK, SCOPE_OPTIONS_METADATA } from '../../constants';
export type InjectableOptions = ScopeOptions;
export function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: object) => {
Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
};
}
처음 이 코드를 봤을 때 당황했습니다. 이게 전부? DI의 핵심이라는 @Injectable()이 하는 일이 고작 Reflect.defineMetadata() 두 줄이었습니다.
하지만 곰곰이 생각해보면, 이것이 NestJS DI의 설계 철학을 보여줍니다. 데코레이터는 "실행"이 아니라 "등록"입니다. 실제 DI 로직은 1편에서 본 Injector가 담당하고, 데코레이터는 나중에 읽힐 메타데이터를 미리 붙여두는 역할만 합니다.
2-1. INJECTABLE_WATERMARK의 의미
📎 packages/common/constants.ts
// packages/common/constants.ts
export const INJECTABLE_WATERMARK = '__injectable__';
export const CONTROLLER_WATERMARK = '__controller__';
export const SCOPE_OPTIONS_METADATA = 'scope:options';
INJECTABLE_WATERMARK는 문자열 '__injectable__'입니다. 이 메타데이터가 true로 설정되어 있으면, NestJS는 "이 클래스는 DI 컨테이너에 등록 가능한 Provider입니다"라고 인식합니다.
"워터마크(watermark)"라는 이름이 재미있습니다. 지폐의 워터마크처럼 눈에 보이지 않지만 진짜인지 확인하는 표식입니다. NestJS는 이 워터마크가 있는 클래스만 Provider로 인정합니다.
// NestJS 내부에서 워터마크를 확인하는 방식
function isInjectable(metatype: Type<any>): boolean {
return !!Reflect.getMetadata(INJECTABLE_WATERMARK, metatype);
}
2-2. @Injectable() 실행 시점 — 언제 메타데이터가 저장되는가
이 부분이 중요합니다. @Injectable()은 클래스가 정의될 때 실행됩니다. NestFactory.create()보다 먼저 실행됩니다.
// 이 파일이 import되는 순간 @Injectable()이 실행됩니다
@Injectable()
export class CatService {
constructor(private readonly catRepository: CatRepository) {}
}
// 실행 순서:
// 1. TypeScript 컴파일러가 CatService 클래스 정의를 처리
// 2. @Injectable() 팩토리 호출 → 데코레이터 함수 반환
// 3. 데코레이터 함수(target => {...}) 실행
// → Reflect.defineMetadata(INJECTABLE_WATERMARK, true, CatService)
// → Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, undefined, CatService)
// 4. (나중에) NestFactory.create()가 이 메타데이터를 읽음
즉, 모듈을 import하는 것만으로도 데코레이터는 이미 실행되어 메타데이터가 저장된 상태입니다. 1편에서 본 DependenciesScanner가 하는 일은 이미 저장된 메타데이터를 읽어오는 것뿐입니다.
3. reflect-metadata의 동작 원리
@Injectable()의 핵심은 Reflect.defineMetadata()였습니다. 이 함수는 JavaScript 표준 API가 아닙니다. reflect-metadata라는 폴리필 라이브러리가 제공하는 함수입니다.
3-1. Reflect.defineMetadata()와 Reflect.getMetadata()
reflect-metadata는 본질적으로 WeakMap 기반의 키-값 저장소입니다.
// reflect-metadata의 개념적 구현
const metadataStore = new WeakMap<object, Map<string, any>>();
function defineMetadata(key: string, value: any, target: object) {
if (!metadataStore.has(target)) {
metadataStore.set(target, new Map());
}
metadataStore.get(target)!.set(key, value);
}
function getMetadata(key: string, target: object): any {
return metadataStore.get(target)?.get(key);
}
실제 구현은 더 복잡하지만(프로토타입 체인 탐색, 프로퍼티 키 지원 등), 핵심 원리는 이것입니다. 객체(클래스)를 키로 사용하는 WeakMap에 메타데이터를 저장하고 나중에 꺼내 읽는 것입니다.
WeakMap을 사용하는 이유는, 클래스가 가비지 컬렉션될 때 메타데이터도 함께 정리되게 하기 위해서입니다.
// @Injectable() 적용 후 메타데이터 상태 (개념적)
metadataStore = {
[CatService] => Map {
'__injectable__' => true,
'scope:options' => undefined,
'design:paramtypes' => [CatRepository], // ← TypeScript 컴파일러가 추가
}
}
3-2. design:paramtypes — TypeScript 컴파일러가 자동 생성하는 메타데이터
여기서 가장 중요한 것은 'design:paramtypes'입니다. 이것은 @Injectable()이 저장한 것이 아닙니다. TypeScript 컴파일러가 자동으로 저장합니다.
tsconfig.json에 emitDecoratorMetadata: true가 설정되어 있으면, TypeScript 컴파일러는 데코레이터가 붙은 클래스에 대해 자동으로 세 가지 메타데이터를 생성합니다.
메타데이터 키 | 저장하는 것 | 적용 대상 |
|---|---|---|
| 프로퍼티의 타입 | 프로퍼티 / 메서드 |
| 생성자/메서드의 파라미터 타입 배열 | 클래스 / 메서드 |
| 메서드의 반환 타입 | 메서드 |
NestJS DI에서 핵심은 design:paramtypes입니다. 이것이 생성자에 어떤 타입의 의존성이 필요한지를 알려줍니다.
3-3. 컴파일 전후 비교 — emitDecoratorMetadata의 효과
TypeScript가 컴파일할 때 실제로 어떤 코드를 생성하는지 확인해보겠습니다.
TypeScript 소스:
@Injectable()
export class CatService {
constructor(
private readonly catRepository: CatRepository,
private readonly logger: LoggerService,
) {}
}
컴파일 결과 (JavaScript):
var __decorate = (this && this.__decorate) || function (decorators, target, ...) { ... };
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function")
return Reflect.metadata(k, v);
};
let CatService = class CatService {
constructor(catRepository, logger) {
this.catRepository = catRepository;
this.logger = logger;
}
};
CatService = __decorate([
Injectable(),
__metadata("design:paramtypes", [CatRepository, LoggerService])
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// TypeScript가 자동 추가 생성자 파라미터 타입 배열
], CatService);
이것이 핵심입니다. TypeScript 컴파일러가 __metadata("design:paramtypes", [CatRepository, LoggerService])를 자동으로 추가합니다. 이 코드는 Reflect.metadata('design:paramtypes', [CatRepository, LoggerService])를 호출하고, 결과적으로 CatService 클래스에 생성자 파라미터 타입 정보가 메타데이터로 저장됩니다.
이제 1편에서 본 Injector의 코드가 완전히 이해됩니다.
// packages/core/injector/injector.ts
const paramTypes = Reflect.getMetadata('design:paramtypes', CatService);
// paramTypes = [CatRepository, LoggerService]
// → CatRepository와 LoggerService의 인스턴스를 찾아서
// → new CatService(catRepositoryInstance, loggerServiceInstance)
3-4. emitDecoratorMetadata가 꺼져 있으면?
tsconfig.json에서 emitDecoratorMetadata: false로 설정하면 어떻게 될까요?
// emitDecoratorMetadata: false 일 때 컴파일 결과
CatService = __decorate([
Injectable()
// __metadata("design:paramtypes", [...]) 가 없음!
], CatService);
design:paramtypes 메타데이터가 생성되지 않습니다. 이 상태에서 NestJS를 실행하면, Injector가 생성자 파라미터 타입을 알 수 없어서 의존성 주입이 실패합니다.
Error: Nest can't resolve dependencies of the CatService (?).
NestJS 프로젝트의 tsconfig.json에 반드시 이 두 옵션이 있어야 하는 이유가 바로 이것입니다.
// tsconfig.json
{
"compilerOptions": {
"emitDecoratorMetadata": true, // design:paramtypes 자동 생성
"experimentalDecorators": true // 데코레이터 문법 활성화
}
}
3-5. 데코레이터가 하나도 없으면 메타데이터도 없습니다
여기서 중요한 사실 하나를 짚겠습니다. emitDecoratorMetadata는 데코레이터가 붙은 클래스에 대해서만 메타데이터를 생성합니다.
// 데코레이터 없음 → 메타데이터 없음
class PlainService {
constructor(private dep: SomeDep) {}
}
// Reflect.getMetadata('design:paramtypes', PlainService) → undefined
// 데코레이터 있음 → 메타데이터 있음
@Injectable()
class DecoratedService {
constructor(private dep: SomeDep) {}
}
// Reflect.getMetadata('design:paramtypes', DecoratedService) → [SomeDep]
이것이 NestJS에서 Provider에 @Injectable()을 붙여야만 하는 이유입니다. @Injectable()이 하는 일은 워터마크 저장이 전부이지만, 이 데코레이터가 존재해야 TypeScript 컴파일러가 design:paramtypes를 생성합니다. 데코레이터의 존재 자체가 트리거인 것입니다.
4. @Controller()와 @Module()도 같은 원리입니다
NestJS의 핵심 데코레이터들이 모두 같은 패턴을 따른다는 것을 확인해보겠습니다.
4-1. @Controller() 소스코드
📎 packages/common/decorators/core/controller.decorator.ts
// packages/common/decorators/core/controller.decorator.ts (간략화)
export function Controller(
prefixOrOptions?: string | string[] | ControllerOptions,
): ClassDecorator {
const defaultPath = '/';
const [path, host, scopeOptions, versionOptions] = isUndefined(prefixOrOptions)
? [defaultPath, undefined, undefined, undefined]
: isString(prefixOrOptions) || Array.isArray(prefixOrOptions)
? [prefixOrOptions, undefined, undefined, undefined]
: [
prefixOrOptions.path || defaultPath,
prefixOrOptions.host,
{ scope: prefixOrOptions.scope, durable: prefixOrOptions.durable },
Array.isArray(prefixOrOptions.version)
? Array.from(new Set(prefixOrOptions.version))
: prefixOrOptions.version,
];
return (target: object) => {
Reflect.defineMetadata(CONTROLLER_WATERMARK, true, target);
Reflect.defineMetadata(PATH_METADATA, path, target);
Reflect.defineMetadata(HOST_METADATA, host, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, scopeOptions, target);
Reflect.defineMetadata(VERSION_METADATA, versionOptions, target);
};
}
@Injectable()보다 저장하는 메타데이터가 많지만, 패턴은 동일합니다. Reflect.defineMetadata()로 메타데이터를 저장하는 것이 전부입니다. CONTROLLER_WATERMARK는 '__controller__'라는 문자열로, NestJS가 "이것은 컨트롤러"라고 식별하는 데 사용됩니다.
4-2. @Module() 소스코드
📎 packages/common/decorators/modules/module.decorator.ts
// packages/common/decorators/modules/module.decorator.ts
export function Module(metadata: ModuleMetadata): ClassDecorator {
const propsKeys = Object.keys(metadata);
validateModuleKeys(propsKeys);
return (target: Function) => {
for (const property in metadata) {
if (Object.hasOwnProperty.call(metadata, property)) {
Reflect.defineMetadata(property, (metadata as any)[property], target);
}
}
};
}
@Module()은 조금 다른 접근을 합니다. 고정된 키 대신, 전달받은 메타데이터 객체의 키를 그대로 메타데이터 키로 사용합니다.
@Module({
imports: [DatabaseModule],
providers: [CatService],
controllers: [CatController],
exports: [CatService],
})
export class CatModule {}
// 저장되는 메타데이터:
// Reflect.defineMetadata('imports', [DatabaseModule], CatModule)
// Reflect.defineMetadata('providers', [CatService], CatModule)
// Reflect.defineMetadata('controllers', [CatController], CatModule)
// Reflect.defineMetadata('exports', [CatService], CatModule)
1편에서 DependenciesScanner.reflectProviders()가 Reflect.getMetadata('providers', module)로 프로바이더 목록을 가져오는 것을 봤습니다. 이제 그 메타데이터가 @Module() 데코레이터에 의해 저장되었다는 것을 알 수 있습니다.
4-3. 데코레이터별 메타데이터 비교
데코레이터 | 저장하는 메타데이터 키 | 값 | 용도 |
|---|---|---|---|
|
|
| Provider 식별 표식 |
| scope 옵션 | Singleton/Request/Transient | |
|
|
| Controller 식별 표식 |
| 라우트 경로 | URL 프리픽스 | |
| 호스트 | 호스트 기반 라우팅 | |
| scope 옵션 | 스코프 설정 | |
| 버전 | API 버저닝 | |
|
| 모듈 배열 | 의존 모듈 |
| 프로바이더 배열 | DI 대상 | |
| 컨트롤러 배열 | 라우트 핸들러 | |
| 내보내기 배열 | 외부 공개 대상 | |
(TypeScript) |
| 파라미터 타입 배열 | 생성자 DI 해석 |
이 표를 보면 재미있는 사실을 알 수 있습니다. NestJS의 핵심 데코레이터들은 모두 같은 메커니즘(Reflect.defineMetadata)을 사용하지만, 저장하는 정보의 종류가 다릅니다. @Injectable()은 "나는 주입 가능한 클래스야"라는 표식만 남기고, @Controller()는 라우팅 정보까지 남기고, @Module()은 모듈 구성 정보를 남깁니다.
5. 데코레이터가 저장한 메타데이터를 DI가 읽는 전체 흐름
지금까지의 내용을 종합해서, 데코레이터가 메타데이터를 저장하는 시점부터 DI가 그것을 읽어서 인스턴스를 생성하는 시점까지의 전체 흐름을 정리하겠습니다.
[ 컴파일 타임 — TypeScript → JavaScript ]
@Injectable() @Module({ providers: [CatService], ... })
class CatService { class CatModule {}
constructor(dep: CatRepo) {}
}
↓ tsc 컴파일 ↓ tsc 컴파일
__decorate([ __decorate([
Injectable(), Module({ providers: [CatService] }),
__metadata("design:paramtypes", ], CatModule);
[CatRepo])
], CatService);
─── ─── ─── ─── ─── ─── ─── ─── ─── ─── ─── ───
[ 런타임 — 모듈 로드 시점 ]
CatService 클래스 정의 실행
→ Reflect.defineMetadata('__injectable__', true, CatService)
→ Reflect.defineMetadata('scope:options', undefined, CatService)
→ Reflect.defineMetadata('design:paramtypes', [CatRepo], CatService)
(TypeScript 컴파일러가 추가한 코드)
CatModule 클래스 정의 실행
→ Reflect.defineMetadata('providers', [CatService], CatModule)
→ Reflect.defineMetadata('controllers', [...], CatModule)
─── ─── ─── ─── ─── ─── ─── ─── ─── ─── ─── ───
[ 런타임 — NestFactory.create(AppModule) ]
1. DependenciesScanner.scan()
│
├─ scanForModules(AppModule)
│ → Reflect.getMetadata('imports', AppModule)
│ → 재귀적으로 모든 모듈 발견 & 등록
│
└─ scanModulesForDependencies()
→ Reflect.getMetadata('providers', CatModule) → [CatService]
→ Reflect.getMetadata('controllers', CatModule) → [CatController]
→ 컨테이너에 등록
2. InstanceLoader.createInstancesOfDependencies()
│
└─ Injector.loadProvider(CatService)
→ Reflect.getMetadata('design:paramtypes', CatService) → [CatRepo]
→ lookupComponent(CatRepo, moduleRef) → catRepoInstance 찾기
→ new CatService(catRepoInstance) → 인스턴스 생성!
전체 흐름을 보면, 세 개의 독립적인 메타데이터 저장이 서로 다른 시점에 일어나고, 나중에 한곳에서 읽힌다는 것을 알 수 있습니다.
메타데이터 | 누가 저장하는가 | 언제 저장되는가 | 누가 읽는가 |
|---|---|---|---|
|
| 클래스 정의 시점 |
|
|
| 클래스 정의 시점 |
|
| TypeScript 컴파일러 | 컴파일 타임 (코드 생성) |
|
6. 실전 심화 — 인터페이스로 주입이 안 되는 이유
design:paramtypes의 동작을 이해하면, NestJS 초보자가 자주 겪는 함정 하나를 명확히 설명할 수 있습니다.
// ❌ 이렇게 하면 DI가 실패합니다
interface ICatRepository {
findAll(): Cat[];
}
@Injectable()
class CatService {
constructor(private readonly repo: ICatRepository) {}
}
왜 인터페이스로는 DI가 안 될까요? TypeScript의 인터페이스는 컴파일 후에 사라지기 때문입니다.
// 컴파일 후 JavaScript
__metadata("design:paramtypes", [Object])
// ^^^^^^
// ICatRepository가 사라지고 Object가 됩니다!
인터페이스는 순수하게 타입 시스템의 개념이라서, JavaScript 런타임에는 존재하지 않습니다. TypeScript 컴파일러가 design:paramtypes를 생성할 때 인터페이스 타입은 Object로 대체됩니다. Injector가 Object를 받으면 어떤 Provider를 주입해야 할지 알 수 없습니다.
이것이 NestJS에서 인터페이스 대신 커스텀 토큰을 사용해야 하는 이유입니다.
// ✅ 해결: @Inject()로 커스텀 토큰 지정
@Injectable()
class CatService {
constructor(
@Inject('CAT_REPOSITORY')
private readonly repo: ICatRepository,
) {}
}
// Module에서 토큰으로 등록
@Module({
providers: [
{
provide: 'CAT_REPOSITORY',
useClass: CatRepositoryImpl,
},
],
})
export class CatModule {}
@Inject('CAT_REPOSITORY')는 파라미터 데코레이터로, design:paramtypes 대신 명시적인 토큰을 사용하도록 Injector에게 알려줍니다. 이렇게 하면 인터페이스 타입이 사라져도 문제없이 올바른 Provider를 찾을 수 있습니다.
7. 보너스 — @Inject() 파라미터 데코레이터의 동작
@Inject()가 어떻게 design:paramtypes를 대체하는지 간략히 살펴보겠습니다.
// packages/common/decorators/core/inject.decorator.ts (개념적)
export function Inject(token?: string | symbol | Type<any>) {
return (target: object, propertyKey: string | undefined, parameterIndex: number) => {
// 파라미터 데코레이터: 특정 파라미터의 주입 토큰을 명시적으로 저장
const existingDeps = Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, target) || [];
existingDeps.push({ index: parameterIndex, param: token });
Reflect.defineMetadata(SELF_DECLARED_DEPS_METADATA, existingDeps, target);
};
}
@Inject()는 SELF_DECLARED_DEPS_METADATA라는 키로 "이 파라미터 인덱스에는 이 토큰을 주입해라"는 정보를 저장합니다. Injector는 design:paramtypes를 읽기 전에 먼저 SELF_DECLARED_DEPS_METADATA를 확인하고, 명시적으로 지정된 토큰이 있으면 그것을 우선 사용합니다.
Injector의 의존성 해석 우선순위:
1. SELF_DECLARED_DEPS_METADATA에 명시적 토큰이 있는가?
→ 있으면 그 토큰으로 Provider 검색
2. 없으면 design:paramtypes에서 타입 정보를 읽는다
→ 해당 타입 클래스로 Provider 검색
3. 둘 다 없으면 → "Nest can't resolve dependencies" 에러
8. TC39 Decorators 제안과 NestJS의 미래
현재 NestJS가 사용하는 데코레이터는 TypeScript의 experimentalDecorators 옵션에 기반한 레거시 데코레이터입니다. 하지만 JavaScript에도 데코레이터 표준이 진행 중입니다.
항목 | 레거시 (TypeScript experimental) | TC39 Stage 3 Decorators |
|---|---|---|
활성화 방법 |
| 별도 설정 불필요 (TS 5.0+) |
메타데이터 |
| Decorator Metadata 제안 (별도 Stage 3) |
파라미터 데코레이터 | 지원 | 미지원 |
NestJS 호환성 | 현재 사용 중 | 향후 마이그레이션 필요 |
TC39 표준 데코레이터에서는 파라미터 데코레이터가 지원되지 않고, emitDecoratorMetadata도 작동하지 않습니다. NestJS 팀은 이 변화를 인지하고 있으며, 향후 메이저 버전에서 대응할 것으로 예상됩니다. 하지만 현재로서는 레거시 데코레이터 + reflect-metadata 조합이 NestJS DI의 근간입니다.
9. 정리 — 데코레이터는 "실행"이 아니라 "등록"입니다
이번 글에서 추적한 내용을 정리하겠습니다.
첫째, @Injectable()이 실제로 하는 일은 Reflect.defineMetadata() 두 줄이 전부입니다. INJECTABLE_WATERMARK를 true로 설정하고, 스코프 옵션을 저장합니다. DI 로직 자체는 데코레이터에 없습니다.
둘째, DI의 진짜 핵심은 TypeScript 컴파일러가 자동 생성하는 design:paramtypes 메타데이터입니다. emitDecoratorMetadata: true가 이것을 활성화하며, 데코레이터가 하나라도 붙어 있어야 생성됩니다.
셋째, @Controller(), @Module()도 같은 패턴입니다. 모두 Reflect.defineMetadata()로 메타데이터를 저장하고, 나중에 DependenciesScanner와 Injector가 읽습니다.
넷째, 인터페이스로 DI가 안 되는 이유는 TypeScript 인터페이스가 컴파일 후 사라져서 design:paramtypes에 Object로 기록되기 때문입니다. @Inject() 커스텀 토큰으로 해결할 수 있습니다.
돌이켜보면, NestJS의 DI는 마법이 아니었습니다. TypeScript 컴파일러의 메타데이터 생성 + reflect-metadata 라이브러리의 키-값 저장 + NestJS 런타임의 메타데이터 조회 — 이 세 가지 메커니즘이 협력하는 것이었습니다.
┌─────────────────────────────────┐
│ TypeScript 컴파일러 │
│ design:paramtypes 자동 생성 │
└──────────┬──────────────────────┘
│ 저장
▼
┌─────────────────────────────────┐
│ reflect-metadata (WeakMap) │
│ 메타데이터 키-값 저장소 │
└──────────┬──────────────────────┘
│ 조회
▼
┌─────────────────────────────────┐
│ NestJS Injector │
│ 메타데이터 읽기 → 의존성 해석 │
│ → 인스턴스 생성 → 주입 │
└─────────────────────────────────┘
관련 글
DI 컨테이너는 어떻게 의존성을 해결하는가 — NestJS 소스코드 추적 (3편)
@Injectable()이 메타데이터를 저장하는 것과 실제로 인스턴스를 생성해서 주입하는 것은 다른 문제입니다. NestContainer, InstanceLoader, Injector — 세 클래스가 협력하여 의존성을 해결하는 과정을 NestJS 소스코드에서 직접 추적합니다.
Custom Decorator 만들기 — createParamDecorator의 내부 (6편)
NestJS에서 @CurrentUser(), @Roles() 같은 커스텀 데코레이터가 내부에서 어떻게 동작하는지 소스코드로 추적합니다. createParamDecorator가 메타데이터를 저장하고, Pipe가 값을 추출하고, SetMetadata + Reflector + Guard가 연결되는 전체 흐름을 확인합니다.
@Module()은 어떻게 앱을 조립하는가 — NestJS 소스코드 추적 (4편)
NestJS 앱은 하나의 거대한 provider 목록이 아니라, @Module()로 나뉜 여러 모듈의 조합입니다. @Module() 데코레이터가 메타데이터를 저장하고, DependenciesScanner가 모듈 그래프를 구축하는 전 과정을 소스코드에서 추적합니다.