NestJS 커스텀 프로바이더 사용법
커스텀 프로바이더가 필요한 이유
종종 Nest에서 제공하는 클래스 프로바이더 형태가 아니라,
그 외의 형태로 프로바이더를 사용하고 싶을 때가 있다.
네스트는 커스텀 프로바이더라는 기능으로 프로바이더에 다양한 자율성을 주고 있다.
네스트의 일반적인 프로바이더
커스텀 프로바이더에 대해 알아보기 전에,
먼저 일반적인 형태의 프로바이더를 살펴보자.
@Module({
controllers: [CatsController],
providers: [CatsService],
})
@Module() 데코레이터에서
providers는 provider로 구성된 배열을 받는다.
우리는 보통 클래스의 이름을 배열에 넣는다.
그러나 사실 클래스 이름을 넣는 것은, 간단히 표현한 형태이고,
정석대로 표현하면 아래와 같다.
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
이렇게 명시적으로 표현하면, 프로바이더 등록 방식을 더 잘 이해할 수 있을 것이다.
provide의 값으로 들어간 것이 토큰으로 일종의 프로바이더의 이름, 프로바이더를 호출할 때 사용하는 열쇠 같은 것으로 이해할 수 있다.
일반적으로 우리가 간단히 표기하던 방식은 토큰과 클래스 네임을 일치시킨 것이다.
커스텀 프로바이더
대표적으로 아래와 같은 상황들에서 커스텀 프로바이더의 사용이 필요하다.
- 네스트의 클래스 초기화를 사용하지 않고, 혹은 캐싱된 인스턴스를 사용하지 않고 커스텀한 인스턴스를 생성하고 싶을 때
- 두 번째 의존성 주입에서 이미 있는 클래스를 재사용하고 싶을 때
- 테스트를 위한 mock 버전으로 클래스를 오버라이드하고 싶을 때
네스트가 제공하는 다양한 커스텀 프로바이더의 종류를 살펴보자.
Value 프로바이더: useValue
import { CatsService } from './cats.service';
const mockCatsService = {
/* mock implementation
...
*/
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class AppModule {}
useValue는 상수 값을 주입할 때 유용한 방식이다.
외부 라이브러리를 네스트 컨테이너에 주입하거나, 구현체를 mock 객체로 대체할 때 사용될 수 있다.
위 예시에서는 CatsService를 토큰으로 하여 호출했을 때, mockCatsService가 반환된다.
useValue 값으로는 value가 입력되어야 하고,
예시에서는 CatsService 클래스와 동일한 인터페이스를 공유하는 리터럴 객체가 할당되었다.
타입스크립트의 structural typing으로 호환 가능한 인터페이스를 갖는 모든 객체와
new 생성자로 생성된 클래스 인스턴스를 모두 사용할 수 있다.
클래스에 기반하지 않는 프로바이더 토큰
provide의 값으로 넣는 토큰이 항상 클래스의 이름이어야 하는 것은 아니다.
import { connection } from './connection';
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
위와 같이 문자열이나, 심볼(symbol)이나 enum도 토큰으로 사용할 수 있다.
위 예시에서는 'CONNECTION'이라는 문자열을 connection이라는 import된 객체와 연결시켰다.
주의할 점은, constructor에 프로바이더를 주입할 때 토큰을 직접 명시해줘야 한다.
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
connection의 클래스 이름을 명시하는 동시에, 토큰은 @nestjs/common의 @Inject() 데코레이터를 사용하여 인자로 입력한다.
@Inject() 데코레이터는 토큰 하나의 인자만 받는다.
위 예시에서는 설명을 위해 'CONNECTION'이라는 문자열을 바로 사용했지만,
클린 코드 원칙에 따르면 constants.ts 같은 파일을 만들어
심볼이나 enum 등으로 따로 관리하는 것이 더 좋다.
클래스 프로바이더: useClass
useClass 프로퍼티를 통해, 토큰이 가리킬 클래스를 동적으로 정할 수 있다.
예를 들어 추상화된 ConfigService 클래스를 두고, 환경에 따라 동적으로 Configuration 서비스의 구현체를 주입하고 싶다면
이때 useClass를 활용하면 된다.
const configServiceProvider = {
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};
@Module({
providers: [configServiceProvider],
})
export class AppModule {}
예시에서 configServiceProvider를 리터럴 객체로 정의한 뒤
module 데코레이터의 porviders 프로퍼티에 입력했다.
또 ConfigsService 클래스 이름을 토큰으로 사용했으므로,
ConfigService 클래스에 의존하는 모든 클래스에서
DevelopmentConfigService 또는 ProductionConfigService 클래스의 인스턴스가 주입될 것이다.
이는 다른 어딘가에서 선언됐을 수 있는 디폴트 구현체를 오버라이드 한다.
(예를 들어, @Injectable() 데코레이터로 선언된 ConfigService)
팩토리 프로바이더: useFactory
useFactory는 프로바이더를 동적으로 생성할 수 있게 해준다.
실제 주입되는 프로바이더는 팩토리 함수의 리턴값이 된다.
팩토리 함수는 간단하거나 복잡하거나 필요에 따라 사용하면 된다.
간단한 팩토리 함수는 다른 프로바이더에 의존하지 않을 수 있지만,
복잡한 팩토리 함수는 다른 프로바이더를 스스로 주입하여 사용할 수 있다.
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (optionsProvider: MyOptionsProvider, optionalProvider?: string) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
// \______________/ \__________________/
// This provider The provider with this token
// is mandatory. can resolve to `undefined`.
};
@Module({
providers: [
connectionProvider,
MyOptionsProvider, // class-based provider
// { provide: 'SomeOptionalProvider', useValue: 'anything' },
],
})
export class AppModule {}
위와 같이 팩토리 함수는 인자를 받을 수 있고,
inject 프로퍼티(옵셔널)는 인스턴스화 단계에서 팩토리 함수의 인자로 전달할 프로바이더들의 배열을 입력받는다.
또, 인자로 사용되는 프로바이더는 옵셔널할 수 있다.
inject 배열의 순서와 팩토리 함수 인자의 순서는 동일해야 한다.
Alias 프로바이더: useExisting
useExisting은 이미 존재하는 프로바이더의 별칭을 지정할 수 있게 해준다.
이는 하나의 프로바이더에 접근하는 두 가지 방법을 만드는 것이라고 할 수 있다.
@Injectable()
class LoggerService {
/* implementation details */
}
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
위의 예시에서 'AliasedLoggerService'는 LoggerService의 별칭 토큰이 된다.
만약 'AliasedLoggerService'와 LoggerService 각가 두 개의 종속성이 있고,
두 종속성이 싱글톤으로 명시되어 있다면, 둘은 동일한 인스턴스를 지칭한다.
서비스에 기반하지 않는 프로바이더
주로 프로바이더가 서비스를 의미하곤 하지만,
반드시 프로바이더가 서비스로 제한되는 것은 아니다.
프로바이더는 어떤 값이든 반환할 수 있다.
const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
},
};
@Module({
providers: [configFactory],
})
export class AppModule {}
예를 들어, 위와 같이 환경에 따라 configuration 객체의 배열을 반환할 수도 있다.
커스텀 프로바이더의 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 {}