Dynamic Module이 필요한 이유: 설정을 외부에서 주입하는 모듈
NestJS의 일반 모듈(@Module())은 내부 구성이 고정되어 있다. 하지만 데이터베이스 연결, HTTP 클라이언트, 캐시 등 사용처마다 다른 설정이 필요한 모듈은 어떻게 만들어야 할까? TypeOrmModule.forRoot({ host: '...' })처럼 설정 객체를 받아 모듈을 구성하는 패턴이 바로 Dynamic Module이다.
NestJS 공식 문서는 Dynamic Module을 “런타임에 프로바이더를 등록하고 구성할 수 있는 모듈을 만드는 API”로 정의한다. 이 글에서는 Dynamic Module의 내부 동작, forRoot/forFeature/forRootAsync 패턴, ConfigurableModuleBuilder(v9+), 그리고 실무 설계 가이드를 공식 문서 기반으로 정리한다.
Static Module vs Dynamic Module
| 항목 | Static Module | Dynamic Module |
|---|---|---|
| 정의 | @Module({ providers: [...] }) |
static 메서드가 DynamicModule 객체 반환 |
| 설정 주입 | ❌ 내부 고정 | ✅ 호출 시 옵션 전달 |
| 사용 예 | imports: [UsersModule] |
imports: [HttpModule.register({ timeout: 5000 })] |
| 재사용성 | 단일 구성 | 호출마다 다른 구성 가능 |
DynamicModule 인터페이스: 반환 객체의 구조
Dynamic Module의 static 메서드는 DynamicModule 객체를 반환한다. 이 객체는 @Module() 데코레이터의 메타데이터와 동일한 속성을 갖는다.
// DynamicModule 인터페이스 (NestJS 소스 기반)
interface DynamicModule {
module: Type<any>; // 필수: 모듈 클래스 자신
imports?: Array<...>; // 의존 모듈
providers?: Provider[]; // 프로바이더 (설정 기반으로 동적 구성)
exports?: Array<...>; // 외부 노출 프로바이더
controllers?: Type<any>[]; // 컨트롤러
global?: boolean; // 글로벌 모듈 여부
}
forRoot / forFeature 패턴: 수동 구현
// notification.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { NotificationService } from './notification.service';
export interface NotificationModuleOptions {
apiKey: string;
sender: string;
retryCount?: number;
}
@Module({})
export class NotificationModule {
static forRoot(options: NotificationModuleOptions): DynamicModule {
return {
module: NotificationModule,
providers: [
{
provide: 'NOTIFICATION_OPTIONS',
useValue: options,
},
NotificationService,
],
exports: [NotificationService],
global: true, // 앱 전체에서 NotificationService 사용 가능
};
}
}
// notification.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { NotificationModuleOptions } from './notification.module';
@Injectable()
export class NotificationService {
constructor(
@Inject('NOTIFICATION_OPTIONS')
private readonly options: NotificationModuleOptions,
) {}
async send(to: string, message: string): Promise<void> {
// this.options.apiKey, this.options.sender 사용
console.log(`Sending from ${this.options.sender} to ${to}: ${message}`);
}
}
// app.module.ts — 사용
@Module({
imports: [
NotificationModule.forRoot({
apiKey: process.env.NOTIFICATION_API_KEY,
sender: 'noreply@example.com',
retryCount: 3,
}),
],
})
export class AppModule {}
forRoot vs forFeature 컨벤션
| 메서드 | 호출 위치 | 역할 | 예시 |
|---|---|---|---|
forRoot() |
루트 모듈 (AppModule) | 전역 설정, 커넥션 생성 | TypeOrmModule.forRoot(dbConfig) |
forFeature() |
기능 모듈 | 모듈별 세부 설정 (엔티티 등록 등) | TypeOrmModule.forFeature([User]) |
register() |
아무 모듈 | 호출마다 독립 인스턴스 | HttpModule.register({ timeout: 5000 }) |
공식 문서에 따르면 이 네이밍은 컨벤션이다. 기술적 강제가 아니지만, NestJS 생태계에서 일관된 API를 위해 따르는 것이 권장된다.
forRootAsync: 비동기 설정 주입
forRoot()는 설정 값을 즉시 전달해야 한다. 하지만 ConfigService에서 읽거나 외부 API에서 가져오는 등 비동기로 설정을 구성해야 하는 경우가 많다. 이때 forRootAsync()를 사용한다.
// notification.module.ts — Async 버전 추가
export interface NotificationModuleAsyncOptions {
imports?: any[];
useFactory: (...args: any[]) => Promise<NotificationModuleOptions> | NotificationModuleOptions;
inject?: any[];
}
@Module({})
export class NotificationModule {
static forRoot(options: NotificationModuleOptions): DynamicModule {
return {
module: NotificationModule,
providers: [
{ provide: 'NOTIFICATION_OPTIONS', useValue: options },
NotificationService,
],
exports: [NotificationService],
global: true,
};
}
static forRootAsync(asyncOptions: NotificationModuleAsyncOptions): DynamicModule {
return {
module: NotificationModule,
imports: asyncOptions.imports || [],
providers: [
{
provide: 'NOTIFICATION_OPTIONS',
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject || [],
},
NotificationService,
],
exports: [NotificationService],
global: true,
};
}
}
// 사용: ConfigService에서 비동기로 설정 읽기
@Module({
imports: [
ConfigModule.forRoot(),
NotificationModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
apiKey: configService.get<string>('NOTIFICATION_API_KEY'),
sender: configService.get<string>('NOTIFICATION_SENDER'),
retryCount: configService.get<number>('NOTIFICATION_RETRY', 3),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
ConfigurableModuleBuilder: 보일러플레이트 제거 (v9+)
NestJS v9부터 ConfigurableModuleBuilder가 도입되어 forRoot/forRootAsync의 반복 코드를 자동 생성할 수 있다.
// notification.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface NotificationModuleOptions {
apiKey: string;
sender: string;
retryCount?: number;
}
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<NotificationModuleOptions>()
.setClassMethodName('forRoot') // 메서드 이름 (기본: register)
.setExtras({ isGlobal: false }, (definition, extras) => ({
...definition,
global: extras.isGlobal, // isGlobal 옵션 추가
}))
.build();
// notification.module.ts — 빌더 기반
import { Module } from '@nestjs/common';
import { ConfigurableModuleClass } from './notification.module-definition';
import { NotificationService } from './notification.service';
@Module({
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule extends ConfigurableModuleClass {
// forRoot()과 forRootAsync()가 자동 생성됨
}
// notification.service.ts — MODULE_OPTIONS_TOKEN 사용
import { Injectable, Inject } from '@nestjs/common';
import {
MODULE_OPTIONS_TOKEN,
NotificationModuleOptions,
} from './notification.module-definition';
@Injectable()
export class NotificationService {
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: NotificationModuleOptions,
) {}
async send(to: string, message: string): Promise<void> {
// this.options.apiKey 사용
}
}
// 사용: 수동 구현과 동일한 API
@Module({
imports: [
// 동기
NotificationModule.forRoot({
apiKey: 'xxx',
sender: 'noreply@example.com',
isGlobal: true, // setExtras로 추가한 옵션
}),
// 비동기
NotificationModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
apiKey: config.get('NOTIFICATION_API_KEY'),
sender: config.get('NOTIFICATION_SENDER'),
}),
inject: [ConfigService],
isGlobal: true,
}),
],
})
export class AppModule {}
| 방식 | 코드량 | forRootAsync 자동 생성 | 커스터마이징 |
|---|---|---|---|
| 수동 구현 | 많음 (30~50줄) | ❌ 직접 작성 | 완전한 제어 |
| ConfigurableModuleBuilder | 적음 (10~15줄) | ✅ 자동 | setExtras로 확장 |
실전 체크리스트: Dynamic Module 설계 6단계
- 옵션 인터페이스 먼저 정의 — 모듈 사용자가 전달할 설정 타입을 명확히 하고, 선택 속성에 기본값을 문서화한다
- forRoot/forFeature/register 네이밍 선택 — 전역 싱글턴이면 forRoot, 기능 모듈별 등록이면 forFeature, 호출마다 독립이면 register
- forRootAsync 반드시 제공 — ConfigService 연동은 실무에서 거의 필수이므로 비동기 버전을 항상 함께 만든다
- v9+ 이면 ConfigurableModuleBuilder 고려 — 보일러플레이트를 줄이고 useFactory/useClass/useExisting을 자동 지원
- global 옵션 지원 — 사용자가 글로벌 여부를 선택할 수 있도록 isGlobal 옵션을 제공한다
- 옵션 토큰을 Symbol이나 상수로 — 문자열 토큰(
'NOTIFICATION_OPTIONS')보다MODULE_OPTIONS_TOKEN같은 Symbol이 충돌을 방지한다
흔한 실수 4가지와 방지법
실수 1: DynamicModule에서 module 속성을 빠뜨림
증상: Nest cannot create the module instance 에러. DynamicModule 반환 객체에 module 프로퍼티가 없다.
방지: module: NotificationModule(모듈 클래스 자신)을 반드시 포함한다. 이 속성이 NestJS에게 어떤 모듈의 동적 구성인지 알려준다.
실수 2: forRootAsync에서 imports를 빠뜨려 inject 실패
증상: useFactory에서 ConfigService를 inject했는데 Nest can't resolve dependencies 에러.
방지: inject에 나열한 프로바이더의 모듈을 imports에 포함해야 한다. ConfigService를 쓰려면 imports: [ConfigModule]이 필수다.
실수 3: forRoot를 여러 모듈에서 중복 호출
증상: DB 커넥션이 중복 생성되거나, 설정 충돌로 예기치 않은 동작.
방지: forRoot는 루트 모듈(AppModule)에서 한 번만 호출한다. 기능 모듈에서는 forFeature를 사용한다. 이 컨벤션이 존재하는 이유다.
실수 4: global: true를 남용해 모듈 의존성이 불투명
증상: 어떤 모듈이 어떤 서비스에 의존하는지 imports 배열만 봐서는 알 수 없다.
방지: global은 정말 앱 전체에서 사용하는 모듈(Config, Logger 등)에만 쓴다. 나머지는 명시적으로 imports에 추가해 의존성을 투명하게 유지한다.
마무리
NestJS Dynamic Module은 설정을 외부에서 주입받는 재사용 가능한 모듈을 만드는 핵심 패턴이다. forRoot으로 전역 설정, forFeature로 기능별 등록, forRootAsync로 비동기 설정을 지원하며, v9의 ConfigurableModuleBuilder로 보일러플레이트를 대폭 줄일 수 있다. 이 글의 모든 내용은 NestJS 공식 문서(Dynamic Modules, ConfigurableModuleBuilder)를 근거로 한다.