DI 컨테이너는 어떻게 의존성을 해결하는가 — NestJS 소스코드 추적 (3편)
들어가며
2편에서 @Injectable() 데코레이터가 Reflect.metadata로 메타데이터를 저장하는 것까지 확인했습니다. 그런데 메타데이터를 저장하는 것과 실제로 인스턴스를 생성해서 주입하는 것은 다른 문제입니다. constructor에 타입만 적으면 알아서 인스턴스가 들어오는 그 "마법"의 실체를 추적해보겠습니다.
이번 글에서 추적할 핵심 소스 파일은 세 개입니다.
packages/core/injector/container.ts — NestContainer (모듈·프로바이더 저장소)
packages/core/injector/instance-loader.ts — InstanceLoader (인스턴스 생성 오케스트레이터)
packages/core/injector/injector.ts — Injector (의존성 해결 엔진)
DI(Dependency Injection)란 — 직접 new하지 않는 이유
먼저 왜 DI가 필요한지 짚고 가겠습니다. 아래 두 코드를 비교해보면 핵심이 보입니다.
직접 new하는 방식
class UserController {
private userService: UserService;
constructor() {
// UserService가 뭘 필요로 하는지 UserController가 알아야 합니다
const repository = new UserRepository(new DatabaseConnection('localhost:27017'));
this.userService = new UserService(repository);
}
}
DI 컨테이너가 생성하는 방식
@Controller('users')
class UserController {
// "UserService가 필요합니다"라고만 선언
constructor(private readonly userService: UserService) {}
}
첫 번째 방식에서 UserController는 UserService뿐 아니라 UserRepository, DatabaseConnection까지 알아야 합니다. 의존성의 의존성까지 전부 직접 관리해야 하는 것입니다. 두 번째 방식에서는 "나는 UserService가 필요하다"는 선언만 하면, 누군가가 알아서 만들어 넣어줍니다.
이 "누군가"가 바로 DI 컨테이너입니다. 객체의 생성과 생명주기를 프레임워크가 관리하는 것 — 이것이 제어의 역전(Inversion of Control)입니다. 개발자가 직접 new를 호출하는 대신, 프레임워크에 제어를 넘기는 것입니다.
그렇다면 NestJS에서 이 "누군가"의 실체는 무엇일까요?
NestContainer — DI 컨테이너의 저장소
packages/core/injector/container.ts에 있는 NestContainer가 모든 모듈과 프로바이더를 보관하는 저장소입니다. 1편에서 NestFactory.create()를 추적할 때 잠깐 스쳐 지나갔던 그 객체입니다.
핵심 자료구조
// packages/core/injector/container.ts
export class NestContainer {
private readonly globalModules = new Set<Module>();
private readonly modules = new ModulesContainer(); // Map<string, Module>
private readonly dynamicModulesMetadata = new Map<string, Partial<DynamicModule>>();
private internalCoreModule: Module;
// ...
}
modules는 ModulesContainer인데, 이것은 Map<string, Module>을 상속한 클래스입니다. 키는 모듈의 토큰(해시), 값은 Module 인스턴스입니다. 여기서 말하는 Module은 우리가 @Module()로 장식하는 클래스가 아니라, NestJS 내부의 packages/core/injector/module.ts에 정의된 내부 클래스입니다.
Module 내부 클래스의 구조
이 내부 Module 클래스가 실제로 프로바이더, 컨트롤러, import/export 관계를 관리합니다.
// packages/core/injector/module.ts
export class Module {
private readonly _providers = new Map<InjectionToken, InstanceWrapper<Injectable>>();
private readonly _controllers = new Map<InjectionToken, InstanceWrapper<Controller>>();
private readonly _injectables = new Map<InjectionToken, InstanceWrapper<Injectable>>();
private readonly _middlewares = new Map<InjectionToken, InstanceWrapper<Injectable>>();
private readonly _imports = new Set<Module>();
private readonly _exports = new Set<InjectionToken>();
// ...
}
모든 것이 Map과 Set으로 관리됩니다. 키는 인젝션 토큰(보통은 클래스 자체의 참조), 값은 InstanceWrapper로 감싸진 인스턴스 정보입니다. InstanceWrapper는 단순히 인스턴스만 들고 있는 게 아니라, 스코프, 의존성 메타데이터, 생성 상태 등 부가 정보를 함께 관리하는 래퍼 클래스입니다.
addProvider()의 분기 처리
Module 클래스의 addProvider()는 프로바이더의 형태에 따라 다른 처리를 합니다.
// packages/core/injector/module.ts (간략화)
public addProvider(provider: Provider): string {
if (this.isCustomProvider(provider)) {
return this.addCustomProvider(provider);
}
// 일반 클래스 프로바이더
this._providers.set(
provider,
new InstanceWrapper({
token: provider,
name: provider.name,
metatype: provider,
instance: null, // 아직 생성 전
isResolved: false,
scope: getClassScope(provider),
}),
);
return provider.name;
}
여기서 중요한 점은 instance: null입니다. 이 시점에서는 아직 인스턴스가 생성되지 않았습니다. 프로바이더를 등록만 한 것입니다. 실제 인스턴스 생성은 나중에 InstanceLoader가 담당합니다.
커스텀 프로바이더의 경우 네 가지로 분기됩니다.
// packages/core/injector/module.ts (간략화)
private addCustomProvider(provider: CustomProvider): string {
if ('useClass' in provider) return this.addCustomClass(provider);
if ('useValue' in provider) return this.addCustomValue(provider);
if ('useFactory' in provider) return this.addCustomFactory(provider);
if ('useExisting' in provider) return this.addCustomUseExisting(provider);
}
각 분기에 대해서는 뒤에서 자세히 다루겠습니다.
전체 등록 구조를 그려보면
NestContainer
├── modules: Map<string, Module>
│ ├── "AppModule_hash" → Module
│ │ ├── _providers: Map
│ │ │ ├── AppService → InstanceWrapper { instance: null }
│ │ │ └── ModuleRef → InstanceWrapper { ... }
│ │ ├── _controllers: Map
│ │ │ └── AppController → InstanceWrapper { instance: null }
│ │ └── _imports: Set<Module>
│ │ └── (AuthModule의 Module 참조)
│ │
│ └── "AuthModule_hash" → Module
│ ├── _providers: Map
│ │ ├── AuthService → InstanceWrapper { instance: null }
│ │ └── JwtService → InstanceWrapper { instance: null }
│ ├── _controllers: Map
│ │ └── AuthController → InstanceWrapper { instance: null }
│ └── _exports: Set
│ └── AuthService (토큰)
│
└── globalModules: Set<Module>
이것이 NestJS DI 시스템의 1단계 — 등록입니다. 모든 프로바이더가 자리를 잡았지만, 아직 아무것도 만들어지지 않은 상태입니다.
InstanceLoader — 인스턴스 생성의 오케스트레이터
등록이 끝나면 이제 실제 인스턴스를 만들 차례입니다. packages/core/injector/instance-loader.ts의 InstanceLoader가 이 역할을 담당합니다.
createInstancesOfDependencies() 추적
// packages/core/injector/instance-loader.ts (간략화)
export class InstanceLoader<TInjector extends Injector = Injector> {
constructor(
protected readonly container: NestContainer,
protected readonly injector: TInjector,
private readonly graphInspector: GraphInspector,
) {}
public async createInstancesOfDependencies(
modules: Map<string, Module> = this.container.getModules(),
) {
this.createPrototypes(modules); // 1단계: 프로토타입 생성
await this.createInstances(modules); // 2단계: 실제 인스턴스 생성
}
// ...
}
두 단계로 나뉩니다. 먼저 프로토타입을 만들고, 그 다음에 인스턴스를 만듭니다.
1단계: 프로토타입 생성
// packages/core/injector/instance-loader.ts (간략화)
private createPrototypes(modules: Map<string, Module>) {
modules.forEach((moduleRef) => {
this.createPrototypesOfProviders(moduleRef);
this.createPrototypesOfControllers(moduleRef);
this.createPrototypesOfInjectables(moduleRef);
});
}
private createPrototypesOfProviders(moduleRef: Module) {
const { providers } = moduleRef;
providers.forEach((wrapper) => {
this.injector.loadPrototype<Injectable>(wrapper, providers);
});
}
프로토타입 생성은 실제 인스턴스를 만드는 게 아닙니다. Object.create(metatype.prototype)으로 빈 껍데기 객체를 먼저 만들어두는 것입니다. 이렇게 하는 이유는 순환 의존성 처리 때문인데, 뒤에서 자세히 설명하겠습니다.
2단계: 실제 인스턴스 생성
// packages/core/injector/instance-loader.ts (간략화)
private async createInstances(modules: Map<string, Module>) {
await Promise.all(
[...modules.values()].map(async (moduleRef) => {
await this.createInstancesOfProviders(moduleRef); // 프로바이더 먼저
await this.createInstancesOfInjectables(moduleRef); // 인젝터블(가드, 파이프 등)
await this.createInstancesOfControllers(moduleRef); // 컨트롤러는 마지막
// 로깅: "AuthModule dependencies initialized"
}),
);
}
순서가 중요합니다. 프로바이더 → 인젝터블 → 컨트롤러 순서로 생성합니다. 컨트롤러가 마지막인 이유는, 컨트롤러가 서비스(프로바이더)에 의존하기 때문입니다. 의존 대상이 먼저 만들어져야 주입할 수 있습니다.
각 모듈은 Promise.all로 병렬 처리되지만, 모듈 내부에서는 프로바이더 → 인젝터블 → 컨트롤러 순서가 보장됩니다.
그런데 실제로 인스턴스를 만드는 건 InstanceLoader가 아닙니다. InstanceLoader는 오케스트레이터일 뿐이고, 실제 작업은 Injector에 위임합니다.
Injector — 의존성 해결의 핵심 엔진
packages/core/injector/injector.ts의 Injector 클래스가 NestJS DI의 핵심입니다. 이 클래스가 실제로 "constructor에 적힌 타입을 보고 인스턴스를 만들어 넣어주는" 일을 합니다.
loadInstance() — 인스턴스 로딩의 진입점
// packages/core/injector/injector.ts (간략화)
public async loadInstance<T>(
wrapper: InstanceWrapper<T>,
collection: Map<InjectionToken, InstanceWrapper>,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
// 이미 해결된 경우 스킵
const settled = wrapper.getSettlementSignal(contextId);
if (settled) {
// 이미 다른 곳에서 로딩 중이면, 완료를 기다림
await settled;
return;
}
// constructor 파라미터 해결 → 인스턴스 생성
await this.resolveConstructorParams<T>(
wrapper,
moduleRef,
inject,
async (instances) => {
await this.instantiateClass(instances, wrapper, targetMetatype, contextId, inquirer);
},
contextId,
inquirer,
);
}
settlement signal은 중복 생성을 방지하는 메커니즘입니다. A가 B를 필요로 하고, C도 B를 필요로 할 때, B가 두 번 만들어지면 안 됩니다. 먼저 B를 로딩하기 시작한 쪽이 signal을 설정하고, 나중에 도착한 쪽은 완료를 기다립니다.
resolveConstructorParams() — constructor 분석
이 메서드가 "마법"의 핵심입니다. constructor에 적힌 타입 정보를 읽어서 실제 인스턴스를 찾아옵니다.
// packages/core/injector/injector.ts (간략화)
public async resolveConstructorParams<T>(
wrapper: InstanceWrapper<T>,
moduleRef: Module,
inject: InjectorDependency[] | undefined,
callback: (args: unknown[]) => void | Promise<void>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
// inject가 있으면 → useFactory의 inject 배열 사용
// inject가 없으면 → Reflect.getMetadata로 타입 정보 읽기
const dependencies = inject
? inject // useFactory의 명시적 의존성
: this.reflectConstructorParams<T>(wrapper.metatype); // 자동 추론
// 각 의존성을 병렬로 해결
const resolvedParams = await Promise.all(
dependencies.map((param, index) =>
this.resolveParam(param, { wrapper, moduleRef, index, contextId, inquirer })
),
);
// 모든 의존성이 해결되면 콜백 호출 (인스턴스 생성)
await callback(resolvedParams);
}
reflectConstructorParams() — 메타데이터에서 타입 읽기
2편에서 본 design:paramtypes 메타데이터를 여기서 읽습니다.
// packages/core/injector/injector.ts (간략화)
public reflectConstructorParams<T>(type: Type<T>): any[] {
// TypeScript 컴파일러가 emitDecoratorMetadata로 저장한 타입 정보
const paramtypes = Reflect.getMetadata(PARAMTYPES_METADATA, type) || [];
// 'design:paramtypes' → [UserRepository, ConfigService, ...]
// @Inject()로 직접 지정한 토큰 (self:paramtypes)
const selfParams = this.reflectSelfParams<T>(type);
// 'self:paramtypes' → [{ index: 0, param: 'DATABASE_CONNECTION' }, ...]
// self-declared가 있으면 해당 인덱스를 오버라이드
selfParams.forEach(({ index, param }) => {
paramtypes[index] = param;
});
return paramtypes;
}
이 과정을 그림으로 그려보겠습니다.
@Injectable()
class UserService {
constructor(
private readonly repo: UserRepository, // index 0
@Inject('CONFIG') private readonly config, // index 1
) {}
}
1. Reflect.getMetadata('design:paramtypes', UserService)
→ [UserRepository, Object]
index 0: 클래스 참조 index 1: Object (타입 정보 유실)
2. Reflect.getMetadata('self:paramtypes', UserService)
→ [{ index: 1, param: 'CONFIG' }]
@Inject('CONFIG')이 저장한 것
3. 병합 결과:
→ [UserRepository, 'CONFIG']
index 0: 클래스 토큰 index 1: 문자열 토큰
@Inject() 데코레이터가 필요한 이유가 여기서 드러납니다. TypeScript의 design:paramtypes는 인터페이스나 문자열 토큰을 저장하지 못합니다. 인터페이스는 런타임에 존재하지 않고, 문자열 토큰은 타입 시스템 밖의 개념이기 때문입니다. 그래서 @Inject('CONFIG')으로 별도 메타데이터(self:paramtypes)에 저장하고, reflectConstructorParams에서 병합하는 것입니다.
lookupComponent() — 토큰으로 프로바이더 찾기
각 파라미터의 토큰이 결정되면, 그 토큰에 해당하는 프로바이더를 찾아야 합니다.
// packages/core/injector/injector.ts (간략화)
public async lookupComponent<T>(
providers: Map<InjectionToken, InstanceWrapper>,
moduleRef: Module,
dependencyContext: InjectorDependencyContext,
wrapper: InstanceWrapper<T>,
contextId = STATIC_CONTEXT,
): Promise<InstanceWrapper> {
const { name } = dependencyContext;
// 1. 현재 모듈의 providers에서 찾기
if (name && providers.has(name)) {
const instanceWrapper = providers.get(name)!;
return instanceWrapper;
}
// 2. 못 찾으면 → import된 모듈에서 찾기 (exports된 것만)
return this.lookupComponentInParentModules(
dependencyContext,
moduleRef,
wrapper,
contextId,
);
}
탐색 순서가 명확합니다. 현재 모듈 먼저, 없으면 import된 모듈에서 탐색합니다. import된 모듈에서는 exports에 포함된 프로바이더만 접근할 수 있습니다. 이것이 NestJS 모듈 시스템의 캡슐화 원리인데, 4편에서 자세히 다루겠습니다.
재귀적 의존성 해결
프로바이더를 찾았는데 그 프로바이더가 아직 인스턴스화되지 않았다면 어떻게 될까요? 재귀적으로 해결합니다.
UserController를 인스턴스화하려면
→ UserService가 필요합니다
→ UserService가 아직 없으므로 먼저 만들어야 합니다
→ UserService를 인스턴스화하려면
→ UserRepository가 필요합니다
→ UserRepository가 아직 없으므로 먼저 만들어야 합니다
→ UserRepository를 인스턴스화하려면
→ DatabaseConnection이 필요합니다
→ DatabaseConnection은 이미 있습니다 (instance !== null)
→ 반환
→ new UserRepository(databaseConnection) 생성
→ 반환
→ new UserService(userRepository) 생성
→ 반환
→ new UserController(userService) 생성
이것이 DI 컨테이너가 "알아서" 의존성을 해결하는 과정입니다. 개발자가 new를 호출하지 않아도, 컨테이너가 의존성 트리를 따라 내려가면서 필요한 인스턴스를 모두 만들어냅니다.
instantiateClass() — 최종 인스턴스 생성
모든 의존성이 해결되면 드디어 인스턴스를 만듭니다.
// packages/core/injector/injector.ts (간략화)
public async instantiateClass<T>(
instances: any[],
wrapper: InstanceWrapper<T>,
targetMetatype: Type<T>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
const isInContext =
wrapper.isStatic(contextId, inquirer) ||
wrapper.isInRequestScope(contextId, inquirer) ||
wrapper.isLazyTransient(contextId, inquirer) ||
wrapper.isExplicitlyRequested(contextId, inquirer);
if (!isInContext) return;
// 드디어 new!
const instance = new targetMetatype(...instances);
wrapper.instance = instance;
wrapper.isResolved = true;
}
결국 new targetMetatype(...instances)입니다. 해결된 의존성들을 spread로 펼쳐서 constructor에 넘기는 것입니다. DI 컨테이너의 "마법"은 이 new 한 줄을 실행하기 위해 앞의 모든 과정이 준비 작업이었던 셈입니다.
Provider 종류별 처리
NestJS는 네 가지 형태의 프로바이더를 지원합니다. 각각 내부에서 다르게 처리됩니다.
| 종류 | 등록 방식 | 내부 처리 | 인스턴스 생성 시점 |
|---|---|---|---|
| useClass | { provide: TokenA, useClass: ClassB } |
ClassB를 metatype으로 등록, resolveConstructorParams로 의존성 해결 후 new ClassB(...deps) |
InstanceLoader 단계 |
| useValue | { provide: 'CONFIG', useValue: { port: 3000 } } |
값을 즉시 instance에 할당, isResolved: true로 마킹 |
등록 즉시 (resolve 불필요) |
| useFactory | { provide: 'DB', useFactory: (config) => ..., inject: [ConfigService] } |
inject 배열의 의존성을 먼저 해결하고, 팩토리 함수 호출 결과를 instance에 할당 |
InstanceLoader 단계 (팩토리 실행) |
| useExisting | { provide: TokenA, useExisting: TokenB } |
TokenB의 프로바이더를 찾아서 같은 인스턴스를 TokenA에도 연결 (별칭) | 원본 resolve 시점 |
useValue의 즉시 할당
// packages/core/injector/module.ts (간략화)
private addCustomValue(provider: ValueProvider) {
const { provide, useValue } = provider;
this._providers.set(
provide,
new InstanceWrapper({
token: provide,
name: provide?.toString(),
metatype: null,
instance: useValue, // 바로 할당!
isResolved: true, // 이미 해결됨
}),
);
}
useValue는 가장 단순합니다. 인스턴스가 이미 있으므로 의존성 해결이 필요 없습니다. 그래서 isResolved: true로 바로 마킹됩니다.
useFactory의 의존성 주입
useFactory가 흥미롭습니다. 팩토리 함수 자체도 의존성을 받을 수 있습니다.
// 이런 프로바이더가 있으면
{
provide: 'DATABASE_CONNECTION',
useFactory: async (configService: ConfigService) => {
const config = configService.get('database');
return createConnection(config);
},
inject: [ConfigService],
}
Injector는 resolveConstructorParams에서 inject 배열을 우선 사용합니다.
// resolveConstructorParams 내부
const dependencies = inject
? inject // [ConfigService] ← 이쪽
: this.reflectConstructorParams<T>(wrapper.metatype);
inject가 있으면 design:paramtypes를 읽지 않습니다. 팩토리 함수는 일반 함수이므로 TypeScript가 타입 메타데이터를 생성하지 않기 때문입니다. 대신 개발자가 inject 배열로 명시적으로 의존성을 알려주는 것입니다.
순환 의존성(Circular Dependency) 처리
A가 B에 의존하고, B가 A에 의존하면 어떻게 될까요?
@Injectable()
class ServiceA {
constructor(private readonly serviceB: ServiceB) {}
}
@Injectable()
class ServiceB {
constructor(private readonly serviceA: ServiceA) {}
}
DI 컨테이너 입장에서 이것은 무한 루프입니다.
ServiceA를 만들려면 ServiceB가 필요
→ ServiceB를 만들려면 ServiceA가 필요
→ ServiceA를 만들려면 ServiceB가 필요
→ ... (무한 반복)
NestJS는 이를 감지하면 명확한 에러 메시지를 던집니다.
Error: A circular dependency has been detected.
Please, make sure that each side of a bidirectional relationships are decorated with "forwardRef()".
forwardRef()의 동작 원리
forwardRef()는 이 문제를 해결하기 위한 메커니즘입니다.
@Injectable()
class ServiceA {
constructor(
@Inject(forwardRef(() => ServiceB))
private readonly serviceB: ServiceB,
) {}
}
@Injectable()
class ServiceB {
constructor(
@Inject(forwardRef(() => ServiceA))
private readonly serviceA: ServiceA,
) {}
}
forwardRef는 화살표 함수로 감싸서 참조를 지연시킵니다. Injector가 이를 어떻게 처리하는지 보겠습니다.
// packages/core/injector/injector.ts
public resolveParamToken<T>(
wrapper: InstanceWrapper<T>,
param: Type<any> | string | symbol | ForwardReference,
) {
if (typeof param === 'object' && 'forwardRef' in param) {
wrapper.forwardRef = true; // forwardRef 플래그 설정
return param.forwardRef(); // 함수를 호출해서 실제 타입 획득
}
return param;
}
forwardRef 플래그가 설정되면, 해당 의존성은 아직 완전히 초기화되지 않은 프로토타입 객체로도 주입될 수 있습니다. 앞서 InstanceLoader에서 프로토타입을 먼저 만들었던 이유가 여기서 드러납니다.
1. 프로토타입 단계
ServiceA.prototype → 빈 객체 생성
ServiceB.prototype → 빈 객체 생성
2. 인스턴스 생성 단계
ServiceA 생성 시작
→ ServiceB가 필요 (forwardRef)
→ ServiceB 생성 시작
→ ServiceA가 필요 (forwardRef)
→ ServiceA는 이미 프로토타입이 있으므로, 프로토타입 참조를 주입
→ new ServiceB(serviceA_prototype) 완료
→ new ServiceA(serviceB_instance) 완료
3. 프로토타입이 실제 인스턴스로 채워짐
→ ServiceB 안의 serviceA 참조도 실제 인스턴스를 가리키게 됨
(JavaScript 객체 참조의 특성상 같은 객체를 가리킴)
솔직히 순환 의존성은 설계 문제인 경우가 대부분입니다. forwardRef는 해결책이라기보다는 우회책에 가깝습니다. 순환이 발생하면 먼저 두 서비스의 책임을 분리할 수 없는지 검토하는 것이 좋겠다는 생각이 듭니다.
Scope: DEFAULT vs REQUEST vs TRANSIENT
NestJS 프로바이더는 세 가지 스코프를 가질 수 있습니다. @Injectable() 데코레이터의 scope 옵션으로 지정합니다.
@Injectable({ scope: Scope.DEFAULT }) // 생략 시 기본값
@Injectable({ scope: Scope.REQUEST })
@Injectable({ scope: Scope.TRANSIENT })
각 스코프에 따라 인스턴스 캐싱 전략이 완전히 다릅니다.
| 스코프 | 인스턴스 수 | 생성 시점 | 공유 범위 | 사용 사례 |
|---|---|---|---|---|
| DEFAULT | 1개 (싱글턴) | 앱 부트스트랩 시 | 전체 앱에서 공유 | 대부분의 서비스 (상태 없음) |
| REQUEST | 요청당 1개 | 매 요청마다 | 같은 요청 내에서만 공유 | 요청별 상태 필요 (사용자 컨텍스트 등) |
| TRANSIENT | 주입 지점마다 1개 | 매 주입마다 | 공유 안 함 | 독립적 인스턴스 필요 (로거 등) |
InstanceWrapper에서의 스코프 처리
InstanceWrapper는 스코프에 따라 인스턴스를 다른 방식으로 저장합니다.
// packages/core/injector/instance-wrapper.ts (간략화)
export class InstanceWrapper<T = any> {
// DEFAULT 스코프: 단일 인스턴스
private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();
// TRANSIENT 스코프: 주입 요청자(inquirer)별로 별도 인스턴스
private transientMap?: Map<string, WeakMap<ContextId, InstancePerContext<T>>>;
// 어떤 컨텍스트에서 인스턴스를 반환할지 결정
public getInstanceByContextId(contextId: ContextId, inquirerId?: string) {
if (this.scope === Scope.TRANSIENT && inquirerId) {
return this.getInstanceByInquirerId(contextId, inquirerId);
}
// DEFAULT나 REQUEST는 contextId로만 구분
return this.values.get(contextId);
}
}
DEFAULT 스코프는 STATIC_CONTEXT라는 고정된 contextId를 사용합니다. 그래서 항상 같은 인스턴스가 반환되어 싱글턴처럼 동작합니다.
REQUEST 스코프는 매 요청마다 새로운 contextId가 생성되므로, 요청별로 다른 인스턴스가 만들어집니다.
TRANSIENT 스코프는 contextId에 더해 inquirerId(주입을 요청한 쪽의 ID)까지 구분합니다. 같은 요청 안에서도 다른 곳에서 주입하면 다른 인스턴스를 받습니다.
스코프의 전파
중요한 특성이 하나 있습니다. 스코프는 의존성 체인을 따라 전파됩니다.
@Injectable({ scope: Scope.DEFAULT })
class ServiceA {
constructor(private readonly serviceB: ServiceB) {}
}
@Injectable({ scope: Scope.REQUEST })
class ServiceB { ... }
→ ServiceA가 ServiceB에 의존
→ ServiceB가 REQUEST 스코프
→ ServiceA도 사실상 REQUEST 스코프로 동작하게 됨
DEFAULT 스코프 프로바이더가 REQUEST 스코프 프로바이더에 의존하면, 의존하는 쪽도 REQUEST 스코프처럼 동작합니다. 싱글턴인 ServiceA 안에 요청마다 바뀌는 ServiceB가 들어가면 모순이 되기 때문입니다. InstanceWrapper의 isDependencyTreeStatic() 메서드가 이를 재귀적으로 확인합니다.
이것이 문서에서 "REQUEST 스코프 사용에 주의하라"고 경고하는 이유입니다. 한 곳에서 REQUEST 스코프를 사용하면 의존성 체인 전체에 영향을 미쳐 성능에 큰 영향을 줄 수 있습니다.
전체 DI 흐름 다이어그램
지금까지 추적한 내용을 하나의 흐름으로 정리하겠습니다.
NestFactory.create(AppModule)
│
├─ 1. DependenciesScanner.scan()
│ 모든 모듈의 providers, controllers를 NestContainer에 등록
│ (이 과정은 4편에서 자세히 다룹니다)
│
├─ 2. NestContainer
│ ┌─────────────────────────────────────────┐
│ │ modules: Map<token, Module> │
│ │ Module._providers: Map<token, Wrapper> │
│ │ Module._controllers: Map │
│ │ Module._imports: Set<Module> │
│ │ Module._exports: Set<token> │
│ └─────────────────────────────────────────┘
│ → 모든 프로바이더 등록됨, instance는 아직 null
│
├─ 3. InstanceLoader.createInstancesOfDependencies()
│ │
│ ├─ 3a. createPrototypes()
│ │ 모든 모듈의 providers/controllers에 대해
│ │ Object.create(metatype.prototype)으로 빈 껍데기 생성
│ │ → 순환 의존성 처리를 위한 사전 준비
│ │
│ └─ 3b. createInstances()
│ 모듈별로 병렬 처리:
│ │
│ ├─ Providers 인스턴스 생성
│ │ 각 Provider에 대해:
│ │ │
│ │ ├─ Injector.loadInstance(wrapper)
│ │ │ ├─ resolveConstructorParams()
│ │ │ │ ├─ reflectConstructorParams()
│ │ │ │ │ → Reflect.getMetadata('design:paramtypes')
│ │ │ │ │ → Reflect.getMetadata('self:paramtypes')
│ │ │ │ │ → 병합하여 토큰 목록 생성
│ │ │ │ │
│ │ │ │ └─ 각 토큰에 대해:
│ │ │ │ ├─ resolveParamToken() — forwardRef 처리
│ │ │ │ └─ lookupComponent() — 프로바이더 검색
│ │ │ │ ├─ 현재 모듈 providers에서 찾기
│ │ │ │ └─ 없으면 → import된 모듈에서 찾기
│ │ │ │
│ │ │ └─ instantiateClass(resolvedDeps, wrapper)
│ │ │ → new Metatype(...resolvedDeps)
│ │ │ → wrapper.instance = 새 인스턴스
│ │ │ → wrapper.isResolved = true
│ │
│ ├─ Injectables 인스턴스 생성
│ └─ Controllers 인스턴스 생성
│
└─ 4. 앱 준비 완료
모든 인스턴스가 생성되고, 의존성이 주입된 상태
정리
NestJS의 DI 컨테이너는 세 클래스의 협력으로 동작합니다.
NestContainer는 모든 모듈과 프로바이더를 Map 자료구조로 보관하는 저장소입니다. 프로바이더를 등록하지만 인스턴스를 직접 만들지는 않습니다.
InstanceLoader는 프로토타입 생성과 인스턴스 생성이라는 두 단계를 오케스트레이션합니다. 프로바이더 → 인젝터블 → 컨트롤러 순서로 생성을 진행합니다.
Injector가 실제 의존성 해결 엔진입니다. design:paramtypes와 self:paramtypes 메타데이터를 읽어 토큰을 결정하고, 토큰으로 프로바이더를 찾고, 재귀적으로 의존성 트리를 해결한 후 최종적으로 new를 호출합니다.
결국 DI의 "마법"이라 느꼈던 것은, TypeScript 컴파일러가 생성한 타입 메타데이터를 읽고(design:paramtypes), Map에서 해당 프로바이더를 찾고(lookupComponent), 재귀적으로 의존성을 해결하고(resolveConstructorParams), new를 호출하는(instantiateClass) — 이 네 단계의 명확한 과정이었습니다.
그런데 3편에서 계속 "모듈"이라는 말이 나왔습니다. 현재 모듈의 providers에서 먼저 찾고, 없으면 import된 모듈에서 찾는다고 했습니다. 그렇다면 @Module()의 imports, exports, providers, controllers — 이 네 개의 배열은 어떻게 앱을 조립하는 것일까요? 4편에서 DependenciesScanner가 모듈 그래프를 구축하는 과정을 추적해보겠습니다.
관련 글
NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)
NestJS로 서버를 만들 때마다 실행하는 NestFactory.create(AppModule) 한 줄. 이 한 줄이 내부에서 DI 컨테이너 생성, 모듈 스캔, 인스턴스 로딩, Express 바인딩까지 5단계를 거친다는 사실을 NestJS 소스코드를 직접 추적하며 확인합니다.
@Injectable()의 실체 — Reflect.metadata와 데코레이터가 하는 일 (2편)
@Injectable() 하나 붙이면 DI가 된다는 건 알지만, 이 데코레이터가 정확히 무슨 일을 하는 걸까요? NestJS 소스코드와 TypeScript 컴파일러 출력을 직접 추적하며, 데코레이터가 '실행'이 아니라 '등록'이라는 사실을 확인합니다.
@Module()은 어떻게 앱을 조립하는가 — NestJS 소스코드 추적 (4편)
NestJS 앱은 하나의 거대한 provider 목록이 아니라, @Module()로 나뉜 여러 모듈의 조합입니다. @Module() 데코레이터가 메타데이터를 저장하고, DependenciesScanner가 모듈 그래프를 구축하는 전 과정을 소스코드에서 추적합니다.