NestJS module는 어떻게 등록되나요? (5)

1. 목표

이전 글과 같이 NestJS 모듈이 초기화되는 내부 구현체를 살펴보고 작동 방식을 이해하며 전체 구조를 파악하는 것이 목표입니다. 인스턴스화된 각각의 모듈들의 관계는 어떻게 알고, 실제로 어떻게 사용되는지 예시를 통해서 직접 확인해보며 알아보려고 합니다. 이번 글에서는 DiscoverableMetaHostCollection 클래스를 통해 어떻게 커스텀 메타데이터를 등록하는지에 대해 살펴보도록 하겠습니다.

2. 예시 코드

//app.service.ts
import { Injectable, SetMetadata, } from "@nestjs/common";
import { DiscoveryService } from "@nestjs/core";

const CustomRoleDecorator = DiscoveryService.createDecorator<{ role: string }>();

@CustomRoleDecorator({ role: 'admin' })
@Injectable()
export class AppService {}
//app.controller.ts
import { Controller, Get, Post } from "@nestjs/common";
import { DiscoveryService } from "@nestjs/core";

const MyDecorator = DiscoveryService.createDecorator<{ role: string }>();

const RETURN_VALUE = "test";

@Controller()
@MyDecorator({ role: 'student' })
export class AppController {

  @Get("test")
  test() {
    return RETURN_VALUE;
  }
}

DiscoveryService의 createDecorator 메서드를 통해 만들어진 커스텀 데코레이터를 각각 service, controller에 부착해두었습니다.

3. scanModulesForDependencies

// scanner.ts
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);
    }
}

다시 돌아와서 reflectImports / reflectProviders / reflectControllers / reflectExports에서 커스텀 데코레이터에 대한 메타데이터를 어떻게 등록하는지에 대해서 다시 알아보겠습니다.

4. reflectProviders

// contianer.ts
public addProvider(
    provider: Provider,
    token: string,
    enhancerSubtype?: EnhancerSubtype,
): string | symbol | Function {
    const moduleRef = this.modules.get(token);
    if (!provider) {
      throw new CircularDependencyException(moduleRef?.metatype.name);
    }
    if (!moduleRef) {
      throw new UnknownModuleException();
    }
    const providerKey = moduleRef.addProvider(provider, enhancerSubtype);
    const providerRef = moduleRef.getProviderByKey(providerKey);

    DiscoverableMetaHostCollection.inspectProvider(this.modules, providerRef);

    return providerKey as Function;
}

addProvider 메서드의 경우 moduleRefaddProvider 메서드를 호출하여 프로바이더를 등록하고, 등록된 프로바이더를 키를 사용해 가져옵니다. 이후 NestJS 내부에서 동적으로 커스텀 프로바이더를 매핑하고 관리하기 위해, 등록된 프로바이더를 DiscoverableMetaHostCollection을 통해 검사합니다.

// discoverable-meta-host-collection.ts
/**
   * Inspects a provider instance wrapper and adds it to the collection of providers
   * if it has a metadata key.
   * @param hostContainerRef A reference to the modules container.
   * @param instanceWrapper A provider instance wrapper.
   * @returns void
*/
public static inspectProvider(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
  ) {
    return this.inspectInstanceWrapper(
      hostContainerRef,
      instanceWrapper,
      this.providersByMetaKey,
    );
}

private static inspectInstanceWrapper(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
    wrapperByMetaKeyMap: WeakMap<
      ModulesContainer,
      Map<string, Set<InstanceWrapper>>
    >,
) {
    const metaKey =
      DiscoverableMetaHostCollection.getMetaKeyByInstanceWrapper(
        instanceWrapper,
      ); // this.getMetaKeyByInstanceWrapper가 되어도 된다.
    if (!metaKey) {
      return;
    }

    let collection: Map<string, Set<InstanceWrapper>>;
    if (wrapperByMetaKeyMap.has(hostContainerRef)) {
      collection = wrapperByMetaKeyMap.get(hostContainerRef);
    } else {
      collection = new Map<string, Set<InstanceWrapper>>();
      wrapperByMetaKeyMap.set(hostContainerRef, collection);
    }
    this.insertByMetaKey(metaKey, instanceWrapper, collection);
}

위에서 호출한 DiscoverableMetaHostCollection 클래스의 inspectProvider 메서드입니다. 이제 차근차근 inspectInstanceWrapper 메서드를 살펴볼까요?

const metaKey =
    DiscoverableMetaHostCollection.getMetaKeyByInstanceWrapper(
        instanceWrapper,
    );

위 코드부터 살펴보도록 하겠습니다.

// discoverable-meta-host-collection.ts 
private static getMetaKeyByInstanceWrapper(
    instanceWrapper: InstanceWrapper<any>,
) {
    return this.metaHostLinks.get(
      /** 
      * NOTE: Regarding the ternary statement below,
      * - The condition `!wrapper.metatype` is needed because when we use `useValue`
      * the value of `wrapper.metatype` will be `null`.
      * - The condition `wrapper.inject` is needed here because when we use
      * `useFactory`, the value of `wrapper.metatype` will be the supplied
      * factory function.
      * For both cases, we should use `wrapper.instance.constructor` instead
      * of `wrapper.metatype` to resolve processor's class properly.
      * But since calling `wrapper.instance` could degrade overall performance
      * we must defer it as much we can.
      **/
      instanceWrapper.metatype || instanceWrapper.inject
        ? (instanceWrapper.instance?.constructor ?? instanceWrapper.metatype)
        : instanceWrapper.metatype,
    );
}

getMetaKeyByInstanceWrapper 메서드의 인자로 받은 instanceWrapper은 providerRef 인데요, 기준 코드의 AppService ref는 아래와 같습니다.

{
  instanceWrapper: InstanceWrapper {
    isAlias: false,
    scope: undefined,
    values: WeakMap { <items unknown> },
    token: [class AppService],
    name: 'AppService',
    metatype: [class AppService],
    durable: undefined,
    host: Module {
      _metatype: [class AppModule],
      container: [NestContainer],
      _imports: [Set],
      _providers: [Map],
      _injectables: Map(0) {},
      _middlewares: Map(0) {},
      _controllers: Map(0) {},
      _entryProviderKeys: Set(0) {},
      _exports: Set(0) {},
      _distance: 0,
      _initOnPreview: false,
      _isGlobal: false,
      _id: '35ff0888907d158b176b0',
      _token: 'adea489307c32cc1e751f9f525d0263633a76043cf0a977421a72ec7cd8fc014'
    },
    [Symbol(instance_metadata:cache)]: {},
    [Symbol(instance_metadata:id)]: 'b176b0ab87faa7abf9e70'
  }
}
instanceWrapper.metatype || instanceWrapper.inject
    ? (instanceWrapper.instance?.constructor ?? instanceWrapper.metatype)
    : instanceWrapper.metatype,

해당 service는 metatype이 있으면서 insatnce는 없기 때문에 instanceWrapper.metatype을 this.metaHostLinks에서 찾습니다.

// this.metaHostLinks
Map(2) {
  [class AppController] => 'e272559d3d7921d071249',
  [class AppService] => '272559d3d7921d071249d'
}

AppServiceDiscoveryService.createDecorator를 사용해서 커스텀 데코레이터를 등록했기 때문에 metaHostLinks에 이미 등록되어 있습니다. metaKey인 272559d3d7921d071249d로 찾을 수 있겠네요!

// discovery-service.ts
/**
   * Creates a decorator that can be used to decorate classes and methods with metadata.
   * The decorator will also add the class to the collection of discoverable classes (by metadata key).
   * Decorated classes can be discovered using the `getProviders` and `getControllers` methods.
   * @returns A decorator function.
  */
static createDecorator<T>(): DiscoverableDecorator<T> {
    const metadataKey = uid(21);
    const decoratorFn =
      (opts: T) =>
      (target: object | Function, key?: string | symbol, descriptor?: any) => {
        if (!descriptor) {
          DiscoverableMetaHostCollection.addClassMetaHostLink(
            target as Function,
            metadataKey,
          );
        }
        SetMetadata(metadataKey, opts ?? {})(target, key, descriptor);
      };

    decoratorFn.KEY = metadataKey;
    return decoratorFn as DiscoverableDecorator<T>;
}

참고로 해당 코드에서 확인할 수 있듯이, DiscoverableMetaHostCollection.addClassMetaHostLink 메서드를 통해 metaHostLinks에 정보를 추가합니다.

// discoverable-meta-host-collection.ts
private static inspectInstanceWrapper(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
    wrapperByMetaKeyMap: WeakMap<
      ModulesContainer,
      Map<string, Set<InstanceWrapper>>
    >,
) {
    ...

    let collection: Map<string, Set<InstanceWrapper>>;
    if (wrapperByMetaKeyMap.has(hostContainerRef)) {
      collection = wrapperByMetaKeyMap.get(hostContainerRef);
    } else {
      collection = new Map<string, Set<InstanceWrapper>>();
      wrapperByMetaKeyMap.set(hostContainerRef, collection);
    }
    this.insertByMetaKey(metaKey, instanceWrapper, collection);
}

public static insertByMetaKey(
    metaKey: string,
    instanceWrapper: InstanceWrapper,
    collection: Map<string, Set<InstanceWrapper>>,
) {
    if (collection.has(metaKey)) {
      const wrappers = collection.get(metaKey);
      wrappers.add(instanceWrapper);
    } else {
      const wrappers = new Set<InstanceWrapper>();
      wrappers.add(instanceWrapper);
      collection.set(metaKey, wrappers);
    }
}

inspectInstanceWrapper 메서드를 이어서 살펴볼까요? 이제 metaKey가 있다는 것을 확인했으니, 해당 provider 인스턴스에 매핑해야할 커스텀 데이터가 있다는 이야기입니다. 등록하는 단계이기 때문에 wrapperByMetaKeyMap에는 없을 것입니다. 따라서 새롭게 wrapperByMetaKeyMap에 collection을 세팅해주고 insertByMetaKey 메서드를 호출합니다.

insertByMetaKey 메서드에서는 collection의 metaKey가 없기 때문에 collections에 metaKey를 기반으로 InstanceWrapper Set를 새로 추가합니다. 이제 이러한 과정을 통해 커스텀 메타데이터가 적용된 provider인 경우에도, 해당 커스텀 메타데이터와 provider가 매핑되도록 작업이 완료되었습니다.

/**
  * A map of metadata keys to instance wrappers (providers) with the corresponding metadata key.
  * The map is weakly referenced by the modules container (unique per application).
*/
private static readonly providersByMetaKey = new WeakMap<
    ModulesContainer,
    Map<string, Set<InstanceWrapper>>
>();

참고로 여기서 collection이란 해당 providersByMetaKey WeakMap 입니다.

5. reflectControllers

// container.ts
public addController(controller: Type<any>, token: string) {
    ...

    const controllerRef = moduleRef.controllers.get(controller);
    DiscoverableMetaHostCollection.inspectController(
      this.modules,
      controllerRef,
    );
}

마찬가지로 controller에서도 동일한 절차를 밟습니다.

이번에는 DiscoverableMetaHostCollection.inspectController를 호출하는데요, inspectInstanceWrapper 메서드 호출 시 인자가 this.controllersByMetaKey라는 점을 제외하고는 흐름이 모두 동일합니다!

// discoverable-meta-host-collection.ts
/**
  * Inspects a controller instance wrapper and adds it to the collection of controllers
  * if it has a metadata key.
  * @param hostContainerRef A reference to the modules container.
  * @param instanceWrapper A controller's instance wrapper.
  * @returns void
*/
public static inspectController(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
) {
    return this.inspectInstanceWrapper(
      hostContainerRef,
      instanceWrapper,
      this.controllersByMetaKey,
    );
}

private static inspectInstanceWrapper(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
    wrapperByMetaKeyMap: WeakMap<
      ModulesContainer,
      Map<string, Set<InstanceWrapper>>
    >,
) {
    const metaKey =
      DiscoverableMetaHostCollection.getMetaKeyByInstanceWrapper(
        instanceWrapper,
      ); // this.getMetaKeyByInstanceWrapper가 되어도 된다.
    if (!metaKey) {
      return;
    }

    let collection: Map<string, Set<InstanceWrapper>>;
    if (wrapperByMetaKeyMap.has(hostContainerRef)) {
      collection = wrapperByMetaKeyMap.get(hostContainerRef);
    } else {
      collection = new Map<string, Set<InstanceWrapper>>();
      wrapperByMetaKeyMap.set(hostContainerRef, collection);
    }
    this.insertByMetaKey(metaKey, instanceWrapper, collection);
}

DiscoverableMetaHostCollection 클래스의 inspectController 메서드입니다.

const metaKey =
    DiscoverableMetaHostCollection.getMetaKeyByInstanceWrapper(
        instanceWrapper,
    );

이번에도 똑같이 inspectInstanceWrapper 메서드를 살펴볼까요? 위 코드부터 살펴보도록 하겠습니다.

// discoverable-meta-host-collection.ts 
private static getMetaKeyByInstanceWrapper(
    instanceWrapper: InstanceWrapper<any>,
) {
    return this.metaHostLinks.get(
      /** 
      * NOTE: Regarding the ternary statement below,
      * - The condition `!wrapper.metatype` is needed because when we use `useValue`
      * the value of `wrapper.metatype` will be `null`.
      * - The condition `wrapper.inject` is needed here because when we use
      * `useFactory`, the value of `wrapper.metatype` will be the supplied
      * factory function.
      * For both cases, we should use `wrapper.instance.constructor` instead
      * of `wrapper.metatype` to resolve processor's class properly.
      * But since calling `wrapper.instance` could degrade overall performance
      * we must defer it as much we can.
      **/
      instanceWrapper.metatype || instanceWrapper.inject
        ? (instanceWrapper.instance?.constructor ?? instanceWrapper.metatype)
        : instanceWrapper.metatype,
    );
}

인자로 받은 instanceWrapper은 controllerRef 인데요, 기준 코드의 AppController의 ref는 아래와 같습니다.

{
  instanceWrapper: InstanceWrapper {
    isAlias: false,
    scope: undefined,
    values: WeakMap { <items unknown> },
    token: [class AppController],
    name: 'AppController',
    metatype: [class AppController],
    durable: undefined,
    host: Module {
      _metatype: [class AppModule],
      container: [NestContainer],
      _imports: [Set],
      _providers: [Map],
      _injectables: Map(0) {},
      _middlewares: Map(0) {},
      _controllers: [Map],
      _entryProviderKeys: Set(0) {},
      _exports: Set(0) {},
      _distance: 0,
      _initOnPreview: false,
      _isGlobal: false,
      _id: '45b1826d1d07e32e3fa3f',
      _token: 'efb27ec58a1b52fda40ef2106a73a1d5b0c26f2626b020a72e0b1a3e71ad28b3'
    },
    [Symbol(instance_metadata:cache)]: {},
    [Symbol(instance_metadata:id)]: '3fa3fda82696b21b3a797'
  }
}
instanceWrapper.metatype || instanceWrapper.inject
    ? (instanceWrapper.instance?.constructor ?? instanceWrapper.metatype)
    : instanceWrapper.metatype,

해당 service는 metatype이 있으면서 insatnce는 없기 때문에 instanceWrapper.metatype을 this.metaHostLinks에서 찾습니다.

// this.metaHostLinks
Map(2) {
  [class AppController] => 'e272559d3d7921d071249',
  [class AppService] => '272559d3d7921d071249d'
}

AppControllerDiscoveryService.createDecorator를 사용해서 커스텀 데코레이터를 등록했기 때문에 metaHostLinks에 이미 등록되어 있습니다. metaKey인 e272559d3d7921d071249로 찾을 수 있겠네요!

// discoverable-meta-host-collection.ts
private static inspectInstanceWrapper(
    hostContainerRef: ModulesContainer,
    instanceWrapper: InstanceWrapper,
    wrapperByMetaKeyMap: WeakMap<
      ModulesContainer,
      Map<string, Set<InstanceWrapper>>
    >,
) {
    ...

    let collection: Map<string, Set<InstanceWrapper>>;
    if (wrapperByMetaKeyMap.has(hostContainerRef)) {
      collection = wrapperByMetaKeyMap.get(hostContainerRef);
    } else {
      collection = new Map<string, Set<InstanceWrapper>>();
      wrapperByMetaKeyMap.set(hostContainerRef, collection);
    }
    this.insertByMetaKey(metaKey, instanceWrapper, collection);
}

public static insertByMetaKey(
    metaKey: string,
    instanceWrapper: InstanceWrapper,
    collection: Map<string, Set<InstanceWrapper>>,
) {
    if (collection.has(metaKey)) {
      const wrappers = collection.get(metaKey);
      wrappers.add(instanceWrapper);
    } else {
      const wrappers = new Set<InstanceWrapper>();
      wrappers.add(instanceWrapper);
      collection.set(metaKey, wrappers);
    }
}

이번에도 등록하는 단계이기 때문에 새롭게 collection에 세팅해 줍니다. 이제 이러한 과정을 통해 커스텀 메타데이터가 적용된 controller인 경우에도, 해당 커스텀 메타데이터와 controller가 매핑되도록 작업이 완료되었습니다.

/**
 * A map of metadata keys to instance wrappers (controllers) with the corresponding metadata key.
 * The map is weakly referenced by the modules container (unique per application).
 */
private static readonly controllersByMetaKey = new WeakMap<
    ModulesContainer,
    Map<string, Set<InstanceWrapper>>
>();

참고로 여기서 collection이란 해당 controllersByMetaKey WeakMap 입니다.

6. 정리하며

// 확인 완료!
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); 
    }
}

scanModulesForDependencies 메서드를 전부 살펴보았습니다.

이제껏 살펴본 흐름을 간단하게 정리하고자 합니다. 내용은 아래 플로우 차트와 같습니다.

flowchart TD A[Start] --> B[Create InternalCoreModule] B --> C[Scan InternalCoreModule] C --> D[Instantiate module] D --> E[Register module in NestContainer] E --> F[Scan AppModule root] F --> G[Instantiate AppModule] G --> H[Gather imports for AppModule] H --> I[Register AppModule in NestContainer] I --> J[Scan registered modules for dependencies] J --> K[Register imports for each module] J --> L[Register providers for each module] J --> M[Register controllers for each module] J --> N[Register exports for each module] K & L & M & N --> O[Store custom metadata in DiscoverableMetaHostCollection] O --> P[Scan Finish] B --> |InternalCoreModuleFactory.create| B C --> |scanForModules| C F --> |scanForModules| F J --> |scanModulesForDependencies| J
  1. InternalCoreModuleFactory.create메서드를 통해 InternalCoreModule를 만든다.
  2. 해당 InternalCoreModulescanForModules 메서드를 통해 스캔을 진행한다. 해당 메서드를 통해 루트 모듈과 자식 모듈들을 모두 인스턴스화해서 NestContainer에 모듈들을 등록한다.
  3. AppModule(루트 모듈)에 대해서도 scanForModules 메서드를 통해 스캔을 진행하여 모듈을 인스턴스화하고 모듈의 imports를 모두 가져와서 모듈 정보에 넣은 후 NestContainer에 모듈을 등록한다.
  4. scanModulesForDependencies 메서드를 통해 등록한 모듈들을 순회하며 각 모듈의 imports, providers, controllers, exports를 모듈에 등록한다. 이때 커스텀 메타데이터에 대해서는 DiscoverableMetaHostCollection 클래스를 통해서 따로 저장해둔다.
  5. 해당 과정들을 통해 모든 모듈들이 모두 스캔되고 인스턴스화 되었으며, 모든 모듈들은 NestContainer에 등록된다.

7. 이어가며

// 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 메서드 부터 이어서 살펴보려고 합니다.