Custom providers

이 글은 Nest 공식문서를 번역한 글입니다. 원문 (opens in a new tab)

이전 장에서는 의존성 주입(DI)의 다양한 측면과 Nest에서 어떻게 사용되는지에 대해 살펴봤습니다. 그 중 한 가지 예로 인스턴스(주로 서비스 공급자)를 클래스에 주입하는 데 사용되는 constructor based (opens in a new tab) 의존성 주입을 들 수 있습니다. 의존성 주입이 Nest 코어에 기본적으로 내장되어 있다는 사실에 놀라지 않으실 것입니다. 지금까지는 한 가지 주요 패턴만 살펴보았습니다. 애플리케이션이 더 복잡해지면 DI 시스템의 모든 기능을 활용해야 할 수도 있으므로 좀 더 자세히 살펴보겠습니다.

DI fundamentals# (opens in a new tab)

종속성 주입은 inversion of control (IoC) (opens in a new tab)를 자체 코드에서 필수적으로 수행하는 대신 IoC 컨테이너(이 경우 NestJS 런타임 시스템)에 위임하는 제어의 역전(IoC) 기법입니다. Providers chapter (opens in a new tab)의 이 예제에서 어떤 일이 일어나고 있는지 살펴봅시다.

먼저 프로바이더를 정의합니다. Injectable() 데코레이터는 CatsService 클래스를 프로바이더로 표시합니다.

cats.service.ts

그런 다음 Nest가 컨트롤러 클래스에 프로바이더를 주입하도록 요청합니다.

cats.controller.ts

마지막으로 Nest IoC 컨테이너에 provider를 등록합니다.

app.module.ts

이 작업을 위해 정확히 어떤 일이 일어나고 있을까요? 이 과정에는 세 가지 핵심 단계가 있습니다.

  1. cats.service.ts에서 @Injectable() 데코레이터는 Nest IoC 컨테이너에서 관리할 수 있는 클래스로 CatsService 클래스를 선언합니다.

  2. cats.controller.ts에서 CatsController는 생성자 주입을 통해 CatsService 토큰에 대한 종속성을 선언합니다.

  3. app.module.ts에서 토큰 CatsServicecats.service.ts 파일의 CatsService 클래스와 연결합니다. 아래에서 이 연결(등록이라고도 함)이 정확히 어떻게 이루어지는지 살펴보겠습니다.

Nest IoC 컨테이너가 CatsController를 인스턴스화할 때, 먼저 종속성을 찾습니다. CatsService 종속성을 찾으면 등록 단계(위 #3)에 따라 CatsService 토큰을 조회하여 CatsService 클래스를 반환합니다. 기본 동작인 SINGLETON 범위를 가정하면 Nest는 CatsService 인스턴스를 생성하여 캐시한 후 반환하거나, 이미 캐시된 인스턴스가 있는 경우 기존 인스턴스를 반환합니다.

이 설명은 요점을 설명하기 위해 약간 단순화했습니다. 우리가 간과한 한 가지 중요한 부분은 종속성에 대한 코드 분석 프로세스가 매우 정교하며 애플리케이션 부트스트랩 중에 발생한다는 것입니다. 한 가지 중요한 특징은 종속성 분석(또는 "종속성 그래프 생성")이 전이적이라는 점입니다. 위의 예에서 CatsService 자체에 종속성이 있다면 종속성 역시 해결될 것입니다. 종속성 그래프는 종속성이 올바른 순서로, 즉 본질적으로 "상향식"으로 해결되도록 보장합니다. 이 메커니즘은 개발자가 복잡한 종속성 그래프를 관리할 필요를 덜어줍니다.

Standard providers# (opens in a new tab)

@Module() 데코레이터를 자세히 살펴봅시다. app.module에서 선언합니다.

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

providers 프로퍼티는 providers 배열을 받습니다. 지금까지는 클래스 이름 목록을 통해 이러한 공급자를 제공했습니다. 사실, providers: [CatsService]는 보다 완전한 구문을 줄여서 표현한 것입니다.

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

이제 이 명시적인 구조를 보았으니 등록 프로세스를 이해할 수 있습니다. 여기서는 토큰 CatsServiceCatsService 클래스와 명확하게 연결하고 있습니다. 약식 표기는 토큰이 같은 이름의 클래스 인스턴스를 요청하는 데 사용되는 가장 일반적인 사용 사례를 단순화하기 위한 편의상 표기일 뿐입니다.

Custom providers# (opens in a new tab)

Standard providers 가 제공하는 요구 사항을 초과하는 경우 어떻게 되나요? 다음은 몇 가지 예입니다:.

  • Nest가 클래스를 인스턴스화(또는 캐시된 인스턴스를 반환)하는 대신 사용자 정의 인스턴스를 만들고자 하는 경우.
  • 두 번째 종속성에서 기존 클래스를 재사용하려는 경우
  • 테스트용 모의 버전으로 클래스를 재정의하려는 경우

Nest를 사용하면 이러한 경우를 처리하기 위해 사용자 정의 공급자를 정의할 수 있습니다. Nest는 custom providers를 정의하는 여러 가지 방법을 제공합니다. 몇 가지 방법을 살펴보겠습니다.

🖋️

HINT

종속성 해결에 문제가 있는 경우 NEST_DEBUG 환경 변수를 설정하여 시작 중에 추가 종속성 해결 로그를 가져올 수 있습니다.

Value providers: useValue# (opens in a new tab)

useValue 구문은 상수 값을 주입하거나 외부 라이브러리를 Nest 컨테이너에 넣거나 실제 구현을 모의 객체로 대체할 때 유용합니다. Nest가 테스트 목적으로 모의 CatsService를 사용하도록 강제하고 싶다고 가정해 보겠습니다.

import { CatsService } from "./cats.service";
 
const mockCatsService = {
  /* mock implementation
  ...
  */
};
 
@Module({
  imports: [CatsModule],
  providers: [
    {
      provide: CatsService,
      useValue: mockCatsService,
    },
  ],
})
export class AppModule {}

이 예제에서 CatsService 토큰은 mockCatsService 모의 객체로 해석됩니다. useValue에는 값(이 경우 대체하는 CatsService 클래스와 동일한 인터페이스를 가진 리터럴 객체)이 필요합니다. TypeScript의 구조적 타이핑 (opens in a new tab)으로 인해 리터럴 객체나 new로 인스턴스화된 클래스 인스턴스를 포함하여 호환되는 인터페이스를 가진 모든 객체를 사용할 수 있습니다.

Non-class-based provider tokens# (opens in a new tab)

지금까지는 클래스 이름을 provider 토큰(provide 배열에 나열된 공급자의 provider 프로퍼티 값)으로 사용했습니다. 이는 constructor based injection (opens in a new tab)에 사용되는 표준 패턴과 일치하며, 토큰도 클래스 이름입니다. (토큰에 대한 개념이 명확하지 않은 경우 DI Fundamentals (opens in a new tab)를 다시 참조하여 토큰에 대해 다시 한 번 정리하세요.) 때로는 문자열이나 기호를 DI 토큰으로 사용하는 유연성을 원할 수도 있습니다. 예를 들어

import { connection } from "./connection";
 
@Module({
  providers: [
    {
      provide: "CONNECTION",
      useValue: connection,
    },
  ],
})
export class AppModule {}

이 예에서는 외부 파일에서 가져온 기존 연결 객체와 문자열 값 토큰('CONNECTION')을 연결하고 있습니다.

🚨

NOTICE

토큰 값으로 문자열을 사용하는 것 외에도 JavaScript symbols나 TypeScript enums을 사용할 수도 있습니다.

앞서 표준 constructor based injection (opens in a new tab) 패턴을 사용하여 프로바이더를 주입하는 방법을 살펴봤습니다. 이 패턴을 사용하려면 클래스 이름으로 종속성을 선언해야 합니다. 'CONNECTION' 사용자 정의 프로바이더는 문자열 값 토큰을 사용합니다. 이러한 프로바이더를 주입하는 방법을 살펴보겠습니다. 이를 위해 @Inject() 데코레이터를 사용합니다. 이 데코레이터는 토큰이라는 단일 인수를 받습니다.

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

위의 예시에서는 예시용으로 'CONNECTION' 문자열을 직접 사용했지만, 깔끔한 코드 구성을 위해 토큰을 constants.ts와 같은 별도의 파일에 정의하는 것이 가장 좋습니다. 자체 파일에 정의하고 필요한 곳에서 가져오는 심볼이나 열거형과 마찬가지로 취급하세요.

Class providers: useClass# (opens in a new tab)

useClass 구문을 사용하면 토큰이 확인해야 하는 클래스를 동적으로 결정할 수 있습니다. 예를 들어 추상(또는 기본) ConfigService 클래스가 있다고 가정해 보겠습니다. 현재 환경에 따라 Nest에서 다른 구성 서비스 구현을 제공하고자 합니다. 다음 코드는 이러한 전략을 구현합니다.

const configServiceProvider = {
  provide: ConfigService,
  useClass: process.env.NODE_ENV === "development" ? DevelopmentConfigService : ProductionConfigService,
};
 
@Module({
  providers: [configServiceProvider],
})
export class AppModule {}

이 코드 샘플에서 몇 가지 세부 사항을 살펴봅시다. 먼저 리터럴 객체로 configServiceProvider를 정의한 다음 모듈 데코레이터의 providers 프로퍼티에 이를 전달한 것을 볼 수 있습니다. 이것은 약간의 코드 구성에 불과하지만 기능적으로는 이 장에서 지금까지 사용한 예제와 동일합니다.

또한 ConfigService 클래스 이름을 토큰으로 사용했습니다. ConfigService에 종속된 모든 클래스의 경우 Nest는 제공된 클래스(DevelopmentConfigService 또는 ProductionConfigService)의 인스턴스를 다른 곳에서 선언되었을 수 있는 기본 구현(예: @Injectable() 데코레이터로 선언된 ConfigService)을 재정의하여 주입합니다.

Factory providers: useFactory# (opens in a new tab)

useFactory 구문을 사용하면 공급자를 동적으로 생성할 수 있습니다. 실제 공급자는 팩토리 함수에서 반환된 값으로 제공됩니다. 팩토리 함수는 필요에 따라 단순하거나 복잡할 수 있습니다. 단순한 팩토리는 다른 프로바이더에 의존하지 않을 수 있습니다. 보다 복잡한 팩토리는 결과를 계산하는 데 필요한 다른 공급자를 자체적으로 주입할 수 있습니다. 후자의 경우 팩토리 공급자 구문에는 한 쌍의 관련 메커니즘이 있습니다.

  • 팩토리 함수는 (선택적) 인수를 받을 수 있습니다.
  • (선택적) inject 프로퍼티는 인스턴스화 프로세스 중에 Nest가 확인하여 팩토리 함수에 인자로 전달할 공급자 배열을 허용합니다. 또한 이러한 공급자는 선택 사항으로 표시할 수 있습니다. 두 목록은 서로 연관되어 있어야 합니다: Nest는 인젝트 목록의 인스턴스를 동일한 순서로 팩토리 함수에 인수로 전달합니다. 아래 예시는 이를 보여줍니다.
const connectionProvider = {
  provide: "CONNECTION",
  useFactory: (optionsProvider: OptionsProvider, optionalProvider?: string) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider, { token: "SomeOptionalProvider", optional: true }],
  //       \_____________/            \__________________/
  //        This provider              The provider with this
  //        is mandatory.              token can resolve to `undefined`.
};
 
@Module({
  providers: [
    connectionProvider,
    OptionsProvider,
    // { provide: 'SomeOptionalProvider', useValue: 'anything' },
  ],
})
export class AppModule {}

Alias providers: useExisting# (opens in a new tab)

useExisting 구문을 사용하면 기존 providers 대한 별칭을 만들 수 있습니다. 이렇게 하면 동일한 provider에 액세스할 수 있는 두 가지 방법이 생성됩니다. 아래 예에서 (문자열 기반) 토큰인 'AliasedLoggerService'는 (클래스 기반) 토큰인 LoggerService의 별칭입니다. 두 개의 서로 다른 종속성이 있다고 가정합니다. 하나는 'AliasedLoggerService'에 대한 종속성이고 다른 하나는 LoggerService에 대한 종속성입니다. 두 종속성이 모두 SINGLETON 범위로 지정되면 둘 다 동일한 인스턴스로 resolve됩니다.

@Injectable()
class LoggerService {
  /* implementation details */
}
 
const loggerAliasProvider = {
  provide: "AliasedLoggerService",
  useExisting: LoggerService,
};
 
@Module({
  providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}

Non-service based providers# (opens in a new tab)

providers는 서비스를 제공하는 경우가 많지만, 그 용도에 국한되지 않습니다. providers는 모든(any) 값을 제공할 수 있습니다. 예를 들어 공급자는 아래 그림과 같이 현재 환경에 따라 구성 개체 배열을 제공할 수 있습니다.

const configFactory = {
  provide: "CONFIG",
  useFactory: () => {
    return process.env.NODE_ENV === "development" ? devConfig : prodConfig;
  },
};
 
@Module({
  providers: [configFactory],
})
export class AppModule {}

Export custom provider# (opens in a new tab)

다른 provider와 마찬가지로 custom provider는 선언하는 모듈로 범위가 지정됩니다. 다른 모듈에서 보이게 하려면 export해야 합니다. custom provider를 export하려면 토큰 또는 전체 공급자 개체를 사용할 수 있습니다.

다음 예제는 토큰을 사용하여 export하는 방법을 보여줍니다.

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

또는 전체 provider 개체를 사용하여 export하세요.

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