홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

@Injectable()의 실체 — Reflect.metadata와 데코레이터가 하는 일 (2편)

정기창·2026년 3월 18일

들어가며 — 데코레이터 하나로 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

(target: Function)

@Injectable(), @Controller(), @Module()

메서드 데코레이터

method

(target, propertyKey, descriptor)

@Get(), @Post(), @UseGuards()

프로퍼티 데코레이터

property

(target, propertyKey)

@Inject()

파라미터 데코레이터

parameter

(target, propertyKey, parameterIndex)

@Body(), @Param(), @Query()

접근자 데코레이터

accessor

(target, propertyKey, descriptor)

(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 컴파일러는 데코레이터가 붙은 클래스에 대해 자동으로 세 가지 메타데이터를 생성합니다.

메타데이터 키

저장하는 것

적용 대상

design:type

프로퍼티의 타입

프로퍼티 / 메서드

design:paramtypes

생성자/메서드의 파라미터 타입 배열

클래스 / 메서드

design:returntype

메서드의 반환 타입

메서드

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. 데코레이터별 메타데이터 비교

데코레이터

저장하는 메타데이터 키

값

용도

@Injectable()

INJECTABLE_WATERMARK ('__injectable__')

true

Provider 식별 표식

SCOPE_OPTIONS_METADATA ('scope:options')

scope 옵션

Singleton/Request/Transient

@Controller()

CONTROLLER_WATERMARK ('__controller__')

true

Controller 식별 표식

PATH_METADATA ('path')

라우트 경로

URL 프리픽스

HOST_METADATA ('host')

호스트

호스트 기반 라우팅

SCOPE_OPTIONS_METADATA

scope 옵션

스코프 설정

VERSION_METADATA ('__version__')

버전

API 버저닝

@Module()

'imports'

모듈 배열

의존 모듈

'providers'

프로바이더 배열

DI 대상

'controllers'

컨트롤러 배열

라우트 핸들러

'exports'

내보내기 배열

외부 공개 대상

(TypeScript)

'design:paramtypes'

파라미터 타입 배열

생성자 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)       → 인스턴스 생성!

전체 흐름을 보면, 세 개의 독립적인 메타데이터 저장이 서로 다른 시점에 일어나고, 나중에 한곳에서 읽힌다는 것을 알 수 있습니다.

메타데이터

누가 저장하는가

언제 저장되는가

누가 읽는가

__injectable__

@Injectable() 데코레이터

클래스 정의 시점

DependenciesScanner

'providers', 'controllers'

@Module() 데코레이터

클래스 정의 시점

DependenciesScanner

'design:paramtypes'

TypeScript 컴파일러

컴파일 타임 (코드 생성)

Injector

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

활성화 방법

experimentalDecorators: true

별도 설정 불필요 (TS 5.0+)

메타데이터

emitDecoratorMetadata + reflect-metadata

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                │
│   메타데이터 읽기 → 의존성 해석  │
│   → 인스턴스 생성 → 주입         │
└─────────────────────────────────┘
NestJSInjectable데코레이터Reflect.metadataTypeScriptDI

관련 글

DI 컨테이너는 어떻게 의존성을 해결하는가 — NestJS 소스코드 추적 (3편)

@Injectable()이 메타데이터를 저장하는 것과 실제로 인스턴스를 생성해서 주입하는 것은 다른 문제입니다. NestContainer, InstanceLoader, Injector — 세 클래스가 협력하여 의존성을 해결하는 과정을 NestJS 소스코드에서 직접 추적합니다.

관련도 94%

Custom Decorator 만들기 — createParamDecorator의 내부 (6편)

NestJS에서 @CurrentUser(), @Roles() 같은 커스텀 데코레이터가 내부에서 어떻게 동작하는지 소스코드로 추적합니다. createParamDecorator가 메타데이터를 저장하고, Pipe가 값을 추출하고, SetMetadata + Reflector + Guard가 연결되는 전체 흐름을 확인합니다.

관련도 93%

@Module()은 어떻게 앱을 조립하는가 — NestJS 소스코드 추적 (4편)

NestJS 앱은 하나의 거대한 provider 목록이 아니라, @Module()로 나뉜 여러 모듈의 조합입니다. @Module() 데코레이터가 메타데이터를 저장하고, DependenciesScanner가 모듈 그래프를 구축하는 전 과정을 소스코드에서 추적합니다.

관련도 92%