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]
    1. AppModule 접근, AppModule의 imports인 test1Module, test2Module의 메타데이터 가져옵니다.
    1. 순회 시작, test1Module 부터 재귀를 시작합니다.
    1. test1Module 접근, test1Module의 imports인 test3Module의 메타데이터 가져옵니다.
    1. 순회 시작, test3Module 재귀 시작합니다.
    1. test3Module 접근, test3Module의 imports의 메타데이터 가져옵니다.
    1. 빈배열이니 순회하지 않음, 인스턴스 반환합니다. (누적: [test3Module])
    1. 3번으로 돌아가서 순회 끝났으니 test1Module 본인 인스턴스 반환합니다. (누적: [test3Module, test1Module])
    1. 2번으로 돌아가서 순회 다시 진행, test2Module 재귀 시작합니다.
    1. test2Module 접근, imports 메타데이터 가져옵니다.
    1. 빈배열이니 순회하지 않음, 인스턴스 반환습니다. (누적: [test3Module, test1Module, test2Module])
    1. 순회 끝, 현재 루트였던 AppModule 본인 모듈 포함해서 인스턴스 배열 반환 후 종료합니다.
    1. 최종 누적 인스턴스: [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들을 모두 등록하는 것입니다.

calculateModulesDistance, addScopedEnhancersMetadata, bindGlobalScope


  • scan 메서드에서 사용하는 나머지 세개 메서드는 코드 없이 간단히 명시하려고 합니다.
  • calculateModulesDistance: 루트 모듈에서 각각의 모듈이 얼마나 떨어져있는지 계산합니다. 루트 모듈은 거리가 1이고, 루트 모듈의 imports 배열에 있는 모듈들은 2가 되는 형식입니다.
  • addScopedEnhancersMetadata프로바이더 스코프가 REQUEST이거나 TRANSIENT인 프로바이더들을, 해당 프로바이더가 속해있는 모듈의 컨트롤러에 EnhancerMetadata로 추가합니다.
  • bindGlobalScope: 모든 글로벌 모듈들을 _imports에 추가합니다. 이에 따라, 전역 모듈은 우리가 추가하지 않아도 모든 곳에서 사용할 수 있게 되는 겁니다.

이어가며


  • 다음 글에서는 instanceLoader.createInstancesOfDependencies() 에 대해 알아보고, 의존성에 대한 인스턴스가 어떻게 만들어지고 컨테이너 초기화가 어떻게 진행되어 마무리되는지 알아보겠습니다.