NestJS 컨테이너, DI는 어떻게 작동할까 (2)
다시 들어가며
- 이어서 instanceLoader, metadataScanner, DependenciesScanner 에 대해 조금 더 깊이 알아보고, 컨테이너 초기화가 어떻게 진행되는지 다시 알아보려고 합니다.
1. IoC 컨테이너 부터 확인해보자
await ExceptionsZone.asyncRun(
async () => {
// 의존성 스캐너(dependenciesScanner)를 사용해 전달된 모듈의 모든 의존성을 스캔합니다.
await dependenciesScanner.scan(module);
// 인스턴스 로더(instanceLoader)를 사용해 모든 의존성의 인스턴스를 생성합니다.
await instanceLoader.createInstancesOfDependencies();
// 의존성 스캐너(dependenciesScanner)를 사용해 애플리케이션의 제공자(Providers)를 적용합니다.
dependenciesScanner.applyApplicationProviders();
},
// asyncRun 블록을 종료하고, 예외가 발생할 경우 teardown을 처리하고 로그 자동 플러시 여부를 설정합니다.
teardown,
this.autoFlushLogs
);
- NestFactory.create 메서드에서 initialize에 사용되는 위 핵심 세가지 메서드와 클래스에 대해 깊게 알아보고 있었습니다.
2 DependenciesScanner
scan
public async scan(
module: Type<any>,
options?: { overrides?: ModuleOverride[] },
) {
// 핵심 모듈을 등록합니다.
await this.registerCoreModule(options?.overrides);
// 전달된 루트 모듈 및 하위 모듈들을 스캔합니다.
await this.scanForModules({
moduleDefinition: module,
overrides: options?.overrides,
});
// 각 모듈에서 의존성을 스캔합니다.
await this.scanModulesForDependencies();
// 각 모듈 간의 의존성 거리를 계산합니다.
this.calculateModulesDistance();
// 모든 컨트롤러 메타데이터에 요청이나 일시적으로 전역 스코프의 향상자(Enhancers)를 추가합니다.
this.addScopedEnhancersMetadata();
// 글로벌 스코프를 모든 모듈에 바인딩합니다.
this.container.bindGlobalScope();
}
- NestContainer initialize 메서드에서 사용하고 있는 scan 메서드부터 살펴보겠습니다.
registerCoreModule
public async registerCoreModule(overrides?: ModuleOverride[]) {
// 내부 코어 모듈을 생성합니다.
const moduleDefinition = InternalCoreModuleFactory.create(
this.container,
this,
this.container.getModuleCompiler(),
this.container.getHttpAdapterHostRef(),
this.graphInspector,
overrides,
);
// 생성된 코어 모듈을 스캔하고 인스턴스화합니다.
const [instance] = await this.scanForModules({
moduleDefinition,
overrides,
});
// 생성된 핵심 모듈의 인스턴스를 컨테이너에 등록합니다.
this.container.registerCoreModuleRef(instance);
}
- 내부 핵심 모듈을 생성하고 등록하는 메서드입니다.
- 코어 모듈을 생성하고 인스턴스화한 후, 컨테이너에 등록합니다.
- 코어 모듈 이란 프레임워크에 핵심 기능을 제공하는 필수 모듈입니다.
- 애플리케이션 전체에 걸쳐 사용되는 기본 서비스와 인프라를 제공하는 모듈입니다.
- 코어 모듈의 예시는 아래와 같습니다.
- 모듈의 컴파일을 관리하고 의존성을 설정하는 컴파일러를 제공하는 Module Compiler
- 현재 사용 중인 HTTP 어댑터에 대한 참조를 제공하는 HTTP Adapter Host
- 의존성 그래프를 시각화하고 분석하는 도구를 제공하는 Graph Inspector
scanForModules
public async scanForModules({
moduleDefinition,
lazy,
scope = [],
ctxRegistry = [],
overrides = [],
}: ModulesScanParameters): Promise<Module[]> {
// 모듈을 삽입하거나 재정의하고 모듈 참조 및 삽입 여부를 반환합니다.
const { moduleRef: moduleInstance, inserted: moduleInserted } =
(await this.insertOrOverrideModule(moduleDefinition, overrides, scope)) ??
{};
// 모듈 재정의가 있는 경우 모듈 정의를 업데이트합니다.
moduleDefinition =
this.getOverrideModuleByModule(moduleDefinition, overrides)?.newModule ??
moduleDefinition;
// // 비동기적으로 모듈이 정의되었을 때 await를 적용합니다
moduleDefinition =
moduleDefinition instanceof Promise
? await moduleDefinition
: moduleDefinition;
// 컨텍스트 레지스트리에 현재 모듈 정의를 추가합니다.
ctxRegistry.push(moduleDefinition);
// 전달 참조(forward reference)인 경우 참조된 모듈을 가져옵니다.
if (this.isForwardReference(moduleDefinition)) {
moduleDefinition = (moduleDefinition as ForwardReference).forwardRef();
}
// 모듈이 동적 모듈인지 아닌지를 판별하고, 관련 메타데이터를 통해 의존성을 반영합니다.
// import에 있는 모듈들의 메타데이터를 모두 가져옵니다.
const modules = !this.isDynamicModule(
moduleDefinition as Type<any> | DynamicModule,
)
// 동적 모듈이 아니면, 모듈의 imports에 들어가 있는 배열을 가져옵니다.
? this.reflectMetadata(
MODULE_METADATA.IMPORTS,
moduleDefinition as Type<any>,
)
: [
...this.reflectMetadata(
MODULE_METADATA.IMPORTS,
(moduleDefinition as DynamicModule).module,
),
...((moduleDefinition as DynamicModule).imports || []),
];
// 이미 등록된 모듈 참조들을 관리하는 배열을 초기화합니다.
let registeredModuleRefs = [];
// 각 import 된 모듈을 순회하며, 순환 의존성이나 잘못된 모듈의 경우 예외를 발생시킵니다.
for (const [index, innerModule] of modules.entries()) {
// 순환 참조시 undefined
if (innerModule === undefined) {
throw new UndefinedModuleException(moduleDefinition, index, scope);
}
if (!innerModule) {
throw new InvalidModuleException(moduleDefinition, index, scope);
}
// 이미 컨텍스트 레지스트리에 있는 모듈을 건너뜁니다.
if (ctxRegistry.includes(innerModule)) {
continue;
}
// 내부 모듈을 스캔합니다.
// 해당 메소드를 보면 재귀라는 것을 알 수 있습니다.
// scope는 현재 모듈의 부모 모듈들이 루트(AppModule)부터 순서대로 들어있습니다.
const moduleRefs = await this.scanForModules({
moduleDefinition: innerModule,
scope: [].concat(scope, moduleDefinition),
ctxRegistry,
overrides,
lazy,
});
// 재귀를 다 돌고 자식 모듈의 자식 모듈까지 모두 스캔을 완료하였다면 등록합니다.
registeredModuleRefs = registeredModuleRefs.concat(moduleRefs);
}
// 모듈 인스턴스가 없으면 현재 등록된 모듈 참조들을 반환합니다.
if (!moduleInstance) {
return registeredModuleRefs;
}
// 지연 로딩 모드인 경우 글로벌 모듈들을 현재 모듈에 바인딩합니다.
if (lazy && moduleInserted) {
this.container.bindGlobalsToImports(moduleInstance);
}
// 현재 모듈 인스턴스와 등록된 모듈 참조들을 결합하여 반환합니다.
return [moduleInstance].concat(registeredModuleRefs);
}
- 모듈 정의와 관련된 매개변수를 받아서 모듈을 스캔하는 메서드입니다.
- 재귀를 통해서 단 한 번의 호출로 해당 모듈의 자식 모듈, 그 자식 모듈의 자식 모듈까지 모두 불러옵니다.
- 모듈의 인스턴스만 불러옵니다.
예시
// app.module.ts
@Module({
imports: [test1Module, test2Module],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
// test1.module.ts
@Module({
imports: [test3Module],
controllers: [],
providers: [],
})
export class test1Module {}
// test2.module.ts
@Module({
imports: [],
controllers: [],
providers: [],
})
export class test2Module {}
// test3.module.ts
@Module({
imports: [],
controllers: [],
providers: [],
})
export class test3Module {}
- 여기서 재귀적으로 import 된 모듈까지 불러오는 것에 대한 설명을 위해 위와 같이 모듈이 구성되어 있다고 하겠습니다.
- 루트 모듈(AppModule)을 스캔하면 다음과 같은 과정으로 스캔하게 됩니다.
flowchart TD
A[Start] --> B[Access AppModule]
B --> C[Get metadata of test1Module and test2Module]
C --> D[Start iteration with test1Module]
D --> E[Access test1Module]
E --> F[Get metadata of test3Module]
F --> G[Start iteration with test3Module]
G --> H[Access test3Module]
H --> I[Get metadata of test3Module imports]
I --> J{Is imports array empty?}
J -->|Yes| K[Return test3Module instance]
K --> L[Return to test1Module]
L --> M[Return test1Module instance]
M --> N[Continue iteration with test2Module]
N --> O[Access test2Module]
O --> P[Get metadata of test2Module imports]
P --> Q{Is imports array empty?}
Q -->|Yes| R[Return test2Module instance]
R --> S[Return AppModule instance]
S --> T[End]
T --> U[Final accumulated instances:<br>test3Module, test1Module, test2Module, AppModule]
-
- AppModule 접근, AppModule의 imports인 test1Module, test2Module의 메타데이터 가져옵니다.
-
- 순회 시작, test1Module 부터 재귀를 시작합니다.
-
- test1Module 접근, test1Module의 imports인 test3Module의 메타데이터 가져옵니다.
-
- 순회 시작, test3Module 재귀 시작합니다.
-
- test3Module 접근, test3Module의 imports의 메타데이터 가져옵니다.
-
- 빈배열이니 순회하지 않음, 인스턴스 반환합니다. (누적: [test3Module])
-
- 3번으로 돌아가서 순회 끝났으니 test1Module 본인 인스턴스 반환합니다. (누적: [test3Module, test1Module])
-
- 2번으로 돌아가서 순회 다시 진행, test2Module 재귀 시작합니다.
-
- test2Module 접근, imports 메타데이터 가져옵니다.
-
- 빈배열이니 순회하지 않음, 인스턴스 반환습니다. (누적: [test3Module, test1Module, test2Module])
-
- 순회 끝, 현재 루트였던 AppModule 본인 모듈 포함해서 인스턴스 배열 반환 후 종료합니다.
-
- 최종 누적 인스턴스: [test3Module, test1Module, test2Module, AppModule]
- 이러한 순서로 재귀적으로 깊이 탐색을 통해 모든 모듈을 가져옵니다.
scanModulesForDependencies
public async scanModulesForDependencies(
// 컨테이너에 등록되어 있는 모듈들을 모두 불러옵니다.
modules: Map<string, Module> = this.container.getModules(),
) {
// 각 모듈을 순회하며 의존성을 반영합니다.
for (const [token, { metatype }] of modules) {
// 모듈의 임포트된 의존성을 반영합니다.
await this.reflectImports(metatype, token, metatype.name);
// 모듈의 프로바이더를 반영합니다.
this.reflectProviders(metatype, token);
// 모듈의 컨트롤러를 반영합니다.
this.reflectControllers(metatype, token);
// 모듈에서 내보낸 의존성을 반영합니다.
this.reflectExports(metatype, token);
}
}
- 각 모듈에서 프로바이더, 컨트롤러 및 내보낸 의존성을 반영하는 메서드입니다.
- reflect 메서드들을 통해 각 요소들을 인스턴스로 만들어 모듈에 등록합니다.
- 이전 글의 2.1.*에 적어두었던 nest 컨테이너 메소드를 사용합니다.
- this.container.addProvider, this.container.addController 등으로 등록합니다.
- 컨테이너에 저장했던 모듈들에 대해 imports, providers, controllers, exports들을 모두 등록하는 것입니다.
- 이전 글의 2.1.*에 적어두었던 nest 컨테이너 메소드를 사용합니다.
calculateModulesDistance, addScopedEnhancersMetadata, bindGlobalScope
- scan 메서드에서 사용하는 나머지 세개 메서드는 코드 없이 간단히 명시하려고 합니다.
- calculateModulesDistance: 루트 모듈에서 각각의 모듈이 얼마나 떨어져있는지 계산합니다. 루트 모듈은 거리가 1이고, 루트 모듈의 imports 배열에 있는 모듈들은 2가 되는 형식입니다.
- addScopedEnhancersMetadata: 프로바이더 스코프가 REQUEST이거나 TRANSIENT인 프로바이더들을, 해당 프로바이더가 속해있는 모듈의 컨트롤러에 EnhancerMetadata로 추가합니다.
- bindGlobalScope: 모든 글로벌 모듈들을 _imports에 추가합니다. 이에 따라, 전역 모듈은 우리가 추가하지 않아도 모든 곳에서 사용할 수 있게 되는 겁니다.
이어가며
- 다음 글에서는 instanceLoader.createInstancesOfDependencies() 에 대해 알아보고, 의존성에 대한 인스턴스가 어떻게 만들어지고 컨테이너 초기화가 어떻게 진행되어 마무리되는지 알아보겠습니다.
