NestJS 공식문서 번역 겸 공부하는 글 입니다.
의역 및 오역이 있을 수 있습니다.
https://docs.nestjs.com/fundamentals/custom-providers

Custom providers

앞선 챕터들에서, 다양한 측면의 의존성 주입(DI)를 어떻게 Nest에서 사용하는지 알아보았다. 하나의 예는 생성자 기반 의존성 주입이다. Nest 코어에서 기본적으로 빌드되는 의존성 주입과 한가지 메인 패턴만을 살펴보았다. 애플레케이션이 더 복잡해질수록 DI 시스템의 모든 특성들을 필요로 하게 될 것이다. 좀 더 자세히 알아보자.

DI fundamentals

의존성 주입은 직접 코드로 작성하는 대신, 의존성의 인스턴스화를 IoC 컨테이너(여기서는 NestJS runtime system)에게 맡기는 inversion of control(IoC) 테크닉이다. Providers 챕터에서 보았던 아래의 예제를 살펴보자.

첫번째로, 우리는 provider를 정의하였다. @Injectable() 데코레이터는 CatsService 클래스를 provider로 마크해준다.

// cats.service.ts

import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  findAll(): Cat[] {
    return this.cats;
  }
}

그 후 우리는 Nest에 controller 클래스에 provider를 주입해달라고 요청한다.

// cats.controller.ts

import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

마지막으로, provider를 Nest IoC container에 등록한다.

// app.module.ts

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}

이러한 일을 위해 정확히 어떤 일이 일어나는 것일까? 프로세스는 3가지 주요 스텝으로 나누어진다.

  1. cats.service.ts 에서 @Injectable() 데코레이터가 CatsService 클래스를 Nest IoC container에게 관리되는 클래스로 선언한다.

  2. cats.controllerts 에서 CatsController는 생성자 주입을 통해 CatsService에 대한 의존성을 선언한다.

  3. app.module.ts 에서 CatsService 토큰을 cats.service.ts의 CatsService와 연관 짓는다. 아래에서 정확히 이러한 관련짓기(registration이라고도 부른다)가 어떻게 일어나는지 알아볼 것이다.

Nest IoC container가 CatsController를 인스턴스화 할 때, 의존성이 있는지를 확인한다. CatsService 의존성을 찾아서 CatsService 클래스를 리턴한다. 싱글톤(기본값이 싱글톤이다)이라고 가정해보자. Nest는 그 후 CatsService 인스턴스를 만들고, 캐싱을 하고, 그것을 리턴할 것이다 또는 이미 캐싱되어있는 경우 존재하는 인스턴스를 리턴할 것이다.

이 설명은 요점을 설명하기 위해 조금 간단하게 한것이다. 중요한 점은 이러한 의존성 관련 코드를 분석하는 것이 매우 정교하며, 애플리케이션이 실행될 때 일어난다는 것이다. 한가지 중요한 특성은 의존성 분석(또는 의존성 그래프를 만드는 것)은 transitive 라는 것이다. 위의 예제에서, CatsService 스스로가 의존성을 가진다면, 이러한 점도 해결될 것이다. 의존성 그래프는 의존성이 올바른 순서(기본적으로 bottom-up)로 resolve 됨을 보장한다. 이러한 메커니즘은 개발자들이 복잡한 의존성 그래프를 직접 다루지 않아도 되게 해준다.

Standard providers

@Module() 데코레이터를 더 자세히 살펴보자. app.module 에서

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})

providers 프로퍼티는 provider 배열(class명)을 받는다. 사실, providers: [CatsService] 는 좀 더 복잡한 문법을 간단하게 작성한 것이다.

providers: [
  {
    provide: CatsService,
    useClass: CatsService,
  },
];

더 명확한 위의 구조를 보았으므로, registration 프로세스에 대해 이해할 수 있다. CatsService 토큰과 CatsService 클래스를 관계짓고 있다. 단축문법이 토큰과 클래스의이름이 같은 경우에 더 편리하다.

Custom providers

standard providers에 제공하는 것 이외에 요구사항은 어떤식으로 처리될까? 여기에 일부의 예시가 있다.

  • Nest 인스턴스화(또는 캐싱된 인스턴스의 리턴) 대신 커스텀 인스턴스를 만들고 싶을 때.
  • second dependency를 통해 존재하는 클래스를 재사용 하고 싶을 때.
  • 테스트를 위해 클래스를 mock 버전으로 오버라이드 하고 싶을 때.

Nest는 위와 같은 상황을 처리할 수 있도록 custom provider를 정의할 수 있게 해준다.

Value providers: useValue

useValue 문법은 외부 라이브러리로부터 Nest container로 상수값을 주입하거나, mock object로 실제 구현을 대체할 때 유용하다. Nest에게 테스트를 위해 mock CatsService를 사용하게 해보자.

import { CatsService } from './cats.service';

const mockCatsService = {
  /* mock implementation
  ...
  */
};

@Module({
  imports: [CatsModule],
  providers: [
    {
      provide: CatsService,
      useValue: mockCatsService,
    },
  ],
})
export class AppModule {}

위의 예제에서, CatsService 토큰은 mockCatsService mock object로 resolve 한다. useValue는 이 경우, CatsService 클래스를 대체하기 위해 같은 인터페이스를 가진 리터럴 오브젝트를 필요로 한다. 타입스크립트의 structural typing을 통해 호환되는 인터페이스를 가진 오브젝트(리터럴 오브젝트나 new를 통한 클래스 인스턴스화)를 사용할 수 있다.

Non-class-based provider tokens

지금까지 클래스명을 provider 토큰으로 사용하였다. 이것은 constructor based injection을 사용한 표준 패턴이다. 때때로, 문자열이나 심볼을 DI 토큰으로 사용하고 싶을 것이다.

import { connection } from './connection';

@Module({
  providers: [
    {
      provide: 'CONNECTION',
      useValue: connection,
    },
  ],
})
export class AppModule {}

위의 예제에서, ‘CONNECTION’이라는 문자열을 외부 파일로부터 import한 이미 존재하는 connection 오브젝트와 관련짓기 위해 사용하였다.

[notice]
토큰 값으로 문자열 외에도 자바스크립트 symbol이나 타입스크립트 enum을 사용할 수 있다.

standard constructor based injection 패턴으로 provide를 주입하는 방법에 대해서는 이미 보았다. 이 패턴은 의존성이 클래스명으로 선언되어야 한다. ‘CONNECTION’ custom provider는 string-value 토큰을 사용한다. 어떻게 이러한 provider를 주입하는지 알아보자. @Inject() 데코레이터를 사용하여 인자로 token을 받는다.

@Injectable()
export class CatsRepository {
  constructor(@Inject('CONNECTION') connection: Connection) {}
}

위 예제에서 설명의 목적으로 직접 ‘CONNECTION’이라는 문자열을 사용했지만, 더 클린한 코드 구조를 위해서는 토큰을 분리된 파일(예를 들어, constants.ts)에 정의하는 것이 좋다. symbol이나 enum으로 정의하고 필요한 곳에 import 하여 사용하면 된다.

Class providers: useClass

useClass 문법은 토큰이 resolve되어야 할 곳에 동적으로 클래스가 결정되게 해준다. 예를 들어, ConfigService 클래스가 있다고 가정하자. 현재의 환경에 따라, Nest가 다른 configuration service를 구현하기를 원한다.

const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService,
};

@Module({
  providers: [configServiceProvider],
})
export class AppModule {}

위 코드를 자세히 살펴보자. 우리는 configServiceProvider를 literal object로 먼저 선언하고, 이 것을 module 데코레이터의 providers 프로퍼티에 넣어주었다. 코드 구조적으로는 조금 다르지만, 기능적으로는 이 챕터에서 위에서 봤던 예제들과 같다.

또한, ConfigService 클래스명을 토큰으로 사용했다. ConfigService에 의존하는 클래스라면, Nest는 제공된 클래스(DevelopmentConfigService 또는 ProductionConfigService)의 인스턴스를 주입할 것이다.

Factory providers: useFactory

useFactory 문법은 provider를 동적으로 만들 수 있게 해준다. 실제 provider는 factory 함수에 의해 리턴되는 값이다. factory 함수는 필요에 따라 간단하거나 복잡해질 수 있다. 간단한 factory는 다른 provider에 의존하지 않을 것이며, 복잡한 factory는 자신을 다른 provider에 주입할 수도 있다. 후자의 경우, factory provider 문법은 한쌍의 관련된 메커니즘을 갖는다.

  1. factory 함수는 (optional) argument를 수용할 수 있다.
  2. (optional) inject property는 Nest가 인스턴스화 프로세스중 전달할 argument나 provider의 배열을 수용할 수 있다. 두 리스트는 서로 연관되어 있다. Nest는 inject list를 factory 함수의 인자로 같은 순서로 전달한다.
const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

@Module({
  providers: [connectionFactory],
})
export class AppModule {}

Alias providers: useExisting

useExisting 문법은 존재하는 provider에 가명을 만들 수 있게 해준다. 이것은 같은 provider에 접근 방법을 두가지로 만들어준다. 아래의 예제에서, 문자열 토큰 ‘AliasedLoggerService’는 LoggerService의 가명이다. 두가지 다른 의존성을 가지고 있다고 가정할 수 있다. 두 의존성이 singleton scope라면, 같은 인스턴스로 resolve 될 것이다.

@Injectable()
class LoggerService {
  /* implementation details */
}

const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService,
};

@Module({
  providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}

Non-service based providers

provider가 종종 service를 지원하는 반면, 사용법에 제한은 없다. provider는 어떠한 값도 지원한다. 예를 들어, provider는 현재 환경에 의존하는 configuration object의 배열일수도 있다.

const configFactory = {
  provide: 'CONFIG',
  useFactory: () => {
    return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
  },
};

@Module({
  providers: [configFactory],
})
export class AppModule {}

Export custom provider

다른 provider와 마찬가지로 custom provider의 scope도 provider가 선언된 module에 의존한다. 이를 다른 module에서 사용하려면 export되어야 한다. custom provider를 export하기 위해서는 provider object나 토큰을 사용하면 된다.

const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION'],
})
export class AppModule {}
const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

@Module({
  providers: [connectionFactory],
  exports: [connectionFactory],
})
export class AppModule {}