NestJS Dynamic Module 설계

NestJS Dynamic Module이란?

NestJS의 일반 모듈은 @Module() 데코레이터에 고정된 설정을 가진다. 반면 Dynamic ModuleforRoot(), forRootAsync(), register() 같은 정적 메서드를 통해 런타임에 설정을 주입받는 모듈이다. ConfigModule.forRoot(), TypeOrmModule.forRoot() 등 NestJS 공식 라이브러리 대부분이 이 패턴을 사용한다.

// 일반 모듈: 설정 고정
@Module({
  providers: [MyService],
  exports: [MyService],
})
export class MyModule {}

// Dynamic Module: 설정을 외부에서 주입
@Module({})
export class CacheModule {
  static forRoot(options: CacheOptions): DynamicModule {
    return {
      module: CacheModule,
      providers: [
        { provide: 'CACHE_OPTIONS', useValue: options },
        CacheService,
      ],
      exports: [CacheService],
    };
  }
}

forRoot vs forFeature vs register

NestJS 커뮤니티에서 통용되는 네이밍 컨벤션이 있다. 이를 따르면 모듈 사용자가 직관적으로 용도를 파악할 수 있다.

메서드 용도 호출 위치 global 여부
forRoot() 전역 설정 (1회) AppModule 보통 global: true
forFeature() 기능별 세부 설정 각 Feature Module 아니오
register() 매번 새 인스턴스 어디서든 아니오
// AppModule에서 1회 글로벌 설정
@Module({
  imports: [
    NotificationModule.forRoot({
      defaultChannel: 'email',
      retryCount: 3,
    }),
  ],
})
export class AppModule {}

// 각 Feature Module에서 세부 설정
@Module({
  imports: [
    NotificationModule.forFeature({
      channel: 'slack',
      webhookUrl: 'https://hooks.slack.com/...',
    }),
  ],
})
export class OrderModule {}

forRootAsync: 비동기 설정 주입

실무에서는 DB 접속 정보, API 키 등을 ConfigService에서 가져와야 한다. forRootAsync()useFactory, useClass, useExisting 패턴으로 비동기 설정을 지원한다.

@Module({})
export class StorageModule {
  static forRootAsync(options: StorageAsyncOptions): DynamicModule {
    return {
      module: StorageModule,
      global: true,
      imports: options.imports || [],
      providers: [
        {
          provide: 'STORAGE_OPTIONS',
          useFactory: options.useFactory,
          inject: options.inject || [],
        },
        StorageService,
      ],
      exports: [StorageService],
    };
  }
}

// 사용 측: ConfigService에서 설정 주입
@Module({
  imports: [
    StorageModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        bucket: config.get('S3_BUCKET'),
        region: config.get('AWS_REGION'),
        credentials: {
          accessKeyId: config.get('AWS_ACCESS_KEY'),
          secretAccessKey: config.get('AWS_SECRET_KEY'),
        },
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

AsyncOptions 인터페이스 표준 패턴

재사용 가능한 Dynamic Module을 만들려면 표준화된 AsyncOptions 인터페이스를 정의한다.

import { ModuleMetadata, Type } from '@nestjs/common';

// 옵션 팩토리 인터페이스
export interface StorageOptionsFactory {
  createStorageOptions(): StorageOptions | Promise<StorageOptions>;
}

export interface StorageAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
  // 방식 1: useFactory
  useFactory?: (...args: any[]) => StorageOptions | Promise<StorageOptions>;
  inject?: any[];

  // 방식 2: useClass (새 인스턴스 생성)
  useClass?: Type<StorageOptionsFactory>;

  // 방식 3: useExisting (기존 프로바이더 재사용)
  useExisting?: Type<StorageOptionsFactory>;
}
// useClass 패턴: 별도 설정 클래스
@Injectable()
export class StorageConfigService implements StorageOptionsFactory {
  constructor(private config: ConfigService) {}

  createStorageOptions(): StorageOptions {
    return {
      bucket: this.config.get('S3_BUCKET'),
      region: this.config.get('AWS_REGION'),
    };
  }
}

// 사용
StorageModule.forRootAsync({
  useClass: StorageConfigService,
})

ConfigurableModuleBuilder (NestJS 9+)

NestJS 9부터 ConfigurableModuleBuilder가 도입되어 보일러플레이트를 대폭 줄일 수 있다. 위의 모든 패턴을 자동 생성해준다.

import { ConfigurableModuleBuilder } from '@nestjs/common';

export interface NotificationOptions {
  defaultChannel: string;
  retryCount: number;
  apiKey: string;
}

// 빌더가 forRoot, forRootAsync, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE 자동 생성
export const {
  ConfigurableModuleClass,
  MODULE_OPTIONS_TOKEN,
  OPTIONS_TYPE,
  ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<NotificationOptions>()
  .setClassMethodName('forRoot')  // 메서드 이름 지정
  .setExtras({ isGlobal: false }, (definition, extras) => ({
    ...definition,
    global: extras.isGlobal,      // global 옵션 지원
  }))
  .build();
// 모듈: ConfigurableModuleClass 상속만 하면 끝
@Module({
  providers: [NotificationService],
  exports: [NotificationService],
})
export class NotificationModule extends ConfigurableModuleClass {}

// 서비스: MODULE_OPTIONS_TOKEN으로 주입
@Injectable()
export class NotificationService {
  constructor(
    @Inject(MODULE_OPTIONS_TOKEN)
    private options: NotificationOptions,
  ) {}

  async send(message: string) {
    console.log(`[${this.options.defaultChannel}] ${message}`);
    // retry logic with this.options.retryCount
  }
}
// 사용: 동기/비동기 모두 자동 지원
@Module({
  imports: [
    // 동기
    NotificationModule.forRoot({
      defaultChannel: 'slack',
      retryCount: 3,
      apiKey: 'xxx',
    }),

    // 비동기
    NotificationModule.forRootAsync({
      isGlobal: true,
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        defaultChannel: config.get('NOTIFY_CHANNEL'),
        retryCount: config.getOrThrow('NOTIFY_RETRY'),
        apiKey: config.getOrThrow('NOTIFY_API_KEY'),
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

forRoot + forFeature 조합 패턴

글로벌 설정(forRoot)과 기능별 설정(forFeature)을 함께 제공하는 실전 패턴이다. 메타데이터 기반 DI와 결합하면 매우 유연한 모듈을 설계할 수 있다.

@Module({})
export class EventBusModule {
  // 글로벌 설정: 커넥션 정보
  static forRoot(options: EventBusOptions): DynamicModule {
    return {
      module: EventBusModule,
      global: true,
      providers: [
        { provide: 'EVENT_BUS_OPTIONS', useValue: options },
        EventBusService,
      ],
      exports: [EventBusService],
    };
  }

  // 기능별 설정: 이벤트 핸들러 등록
  static forFeature(handlers: Type<EventHandler>[]): DynamicModule {
    return {
      module: EventBusModule,
      providers: [
        ...handlers,
        {
          provide: 'EVENT_HANDLERS',
          useFactory: (...instances: EventHandler[]) => instances,
          inject: handlers,
        },
      ],
    };
  }
}

// AppModule: 전역 설정
EventBusModule.forRoot({ url: 'amqp://localhost' })

// OrderModule: 핸들러 등록
EventBusModule.forFeature([OrderCreatedHandler, OrderCancelledHandler])

테스트에서 Dynamic Module 오버라이드

테스트 시 Dynamic Module의 옵션을 오버라이드하는 패턴이다.

const module = await Test.createTestingModule({
  imports: [
    // 테스트용 설정으로 오버라이드
    StorageModule.forRoot({
      bucket: 'test-bucket',
      region: 'us-east-1',
    }),
  ],
})
  .overrideProvider(StorageService)
  .useValue(mockStorageService)  // 또는 서비스만 mock
  .compile();

MODULE_OPTIONS_TOKEN을 직접 오버라이드하면 실제 서비스 로직은 유지하면서 설정만 교체할 수도 있다. Interceptor 테스트와 함께 활용하면 통합 테스트 효율이 높아진다.

정리

NestJS Dynamic Module은 런타임 설정 주입의 핵심 패턴이다. forRoot/forFeature/register 네이밍 컨벤션을 따르고, forRootAsync로 ConfigService 연동을 지원하며, NestJS 9+에서는 ConfigurableModuleBuilder로 보일러플레이트를 제거할 수 있다. 재사용 가능한 라이브러리 모듈을 만들 때 이 패턴을 마스터하면 NestJS 생태계의 설계 철학을 완전히 이해하게 된다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux