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

1. 목표

이전 글과 같이 NestJS 모듈이 초기화되는 내부 구현체를 살펴보고 작동 방식을 이해하며 전체 구조를 파악하는 것이 목표입니다.

인스턴스화된 각각의 모듈들의 관계는 어떻게 알고, 실제로 어떻게 사용되는지 예시를 통해서 직접 확인해보며 알아보려고 합니다.

2. 기준 코드

기준 코드는 이전과 동일합니다. 해당 모듈을 통해서 직접 테스트해보며 어떻게 사용되는지 확인해볼 예정입니다.

// 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],
})
export class AppModule {}

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

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

3. scanModulesForDependencies

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

이제 이어서 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);
    }
}

scanModulesForDependencies 메서드는 위와 같이 이전에 스캔 & 인스턴스화 한 모듈들을 모두 꺼내옵니다.

이때 꺼내온 모듈의 token과 metatype은 아래와 같습니다.

// InternalCoreModule
{
  metatype: [class InternalCoreModule],
  token: 'cd9ac5124fd2f83e60a9cb47773e4a7619b6d92e2e86050eb87524953faa238f'
}

// AppModule
{
  metatype: [class AppModule],
  token: '35ad7d110d7ccbde017257cea8eb5ce18556566da184bb4fcf249d189bb2ddf3'
}

// TestModule
{
  metatype: [class TestModule],
  token: '5c14a869c3b6a379c0bd972ab4f36f9f72246f4c254a33d2510dc17da016638d'
}

이후 순회하며 각 모듈마다 imports, providers, controllers, exports들을 refelct 합니다. 각각 reflect를 어떻게 하는지 하나씩 알아보겠습니다.

3.1 reflectImports

// scanner.ts
public async reflectImports(
    module: Type<unknown>,
    token: string,
    context: string,
  ) {
    const modules = [
      ...this.reflectMetadata(MODULE_METADATA.IMPORTS, module),
      ...this.container.getDynamicMetadataByToken(
        token,
        MODULE_METADATA.IMPORTS as 'imports',
      ),
    ];
    for (const related of modules) {
      await this.insertImport(related, token, context);
    }
}

reflectMetadata 메서드를 통해 해당 모듈의 imports를 가져오고, dynamicMetadata의 경우는 동적으로 등록했으니 getDynamicMetadataByToken 메서드를 통해서 가져옵니다. 두 imports는 다른 곳에 저장되지만, token은 동일합니다.

이제 모듈의 모든 imports를 insertImport 메서드를 통해 주입합니다.

// scanner.ts
public async insertImport(related: any, token: string, context: string) {
    if (isUndefined(related)) {
      throw new CircularDependencyException(context);
    }
    if (this.isForwardReference(related)) {
      return this.container.addImport(related.forwardRef(), token);
    }
    await this.container.addImport(related, token);
}

private isForwardReference(
    module: ModuleDefinition,
  ): module is ForwardReference {
    return module && !!(module as ForwardReference).forwardRef;
}

현재 기준 코드로는 imports를 명시한 모듈은 AppModule 밖에 없습니다. 이로 인해 해당 insertImport 메서드를 호출하는 모듈은 해당 모듈 밖에 없는데요, 인자인 related / token / context는 아래와 같습니다.

{
  related: [class TestModule],
  token: "33d10885597010872025fedccb34addf96ac47c9f15b81f1acb818093ea79533",
  context: "AppModule"
}

해당 TestModule의 경우 isUndefined도 아니고 forwardRef 또한 아니죠! 따라서 그대로 NestContainer에 addImport를 진행합니다.

// container.ts
public async addImport(
    relatedModule: Type<any> | DynamicModule,
    token: string,
  ) {
    if (!this.modules.has(token)) {
      return;
    }
    const moduleRef = this.modules.get(token);
    const { token: relatedModuleToken } =
      await this.moduleCompiler.compile(relatedModule);
    const related = this.modules.get(relatedModuleToken);
    moduleRef.addImport(related);
}

addImport 메서드는 related import 모듈이 이미 있으면 건너뜁니다. 그렇지 않으면 related 모듈을 컴파일하고, 토큰을 통해 ref(class)를 가져옵니다. 이제 기존 모듈에 addImport를 진행하여 module 객체의 _imports Set을 채워줍니다.

// module.ts
public addImport(moduleRef: Module) {
  this._imports.add(moduleRef);
}

혹시 이전에 scanner.insertOrOverrideModule 메서드에서 모듈을 초기화 하는 과정이 기억나시나요? 초기화된 moduleRef를 보면 아래와 같이 _imports가 비어있는 것을 확인할 수 있습니다. 해당 과정에서는 Module 껍데기만 만든거죠!

{
  moduleInstance: 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(0) {}, // 비어있음
    _providers: Map(3) {
      [class AppModule] => [InstanceWrapper],
      [class ModuleRef extends AbstractInstanceResolver] => [InstanceWrapper],
      [class ApplicationConfig] => [InstanceWrapper]
    },
    _injectables: Map(0) {},
    _middlewares: Map(0) {},
    _controllers: Map(0) {},
    _entryProviderKeys: Set(0) {},
    _exports: Set(0) {},
    _distance: 0,
    _initOnPreview: false,
    _isGlobal: false,
    _id: 'a5845b8d339886ef17680',
    _token: 'e521bfd8748aa84f7a6f70e230f3f65f626c3c8e97c59fe73d6af1b1df3397e2'
  }
}

추가가 완료된 후 moduleRef(class)는 어떻게 생겼을까요? 바로 아래와 같이 _imports가 추가된 것을 확인할 수 있습니다.

{
  moduleRef: 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] }, // 추가! (testModule)
    _providers: Map(3) {
      [class AppModule] => [InstanceWrapper],
      [class ModuleRef extends AbstractInstanceResolver] => [InstanceWrapper],
      [class ApplicationConfig] => [InstanceWrapper]
    },
    _injectables: Map(0) {},
    _middlewares: Map(0) {},
    _controllers: Map(0) {},
    _entryProviderKeys: Set(0) {},
    _exports: Set(0) {},
    _distance: 0,
    _initOnPreview: false,
    _isGlobal: false,
    _id: '1a46b2b59b171aa1ab9ee',
    _token: '6627e63b074fdc5b32495f8e05f649e964c7b84f2af0c9bbeba5677a1683f5dd'
  }
}

3.2 reflectProviders

// scanner.ts
public reflectProviders(module: Type<any>, token: string) {
    const providers = [
      ...this.reflectMetadata(MODULE_METADATA.PROVIDERS, module),
      ...this.container.getDynamicMetadataByToken(
        token,
        MODULE_METADATA.PROVIDERS as 'providers',
      ),
    ];
    providers.forEach(provider => {
      this.insertProvider(provider, token);
      this.reflectDynamicMetadata(provider, token);
    });
}

이제 providers를 어떻게 추가하는지 확인해보겠습니다. 구조는 모두 비슷합니다. reflectMetadata 메서드를 통해 해당 모듈의 providers를 가져오고, dynamicMetadata의 경우는 동적으로 등록했으니 getDynamicMetadataByToken 메서드를 통해서 가져옵니다.

가져온 providers를 순회하며 각 provider 마다 insertProvider, reflectDynamicMetadata 메서드를 실행합니다.

InternalCoreModule의 providers는 아래와 같습니다.

// 기존
{ name: 'InternalCoreModule', provider: [class Reflector] }
{
  name: 'InternalCoreModule',
  provider: { provide: 'Reflector', useExisting: [class Reflector] }
}
{
  name: 'InternalCoreModule',
  provider: { provide: 'REQUEST', scope: 2, useFactory: [Function: noop] }
}
{
  name: 'InternalCoreModule',
  provider: { provide: 'INQUIRER', scope: 1, useFactory: [Function: noop] }
}
// 동적 추가
{
  name: 'InternalCoreModule',
  provider: {
    provide: [class ExternalContextCreator],
    useFactory: [Function: useFactory]
  }
}
{
  name: 'InternalCoreModule',
  provider: {
    provide: [class ModulesContainer extends Map],
    useFactory: [Function: useFactory]
  }
}
{
  name: 'InternalCoreModule',
  provider: {
    provide: [class HttpAdapterHost],
    useFactory: [Function: useFactory]
  }
}
{
  name: 'InternalCoreModule',
  provider: {
    provide: [class LazyModuleLoader],
    useFactory: [Function: lazyModuleLoaderFactory]
  }
}
{
  name: 'InternalCoreModule',
  provider: {
    provide: [class SerializedGraph] { INTERNAL_PROVIDERS: [Array] },
    useFactory: [Function: useFactory]
  }
}

InternalCoreModuleDynamicModule 이였던 것을 기억하시나요? 동적으로 요소들이 추가된 해당 모듈의 형태는 아래와 같습니다.

{
  // 기존 InternalCoreModule 
  module: {
    providers: [
      Reflector,
      ReflectorAliasProvider,
      requestProvider,
      inquirerProvider,
    ],
    exports: [
      Reflector,
      ReflectorAliasProvider,
      requestProvider,
      inquirerProvider,
    ],
  },
  // 동적으로 추가된 providers & exports 
  providers: [
    {
      provide: ExternalContextCreator,
      useFactory: () => ExternalContextCreator.fromContainer(container),
    },
    {
      provide: ModulesContainer,
      useFactory: () => container.getModules(),
    },
    {
      provide: HttpAdapterHost,
      useFactory: () => httpAdapterHost,
    },
    {
      provide: LazyModuleLoader,
      useFactory: lazyModuleLoaderFactory,
    },
    {
      provide: SerializedGraph,
      useFactory: () => container.serializedGraph,
    },
  ],
  exports: [
    ExternalContextCreator,
    ModulesContainer,
    HttpAdapterHost,
    LazyModuleLoader,
    SerializedGraph
  ],
};
// scanner.ts
public insertProvider(provider: Provider, token: string) {
    const isCustomProvider = this.isCustomProvider(provider);
    if (!isCustomProvider) {
      return this.container.addProvider(provider as Type<any>, token);
    }
    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,
    });

    const newProvider = {
      ...provider,
      provide: providerToken,
      scope,
    } as Provider;

    const enhancerSubtype =
      ENHANCER_TOKEN_TO_SUBTYPE_MAP[
      type as
      | typeof APP_GUARD
      | typeof APP_PIPE
      | typeof APP_FILTER
      | typeof APP_INTERCEPTOR
      ];
    const factoryOrClassProvider = newProvider as
      | FactoryProvider
      | ClassProvider;
    if (this.isRequestOrTransient(factoryOrClassProvider.scope)) {
      return this.container.addInjectable(newProvider, token, enhancerSubtype);
    }
    this.container.addProvider(newProvider, token, enhancerSubtype);
}

이제 해당 provider들이 어떻게 추가되는지 확인해보겠습니다.

const isCustomProvider = this.isCustomProvider(provider);
if (!isCustomProvider) {
  return this.container.addProvider(provider as Type<any>, token);
}

isCustomProvider 메서드를 호출하여 현재 provider가 커스텀 프로바이더인지 확인합니다. 커스텀 프로바이더가 아니라면, 컨테이너에 일반 프로바이더로 바로 추가 addProvider합니다.

InternalCoreModule의 등록된 providers일반 프로바이더인 Reflector class를 제외하고는 모두 커스텀 프로바이더입니다.

return InternalCoreModule.register([
    {
      provide: ExternalContextCreator,
      useFactory: () => ExternalContextCreator.fromContainer(container),
    },
    {
      provide: ModulesContainer,
      useFactory: () => container.getModules(),
    },
    {
      provide: HttpAdapterHost,
      useFactory: () => httpAdapterHost,
    },
    {
      provide: LazyModuleLoader,
      useFactory: lazyModuleLoaderFactory,
    },
    {
      provide: SerializedGraph,
      useFactory: () => container.serializedGraph,
    },
  ]);
}

위와 같이 동적으로 register할 때 useFactory를 사용하기 때문이지요! 흔히 사용하는 Service는 커스텀 프로바이더가 아니기 때문에 해당 분기에서 바로 프로바이더로 추가되고 종료됩니다. 기준 코드를 토대로 보았을 때 아래 프로바이더들이 바로 추가되고 종료됩니다.

// InternalCoreModule provider
{
  provider: [class Reflector], 
  token: '3f87b7431b7bd558e3cd9bcd3c902e752be9a9ae526a4ec18a2b2bd0f6ee27e8'
}
// AppModule provider
{
  provider: [class AppService],
  token: 'aa5ef6b4cdb9f21fe2b5d628979ded531e4593cddb7b3afa86ce197027cce02f'
}
const applyProvidersMap = this.getApplyProvidersMap();
const providersKeys = Object.keys(applyProvidersMap);
const type = (
  provider as
  | ClassProvider
  | ValueProvider
  | FactoryProvider
  | ExistingProvider
).provide;

이어서 applyProvidersMap 메서드를 통해 프로바이더 타입에 따라 특정 처리를 수행하기 위한 매핑 객체를 가져옵니다. type은 현재 provider의 provide 속성 값을 추출합니다. 이는 프로바이더의 고유 식별자 역할을 합니다.

if (!providersKeys.includes(type as string)) {
  return this.container.addProvider(provider as any, token);
}

현재 프로바이더의 타입이 applyProvidersMap에 등록되지 않은 경우, 컨테이너에 일반 프로바이더로 추가하고 메서드를 종료합니다. applyProvidersMap은 아래와 같은데요, 기본적인 프로바이더인 APP_INTERCEPTOR / APP_PIPE / APP_GUARD / APP_FILTER 가 포함되어 있는 것을 확인할 수 있습니다.

  getApplyProvidersMap() {
      return {
          [constants_2.APP_INTERCEPTOR]: (interceptor) => this.applicationConfig.addGlobalInterceptor(interceptor),
          [constants_2.APP_PIPE]: (pipe) => this.applicationConfig.addGlobalPipe(pipe),
          [constants_2.APP_GUARD]: (guard) => this.applicationConfig.addGlobalGuard(guard),
          [constants_2.APP_FILTER]: (filter) => this.applicationConfig.addGlobalFilter(filter),
      };
  }

현재 InternalCoreModule에 등록된 providers는 ApplyProvidersMap 키값에 해당하는 것이 없군요. 이제 container.addProvider 메서드를 진행하고 종료합니다.

// 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을 통해 검사합니다.

// module.ts
public addProvider(provider: Provider, enhancerSubtype?: EnhancerSubtype) {
    if (this.isCustomProvider(provider)) {
      if (this.isEntryProvider(provider.provide)) {
        this._entryProviderKeys.add(provider.provide);
      }
      return this.addCustomProvider(provider, this._providers, enhancerSubtype);
    }

    const isAlreadyDeclared = this._providers.has(provider);
    if (this.isTransientProvider(provider) && isAlreadyDeclared) {
      return provider;
    }

    this._providers.set(
      provider,
      new InstanceWrapper({
        token: provider,
        name: (provider as Type<Injectable>).name,
        metatype: provider as Type<Injectable>,
        instance: null,
        isResolved: false,
        scope: getClassScope(provider),
        durable: isDurable(provider),
        host: this,
      }),
    );

    if (this.isEntryProvider(provider)) {
      this._entryProviderKeys.add(provider);
    }

    return provider as Type<Injectable>;
}

이제 Module에 최종적으로 Provider를 등록하는 addProvider 메서드를 살펴보겠습니다.

if (this.isCustomProvider(provider)) {
  if (this.isEntryProvider(provider.provide)) {
    this._entryProviderKeys.add(provider.provide);
  }
  return this.addCustomProvider(provider, this._providers, enhancerSubtype);
}

프로바이더가 ClassProvider, ValueProvider, FactoryProvider, ExistingProvider 등 커스텀 프로바이더인지 확인합니다.

커스텀 프로바이더라면, isEntryProvider를 통해 엔트리 프로바이더인지 확인하고, 맞다면 _entryProviderKeys에 추가합니다. 이후 addCustomProvider를 호출하여 커스텀 프로바이더를 처리하고 등록합니다. 현재 보고 있는 InternalCoreModuleproviders는 Reflector를 제외하고 모두 customProvider 입니다. 따라서 여기서 대부분의 프로바이더를 등록하게 됩니다.

// module.ts
public addCustomProvider(
    provider:
      | ClassProvider
      | FactoryProvider
      | ValueProvider
      | ExistingProvider,
    collection: Map<Function | string | symbol, any>,
    enhancerSubtype?: EnhancerSubtype,
) {
    if (this.isCustomClass(provider)) {
      this.addCustomClass(provider, collection, enhancerSubtype);
    } else if (this.isCustomValue(provider)) {
      this.addCustomValue(provider, collection, enhancerSubtype);
    } else if (this.isCustomFactory(provider)) {
      this.addCustomFactory(provider, collection, enhancerSubtype);
    } else if (this.isCustomUseExisting(provider)) {
      this.addCustomUseExisting(provider, collection, enhancerSubtype);
    }
    return provider.provide;
}

각각의 InternalCoreModule의 커스텀 프로바이더는 아래와 같이 분기를 타는데요, 커스텀 프로바이더의 형태에 따라서 다르게 처리됩니다. 아래 값에서 볼 수 있듯이 Reflector를 제외하고는 모두 CustomFactory 입니다.

{
  type: 'customUseExisting',
  provider: { provide: 'Reflector', useExisting: [class Reflector] }
}
{
  type: 'customFactory',
  provider: { provide: 'REQUEST', scope: 2, useFactory: [Function: noop] }
}
{
  type: 'customFactory',
  provider: { provide: 'INQUIRER', scope: 1, useFactory: [Function: noop] }
}
{
  type: 'customFactory',
  provider: {
    provide: [class ExternalContextCreator],
    useFactory: [Function: useFactory]
  }
}
{
  type: 'customFactory',
  provider: {
    provide: [class ModulesContainer extends Map],
    useFactory: [Function: useFactory]
  }
}
{
  type: 'customFactory',
  provider: {
    provide: [class HttpAdapterHost],
    useFactory: [Function: useFactory]
  }
}
{
  type: 'customFactory',
  provider: {
    provide: [class LazyModuleLoader],
    useFactory: [Function: lazyModuleLoaderFactory]
  }
}
{
  type: 'customFactory',
  provider: {
    provide: [class SerializedGraph] { INTERNAL_PROVIDERS: [Array] },
    useFactory: [Function: useFactory]
  }
}
// module.ts
public addCustomFactory(
    provider: FactoryProvider,
    collection: Map<Function | string | symbol, InstanceWrapper>,
    enhancerSubtype?: EnhancerSubtype,
  ) {
    const {
      useFactory: factory,
      inject,
      scope,
      durable,
      provide: providerToken,
    } = provider;

    collection.set(
      providerToken,
      new InstanceWrapper({
        token: providerToken,
        name: (providerToken as Function)?.name || providerToken,
        metatype: factory as any,
        instance: null,
        isResolved: false,
        inject: inject || [],
        scope,
        durable,
        host: this,
        subtype: enhancerSubtype,
      }),
    );
}

addCustomFactory 메서드부터 살펴보겠습니다. collection은 해당 모듈의 _providers 인데요, 해당 모듈에 새 프로바이더를 등록합니다. metatype: factory로 등록되는 것을 확인할 수 있네요.

// module.ts
public addCustomUseExisting(
    provider: ExistingProvider,
    collection: Map<Function | string | symbol, InstanceWrapper>,
    enhancerSubtype?: EnhancerSubtype,
  ) {
    const { useExisting, provide: providerToken } = provider;
    collection.set(
      providerToken,
      new InstanceWrapper({
        token: providerToken,
        name: (providerToken as Function)?.name || providerToken,
        metatype: (instance => instance) as any,
        instance: null,
        isResolved: false,
        inject: [useExisting],
        host: this,
        isAlias: true,
        subtype: enhancerSubtype,
      }),
    );
}

addCustomUseExisting 메서드입니다. 동일하게 collection은 해당 모듈의 _providers 인데요, 해당 모듈에 새 프로바이더를 등록합니다.

나머지 분기들도 똑같은 형식으로 InstanceWrapper의 초기화 값만 다를 뿐 해당 모듈의 provider에 추가해주는 방식입니다. 이렇게 커스텀 프로바이더들이 모듈에 추가되었네요!

// module.ts
public addProvider(provider: Provider, enhancerSubtype?: EnhancerSubtype) {
  // 커스텀 프로바이더 등록
  ...

  const isAlreadyDeclared = this._providers.has(provider);
  if (this.isTransientProvider(provider) && isAlreadyDeclared) {
    return provider;
  }

  ...
}

물론 모든 providers가 customProvider라면 위에서 종료되겠지만, Service와 같이 @Injectable 데코레이터가 붙으면 일반 프로바이더이기 때문에 아래 과정에서 추가됩니다. 계속 살펴보시죠!

InternalCoreModule에서 해당 프로바이더는 일반 프로바이더 입니다. provider: [class Reflector]

현재 프로바이더가 _providers 맵에 이미 등록되어 있는지 확인하고, 등록되어 있으며 프로바이더가 Transient 스코프 이라면 추가 작업 없이 반환합니다. 왜냐하면 해당 스코프는 요청마다 새로운 인스턴스를 생성해야하기 때문입니다.

this._providers.set(
  provider,
  new InstanceWrapper({
    token: provider,
    name: (provider as Type<Injectable>).name,
    metatype: provider as Type<Injectable>,
    instance: null,
    isResolved: false,
    scope: getClassScope(provider),
    durable: isDurable(provider),
    host: this,
  }),
);

if (this.isEntryProvider(provider)) {
  this._entryProviderKeys.add(provider);
}

프로바이더를 _providers 맵에 추가합니다. InstanceWrapper는 NestJS에서 프로바이더 메타데이터를 관리하는 래퍼 클래스입니다. 마지막으로 프로바이더가 엔트리 프로바이더인지 확인하고, 맞다면 _entryProviderKeys에 추가하고 종료합니다.

// scanner.ts
public insertProvider(provider: Provider, token: string) {
    ...

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

    const newProvider = {
      ...provider,
      provide: providerToken,
      scope,
    } as Provider;

    const enhancerSubtype =
      ENHANCER_TOKEN_TO_SUBTYPE_MAP[
      type as
      | typeof APP_GUARD
      | typeof APP_PIPE
      | typeof APP_FILTER
      | typeof APP_INTERCEPTOR
      ];
    const factoryOrClassProvider = newProvider as
      | FactoryProvider
      | ClassProvider;
    if (this.isRequestOrTransient(factoryOrClassProvider.scope)) {
      return this.container.addInjectable(newProvider, token, enhancerSubtype);
    }
    this.container.addProvider(newProvider, token, enhancerSubtype);
}

물론 InternalCoreModule 예시의 경우 위 코드에서 종료되지만, 아래를 더 살펴볼까요?

type이 APP_INTERCEPTOR / APP_PIPE / APP_GUARD / APP_FILTER일 경우에만 해당 코드를 실행합니다. providerToken을 생성하고 scope를 지정합니다. scope는 프로바이더의 생존 범위 (Singleton, Transient, Request 등)를 나타냅니다.

이 프로바이더의 메타데이터applicationProvidersApplyMap에 저장합니다.

const newProvider = {
  ...provider,
  provide: providerToken,
  scope,
} as Provider;

const enhancerSubtype =
  ENHANCER_TOKEN_TO_SUBTYPE_MAP[
    type as
    | typeof APP_GUARD
    | typeof APP_PIPE
    | typeof APP_FILTER
    | typeof APP_INTERCEPTOR
  ];

if (this.isRequestOrTransient(factoryOrClassProvider.scope)) {
  return this.container.addInjectable(newProvider, token, enhancerSubtype);
}
this.container.addProvider(newProvider, token, enhancerSubtype);

기존 프로바이더 데이터를 기반으로 provide와 scope를 업데이트하여 새 프로바이더를 생성합니다. scope가 Request 또는 Transient인 경우 addInjectable을 호출하여 등록합니다. 그렇지 않은 경우 addProvider를 호출합니다.

근데 여기서, 왜 스코프별로 다른 방식으로 동작할까요?

NestJS에서는 의존성의 생애주기를 스코프에 따라 다르게 관리하고 있는데요, 스코프마다 아래와 같이 동작합니다.

Singleton (기본값): 애플리케이션 전체에서 하나의 인스턴스를 공유합니다, 모든 요청(Request)에서 동일한 객체를 반환합니다.

Request: 각 요청마다 새로운 인스턴스를 생성합니다.

Transient: 주입될 때마다 새로운 인스턴스를 생성합니다.

따라서 스코프가 Request나 Transient인 경우 객체의 생애주기를 관리하기 위해 다른 등록 방식을 택해야합니다. addInjectable를 사용하는 경우는 주입 가능한 객체 즉 injectable로 등록할 때이며, 주로 Request 또는 Transient 스코프를 처리합니다. Enhancer (Guard, Pipe, Interceptor, Filter 등)와 같은 요청/응답 단위의 객체에 사용됩니다. addProvider를 사용하는 경우는 일반적인 Singleton 프로바이더를 처리할 때이며 이 경우 생애주기를 별도로 관리할 필요가 없고, 컨테이너에서 단일 인스턴스를 공유합니다. 일반적으로 service, repository, config 등 단일 인스턴스를 공유해야 하는 객체에 사용됩니다.

// contianer.ts
public addInjectable(
    injectable: Provider,
    token: string,
    enhancerSubtype: EnhancerSubtype,
    host?: Type<Injectable>,
  ) {
    if (!this.modules.has(token)) {
      throw new UnknownModuleException();
    }
    const moduleRef = this.modules.get(token);
    return moduleRef.addInjectable(injectable, enhancerSubtype, host);
}

addInjectable 메서드는 간단합니다. 모듈에 injectable을 추가하는 구조입니다. 이렇게 provider 정보가 모듈에 추가되는 과정을 살펴볼 수 있었네요!

3.3 InstanceWrapper의 차이는 무엇인가?

InstanceWrapper는 NestJS 내부에서 의존성(Provider)에 대한 메타데이터를 관리하는 핵심 클래스입니다.

인스턴스를 새로 만들때 생성자에 넣는 인자들은 어떤 것을 의미할까요?

token: 의존성의 고유 식별자 (보통 provide로 지정된 값).

name: 클래스나 토큰의 이름.

metatype: 클래스의 타입 또는 팩토리 함수.

instance: 실제 인스턴스. 초기값은 null이거나 useValue를 사용하는 경우 값이 설정됩니다.

isResolved: 인스턴스가 생성되었는지 여부.

scope: 프로바이더의 생애주기 (Singleton, Request, Transient).

inject: 의존성 주입에 필요한 토큰 배열 (팩토리 프로바이더 및 useExisting에 사용).

durable: Durable 프로바이더인지 여부 (재사용 가능한지 여부).

host: 이 프로바이더를 소유하는 모듈.

subtype: Enhancer 타입 (Guard, Pipe 등).

isAlias: 현재 프로바이더가 다른 프로바이더의 별칭인지 여부.

위에서 커스텀 프로바이더 / 일반 프로바이더 일 때 생성자 인자가 다릅니다. 커스텀 프로바이더 안에서도 ClassProvider / ValueProvider / FactoryProvider / ExistingProvider 인지에 따라 생성자 인자가 다릅니다.

3.3.1 일반 클래스 프로바이더

this._providers.set(
  provider,
  new InstanceWrapper({
    token: provider,
    name: (provider as Type<Injectable>).name,
    metatype: provider as Type<Injectable>,
    instance: null,
    isResolved: false,
    scope: getClassScope(provider),
    durable: isDurable(provider),
    host: this,
  }),
);

클래스를 직접 providers 배열에 등록한 경우 위와 같이 등록됩니다. metatype에 클래스 자체가 설정됩니다. 인스턴스는 아직 생성되지 않았으므로 instance는 null입니다. 스코프는 클래스의 Injectable 데코레이터 설정에 따라 결정됩니다. (getClassScope)

모듈단에서의 사용 예시는 아래와 같습니다.

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

@Module({
  imports: [TestModule],
  controllers: [],
  providers: [TestService], // 직접 등록
})
export class AppModule {}

3.3.2 useClass 커스텀 프로바이더

collection.set(
  token,
  new InstanceWrapper({
    token,
    name: useClass?.name || useClass,
    metatype: useClass,
    instance: null,
    isResolved: false,
    scope,
    durable,
    host: this,
    subtype: enhancerSubtype,
  }),
);

useClass를 사용하는 커스텀 프로바이더일 경우 위와 같이 등록됩니다.

token은 provide에 해당합니다. metatype에 실제 사용할 클래스(useClass)가 설정됩니다. 스코프는 useClass에 따라 설정됩니다.

모듈단에서의 사용 예시는 아래와 같습니다.

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

  @Module({
    imports: [TestModule],
    controllers: [],
    providers: [
      { provide: 'MyService', useClass: MyOtherService }, // useClass 사용
    ],
  })
  export class AppModule {}

3.3.3 useValue 커스텀 프로바이더

collection.set(
  providerToken,
  new InstanceWrapper({
    token: providerToken,
    name: (providerToken as Function)?.name || providerToken,
    metatype: null,
    instance: value,
    isResolved: true,
    async: value instanceof Promise,
    host: this,
    subtype: enhancerSubtype,
  }),
);

useValue를 사용하는 커스텀 프로바이더일 경우 위와 같이 등록됩니다. instance에 useValue 값이 설정되며, 값은 이미 생성된 상태이므로 isResolved는 true입니다. metatype은 없으므로 null입니다.

모듈단에서의 사용 예시는 아래와 같습니다.

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

  @Module({
    imports: [TestModule],
    controllers: [],
    providers: [
      { provide: 'MY_CONFIG', useValue: { key: 'value' } }, // useValue 사용
    ],
  })
  export class AppModule {}

3.3.4 useFactory 커스텀 프로바이더

collection.set(
  providerToken,
  new InstanceWrapper({
    token: providerToken,
    name: (providerToken as Function)?.name || providerToken,
    metatype: factory as any,
    instance: null,
    isResolved: false,
    inject: inject || [],
    scope,
    durable,
    host: this,
    subtype: enhancerSubtype,
  }),
);

useFactory를 사용하는 커스텀 프로바이더일 경우 위와 같이 등록됩니다. metatype에 팩토리 함수가 설정되며, 팩토리 함수에서 필요한 의존성은 inject 배열에 지정됩니다.

팩토리 함수 호출 전이므로 instance는 null이며 isResolved는 false입니다.

모듈단에서의 사용 예시는 아래와 같습니다.

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

  @Module({
    imports: [TestModule],
    controllers: [],
    providers: [
      { provide: 'MyService', useFactory: () => new Date(), inject: [] }, // useFactory 사용
    ],
  })
  export class AppModule {}

3.3.5 useExisting 커스텀 프로바이더

collection.set(
  providerToken,
  new InstanceWrapper({
    token: providerToken,
    name: (providerToken as Function)?.name || providerToken,
    metatype: (instance => instance) as any,
    instance: null,
    isResolved: false,
    inject: [useExisting],
    host: this,
    isAlias: true,
    subtype: enhancerSubtype,
  }),
);

useExisting 사용하는 커스텀 프로바이더일 경우 위와 같이 등록됩니다. 기존에 등록된 프로바이더를 재사용하며, inject 배열에 기존 토큰(useExisting)이 포함됩니다.

isAlias가 true로 설정되며, instance는 여전히 null로 설정되며, 원본 프로바이더를 참조합니다.

모듈단에서의 사용 예시는 아래와 같습니다.

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

  @Module({
    imports: [TestModule],
    controllers: [],
    providers: [
      { provide: 'MyAliasService', useExisting: 'MyService' }, // useExisting 사용
    ],
  })
  export class AppModule {}

4. 이어가며

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 메서드reflectImports / reflectProviders 부분까지는 살펴보았는데요, 내용이 너무 길어진 것 같습니다.

다만 패턴이 비슷하다는 것을 확인할 수 있어서 좀 더 수월하게 살펴볼 수 있을 것 같습니다. 다음 글에 이어서 reflectControllers / reflectExports를 살펴보도록 하겠습니다.