NestJS Dynamic Module 설계

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 외에 useClassuseExisting으로도 비동기 설정을 제공할 수 있습니다.

// 옵션 팩토리 인터페이스
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 {}

ConfigurableModuleBuilderuseFactory, 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로 시작하세요.

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