1. Dependency Injection (의존성 주입)

NestJS 에서 의존성 주입은 IoC Container 에 의해서 수행될 수 있다. 개발자는 직접 의존성을 주입하거나, 인스턴스를 생성하거나, 혹은 초기화할 필요가 없다.

NestJS 의 Provider 또한 IoC Container 의 의존성 주입으로 사용할 수 있다. 의존성 주입을 하는 Provider 의 타입을 이 Provider 가 상속 받은 추상 클래스로 선언하고 싶었다.

의존성 주입을 통해 의존할 대상을 내부에서 직접 생성하는게 아니라 외부로부터 전달 받을 수 있다. 내부에서 직접 의존할 대상을 생성하지 않게 되면서 런타임에 다양한 인스턴스를 외부에서 주입 받을 수 있다.

예를 들어 아래와 같은 추상 클래스 Car 를 상속받은 Mercedes, BMW 클래스가 있다고 가정한다.


abstract class Car {
  abstract drive(): void;
}

class Mercedes extends Car {
  @override
  drive() {
    console.log('mercedes');
  }
}

class BMW extends Car {
  @override
  drive() {
    console.log('bmw');
  }
}

Driver 클래스에서 Car 를 주입 받는다. 여기서 car 의 타입을 Mercedes 나 BMW 가 아닌 Car 로 선언했다.


class Driver {
  private car: Car;

  constructor(car: Car) {
    this.car = car;
  }

  void operate() {
    car.drive();
  }
}

Driver 의 인스턴스를 생성할때 Mercedes, BMW 중 원하는 클래스를 주입한다. 만약에 Driver 의 car 변수의 타입을 Mercedes, BMW 둘 중 하나로 선언하면 선언한 타입에 따라 Mercedes, BMW 둘 중 하나만 주입할 수 있다.


const driver1: Driver = new Driver(new Mercedes());
driver1.operate();
// 'mercedes'

const driver2: Driver = new Driver(new BMW());
driver2.operate();
// 'bmw'

2. Abstract Class Injection (추상 클래스 주입)

이를 응용해서 NestJS 에서도 추상 클래스 타입으로 의존성을 주입받고 싶었으나 원하는대로 되지 않았다.

비슷한 기능을 하는 여러 클래스들을 추상 클래스로 묶고 싶었다. 예를 들어 포인트를 적립하는 기능을 개발하는데 포인트 종류가 여러개면 포인트 종류별로 클래스를 만들 수 있다.

포인트 클래스의 기능은 공통적으로 적립 기능인데 클래스마다 구현이 달라진다. 포인트 종류가 추가될 때마다 클래스도 추가 되어야 하는데 만약 메소드명이 클래스마다 다르다면 같은 기능이더라도 각 클래스마다 호출할 때 메소드명이 무엇인지 파악해야 한다. 이러한 불편함을 해결하기 위해 상위 클래스로 추상 클래스를 사용할 수 있다.


export abstract class Point {
  abstract savePoint(): void;
}

포인트 종류가 추가되어 새 클래스를 구현할때 이제 Point 를 상속 받아서 추상 메소드인 savePoint 를 구현하도록 할 수 있고 메소드를 호출할 때도 동일한 메소드명이 호출되어서 코드를 파악하기도 편해졌다.


@Injectable
export class APointService extends Point {
  savePoint() {
    console.log('a-point');
  }
}
@Injectable
export class BPointService extends Point {
  savePoint() {
    console.log('b-point');
  }
}

PointModule 에서는 PointFactoryModule 을 import 해온다. 그리고 PointController, PointService 를 각각 controller 와 provider 로 등록한다.


@Module({
  imports: [PointFactoryModule],
  controllers: [PointController],
  providers: [PointService],
})
export class PointModule {}

PointController 에서 PointService 의 savePoint 메소드를 호출한다.


@Controller('point')
export class PointController {
  constructor(
    private readonly pointService: PointService,
  ) {}

  @Post('point')
  async savePoint() {
    return await this.pointService.savePoint();
  }
}

PointService 에서는 PointFactoryService 를 주입 받아 사용한다. savePoint 메소드에서 getPointService 메소드를 통해 APointService 혹은 BPointService 를 리턴받아 해당 클래스에 정의한 savePoint 메소드를 호출하게 된다.

편의상 여기서는 getPointService 의 인자를 직접 PointType.A 로 넣어줬다.


@Injectable()
export class PointService {
  constructor(
    private readonly pointFactoryService: PointFactoryService,
  ) {}

  async savePoint() {
    return this.pointFactoryService
      .getPointService(PointType.A)
      .savePoint();
  }
}

팩토리 패턴 을 통해 PointFactoryService 에서 Point 클래스를 상속받은 서비스를 찾아준다. APointService, BPointService 를 PointFactoryService 의 providers 프로퍼티에 등록한다. Nest IoC Container 에 의해 PointFactoryService 는 APointService, BPointService 를 주입 받아 사용할 수 있다.

APointService, BPointService 를 Custom Provider 로 등록한다. NestJS 에서 내부적으로 Custom Provider 여부를 어떻게 판단하는지는 마지막에 다룰 예정이다.


@Module({
  providers: [
    PointFactoryService,
    {
      provide: Point,
      useClass: APointService,
    },
    {
      provide: Point,
      useClass: BPointService,
    }
  ],
  exports: [PointFactoryService],
})
export class PointFactoryModule {}

PointFactoryService 에서는 주입받은 APointService, BPointService 를 pointMap 으로 선언한 Map 에 등록한다. getPointService 메소드에서 PointType 에 해당하는 key 로 pointMap 를 조회해서 value 로 등록한 서비스를 리턴한다.


@Injectable()
export class PointFactoryService {
  private pointMap = new Map<PointType, Point>();

  constructor(
    private readonly aPointService: Point,

    private readonly bPointService: Point,
  ) {
    this.pointMap.set(PointType.A, this.aPointService);
    this.pointMap.set(PointType.B, this.bPointService);
  }

  getPointService(pointType: PointType) {
    return this.pointMap.get(pointType);
  }
}
export enum PointType {
  A = 'a',
  B = 'b',
}

3. 문제 상황

위에서 언급한 PointService 코드를 다시 살펴보겠다. savePoint 메소드를 호출했을때 기대한 결과는 'a-point' 인데 실제로는 'b-point' 가 나온다.


@Injectable()
export class PointService {
  constructor(
    private readonly pointFactoryService: PointFactoryService,
  ) {}

  async savePoint() {
    return this.pointFactoryService
      .getPointService(PointType.A)
      .savePoint();
      // b-point
  }
}

4. 문제 원인

왜 이런 결과가 나왔는지 궁금해서 NestJS 의 소스코드를 보게 됐다. NestJS 의 소스코드가 굉장히 방대하기도 하고, 아직 모든 소스코드를 이해할 수 있는 역량이 되지 못해서 필요한 부분 위주로 살펴봤다.

소스코드에서 필요한 부분을 찾는 것 조차도 쉽지 않았는데 이분 의 글이 아주 많은 도움이 됐다. NestJS 의 의존성 주입은 1) 코드에 작성된 의존성들을 스캔한 후에 2) 스캔한 의존성들의 인스턴스를 생성하고 3) 이 인스턴스를 적용하는 과정을 거쳐서 이루어진다.

이번 글에서는 의존성 스캔을 중심으로 살펴볼 예정이다. 스캔한 의존성을 바탕으로 인스턴스를 생성하기 때문에 의존성 스캔을 어떻게 하느냐에 따라서 생성될 인스턴스가 결정된다.


// core/nest-factory.ts
export class NestFactoryStatic {
  private async initialize(
    module: any,
    container: NestContainer,
    graphInspector: GraphInspector,
    config = new ApplicationConfig(),
    options: NestApplicationContextOptions = {},
    httpServer: HttpServer = null,
  ) {
    // ...
    try {
      this.logger.log(MESSAGES.APPLICATION_START);

      await ExceptionsZone.asyncRun(
        async () => {
          await dependenciesScanner.scan(module); // 의존성 스캔
          await instanceLoader.createInstancesOfDependencies(); // 스캔한 의존성들의 인스턴스 생성
          dependenciesScanner.applyApplicationProviders(); // 인스턴스 적용
        },
        teardown,
        this.autoFlushLogs,
      );
    } catch (e) {
      this.handleInitializationError(e);
    }
  }
}
4-1. 의존성 스캔

의존성 스캔은 DependenciesScanner 클래스의 scan 메소드로 수행된다.

다음은 DependenciesScanner 클래스의 일부 코드다. 모듈들을 스캔한 후에 스캔한 모듈들의 의존성을 스캔한다. 여기서 모듈은 AppModule 과 AppModule 의imports 에 선언한 Module 들을 가리킨다.

모듈 스캔은 AppModule 을 중심으로 재귀적으로 AppModule 에 선언한 Module 들을 스캔한다. 그리고 이 모듈들의 의존성 스캔은 controllers, providers 등으로 선언한 의존성을 스캔한다. 이 글에서는 providers 를 중심으로 살펴볼 예정이다.


// core/scanner.ts
export class DependenciesScanner {
  public async scan(module: Type<any>) {
    await this.registerCoreModule();
    await this.scanForModules(module); // 모듈들 스캔
    await this.scanModulesForDependencies(); // (1) scanForModules 로 스캔한 모듈들의 의존성 스캔
    this.calculateModulesDistance();

    this.addScopedEnhancersMetadata();
    this.container.bindGlobalScope();
  } 

  public async scanModulesForDependencies(
    modules: Map<string, Module> = this.container.getModules(),
  ) {
    // modules -> ModulesContainer
    // ModulesContainer 는 Map<String, Module> 을 상속받은 클래스로
    // 키가 string, 값이 Module 이다
    // 
    // modules 에서 for loop 돌면서 token, { metatype } 꺼낸다
    // key 가 token 이고, value 는 Module 클래스인데 Module 의 metatype 속성을 구조 분해 할당으로 꺼낸다
    for (const [token, { metatype }] of modules) {
      await this.reflectImports(metatype, token, metatype.name);
      this.reflectProviders(metatype, token); // (2) 모듈의 provider 스캔
      this.reflectControllers(metatype, token);
      this.reflectExports(metatype, token);
    }
  } 

  public reflectProviders(module: Type<any>, token: string) {
    const providers = [
      // reflectMetadata 메소드는
      // MODULE_METADATA.PROVIDERS 를 key, module 을 target 으로
      // Reflect 모듈의 getMetadata 메소드를 호출한다
      // providers 를 키로 사전에 메타데이터 등록을 해놓은 provider 중에 
      // module 에 해당하는 객체를 찾는다
      ...this.reflectMetadata(MODULE_METADATA.PROVIDERS, module),
      ...this.container.getDynamicMetadataByToken(
        token,
        MODULE_METADATA.PROVIDERS as 'providers',
      ),
    ];

    // 위에서 찾은 providers 를 forEach 문을 돌면서
    // insertProvider 메소드를 통해
    // NestContainer 의 변수인 modules (ModulesContainer 인스턴스) 로부터 Module 을 조회하여
    // 해당 Module 에 provider 를 등록한다
    providers.forEach(provider => {
      this.insertProvider(provider, token); // (3) 
      this.reflectDynamicMetadata(provider, token);
    });
  }

  public insertProvider(provider: Provider, token: string) {
    const isCustomProvider = this.isCustomProvider(provider);
    if (!isCustomProvider) {
      // custom provider 가 아니면 provider 를 바로 Module 에 등록한다
      return this.container.addProvider(provider as Type<any>, token);
    }

    // Global 로 선언한 Interceptor, Pipe, Guard, Filter 등을 담은 객체
    const applyProvidersMap = this.getApplyProvidersMap();
    const providersKeys = Object.keys(applyProvidersMap);
    // 해당 Provider 가 provide 키로 등록한 값을 조회하여 type 변수에 대입한다
    // ex> { provide: AService, useClass: AService }
    // 위의 경우 AService 가 type 변수에 담긴다
    const type = (
      provider as
        | ClassProvider
        | ValueProvider
        | FactoryProvider
        | ExistingProvider
    ).provide;

    // Provider 가 Global 로 선언한 Interceptor, Pipe, Guard, Filter 에 해당하지 않는다면
    // addProvider 메소드를 호출한다
    // 위에서 생성한 APointService, BPointService 등은 이 if 문을 충족해서
    // addProvider 메소드를 호출한다
    if (!providersKeys.includes(type as string)) {
      return this.container.addProvider(provider as any, token);
    }
    // UuidFactory의 mode 는 random, deterministic 두가지가 있는데
    // 이는 NestFactoryStatic 의 create 함수를 호출하는 방식에 의해 결정된다
    // NestFactoryStatic 의 create 함수는 NestJS 의 진입점인 
    // main.ts 의 bootstrap 함수에서 호출된다
    // 디폴트로는 create 에 AppModule 만 인자로 넣어서 호출하는데 
    // 선택적으로 두번째 인자로 NestApplicationOptions 를 넣어줄 수 있다
    // 이를 따로 넣지 않는한 random mode 가 된다
    // random mode 는 randomStringGenerator 함수를 통해 (uid 모듈을 사용하여)
    // 21자리 랜덤한 문자열을 생성한다
    // 이렇게 생성한 랜덤 문자열을 uuid 변수에 담는다
    // (UuidFactory.get 에서 type.toString() 인자를 넣는데 random mode 에서는 이 인자를 사용하지 않고 인자와 관계없이 랜덤 문자열을 생성한다)
    const uuid = UuidFactory.get(type.toString());
    // type 과 uuid 를 결합해 providerToken 을 생성한다
    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;
    // Scope 의 종류로 DEFAULT, TRANSIENT, REQUEST 가 있는데
    // 디폴트는 DEFAULT 다
    // 그래서 이 함수의 조건은 충족하지 않는다
    if (this.isRequestOrTransient(factoryOrClassProvider.scope)) {
      return this.container.addInjectable(newProvider, token, enhancerSubtype);
    }
    this.container.addProvider(newProvider, token, enhancerSubtype);
  }
}
4-2. 스캔한 의존성 등록

스캔한 의존성들을 등록한다.


// core/injector/container.ts
export class NestContainer {
  private readonly modules = new ModulesContainer();

  public getModules(): ModulesContainer {
    return this.modules;
  }

  public addProvider(
    provider: Provider,
    token: string,
    enhancerSubtype?: EnhancerSubtype,
  ): string | symbol | Function {
    // ModulesContainer 클래스에서 token 으로 module 을 조회한다
    // ModulesContainer 클래스는 Map<string, Module> 을 상속 받은 클래스다
    // 키가 token, 값이 Module 이다
    // token 은 위의 DependenciesScanner 에서 살펴본 providerToken 이다
    const moduleRef = this.modules.get(token);
    if (!provider) {
      throw new CircularDependencyException(moduleRef?.metatype.name);
    }
    if (!moduleRef) {
      throw new UnknownModuleException();
    }
    return moduleRef.addProvider(provider, enhancerSubtype) as Function;
  }
}

Module 클래스의 addCustomClass 메소드가 추상 클래스 주입시 발생한 문제의 핵심이다. @Module 의 providers 에 등록할때 provide, useClass 를 사용했었는데 이때 provide 를 키로 사용해서 Map 에 등록하게 된다. 결국 Map 에는 키가 중복되지 않아서 동일한 키로 여러개의 값을 등록할 경우 마지막에 등록한 값으로 덮인다.


export class Module {
  // Module 들을 ModulesContainer 에서 Map 으로 관리하듯이
  // 각 Module 도 Map 으로 자신의 provider 들을 _providers 변수로 관리한다
  // ModulesContainer -> Module 들 관리
  // Module -> Provider 를 포함한 자신의 의존성 관리
  private readonly _providers = new Map<
    InstanceToken,
    InstanceWrapper<Injectable>
  >();

  get providers(): Map<InstanceToken, InstanceWrapper<Injectable>> {
    return this._providers;
  }

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

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

  // CustomProvider 등록
  public addCustomProvider(
    provider:
      | ClassProvider
      | FactoryProvider
      | ValueProvider
      | ExistingProvider,
    collection: Map<Function | string | symbol, any>,
    enhancerSubtype?: EnhancerSubtype,
  ) {
    // useClass 속성으로 등록했다면 CustomClass 에 해당한다
    // CustomClass 일 경우
    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;
  }

  // CustomClass 등록
  public addCustomClass(
    provider: ClassProvider,
    collection: Map<InstanceToken, InstanceWrapper>,
    enhancerSubtype?: EnhancerSubtype,
  ) {
    let { scope, durable } = provider;

    const { useClass } = provider;
    if (isUndefined(scope)) {
      scope = getClassScope(useClass);
    }
    if (isUndefined(durable)) {
      durable = isDurable(useClass);
    }

    // provider 의 provide 키의 값을 token 으로 선언
    const token = provider.provide;
    // token 을 collection 의 키로 설정한다
    // 즉 provide 에 입력한 값이 키가 되는 것이다
    // 이 collection 은 Module 클래스에 private 으로 선언된
    // _providers 를 가리킨다 
    collection.set(
      token,
      new InstanceWrapper({
        token,
        name: useClass?.name || useClass,
        metatype: useClass,
        instance: null,
        isResolved: false,
        scope,
        durable,
        host: this,
        subtype: enhancerSubtype,
      }),
    );
  }
}

5. 결론

NestJS 는 내부적으로 각 Module 의 providers 를 Map 형태로 관리한다.

@Module 의 providers 에 서비스들을 등록할때 provide 키를 이용해서 등록할 경우 provide 에 선언한 값을 Map 의 키로 사용한다. 그래서 provide 에 동일하게 선언한 값이 여러개일 경우 마지막에 선언한 값으로 Map 에 덮여 쓰인다.

위의 경우 APointService, BPointService 가 모두 provide 로 Point 를 사용했는데 APointService 이후에 작성한 BPointService 로 덮인다. 그래서 PointFactoryModule 의 providers 에서 Point 키로 등록된 값은 BPointService 가 된다.


@Module({
  providers: [
    PointFactoryService,
    {
      provide: Point,
      useClass: APointService,
    },
    {
      provide: Point,
      useClass: BPointService,
    }
  ],
  exports: [PointFactoryService],
})
export class PointFactoryModule {}

PointFactoryService 에서는 APointService 와 BPointService 모두 사용하고 싶어도 BPointService 만 주입받게 된다. APointService 의 메소드를 호출할 수 없고 BPointService 의 메소드가 호출된다. 위의 경우 aPointService, bPointService 변수를 선언했지만 BPointService 2개가 변수명만 다르게 주입된 상황이다.


@Injectable()
export class PointFactoryService {
  constructor(
    private readonly aPointService: Point,
    private readonly bPointService: Point,
  ) {}
}

6. 테스트

다음의 테스트는 통과하지 못한다.


describe('PointFactoryService', () => {
  let service: PointFactoryService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        PointFactoryService,
        {
          provide: Point,
          useClass: APointService,
        },
        {
          provide: Point,
          useClass: BPointService,
        }
      ],
    }).compile();

    service = module.get<PointFactoryService>(PointFactoryService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('getPointService', () => {
    it('find APointService', () => {
      expect(service.getPointService(PointType.A)).toBeInstanceOf(APointService);
    });
  });
});

PointFactoryService 의 getPointService 메소드를 테스트했다. PointType.A 를 인자로 넣었는데 결과는 BPointService 가 조회된다.



추가 - custom provider 를 판단하는 방법

NestJS 가 custom provider 를 판단하는 방법은 Provider 타입에 provide 프로퍼티가 있는지 여부로 판단한다.


// common/interfaces/modules/provider.interface.ts
export type Provider<T = any> =
  | Type<any>
  | ClassProvider<T>
  | ValueProvider<T>
  | FactoryProvider<T>
  | ExistingProvider<T>;

isNil 은 NestJS 에서 내부적으로 undefined 혹은 null 여부를 판단할 때 쓰는 함수다.


// core/scanner.ts
public isCustomProvider(
  provider: Provider,
): provider is
  | ClassProvider
  | ValueProvider
  | FactoryProvider
  | ExistingProvider {
  return provider && !isNil((provider as any).provide);
}
// core/injector/module.ts
public isCustomProvider(
  provider: Provider,
): provider is
  | ClassProvider
| FactoryProvider
| ValueProvider
| ExistingProvider {
  return !isNil(
    (
      provider as
      | ClassProvider
      | FactoryProvider
      | ValueProvider
      | ExistingProvider
    ).provide,
  );
}
// common/utils/shared.utils.ts
export const isUndefined = (obj: any): obj is undefined =>
  typeof obj === 'undefined';
export const isNil = (val: any): val is null | undefined =>
  isUndefined(val) || val === null;

<참고>

https://docs.nestjs.com/providers

https://docs.nestjs.com/fundamentals/custom-providers

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

https://velog.io/@taeni-develop/Spring-%ED%8C%A9%ED%86%A0%EB%A6%AC%ED%8C%A8%ED%84%B4%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B4%80%EB%A6%AC-8mvd0mqy

https://velog.io/@coalery/nest-injection-how

+ Recent posts