NestJS module는 어떻게 등록되나요? (6)
1. 목표
이전 글과 같이 NestJS 모듈이 초기화되는 내부 구현체를 살펴보고 작동 방식을 이해하며 전체 구조를 파악하는 것이 목표입니다. 인스턴스화된 각각의 모듈들의 관계는 어떻게 알고, 실제로 어떻게 사용되는지 예시를 통해서 직접 확인해보며 알아보려고 합니다.
// scanner.ts
export class DependenciesScanner {
...
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();
this.addScopedEnhancersMetadata();
this.container.bindGlobalScope();
}
이번 글에서는 다시 scanner.scan 메서드로 돌아와서 나머지 calculateModulesDistance, addScopedEnhancersMetadata, bindGlobalScope 메서드를 이어서 살펴보려고 합니다.
2. calculateModulesDistance
// scanner.ts
public calculateModulesDistance() {
const modulesGenerator = this.container.getModules().values();
// Skip "InternalCoreModule" from calculating distance
modulesGenerator.next();
const modulesStack = [];
const calculateDistance = (moduleRef: Module, distance = 1) => {
if (!moduleRef || modulesStack.includes(moduleRef)) {
return;
}
modulesStack.push(moduleRef);
const moduleImports = moduleRef.imports;
moduleImports.forEach(importedModuleRef => {
if (importedModuleRef) {
if (distance > importedModuleRef.distance) {
importedModuleRef.distance = distance;
}
calculateDistance(importedModuleRef, distance + 1);
}
});
};
const rootModule = modulesGenerator.next().value as Module;
calculateDistance(rootModule);
}
우선 getModules 메서드를 통해 등록했던 모듈들을 다시 꺼내옵니다. 이전 글에서 예시 코드로 AppModule, TestModule을 등록했던 것을 기억하시나요?
순서로 따지면 NestContainer에 InternalCoreModule -> AppModule -> TestModule 순으로 등록되었었습니다. distance를 계산할 때 InternalCoreModule는 제외합니다. 가장 먼저 next()를 호출하게되면 반환값은 InternalCoreModule 입니다.
이제 AppModule를 찾기 위해 next()를 한번 더 호출하는데요, InternalCoreModule 다음으로 호출된게 AppModule 이기 때문입니다. calculateDistance 메서드에 rootModule인 AppModule을 인자로 넣어줍니다.
{
value: Module {
_metatype: [class AppModule],
container: NestContainer {
_applicationConfig: [ApplicationConfig],
globalModules: [Set],
moduleTokenFactory: [ModuleTokenFactory],
moduleCompiler: [ModuleCompiler],
modules: [ModulesContainer [Map]],
dynamicModulesMetadata: [Map],
internalProvidersStorage: [InternalProvidersStorage],
_serializedGraph: [SerializedGraph],
internalCoreModule: [Module]
},
_imports: Set(1) { [Module] },
_providers: Map(4) {
[class AppModule] => [InstanceWrapper],
[class ModuleRef extends AbstractInstanceResolver] => [InstanceWrapper],
[class ApplicationConfig] => [InstanceWrapper],
[class AppService] => [InstanceWrapper]
},
_injectables: Map(0) {},
_middlewares: Map(0) {},
_controllers: Map(1) { [class AppController] => [InstanceWrapper] },
_entryProviderKeys: Set(0) {},
_exports: Set(1) { [class AppService] },
_distance: 0,
_initOnPreview: false,
_isGlobal: false,
_id: '2ad0809e20d38c59c2ddb',
_token: '029a16f565d3177f7365e4f29bff28651a3b67f7493130e81f6e7d44d8a607a9'
},
done: false
}
rootModule, 즉 AppMoudle은 위와 같습니다.
const calculateDistance = (moduleRef: Module, distance = 1) => {
if (!moduleRef || modulesStack.includes(moduleRef)) {
return;
}
modulesStack.push(moduleRef);
const moduleImports = moduleRef.imports;
moduleImports.forEach(importedModuleRef => {
if (importedModuleRef) {
if (distance > importedModuleRef.distance) {
importedModuleRef.distance = distance;
}
calculateDistance(importedModuleRef, distance + 1);
}
});
};
이제 AppModule을 시작으로 calculateDistance 메서드를 실행하는데요, 해당 메서드는 재귀적으로 실행됩니다.
깊이 우선 탐색으로 모듈간의 거리를 계산하는데요, 가장 루트 모듈이였던 AppModule의 distance값은 0입니다. 모든 모듈도 처음 초기화될때는 distance값을 0으로 저장하고 있습니다. moduleImports, AppModule에 포함된 모듈을 순회하면서 distance를 적용해줍니다.
if (distance > importedModuleRef.distance)
calculateDistance 메서드의 첫 호출 시 distance는 1이고, import 모듈의 distance는 0이기 때문에 해당 모듈의 distance는 1이 됩니다.
importedModuleRef.distance = distance;
그렇다면 AppModule에서 import한 TestModule의 distance는 1이겠군요!
calculateDistance(importedModuleRef, distance + 1);
이후 calculateDistance 메서드를 해당 모듈 상대로 재귀적으로 실행해 똑같이 imports가 있다면 distance를 설정해줍니다. TestModule에 import한 모듈들이 있으면 distance가 2가 되겠습니다.
이렇게 calculateModulesDistance 메서드를 통해 루트 모듈, AppMoudle로 부터 떨어진 거리를 계산해서 모듈에 저장해줍니다.
3. addScopedEnhancersMetadata
/**
* Add either request or transient globally scoped enhancers
* to all controllers metadata storage
*/
public addScopedEnhancersMetadata() {
iterate(this.applicationProvidersApplyMap)
.filter(wrapper => this.isRequestOrTransient(wrapper.scope))
.forEach(({ moduleKey, providerKey }) => {
const modulesContainer = this.container.getModules();
const { injectables } = modulesContainer.get(moduleKey);
const instanceWrapper = injectables.get(providerKey);
const iterableIterator = modulesContainer.values();
iterate(iterableIterator)
.map(moduleRef =>
Array.from<InstanceWrapper>(moduleRef.controllers.values()).concat(
moduleRef.entryProviders,
),
)
.flatten()
.forEach(controllerOrEntryProvider =>
controllerOrEntryProvider.addEnhancerMetadata(instanceWrapper),
);
});
}
addScopedEnhancersMetadata 메서드는 request 혹은 transient로 스코프가 설정된 글로벌 enhancer를 모든 controller metadata storage에 추가하는 메서드입니다.
// request-scope.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Scope,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable({ scope: Scope.REQUEST })
export class RequestScopeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('Request-scoped interceptor: Before...');
return next.handle().pipe(
tap(() => console.log('Request-scoped interceptor: After...')),
);
}
}
// app.moudle.ts
import { AppController } from "./app.controller";
import { Module, Scope } from "@nestjs/common";
import { TestModule } from "./test.module";
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestScopeInterceptor } from './request-scope.interceptor';
@Module({
imports: [TestModule],
controllers: [AppController],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: RequestScopeInterceptor,
scope: Scope.REQUEST,
}
],
exports: []
})
export class AppModule { }
가령 위와 같이 scope가 REQUEST인 RequestScopeInterceptor를 AppModule에 등록했다고 가정하겠습니다.
[
{
type: 'APP_INTERCEPTOR',
moduleKey: '7cbe17487ff3a82e5ead59d134d779a2a673cb4a177ffdf060c5a4a4c558ed75', // AppMoudle
providerKey: 'APP_INTERCEPTOR (UUID: 75a6d49d96bf0faeb150e)',
scope: 2
}
]
applicationProvidersApplyMap은 위와 같습니다.
Scope의 경우 DEFAULT = 0, TRANSIENT = 1, REQUEST = 2 입니다. 참고로 TRANSIENT로 설정 되었다면 이 프로바이더를 주입(Injection)할 때마다 항상 새로운 인스턴스가 생성됩니다. 여러 곳에서 같은 프로바이더를 주입받더라도, 각각 별개의 인스턴스를 갖게 됩니다. REQUEST로 설정되었더라면 HTTP 요청마다 새로운 인스턴스가 생성됩니다. 요청이 들어올 때마다 해당 프로바이더는 새로 만들어지고, 요청 처리가 끝날 때 소멸됩니다.
// scanner.ts
public insertProvider(provider: Provider, token: string) {
const applyProvidersMap = this.getApplyProvidersMap();
const providersKeys = Object.keys(applyProvidersMap);
const type = (
provider as
| ClassProvider
| ValueProvider
| FactoryProvider
| ExistingProvider
).provide;
if (!providersKeys.includes(type as string)) {
return this.container.addProvider(provider as any, token);
}
const uuid = UuidFactory.get(type.toString());
const providerToken = `${type as string} (UUID: ${uuid})`;
let scope = (provider as ClassProvider | FactoryProvider).scope;
if (isNil(scope) && (provider as ClassProvider).useClass) {
scope = getClassScope((provider as ClassProvider).useClass);
}
// 추가!
this.applicationProvidersApplyMap.push({
type,
moduleKey: token,
providerKey: providerToken,
scope,
});
public getApplyProvidersMap(): { [type: string]: Function } {
return {
[APP_INTERCEPTOR]: (interceptor: NestInterceptor) =>
this.applicationConfig.addGlobalInterceptor(interceptor),
[APP_PIPE]: (pipe: PipeTransform) =>
this.applicationConfig.addGlobalPipe(pipe),
[APP_GUARD]: (guard: CanActivate) =>
this.applicationConfig.addGlobalGuard(guard),
[APP_FILTER]: (filter: ExceptionFilter) =>
this.applicationConfig.addGlobalFilter(filter),
};
}
참고로 해당 applicationProvidersApplyMap은 이전에 살펴보았던 insertProvider 메서드에서 추가되는데요, provider중에서 APP_INTERCEPTOR, APP_PIPE, APP_GUARD, APP_FILTER인 경우에 추가됩니다.
이제 이 applicationProvidersApplyMap을 순회하는 과정을 살펴볼까요?
.filter(wrapper => this.isRequestOrTransient(wrapper.scope))
isRequestOrTransient(scope) {
return scope === interfaces_1.Scope.REQUEST || scope === interfaces_1.Scope.TRANSIENT;
}
우선 isRequestOrTransient 조건은 모두 만족합니다. REQUEST로 설정되어 있으니까요!
.forEach(({ moduleKey, providerKey }) => {
const modulesContainer = this.container.getModules();
const { injectables } = modulesContainer.get(moduleKey);
const instanceWrapper = injectables.get(providerKey);
const iterableIterator = modulesContainer.values();
iterate(iterableIterator)
.map(moduleRef =>
Array.from<InstanceWrapper>(moduleRef.controllers.values()).concat(
moduleRef.entryProviders,
),
)
.flatten()
.forEach(controllerOrEntryProvider =>
controllerOrEntryProvider.addEnhancerMetadata(instanceWrapper),
);
});
const modulesContainer = this.container.getModules();
const { injectables } = modulesContainer.get(moduleKey);
const instanceWrapper = injectables.get(providerKey);
우선 각 프로바이더를 실제 인스턴스(InstanceWrapper)로 가져옵니다.
moduleKey(AppMoudle)와 providerKey(APP_INTERCEPTOR) 정보를 이용해, container.getModules 메서드에서 모듈과 해당 프로바이더의 instanceWrapper를 조회합니다.
const iterableIterator = modulesContainer.values();
iterate(iterableIterator)
.map(moduleRef =>
Array.from<InstanceWrapper>(moduleRef.controllers.values()).concat(
moduleRef.entryProviders,
),
)
NestContainer 내 존재하는 모든 모듈의 컨트롤러와 entryProviders를 전부 가져옵니다. modulesContainer.values()를 순회하며 moduleRef.controllers.values()와 moduleRef.entryProviders를 합쳐 하나의 리스트로 만듭니다.
.flatten()
.forEach(controllerOrEntryProvider =>
controllerOrEntryProvider.addEnhancerMetadata(instanceWrapper),
);
-
합쳐진 리스트를 각 컨트롤러(또는 entryProvider)에 enhancer 메타데이터를 추가합니다.
// instance-wrapper.ts public addEnhancerMetadata(wrapper: InstanceWrapper) { if (!this[INSTANCE_METADATA_SYMBOL].enhancers) { this[INSTANCE_METADATA_SYMBOL].enhancers = []; } this[INSTANCE_METADATA_SYMBOL].enhancers.push(wrapper); } -
가져온 인스턴스(instanceWrapper)를 addEnhancerMetadata(instanceWrapper) 메서드로 등록하여, REQUEST / TRANSIENT 스코프로 지정된 프로바이더가 전역으로 enhancer처럼 동작하도록 만듭니다.
-
정리해보자면, addScopedEnhancersMetadata 메서드란 전역으로 적용해야 하는(단, 스코프가 request/transient) 프로바이더들을 전부 찾아, Nest 애플리케이션 내 모든 컨트롤러 및 엔트리 프로바이더에 Enhancer 형태로 달아주는 작업 해주는 메서드입니다.
이러한 작업을 해주는 이유는 Request / Transient 스코프인 글로벌 프로바이더의 경우, 각 요청마다 혹은 주입 시점마다 새 인스턴스가 필요합니다. Nest는 이를 위해 각 컨트롤러별 Enhancer로 등록해 두고 매 요청마다 / 주입 시점마다 새 객체를 생성하도록 관리합니다. 만약 scope 지정 없이 글로벌 인터셉터(인터셉터, 가드 등)을 쓸 때, 기본 싱글턴 스코프이기 때문에 한 번만 인스턴스화하면 되므로 Nest가 일반 글로벌 인터셉터 초기화 로직을 통해 컨트롤러에 붙이게 되어 addScopedEnhancersMetadata()와는 관련이 없습니다.
4. this.container.bindGlobalScope()
// container.ts
public bindGlobalScope() {
this.modules.forEach(moduleRef => this.bindGlobalsToImports(moduleRef));
}
public bindGlobalsToImports(moduleRef: Module) {
this.globalModules.forEach(globalModule =>
this.bindGlobalModuleToModule(moduleRef, globalModule),
);
}
public bindGlobalModuleToModule(target: Module, globalModule: Module) {
if (target === globalModule || target === this.internalCoreModule) {
return;
}
target.addImport(globalModule);
}
마지막으로 bindGlobalScope 메서드입니다.
글로벌 모듈, @Global() 데코레이터가 달린 모듈을 모든 모듈에 자동으로 import 시켜주기 위한 메서드입니다. 해당 메서드는 모듈들을 모두 돌면서 bindGlobalsToImports 메서드를 호출하는데요, 전역 모듈 목록(this.globalModules)을 순회하면서, 현재 모듈(moduleRef)과 전역 모듈(globalModule)을 매핑해 주는 bindGlobalModuleToModule 메서드를 호출합니다. bindGlobalModuleToModule 메서드에서는 대상 모듈이 곧바로 전역 모듈(globalModule)이거나, 혹은 Nest의 내부 코어 모듈이라면, 별도의 처리를 하지 않습니다. 그렇지 않다면 target.addImport 메서드를를 호출하여 대상 모듈에 전역 모듈을 import로 등록합니다.
5. 정리하며
이제껏 살펴본 흐름을 추가하여 간단하게 정리하고자 합니다. 내용은 아래 플로우 차트와 같습니다.
- InternalCoreModuleFactory.create메서드를 통해 InternalCoreModule를 만든다.
- 해당 InternalCoreModule을 scanForModules 메서드를 통해 스캔을 진행한다. 해당 메서드를 통해 루트 모듈과 자식 모듈들을 모두 인스턴스화해서 NestContainer에 모듈들을 등록한다.
- AppModule(루트 모듈)에 대해서도 scanForModules 메서드를 통해 스캔을 진행하여 모듈을 인스턴스화하고 모듈의 imports를 모두 가져와서 모듈 정보에 넣은 후 NestContainer에 모듈을 등록한다.
- scanModulesForDependencies 메서드를 통해 등록한 모듈들을 순회하며 각 모듈의 imports, providers, controllers, exports를 모듈에 등록한다. 이때 커스텀 메타데이터에 대해서는 DiscoverableMetaHostCollection 클래스를 통해서 따로 저장해둔다.
- 해당 과정들을 통해 모든 모듈들이 모두 스캔되고 인스턴스화 되었으며, 모든 모듈들은 NestContainer에 등록된다.
- AppModule로 부터 각 모듈의 거리가 어떻게 되는지 계산하고 각 모듈에 저장한다.
- 전역으로 적용되어야하는 프로바이더(enhancer) 중 request, transient scope로 지정된 프로바이더를 모두 찾아서 모든 컨트롤러 및 엔트리 프로바이더에 enhancer 형태로 달아준다.
- 글로벌 모듈이 있다면, 모든 모듈의 _imports에 등록한다.
- 모든 모듈의 대한 스캔 및 등록이 완료된다.
module과 container는 위와 같은 형태를 띄게 됩니다!
8. 글들을 마치며
긴 호흡을 통해서 NestJS의 moudle이 어떻게 등록되는지에 대해서 알아보았습니다. 이제야 조금은 모듈과 의존성이 어떻게 스캔되고 등록되는지 알 것 같습니다. 살펴보았던 과정 뒤에는 createInstances 메서드가 남아있습니다.
예를들어 Service 클래스를 작성하면 해당 Service에 의존성 주입이 필요한 요소들에 대해서 생성자에 명시해주면 의존성 주입이 되는데요, 이러한 과정이 바로 createInstances 메서드에서 이루어집니다.
해당 내용은 해당 포스트에서 이어서 찾아볼 수 있습니다!
