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

1. 목표

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

이번 글에서는 controller, exports를 어떻게 등록하는지에 대해 살펴보도록 하겠습니다.

2. 기준 코드

기준 코드로는 이전 코드에 exports를 추가하였습니다. 해당 모듈을 통해서 직접 테스트해보며 어떻게 사용되는지 확인해볼 예정입니다.

// module
// app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { TestModule } from "./test.module";

@Module({
  imports: [TestModule],
  controllers: [AppController],
  providers: [AppService],
  exports: [AppService],
})
export class AppModule {}

// module
// test.module.ts
import { Module } from "@nestjs/common";

@Module({
  imports: [],
  controllers: [],
  providers: [],
})
export class TestModule {}

// controller
// app.controller.ts
import { Controller, Get } from "@nestjs/common";

const RETURN_VALUE = "test";

@Controller()
export class AppController {
  @Get("test")
  test() {
    return RETURN_VALUE;
  }
}

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);
    }
}

이제 이어서 reflectControllers / reflectExports를 살펴보도록 하겠습니다.

4. reflectControllers

// scanner.ts
public reflectControllers(module: Type<any>, token: string) {
    const controllers = [
      ...this.reflectMetadata(MODULE_METADATA.CONTROLLERS, module),
      ...this.container.getDynamicMetadataByToken(
        token,
        MODULE_METADATA.CONTROLLERS as 'controllers',
      ),
    ];
    controllers.forEach(item => {
      this.insertController(item, token);
      this.reflectDynamicMetadata(item, token);
    });
}

controllers를 어떻게 추가하는지 확인해보겠습니다.

reflectMetadata 메서드를 통해 해당 모듈의 controllers를 가져오고, dynamicMetadata의 경우는 동적으로 등록했으니 getDynamicMetadataByToken 메서드를 통해서 가져옵니다.

가져온 controllers를 순회하며 각 controller 마다 insertController, reflectDynamicMetadata 메서드를 실행합니다.

  { controllers: [ [class AppController] ] }

기준 코드의 AppModule의 controllers는 위와 같습니다.

// scanner.ts
public insertController(controller: Type<Controller>, token: string) {
    this.container.addController(controller, token);
}

// container.ts
public addController(controller: Type<any>, token: string) {
    if (!this.modules.has(token)) {
      throw new UnknownModuleException();
    }
    const moduleRef = this.modules.get(token);
    moduleRef.addController(controller);

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

// module.ts
public addController(controller: Type<Controller>) {
    this._controllers.set(
      controller,
      new InstanceWrapper({
        token: controller,
        name: controller.name,
        metatype: controller,
        instance: null,
        isResolved: false,
        scope: getClassScope(controller),
        durable: isDurable(controller),
        host: this,
      }),
    );

    this.assignControllerUniqueId(controller);
}

간단합니다, container에 해당 컨트롤러를 추가합니다. 해당 모듈에도 컨트롤러를 등록합니다. 이후 provider를 등록할 때와 마찬가지로, NestJS 내부에서 동적으로 분석하고 관리하기 위해 등록된 컨트롤러를 DiscoverableMetaHostCollection을 통해 검사합니다.

이렇게 모듈의 controller들이 등록되었군요!

5. reflectExports

public reflectExports(module: Type<unknown>, token: string) {
    const exports = [
      ...this.reflectMetadata(MODULE_METADATA.EXPORTS, module),
      ...this.container.getDynamicMetadataByToken(
        token,
        MODULE_METADATA.EXPORTS as 'exports',
      ),
    ];
    exports.forEach(exportedProvider =>
      this.insertExportedProvider(exportedProvider, token),
    );
}

exports를 추가하는 방법도 동일합니다. reflectMetadata 메서드를 통해 해당 모듈의 exports를 가져오고, dynamicMetadata의 경우는 동적으로 등록했으니 getDynamicMetadataByToken 메서드를 통해서 가져옵니다.

가져온 exports를 순회하며 각 exportedProvider 마다 insertExportedProvider 메서드를 실행합니다.

  { exports: [ [class AppService] ] }

기준 코드의 AppModule의 exports는 위와 같습니다.

// scanner.ts
public insertExportedProvider(
    // TODO: improve the type definition below because it doesn't reflects the real usage of this method
    exportedProvider: Type<Injectable> | ForwardReference,
    token: string,
  ) {
    const fulfilledProvider = this.isForwardReference(exportedProvider)
      ? exportedProvider.forwardRef()
      : exportedProvider;
    this.container.addExportedProvider(fulfilledProvider, token);
  }

// container.ts
public addExportedProvider(provider: Type<any>, token: string) {
    if (!this.modules.has(token)) {
      throw new UnknownModuleException();
    }
    const moduleRef = this.modules.get(token);
    moduleRef.addExportedProvider(provider);
}

해당 과정 또한 간단합니다. 해당 모듈에도 exportedProvider를 등록합니다.

// module.ts
public addExportedProvider(
    provider: Provider | string | symbol | DynamicModule,
) {
    const addExportedUnit = (token: InjectionToken) =>
      this._exports.add(this.validateExportedProvider(token));

    if (this.isCustomProvider(provider as any)) {
      return this.addCustomExportedProvider(provider as any);
    } else if (isString(provider) || isSymbol(provider)) {
      return addExportedUnit(provider);
    } else if (this.isDynamicModule(provider)) {
      const { module: moduleClassRef } = provider;
      return addExportedUnit(moduleClassRef);
    }
    addExportedUnit(provider as Type<any>);
}

프로바이더의 종류에 따라서 분기처리가 되는데요, 각각의 분기문은 아래와 같습니다.

// module.ts
public isCustomProvider(
    provider: Provider,
  ): provider is
    | ClassProvider
    | FactoryProvider
    | ValueProvider
    | ExistingProvider {
    return !isNil(
      (
        provider as
          | ClassProvider
          | FactoryProvider
          | ValueProvider
          | ExistingProvider
      ).provide,
    );
}

// module.ts
public isDynamicModule(exported: any): exported is DynamicModule {
    return exported && exported.module;
}

// shared.utils.ts
const isString = (val: any): val is string => typeof val === 'string';
const isSymbol = (val: any): val is symbol => typeof val === 'symbol';

isCustomProvider: 커스텀 제공자는 ClassProvider, FactoryProvider, ValueProvider, ExistingProvider 유형인데요, 아래 유형은 공통적으로 provide라는 속성을 가지고 있습니다. 해당 필드를 바탕으로 커스텀 제공자인지 구별합니다.

isDynamicModule: 주어진 exported 객체가 동적 모듈인지 확인합니다. 동적 모듈 객체는 일반적으로 module이라는 속성을 포함합니다. 마찬가지로 해당 필드를 바탕으로 동적 모듈인지 구별합니다.

isString, isSymbol: 각각 string 타입인지 / symbol 타입인지 구별합니다.

public addCustomExportedProvider(
    provider:
      | FactoryProvider
      | ValueProvider
      | ClassProvider
      | ExistingProvider,
  ) {
    const provide = provider.provide;
    if (isString(provide) || isSymbol(provide)) {
      return this._exports.add(this.validateExportedProvider(provide));
    }
    this._exports.add(this.validateExportedProvider(provide));
}
  • provider가 커스텀 프로바이더인 경우 addCustomExportedProvider 메서드가 호출됩니다.
  • 우선 provider.provide 값을 추출하고, provide가 string인지 symbol인지 확인합니다.
  • 해당 조건문을 타는 것과 관계없이 로직은 동일합니다. 해당 부분은 확장성을 고려해서 의도적으로 짜여있는 것인지 실수인지 확인을 추후에 해보도록 하겠습니다.
    • 개인적인 생각으로 상위 메서드에서도 해당 메서드의 리턴값을 그대로 리턴하기 때문에 리턴값이 없으면 안될 것 같습니다.
public validateExportedProvider(token: InjectionToken) {
    if (this._providers.has(token)) {
      return token;
    }
    const imports = iterate(this._imports.values())
      .filter(item => !!item)
      .map(({ metatype }) => metatype)
      .filter(metatype => !!metatype)
      .toArray();

    if (!imports.includes(token as Type<unknown>)) {
      const { name } = this.metatype;
      const providerName = isFunction(token) ? (token as Function).name : token;
      throw new UnknownExportException(providerName as string, name);
    }
    return token;
}

일단 validateExportedProvider 메서드를 살펴볼까요? 먼저 this._providers에 존재를 확인합니다. token이 현재 모듈의 제공자 목록(_providers)에 존재하면 검증 통과합니다.

모듈의 providers에 없다면 _imports를 확인하는데요, _imports는 현재 모듈이 의존하는 다른 모듈의 목록입니다. 해당 목록의 요소당 _imports.values()를 순회하며 각 모듈의 metatype(클래스 참조)을 가져옵니다. 해당 token이 이 metatype 리스트에 포함되어 있는지 확인합니다.

만약 token이 _providers에도 없고 _imports에도 없으면, UnknownExportException을 던집니다. 모듈에서 없는 것export 할 수 없으니까요!

문제가 없다면 token이 리턴되고, 해당 토큰이 모듈의 this._exports에 추가됩니다.

const addExportedUnit = (token: InjectionToken) =>
    this._exports.add(this.validateExportedProvider(token));

provider가 DynamicModule, string symbol인 경우 위와 같이 addExportedUnit 메서드를 통해 바로 validateExportedProvider 메서드를 통해 validate를 진행하고 모듈의 this._exports에 추가됩니다.

let token;
if (this.isCustomProvider(provider as any)) {
    token = provider.provide;
} else if (isString(provider) || isSymbol(provider)) {
    token = provider;
} else if (this.isDynamicModule(provider)) {
    token = provider.module;
}

this._exports.add(this.validateExportedProvider(token));

코드가 왔다갔다 상당히 복잡한데요, 제가 한번에 보기 편한 용도로만 임의로 코드를 수정해보겠습니다.

요약하자면, 위와 같이 결국에는 프로바이더의 케이스마다 토큰이 다른 형태로 존재하기 때문에 올바른 형태로 뽑아내고, 그 후에 공통적으로 validate를 하고 모듈의 exports에 추가합니다.

이렇게 모듈의 export들이 등록되었군요!

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 메서드를 전부 살펴보았는데요, 해당 메서드를 통해서 등록했던 모듈에 선언된 imports / providers / controllers / exports가 모듈에 등록되는 것을 확인할 수 있었습니다.

하지만 이렇게 메모듈의 각 요소들은 메타데이터를 기반으로 가져오고 등록되었지만, 커스텀 데코레이터들은 어떻게 알고 등록되는 걸까요?

바로 제가 분석 없이 넘어갔던 DiscoverableMetaHostCollection 클래스를 통해서인데요, 넘어가지 않고 해당 클래스부터 다시 짚어보며 이어서 작성해보도록 하겠습니다.