커스텀 프로바이더 (Custom Providers) — 의존성 주입을 내 방식대로
커스텀 프로바이더 (Custom Providers) — 의존성 주입을 내 방식대로
지금까지 Nest의 의존성 주입(Dependency Injection, DI) 과 그것이 어떻게 쓰이는지 여러 측면을 살펴봤어요. 대표적인 예가 클래스에 인스턴스(보통 서비스 프로바이더)를 주입하는 생성자 기반 의존성 주입이죠. 의존성 주입이 Nest 코어에 근본적으로 내장되어 있다는 사실은 놀랄 일이 아니에요. 그런데 지금까지 우리는 주요 패턴 하나만 탐구했어요. 애플리케이션이 점점 복잡해지면 DI 시스템의 전체 기능을 활용해야 할 때가 오는데, 그때 필요한 것들을 이번에 자세히 알아볼게요.
의존성 주입은 의존성의 인스턴스화를 우리 코드에서 명령적으로 직접 하는 대신 IoC 컨테이너(여기서는 NestJS 런타임 시스템)에 위임하는 제어의 역전(IoC) 기법이에요. 기본적인 흐름을 【Providers】 챕터의 예시로 살펴보고, 그다음에 표준 프로바이더에서 벗어난 커스텀 프로바이더를 만드는 다양한 방법을 하나씩 다룰게요. 🧩
본문
DI 기초(DI fundamentals)
먼저 프로바이더를 정의해요. @Injectable() 데코레이터가 CatsService 클래스를 프로바이더로 표시해요.
@@filename(cats.service)
import { Injectable } from '@nestjs/common';
import type { Cat } from './interfaces/cat.interface.js';
@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];
findAll(): Cat[] {
return this.cats;
}
}
@@switch
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatsService {
constructor() {
this.cats = [];
}
findAll() {
return this.cats;
}
}
그다음 Nest에 프로바이더를 컨트롤러 클래스에 주입하라고 요청해요.
@@filename(cats.controller)
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service.js';
import type { Cat } from './interfaces/cat.interface.js';
@Controller('cats')
export class CatsController {
constructor(private catsService: CatsService) {}
@Get()
async findAll(): Promise<Cat[]> {
return this.catsService.findAll();
}
}
@@switch
import { Controller, Get, Bind, Dependencies } from '@nestjs/common';
import { CatsService } from './cats.service.js';
@Controller('cats')
@Dependencies(CatsService)
export class CatsController {
constructor(catsService) {
this.catsService = catsService;
}
@Get()
async findAll() {
return this.catsService.findAll();
}
}
마지막으로 프로바이더를 Nest IoC 컨테이너에 등록해요.
@@filename(app.module)
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller.js';
import { CatsService } from './cats/cats.service.js';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}
내부에서 정확히 무슨 일이 일어나는 걸까요? 이 과정에는 세 가지 핵심 단계가 있어요.
cats.service.ts에서@Injectable()데코레이터가CatsService클래스를 Nest IoC 컨테이너가 관리할 수 있는 클래스로 선언해요.cats.controller.ts에서CatsController가 생성자 주입으로CatsService토큰에 대한 의존성을 선언해요.
constructor(private catsService: CatsService)
app.module.ts에서 토큰CatsService와cats.service.ts파일의CatsService클래스를 연결해요. 이 연결(등록이라고도 불러요)이 어떻게 일어나는지는 아래에서 볼게요.
Nest IoC 컨테이너가 CatsController를 인스턴스화할 때 먼저 의존성이 있는지 살펴봐요*. CatsService 의존성을 찾으면 등록 단계(#3)에 따라 CatsService 토큰을 조회해서 CatsService 클래스를 얻어요. SINGLETON 스코프(기본 동작)를 가정하면 Nest는 CatsService 인스턴스를 만들고 캐시해서 반환하거나, 이미 캐시돼 있으면 기존 인스턴스를 반환해요.
*이 설명은 핵심을 전달하기 위해 다소 단순화했어요. 간과한 중요한 부분 하나는 의존성을 분석하는 과정이 매우 정교하며 애플리케이션 부트스트랩 중에 일어난다는 거예요. 핵심 기능 중 하나는 의존성 분석(또는 '의존성 그래프 생성')이 전이적(transitive) 이라는 점이에요. 위 예시에서 CatsService 자체에 의존성이 있다면 그것들도 해결돼요. 의존성 그래프는 의존성이 올바른 순서로 — 본질적으로 '아래에서 위로' — 해결되도록 보장해요. 이 메커니즘 덕분에 개발자는 그런 복잡한 의존성 그래프를 직접 관리할 필요가 없어요.
표준 프로바이더(Standard providers)
@Module() 데코레이터를 자세히 살펴볼게요. app.module에서 우리는 이렇게 선언했어요.
@Module({
controllers: [CatsController],
providers: [CatsService],
})
providers 속성은 프로바이더 배열을 받아요. 지금까지는 클래스 이름 목록으로 프로바이더를 공급했죠. 사실 providers: [CatsService] 구문은 더 완전한 구문의 축약형이에요.
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
이 명시적 구성을 보면 이제 등록 과정을 이해할 수 있어요. 여기서는 토큰 CatsService와 클래스 CatsService를 명확히 연결하고 있어요. 축약 표기는 이름이 같은 클래스의 인스턴스를 요청하는 가장 흔한 용도를 단순화하기 위한 편의일 뿐이에요.
커스텀 프로바이더(Custom providers)
요구 사항이 _표준 프로바이더_가 제공하는 것 이상으로 넘어갈 때는 어떻게 할까요? 몇 가지 예를 들어볼게요.
- Nest가 클래스의 인스턴스를 만들(또는 캐시된 인스턴스를 반환하)는 대신 직접 커스텀 인스턴스를 만들고 싶을 때
- 두 번째 의존성에서 기존 클래스를 재사용하고 싶을 때
- 테스트를 위해 클래스를 목(mock) 버전으로 오버라이드하고 싶을 때
Nest는 이런 경우를 처리하기 위해 커스텀 프로바이더를 정의할 수 있게 해줘요. 커스텀 프로바이더를 정의하는 여러 방법이 있는데 하나씩 살펴볼게요.
💡 의존성 해결에 문제가 있다면
NEST_DEBUG환경 변수를 설정하면 시작 시 추가 의존성 해결 로그를 얻을 수 있어요.
값 프로바이더: useValue
useValue 구문은 상수 값을 주입하거나, 외부 라이브러리를 Nest 컨테이너에 넣거나, 실제 구현을 목 객체로 교체할 때 유용해요. 테스트 목적으로 Nest가 목 CatsService를 사용하도록 강제해 보고 싶다고 해볼게요.
import { CatsService } from './cats.service.js';
const mockCatsService = {
/* mock implementation
...
*/
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class AppModule {}
이 예시에서 CatsService 토큰은 mockCatsService 목 객체로 해결돼요. useValue는 값을 요구하는데, 이 경우 교체하는 CatsService 클래스와 같은 인터페이스를 가진 리터럴 객체예요. TypeScript의 구조적 타이핑 덕분에 인터페이스가 호환되는 아무 객체나 쓸 수 있어요 — 리터럴 객체든, new로 인스턴스화한 클래스 인스턴스든요.
클래스가 아닌 프로바이더 토큰(Non-class-based provider tokens)
지금까지는 클래스 이름을 프로바이더 토큰(providers 배열에 나열된 프로바이더의 provide 속성 값)으로 사용했어요. 이는 토큰 또한 클래스 이름인 생성자 기반 주입에 쓰이는 표준 패턴과 일치해요. 때로는 문자열이나 심볼을 DI 토큰으로 쓰는 유연성이 필요할 수 있어요. 예를 들어:
import { connection } from './connection.js';
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
이 예시에서는 문자열 값 토큰('CONNECTION')을 외부 파일에서 import한 기존 connection 객체와 연결하고 있어요.
⚠️ 토큰 값으로 문자열을 쓰는 것 외에도 JavaScript 심볼이나 TypeScript enum을 사용할 수도 있어요.
프로바이더를 표준 생성자 기반 주입 패턴으로 주입하는 방법을 앞서 봤어요. 이 패턴은 의존성이 클래스 이름으로 선언되는 것을 요구해요. 'CONNECTION' 커스텀 프로바이더는 문자열 값 토큰을 사용해요. 이런 프로바이더를 주입하는 방법을 알아볼게요. 이때 @Inject() 데코레이터를 사용해요. 이 데코레이터는 토큰 하나를 인자로 받아요.
@@filename()
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
@@switch
@Injectable()
@Dependencies('CONNECTION')
export class CatsRepository {
constructor(connection) {}
}
💡
@Inject()데코레이터는@nestjs/common패키지에서 가져와요.
설명을 위해 위 예시에서 문자열 'CONNECTION'을 직접 사용했지만, 깔끔한 코드 구성을 위해서는 토큰을 constants.ts 같은 별도 파일에 정의하는 것이 모범 사례예요. 심볼이나 enum을 자기 파일에 정의해 필요한 곳에서 import하는 것처럼 다루면 돼요.
인터페이스와 추상 클래스(Interfaces and abstract classes)
TypeScript 타입/인터페이스는 컴파일 중에 지워지기 때문에 Nest가 런타임에 그것들을 참조할 수 없어요. 즉 인터페이스는 의존성의 형태(shape)를 설명할 수 있지만 그 자체로 DI 토큰으로는 쓰일 수 없어요.
Nest는 런타임 토큰으로 프로바이더를 해결하므로, 인터페이스용 프로바이더를 등록할 때는 문자열이나 Symbol 토큰을 사용해요.
export interface LoggerService {
log(message: string): void;
}
export const LOGGER_SERVICE = Symbol('LOGGER_SERVICE');
@Injectable()
export class PinoLoggerService implements LoggerService {
log(message: string) {
// implementation details
}
}
@Module({
providers: [
{
provide: LOGGER_SERVICE,
useClass: PinoLoggerService,
},
],
})
export class AppModule {}
이 프로바이더를 주입하려면 그 토큰을 @Inject() 데코레이터에 넘겨요.
@Injectable()
export class CatsService {
constructor(
@Inject(LOGGER_SERVICE)
private readonly logger: LoggerService,
) {}
}
추상 클래스는 인터페이스와 달리 런타임에 존재해요. 추상 클래스를 TypeScript 계약이자 DI 토큰으로 사용할 수 있어요.
export abstract class LoggerService {
abstract log(message: string): void;
}
@Injectable()
export class PinoLoggerService implements LoggerService {
log(message: string) {
// implementation details
}
}
@Module({
providers: [
{
provide: LoggerService,
useClass: PinoLoggerService,
},
],
})
export class AppModule {}
추상 클래스 토큰을 쓰면 생성자 기반 주입이 추상 클래스 타입을 직접 사용할 수 있고 @Inject()가 필요 없어요.
@Injectable()
export class CatsService {
constructor(private readonly logger: LoggerService) {}
}
런타임 DI 토큰이 클래스 산출물과 분리되어야 할 때는 문자열이나 Symbol 토큰을 사용해요. Symbol 토큰은 각 심볼이 고유한 런타임 정체성을 가지므로, 관련 없는 프로바이더가 같은 문자열 토큰을 쓸 때 발생할 수 있는 우연한 충돌을 피하는 데 특히 유용해요 — 라이브러리와 대형 애플리케이션에 적합하죠. 심볼 토큰을 사용할 때는 공유 파일에서 export하고, 프로바이더를 등록하고 주입하는 모든 곳에서 같은 심볼 인스턴스를 재사용해요. 하나의 산출물이 계약이자 런타임 토큰 역할을 해야 하고 더 단순한 생성자 주입을 선호한다면 추상 클래스를 사용해요. 타입이 컴파일 타임 검사에만 쓰이고 DI 토큰이 필요 없다면 평범한 인터페이스도 여전히 좋은 선택이에요.
클래스 프로바이더: useClass
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)을 오버라이드해요.
팩토리 프로바이더: useFactory
useFactory 구문을 사용하면 프로바이더를 동적으로 만들 수 있어요. 실제 프로바이더는 팩토리 함수가 반환하는 값으로 공급돼요. 팩토리 함수는 필요에 따라 단순할 수도 복잡할 수도 있어요. 단순한 팩토리는 다른 프로바이더에 의존하지 않을 수 있어요. 더 복잡한 팩토리는 그 결과를 계산하는 데 필요한 다른 프로바이더를 자체적으로 주입할 수 있어요. 후자의 경우 팩토리 프로바이더 구문에는 관련된 두 가지 메커니즘이 있어요.
- 팩토리 함수는 (선택적) 인자를 받을 수 있어요.
- (선택적)
inject속성은 인스턴스화 과정 중에 Nest가 해결해 팩토리 함수에 인자로 전달할 프로바이더의 배열을 받아요. 또한 이 프로바이더들은 선택적(optional)으로 표시할 수 있어요. 두 목록은 서로 연관되어야 해요: Nest는inject목록의 인스턴스를 같은 순서로 팩토리 함수의 인자에 전달해요. 아래 예시가 이를 보여줘요.
@@filename()
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 {}
@@switch
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (optionsProvider, optionalProvider) => {
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 {}
별칭 프로바이더: useExisting
useExisting 구문을 사용하면 기존 프로바이더에 대한 별칭을 만들 수 있어요. 이렇게 하면 같은 프로바이더에 접근하는 두 가지 방법이 생겨요. 아래 예시에서 (문자열 기반) 토큰 'AliasedLoggerService'는 (클래스 기반) 토큰 LoggerService의 별칭이에요. 두 가지 다른 의존성, 하나는 'AliasedLoggerService', 하나는 LoggerService가 있다고 가정해볼게요. 둘 다 SINGLETON 스코프로 지정되면 같은 인스턴스로 해결돼요.
@Injectable()
class LoggerService {
/* implementation details */
}
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
서비스가 아닌 프로바이더(Non-service based providers)
프로바이더가 서비스를 공급하는 경우가 많지만 그 용도에 한정되지는 않아요. 프로바이더는 어떤 값이든 공급할 수 있어요. 예를 들어 현재 환경에 기반한 구성 객체 배열을 공급하는 프로바이더를 아래처럼 만들 수 있어요.
const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
},
};
@Module({
providers: [configFactory],
})
export class AppModule {}
커스텀 프로바이더 내보내기(Export custom provider)
다른 어떤 프로바이더처럼 커스텀 프로바이더도 선언한 모듈로 범위가 제한돼요. 다른 모듈에서 보이게 하려면 반드시 내보내야 해요. 커스텀 프로바이더를 내보내려면 토큰이나 전체 프로바이더 객체를 사용할 수 있어요.
토큰으로 내보내는 예시는 다음과 같아요.
@@filename()
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 {}
@@switch
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: ['CONNECTION'],
})
export class AppModule {}
또는 전체 프로바이더 객체로 내보낼 수도 있어요.
@@filename()
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 {}
@@switch
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: [connectionFactory],
})
export class AppModule {}
더 알아보기
- NestJS 공식 문서 - Custom providers
- Providers 챕터 — 표준 프로바이더와 의존성 주입 기초
- 주입 스코프(Injection scopes) — 프로바이더 생명주기 제어
- 동적 모듈(Dynamic modules) — 모듈을 동적으로 구성하는 법