Dynamic Module이란?
NestJS의 Dynamic Module은 런타임에 설정을 주입받아 모듈을 구성하는 패턴이다. 일반 모듈은 정적으로 고정된 설정을 사용하지만, Dynamic Module은 forRoot(), forRootAsync(), forFeature() 같은 정적 메서드를 통해 소비자가 설정을 전달한다. TypeORM, ConfigModule, HttpModule 등 NestJS 핵심 라이브러리가 모두 이 패턴을 사용한다.
기본 Dynamic Module 구현
// payment.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { PaymentService } from './payment.service';
export interface PaymentModuleOptions {
apiKey: string;
gateway: 'stripe' | 'toss' | 'portone';
sandbox?: boolean;
}
export const PAYMENT_OPTIONS = 'PAYMENT_OPTIONS';
@Module({})
export class PaymentModule {
static forRoot(options: PaymentModuleOptions): DynamicModule {
return {
module: PaymentModule,
global: true, // 전역 등록 — 다른 모듈에서 import 없이 사용
providers: [
{
provide: PAYMENT_OPTIONS,
useValue: options,
},
PaymentService,
],
exports: [PaymentService],
};
}
}
// payment.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { PAYMENT_OPTIONS, PaymentModuleOptions } from './payment.module';
@Injectable()
export class PaymentService {
constructor(
@Inject(PAYMENT_OPTIONS) private readonly options: PaymentModuleOptions,
) {}
async charge(amount: number): Promise<string> {
const env = this.options.sandbox ? 'sandbox' : 'production';
console.log(`[${this.options.gateway}][${env}] Charging ${amount}`);
// 실제 PG 연동 로직
return `txn_${Date.now()}`;
}
}
// app.module.ts — 사용 측
@Module({
imports: [
PaymentModule.forRoot({
apiKey: 'sk_test_xxx',
gateway: 'toss',
sandbox: true,
}),
],
})
export class AppModule {}
forRootAsync: 비동기 설정 주입
실무에서는 API 키를 환경변수나 ConfigModule에서 가져와야 한다. forRootAsync()는 팩토리 함수, 클래스, 기존 프로바이더를 통해 비동기적으로 옵션을 구성한다.
// 비동기 옵션 인터페이스
export interface PaymentModuleAsyncOptions {
imports?: any[];
useFactory?: (...args: any[]) => Promise<PaymentModuleOptions> | PaymentModuleOptions;
inject?: any[];
useClass?: Type<PaymentOptionsFactory>;
useExisting?: Type<PaymentOptionsFactory>;
}
export interface PaymentOptionsFactory {
createPaymentOptions(): Promise<PaymentModuleOptions> | PaymentModuleOptions;
}
@Module({})
export class PaymentModule {
static forRoot(options: PaymentModuleOptions): DynamicModule {
return {
module: PaymentModule,
global: true,
providers: [
{ provide: PAYMENT_OPTIONS, useValue: options },
PaymentService,
],
exports: [PaymentService],
};
}
static forRootAsync(asyncOptions: PaymentModuleAsyncOptions): DynamicModule {
return {
module: PaymentModule,
global: true,
imports: asyncOptions.imports || [],
providers: [
...this.createAsyncProviders(asyncOptions),
PaymentService,
],
exports: [PaymentService],
};
}
private static createAsyncProviders(
options: PaymentModuleAsyncOptions,
): Provider[] {
if (options.useFactory) {
return [
{
provide: PAYMENT_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
const useClass = options.useClass || options.useExisting;
return [
{
provide: PAYMENT_OPTIONS,
useFactory: async (factory: PaymentOptionsFactory) =>
factory.createPaymentOptions(),
inject: [useClass],
},
...(options.useClass ? [{ provide: useClass, useClass }] : []),
];
}
}
// 사용 예: useFactory
@Module({
imports: [
ConfigModule.forRoot(),
PaymentModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
apiKey: config.getOrThrow('PAYMENT_API_KEY'),
gateway: config.get('PAYMENT_GATEWAY', 'toss'),
sandbox: config.get('NODE_ENV') !== 'production',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
forFeature: 모듈별 부분 설정
forRoot()는 전역 설정이고, forFeature()는 각 모듈에서 추가 설정을 등록하는 패턴이다. TypeORM의 forFeature([Entity])가 대표적이다.
// notification.module.ts
export interface NotificationChannel {
type: 'email' | 'slack' | 'sms';
config: Record<string, any>;
}
export const NOTIFICATION_CHANNELS = 'NOTIFICATION_CHANNELS';
@Module({})
export class NotificationModule {
// 전역 기본 설정
static forRoot(defaultConfig: { from: string }): DynamicModule {
return {
module: NotificationModule,
global: true,
providers: [
{ provide: 'NOTIFICATION_DEFAULT', useValue: defaultConfig },
NotificationService,
],
exports: [NotificationService],
};
}
// 모듈별 채널 등록
static forFeature(channels: NotificationChannel[]): DynamicModule {
return {
module: NotificationModule,
providers: [
{
provide: NOTIFICATION_CHANNELS,
useValue: channels,
},
ChannelDispatcher,
],
exports: [ChannelDispatcher],
};
}
}
// orders.module.ts — 주문 모듈은 이메일+슬랙 채널 사용
@Module({
imports: [
NotificationModule.forFeature([
{ type: 'email', config: { template: 'order-confirm' } },
{ type: 'slack', config: { channel: '#orders' } },
]),
],
})
export class OrdersModule {}
ConfigurableModuleBuilder (NestJS 9+)
NestJS 9에서 도입된 ConfigurableModuleBuilder는 보일러플레이트를 대폭 줄여준다. forRoot()와 forRootAsync()를 자동 생성한다.
// storage.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface StorageModuleOptions {
bucket: string;
region: string;
accessKey?: string;
secretKey?: string;
}
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<StorageModuleOptions>()
.setClassMethodName('forRoot') // 메서드명 커스텀
.setExtras({ isGlobal: false }, (definition, extras) => ({
...definition,
global: extras.isGlobal,
}))
.build();
// storage.module.ts — 극도로 간결!
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule extends ConfigurableModuleClass {}
// storage.service.ts
@Injectable()
export class StorageService {
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: StorageModuleOptions,
) {}
async upload(key: string, data: Buffer): Promise<string> {
return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com/${key}`;
}
}
// 사용 — forRoot, forRootAsync 모두 자동 생성됨
@Module({
imports: [
StorageModule.forRoot({
bucket: 'my-app-uploads',
region: 'ap-northeast-2',
isGlobal: true, // setExtras로 추가한 옵션
}),
// 또는 비동기
StorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
bucket: config.getOrThrow('S3_BUCKET'),
region: config.get('AWS_REGION', 'ap-northeast-2'),
}),
inject: [ConfigService],
isGlobal: true,
}),
],
})
export class AppModule {}
Dynamic Module 테스트
describe('PaymentModule', () => {
let service: PaymentService;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
PaymentModule.forRoot({
apiKey: 'test_key',
gateway: 'stripe',
sandbox: true,
}),
],
}).compile();
service = module.get(PaymentService);
});
it('forRoot 옵션이 주입된다', () => {
expect(service).toBeDefined();
});
it('sandbox 모드로 결제한다', async () => {
const txnId = await service.charge(10000);
expect(txnId).toMatch(/^txn_/);
});
});
describe('PaymentModule.forRootAsync', () => {
it('useFactory로 비동기 설정 주입', async () => {
const module = await Test.createTestingModule({
imports: [
PaymentModule.forRootAsync({
useFactory: () => ({
apiKey: 'async_key',
gateway: 'toss',
}),
}),
],
}).compile();
const service = module.get(PaymentService);
expect(service).toBeDefined();
});
});
설계 베스트 프랙티스
- forRoot은 한 번만: AppModule에서 전역 설정. 중복 호출 방지를 위해
global: true설정 - forFeature는 여러 번: 각 도메인 모듈에서 부분 설정 등록
- NestJS 9+는 ConfigurableModuleBuilder 사용: 수동 보일러플레이트 제거
- 옵션 토큰은 Symbol 또는 문자열 상수: 충돌 방지
- forRootAsync 필수 제공: 실무에서는 환경변수 기반 설정이 표준
- Custom Decorator와 조합하면 선언적이고 재사용 가능한 모듈 설계가 가능하다