NestJS Dynamic Module이란?
NestJS의 모듈은 기본적으로 Static Module입니다. @Module() 데코레이터에 providers, controllers를 고정으로 등록합니다. 반면 Dynamic Module은 런타임에 설정값을 받아 모듈의 동작을 결정합니다. ConfigModule.forRoot(), TypeOrmModule.forFeature() 같은 패턴이 모두 Dynamic Module입니다.
라이브러리를 만들거나, 설정 기반으로 동작이 달라지는 모듈을 설계할 때 Dynamic Module은 필수 패턴입니다.
forRoot / forFeature 패턴
가장 기본적인 Dynamic Module 패턴입니다.
// mail.module.ts
import { DynamicModule, Module } from '@nestjs/common';
export interface MailModuleOptions {
apiKey: string;
from: string;
templateDir?: string;
}
export const MAIL_OPTIONS = 'MAIL_OPTIONS';
@Module({})
export class MailModule {
// forRoot — AppModule에서 한 번만 호출 (싱글턴 설정)
static forRoot(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
global: true, // 전역 모듈로 등록 — 다른 모듈에서 import 불필요
providers: [
{
provide: MAIL_OPTIONS,
useValue: options,
},
MailService,
],
exports: [MailService],
};
}
}
// mail.service.ts
@Injectable()
export class MailService {
constructor(
@Inject(MAIL_OPTIONS) private readonly options: MailModuleOptions,
) {}
async send(to: string, subject: string, body: string) {
// this.options.apiKey, this.options.from 사용
}
}
// app.module.ts — 사용
@Module({
imports: [
MailModule.forRoot({
apiKey: 'sg_xxxx',
from: 'noreply@example.com',
}),
],
})
export class AppModule {}
forRootAsync: 비동기 설정 주입
설정값을 ConfigService나 외부 소스에서 비동기로 가져와야 할 때 forRootAsync를 사용합니다.
// mail.module.ts
export interface MailModuleAsyncOptions {
imports?: any[];
useFactory: (...args: any[]) => Promise<MailModuleOptions> | MailModuleOptions;
inject?: any[];
}
@Module({})
export class MailModule {
static forRoot(options: MailModuleOptions): DynamicModule { /* ... */ }
static forRootAsync(asyncOptions: MailModuleAsyncOptions): DynamicModule {
return {
module: MailModule,
global: true,
imports: asyncOptions.imports || [],
providers: [
{
provide: MAIL_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject || [],
},
MailService,
],
exports: [MailService],
};
}
}
// app.module.ts — ConfigService에서 설정 읽기
@Module({
imports: [
ConfigModule.forRoot(),
MailModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
apiKey: config.getOrThrow('MAIL_API_KEY'),
from: config.getOrThrow('MAIL_FROM'),
templateDir: config.get('MAIL_TEMPLATE_DIR', './templates'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
useClass / useExisting 패턴
useFactory 외에 useClass와 useExisting으로도 비동기 설정을 제공할 수 있습니다.
// 옵션 팩토리 인터페이스
export interface MailOptionsFactory {
createMailOptions(): Promise<MailModuleOptions> | MailModuleOptions;
}
// useClass용 확장
export interface MailModuleAsyncOptions {
imports?: any[];
useFactory?: (...args: any[]) => Promise<MailModuleOptions> | MailModuleOptions;
useClass?: Type<MailOptionsFactory>;
useExisting?: Type<MailOptionsFactory>;
inject?: any[];
}
@Module({})
export class MailModule {
static forRootAsync(options: MailModuleAsyncOptions): DynamicModule {
const providers: Provider[] = [MailService];
if (options.useFactory) {
providers.push({
provide: MAIL_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
});
} else if (options.useClass) {
providers.push(
{ provide: options.useClass, useClass: options.useClass },
{
provide: MAIL_OPTIONS,
useFactory: (factory: MailOptionsFactory) => factory.createMailOptions(),
inject: [options.useClass],
},
);
} else if (options.useExisting) {
providers.push({
provide: MAIL_OPTIONS,
useFactory: (factory: MailOptionsFactory) => factory.createMailOptions(),
inject: [options.useExisting],
});
}
return {
module: MailModule,
global: true,
imports: options.imports || [],
providers,
exports: [MailService],
};
}
}
// useClass 사용 예 — 별도 클래스에서 설정 생성
@Injectable()
export class MailConfigService implements MailOptionsFactory {
constructor(private readonly config: ConfigService) {}
createMailOptions(): MailModuleOptions {
return {
apiKey: this.config.getOrThrow('MAIL_API_KEY'),
from: this.config.getOrThrow('MAIL_FROM'),
};
}
}
// app.module.ts
MailModule.forRootAsync({
imports: [ConfigModule],
useClass: MailConfigService,
})
forFeature: 모듈 내 세부 등록
forRoot이 글로벌 설정이라면, forFeature는 각 모듈에서 필요한 세부 요소를 등록합니다.
// cache.module.ts
@Module({})
export class CacheModule {
// forRoot — Redis 연결 설정 (글로벌, 한 번)
static forRoot(options: CacheModuleOptions): DynamicModule {
return {
module: CacheModule,
global: true,
providers: [
{ provide: CACHE_OPTIONS, useValue: options },
CacheConnectionManager,
],
exports: [CacheConnectionManager],
};
}
// forFeature — 각 모듈별 캐시 네임스페이스 등록
static forFeature(namespace: string, ttl = 3600): DynamicModule {
const token = `CACHE_${namespace.toUpperCase()}`;
return {
module: CacheModule,
providers: [
{
provide: token,
useFactory: (manager: CacheConnectionManager) =>
manager.createNamespace(namespace, ttl),
inject: [CacheConnectionManager],
},
],
exports: [token],
};
}
}
// user.module.ts — 사용
@Module({
imports: [CacheModule.forFeature('users', 1800)],
providers: [UserService],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@Inject('CACHE_USERS') private readonly cache: CacheNamespace,
) {}
}
ConfigurableModuleBuilder (NestJS 9+)
NestJS 9부터 ConfigurableModuleBuilder를 사용하면 보일러플레이트를 대폭 줄일 수 있습니다.
// mail.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface MailModuleOptions {
apiKey: string;
from: string;
}
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<MailModuleOptions>()
.setClassMethodName('forRoot') // forRoot / forRootAsync 자동 생성
.setExtras({ isGlobal: false }, (definition, extras) => ({
...definition,
global: extras.isGlobal,
}))
.build();
// mail.module.ts — 깔끔!
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule extends ConfigurableModuleClass {}
// mail.service.ts
@Injectable()
export class MailService {
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: MailModuleOptions,
) {}
}
// app.module.ts — forRoot, forRootAsync 모두 자동 사용 가능
@Module({
imports: [
MailModule.forRoot({ apiKey: 'xxx', from: 'a@b.com', isGlobal: true }),
// 또는
MailModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
apiKey: config.getOrThrow('MAIL_API_KEY'),
from: config.getOrThrow('MAIL_FROM'),
}),
inject: [ConfigService],
isGlobal: true,
}),
],
})
export class AppModule {}
ConfigurableModuleBuilder는 useFactory, useClass, useExisting을 모두 자동 지원합니다. 새로운 모듈을 만들 때는 이 방식을 권장합니다.
테스트에서의 Dynamic Module
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
// 테스트용 설정으로 Dynamic Module 초기화
MailModule.forRoot({
apiKey: 'test-key',
from: 'test@example.com',
}),
],
providers: [UserService],
}).compile();
service = module.get(UserService);
});
// 또는 옵션 토큰만 모킹
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
MailService,
{
provide: MAIL_OPTIONS,
useValue: { apiKey: 'test', from: 'test@test.com' },
},
],
}).compile();
});
});
Dynamic Module의 설정 토큰(MAIL_OPTIONS)을 직접 모킹하면 실제 외부 서비스 연결 없이 테스트할 수 있습니다. NestJS 테스트 전략에서 다룬 모킹 패턴과 함께 활용하세요.
실전 설계 원칙
| 원칙 | 설명 |
|---|---|
| forRoot = 글로벌 1회 | 연결, 인증 등 앱 전체 설정 |
| forFeature = 모듈별 N회 | 엔티티, 네임스페이스 등 세부 등록 |
| Async 필수 제공 | ConfigService 주입을 위해 forRootAsync 항상 구현 |
| NestJS 9+는 Builder 사용 | ConfigurableModuleBuilder로 보일러플레이트 제거 |
Dynamic Module은 NestJS 캐싱, 데이터베이스, 메일, 큐 등 모든 인프라 통합의 기반 패턴입니다. forRoot/forRootAsync/forFeature 3가지 메서드를 제공하면 어떤 환경에서든 유연하게 사용할 수 있는 모듈이 됩니다.
정리
NestJS Dynamic Module의 핵심은 “설정을 외부에서 주입받는 모듈”입니다. forRoot으로 글로벌 설정, forFeature로 모듈별 세부 등록, forRootAsync로 비동기 설정 주입까지 — 이 패턴을 익히면 재사용 가능한 라이브러리 수준의 모듈을 설계할 수 있습니다. NestJS 9 이상이라면 ConfigurableModuleBuilder로 시작하세요.