[NestJS] 11. 커스텀 프로바이더
이전 챕터에서, DI의 다양한 측면과 Nest에서 이를 활용하는 방법을 다루었습니다. 생성자 기반 의존성 주입을 사용해 인스턴스를 클래스에 넣는 것이 그 예입니다. 지금까지 한 가지 주요한 패턴에 대해서만 알아봤습니다. 어플리케이션이 점점 복잡해질수록, DI 시스템의 모든 기능을 사용해야 합니다.
의존성 주입 기초
의존성 주입은 제어 역전(inversion of control, IoC) 기술입니다. 여기서 자체적으로 코드에서 수행하는 대신 의존성의 인스턴스화를 IoC 컨테이너(여기서는 NestJS의 런타임 시스템)에 위임합니다.
먼저, 프로바이더를 정의합니다. @Injectable() 데코레이터는 CatsService 클래스를 프로바이더로 지정합니다.
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가 프로바이더를 컨트롤러 클래스에 주입하도록 요청합니다.
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();
}
}
마지막으로, 프로바이더를 Nest IoC 컨테이너에 등록합니다.
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 {}
이 작업을 위해서 정확히 어떤 일이 일어나고 있을까요? 여기에는 세 가지 중요한 단계가 있습니다.
- cats.service.ts에서 @Injectable() 데코레이터가 CatsService 클래스를 Nest IoC 컨테이너로 관리될 수 있는 클래스로 선언합니다.
- cats.controller.ts에서 CatsController는 생성자 주입으로 CatsService 토큰에 대한 의존성을 선언합니다.
- app.module.ts에서 CatsService 토큰을 cats.service.ts 파일로부터 CatsService 클래스와 연결합니다. 아래에서 정확히 어떻게 이런 연결(등록으로도 불리는)이 발생하는지 알아보겠습니다.
Nest IoC 컨테이너가 CatsController를 인스턴스화할 때, IoC 컨테이너는 모든 의존성을 탐색합니다. IoC가 CatsService 의존성을 발견했을 때, 등록 단계에 따라 CatsService 클래스를 반환하는 CatsService 토큰을 조회합니다. SINGLETON(객체의 인스턴스가 1개인 패턴) 스코프를 가정하면, Nest는 CatsService의 인스턴스를 생성하고, 캐시화하며, 그대로 반환하거나 이미 캐시화됐다면 존재하는 인스턴스를 반환합니다.
의존성에 대한 코드 분석 처리는 매우 정교하며 어플리케이션이 부팅하는 동안 발생합니다. 한 가지 주요 기능은 의존성 분석(혹은 의존성 그래프를 만드는 것)이 옮아가는 성격이 있다는 것입니다. 위의 코드를 보면, CatsService 자체가 의존성을 갖고 있다면, 의존성도 해결됩니다. 의존성 그래프는 의존성이 올바른 상향식 순서로 해결됨을 보장합니다. 이러한 과정은 개발자가 이런 복잡한 의존성 그래프를 관리해야 하는 부담을 줄여줍니다.
표준 프로바이더
@Module() 데코레이터를 조금 더 자세히 봅시다.
@Module({
controllers: [CatsController],
providers: [CatsService],
})
providers 프로퍼티는 providers 배열을 갖습니다. 지금까지 클래스 이름의 리스트로 프로바이더들을 작성했습니다. 사실, providers: [CatsService]
문법은 복잡한 문법에 대한 간략한 버전입니다.
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
이제 명시적인 구성을 봤으므로, 어떻게 프로바이더를 등록하는지 이해할 수 있습니다. 명확하게 CatsService와 CatsService 클래스를 연결시키고 있습니다. 간소화하게 작성하는 것은 클래스의 인스턴스와 같은 이름으로 토큰이 요청에 사용되는 대부분의 경우에서 단지 편리하게 작성하기 위함입니다.
사용자 지정 프로바이더
표준 프로바이더 이상으로 요구하는 것이 있을 때, 어떻게 될까요? 예를 들어,
- Nest의 클래스를 인스턴스화 하는 대신 사용자 지정 인스턴스를 만들고 싶을 수 있습니다.
- 존재하는 클래스를 두 번째 의존성에서 재사용하고 싶을 수 있습니다.
- 테스트를 위해 모킹한 클래스를 덮어쓰고 싶을 수 있습니다.
Nest는 이러한 경우를 처리하기 위해 사용자 지정 프로바이더를 정의할 수 있게 해줍니다. 사용자 지정 프로바이더를 정의하기 위한 여러 방법을 제공합니다.
값 프로바이더(Value Providers)
useValue 문법은 const 값을 주입하고, 외부 라이브러리를 Nest 컨테이너에 적용하거나, 실제 구현한 것을 모의 객체로 대체하는 데에 유용합니다. Nest에게 테스트 목적으로 흉내낸 CatsService를 사용하고 싶다고 말해봅시다!
import { Module } from '@nestjs/common';
import { CatsModule } from './cats.module';
import { CatsService } from './cats.service';
const mockCatsService = {
annonymous: 'test',
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class AppModule {}
이 예제에서, CatsService 토큰은 흉내낸 객체인 mockCatsService와 함께 사용될 것입니다. useValue는 값을 요구합니다. 여기에서는 CatsService 클래스를 대체하여 동일한 인터페이스를 가진 리터럴 객체를 사용했습니다. 타입스크립트의 구조적인 타입 작성 덕분에, new와 함께 인스턴스화된 클래스 인스턴스나 리터럴 객체를 포함하여 인터페이스와 호환되는 모든 객체를 사용할 수 있습니다.
클래스가 아닌 프로바이더 토큰
지금까지, 클래스 이름을 프로바이더 토큰으로 사용했습니다(providers 배열에 리스트로 나열된 provide 프로퍼티의 값). 이는 생성자 기반 주입과 함께 하는 일반적인 패턴과 같습니다. 여기에서도 토큰은 클래스 이름입니다. 때때로, 유연하게 문자열이나 심볼을 의존성 토큰으로 사용하기 싶을 수 있습니다.
import { connection } from './connection';
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
위 코드에서는, 문자열 값 토큰인 'CONNECTION'과 이미 존재하는 connection 객체를 연결하고 있습니다.
이전에 어떻게 생성자 기반 주입 패턴을 사용해 의존성을 주입하는지 봤습니다. 이런 패턴은 클래스 이름으로 의존성이 선언되어야 합니다. 'CONNECTION' 사용자 지정 프로바이더는 문자열 값 토큰을 사용합니다. 그러면 어떻게 이런 프로바이더를 주입하는지 봅시다. 이를 위해서, @Inject() 데코레이터를 사용합니다. 이 데코레이터는 하나의 인수(토큰)을 가집니다.
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
직접 'CONNECTION' 문자열을 사용하는 반면 깔끔한 코드 조직을 위해서 constant.ts와 같이 분리된 파일에서 토큰을 정의하는 것이 좋은 방법입니다. 자체적으로 파일에서 정의되거나 외부로부터 가져온 심볼이나 열거 자료형도 마찬가지로 취급합니다.
클래스 프로바이더: useClass
useClass 문법은 토큰에 녹아들 클래스를 동적으로 지정할 수 있게 해줍니다. 예를 들어, abstract ConfigService 클래스가 있다고 가정해봅시다. 현재 환경에 따라서, Nest가 설정 서비스의 구현을 다르게 제공하도록 하고 싶을 것입니다. 아래의 코드는 이러한 전략을 직접 구현합니다.
const configureServiceProvider = {
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};
@Module({
providers: [configServiceProvider],
})
export class AppModule {}
여기서 먼저 configServiceProvider를 리터럴 객체로 정의했으며, 그후 모듈 데코레이터의 providers 프로퍼티에 전달하였습니다. 이것은 단지 약간의 코드 구성에 불과하지만, 기능적으로는 지금까지 이번 챔터에서 사용한 예제와 동일합니다.
또한, ConfigService 클래스를 토큰으로 사용했습니다. ConfigService에 관한 모든 클래스에 대해 Nest는 다른 곳에서 선언되었을 수 있는 기본 구현을 재정의하는 제공된 클래스들의 인스턴스를 주입합니다. 공식문서에서 좀 어렵게 나와 있는데, 그냥 동적으로 프로바이더를 사용하기 때문에 동적으로 값이 달라지면 프로바이더가 재정의되어 그 상황에서 사용하는 클래스를 인스턴스로서 주입한다는 말입니다.
팩토리 프로바이더: useFactory
useFactory 문법은 동적으로 프로바이더를 생성할 수 있도록 해줍니다. 실제 프로바이더는 팩토리 함수으로부터 반환된 값이 사용됩니다. 팩토리 함수는 필요에 따라 단순해지거나 복잡해질 수 있습니다. 단순한 팩토리는 다른 프로바이더들에 의존하지 않습니다. 더 복잡한 팩토리는 스스로 다른 프로바이더를 주입할 수 있습니다. 후자의 경우, 팩토리 프로바이더 문법은 한 쌍의 관련된 메커니즘을 갖고 있습니다.
- 팩토리 함수는 선택적인 인수를 받아들일 수 있다.
- 선택적인 inject 프로퍼티는 Nest가 해결하고 인스턴스화 과정에서 팩토리 함수에 인수로 전달할 프로바이더 배열을 이용합니다. 또한, 이런 프로바이더들은 선택적으로 사용될 수 있습니다. 두 개의 리스트는 상관 관계가 있어야 합니다. Nest는 inject 리스트로부터 인스턴스를 팩토리 함수에 동일한 순서대로 인수로 전달할 것입니다.
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (optionsProvider, optionalProvider) => {
const options = optionsProvder.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
};
@Module({
providers: [
connectionProvider,
OptionsProvider,
],
})
export class AppModule {}
별칭 프로바이더:useExisting
useExisting 문법은 존재하는 프로바이더들에 대한 별칭을 만들 수 있도록 해줍니다. 이는 동일한 프로바이더에 대한 두 가지 접근 방법을 만들어줍니다. 아래의 예에서, 문자열 값 토큰 'AliasedLoggerService'는 클래스 기반 토큰 LoggerService에 대한 별칭입니다. 'AliasedLoggerService'에 대한 의존성과 LoggerService에 대한 의존성으로 총 두 개의 개별적인 의존성이 있다고 해봅시다. 만약 두 의존성이 SINGLETON 스코프에서 지정되면, 둘은 같은 인스턴스로 확인됩니다.
@Injectable()
class LoggerService {
/* 세부사항 구현 */
}
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
비 서비스 기반 프로바이더
프로바이더가 종종 서비스를 공급하나, 이러한 사용에는 제한이 없습니다. 프로바이더는 어떠한 값이든 공급할 수 있습니다. 예를 들어, 프로바이더는 현재 환경에 기반하는 configuration의 배열을 제공할 수 있습니다.
const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig: prodConfig;
},
};
@Module({
providers: [configFactory],
})
export class AppModule {}
사용자 정의 프로바이더 Export
모든 프로바이더가 그렇듯이 사용자 지정 프로바이더는 선언되는 모듈의 스코프로 지정됩니다. 다른 모듈들에게도 사용하려면 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 {}
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 {}