NestJS Dynamic Module 설계

Dynamic Module이란?

NestJS에서 Static Module@Module() 데코레이터로 고정된 providers/imports를 선언합니다. 반면 Dynamic ModuleforRoot(), forRootAsync(), forFeature() 같은 정적 메서드를 통해 런타임에 설정을 주입받아 모듈을 구성합니다.

ConfigModule.forRoot(), TypeOrmModule.forRoot()을 사용해본 적이 있다면 이미 Dynamic Module을 쓰고 있는 것입니다. 이 글에서는 직접 Dynamic Module을 설계하고 구현하는 방법을 심화 분석합니다.

기본 구조: forRoot 패턴

가장 기본적인 Dynamic Module 패턴입니다. 외부 API 클라이언트 모듈을 예시로 구현합니다:

// notification.interfaces.ts
export interface NotificationModuleOptions {
  apiKey: string;
  baseUrl: string;
  timeout?: number;
  retryCount?: number;
}

export const NOTIFICATION_OPTIONS = 'NOTIFICATION_OPTIONS';

// notification.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationModuleOptions, NOTIFICATION_OPTIONS } from './notification.interfaces';

@Module({})
export class NotificationModule {
  static forRoot(options: NotificationModuleOptions): DynamicModule {
    return {
      module: NotificationModule,
      global: true,  // 앱 전역에서 사용 가능
      providers: [
        {
          provide: NOTIFICATION_OPTIONS,
          useValue: options,
        },
        NotificationService,
      ],
      exports: [NotificationService],
    };
  }
}

사용하는 쪽에서는 이렇게 호출합니다:

// app.module.ts
@Module({
  imports: [
    NotificationModule.forRoot({
      apiKey: 'sk-xxxxx',
      baseUrl: 'https://api.notification.io',
      timeout: 5000,
      retryCount: 3,
    }),
  ],
})
export class AppModule {}

forRootAsync: 비동기 설정 주입

실무에서는 API 키를 하드코딩하지 않습니다. ConfigService나 외부 Vault에서 비동기로 가져오는 forRootAsync()가 필수입니다:

// notification.interfaces.ts
export interface NotificationModuleAsyncOptions {
  imports?: any[];
  useFactory: (...args: any[]) => Promise<NotificationModuleOptions> | NotificationModuleOptions;
  inject?: any[];
}

// notification.module.ts
@Module({})
export class NotificationModule {
  static forRoot(options: NotificationModuleOptions): DynamicModule {
    return {
      module: NotificationModule,
      global: true,
      providers: [
        { provide: NOTIFICATION_OPTIONS, useValue: options },
        NotificationService,
      ],
      exports: [NotificationService],
    };
  }

  static forRootAsync(asyncOptions: NotificationModuleAsyncOptions): DynamicModule {
    return {
      module: NotificationModule,
      global: true,
      imports: asyncOptions.imports || [],
      providers: [
        {
          provide: NOTIFICATION_OPTIONS,
          useFactory: asyncOptions.useFactory,
          inject: asyncOptions.inject || [],
        },
        NotificationService,
      ],
      exports: [NotificationService],
    };
  }
}

ConfigService를 주입하여 환경 변수에서 설정을 읽는 패턴:

// app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot(),
    NotificationModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        apiKey: config.getOrThrow('NOTIFICATION_API_KEY'),
        baseUrl: config.getOrThrow('NOTIFICATION_BASE_URL'),
        timeout: config.get('NOTIFICATION_TIMEOUT', 5000),
        retryCount: config.get('NOTIFICATION_RETRY', 3),
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

useClass / useExisting 패턴

useFactory 외에도 클래스 기반 옵션 제공자 패턴이 있습니다. 더 객체지향적인 설정이 가능합니다:

// notification.interfaces.ts
export interface NotificationOptionsFactory {
  createNotificationOptions(): Promise<NotificationModuleOptions> | NotificationModuleOptions;
}

export interface NotificationModuleAsyncOptions {
  imports?: any[];
  useFactory?: (...args: any[]) => Promise<NotificationModuleOptions> | NotificationModuleOptions;
  useClass?: Type<NotificationOptionsFactory>;
  useExisting?: Type<NotificationOptionsFactory>;
  inject?: any[];
}

// 커스텀 옵션 팩토리 클래스
@Injectable()
export class NotificationConfigService implements NotificationOptionsFactory {
  constructor(
    private readonly config: ConfigService,
    private readonly vault: VaultService,
  ) {}

  async createNotificationOptions(): Promise<NotificationModuleOptions> {
    const apiKey = await this.vault.getSecret('notification/api-key');
    return {
      apiKey,
      baseUrl: this.config.getOrThrow('NOTIFICATION_BASE_URL'),
      timeout: 5000,
      retryCount: 3,
    };
  }
}

// 사용
NotificationModule.forRootAsync({
  imports: [ConfigModule, VaultModule],
  useClass: NotificationConfigService,
})

모듈 내부에서 useClass를 처리하는 로직:

static forRootAsync(asyncOptions: NotificationModuleAsyncOptions): DynamicModule {
  const providers: Provider[] = [NotificationService];

  if (asyncOptions.useFactory) {
    providers.push({
      provide: NOTIFICATION_OPTIONS,
      useFactory: asyncOptions.useFactory,
      inject: asyncOptions.inject || [],
    });
  } else if (asyncOptions.useClass) {
    providers.push(
      { provide: asyncOptions.useClass, useClass: asyncOptions.useClass },
      {
        provide: NOTIFICATION_OPTIONS,
        useFactory: (factory: NotificationOptionsFactory) =>
          factory.createNotificationOptions(),
        inject: [asyncOptions.useClass],
      },
    );
  } else if (asyncOptions.useExisting) {
    providers.push({
      provide: NOTIFICATION_OPTIONS,
      useFactory: (factory: NotificationOptionsFactory) =>
        factory.createNotificationOptions(),
      inject: [asyncOptions.useExisting],
    });
  }

  return {
    module: NotificationModule,
    global: true,
    imports: asyncOptions.imports || [],
    providers,
    exports: [NotificationService],
  };
}

forFeature: 멀티 인스턴스 패턴

forRoot()가 글로벌 설정이라면, forFeature()는 모듈별 커스터마이징을 담당합니다. 대표 사례가 캐시 모듈입니다:

// cache.module.ts
@Module({})
export class CacheModule {
  // 글로벌 Redis 연결 설정
  static forRoot(options: CacheRootOptions): DynamicModule {
    return {
      module: CacheModule,
      global: true,
      providers: [
        { provide: CACHE_ROOT_OPTIONS, useValue: options },
        RedisConnectionService,
      ],
      exports: [RedisConnectionService],
    };
  }

  // 각 도메인별 캐시 네임스페이스 설정
  static forFeature(namespace: string, ttl: number = 300): DynamicModule {
    const token = getCacheServiceToken(namespace);
    return {
      module: CacheModule,
      providers: [
        {
          provide: token,
          useFactory: (redis: RedisConnectionService) =>
            new NamespacedCacheService(redis, namespace, ttl),
          inject: [RedisConnectionService],
        },
      ],
      exports: [token],
    };
  }
}

// 사용 예시
@Module({
  imports: [
    CacheModule.forFeature('users', 600),      // users: 10분 TTL
    CacheModule.forFeature('products', 1800),   // products: 30분 TTL
  ],
})
export class UsersModule {}

// 서비스에서 주입
@Injectable()
export class UsersService {
  constructor(
    @Inject(getCacheServiceToken('users'))
    private readonly cache: NamespacedCacheService,
  ) {}
}

ConfigurableModuleBuilder (NestJS 9+)

NestJS 9부터 제공되는 ConfigurableModuleBuilder는 위의 보일러플레이트를 자동 생성합니다:

// notification.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { NotificationModuleOptions } from './notification.interfaces';

export const {
  ConfigurableModuleClass,
  MODULE_OPTIONS_TOKEN,
  OPTIONS_TYPE,
  ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<NotificationModuleOptions>()
  .setClassMethodName('forRoot')   // forRoot / forRootAsync 자동 생성
  .setExtras({ isGlobal: true }, (definition, extras) => ({
    ...definition,
    global: extras.isGlobal,
  }))
  .build();

// notification.module.ts — 극도로 간결해짐
@Module({
  providers: [NotificationService],
  exports: [NotificationService],
})
export class NotificationModule extends ConfigurableModuleClass {}

// notification.service.ts
@Injectable()
export class NotificationService {
  constructor(
    @Inject(MODULE_OPTIONS_TOKEN)
    private readonly options: NotificationModuleOptions,
  ) {}

  async send(to: string, message: string) {
    const response = await fetch(`${this.options.baseUrl}/send`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.options.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ to, message }),
      signal: AbortSignal.timeout(this.options.timeout ?? 5000),
    });
    return response.json();
  }
}

ConfigurableModuleBuilder가 자동으로 forRoot(), forRootAsync(), 옵션 토큰, 타입 정의를 모두 생성합니다. NestJS 9 이상이라면 이 방식을 권장합니다.

실전: 멀티 테넌트 DB 모듈

Dynamic Module의 강력함을 보여주는 실전 예시입니다. 테넌트별로 다른 DB 연결을 사용하는 모듈:

// tenant-database.module.ts
@Module({})
export class TenantDatabaseModule {
  static forTenant(tenantId: string): DynamicModule {
    const dataSourceToken = getDataSourceToken(tenantId);

    return {
      module: TenantDatabaseModule,
      providers: [
        {
          provide: dataSourceToken,
          useFactory: async (config: ConfigService) => {
            const dataSource = new DataSource({
              type: 'postgres',
              host: config.get('DB_HOST'),
              database: `tenant_${tenantId}`,
              entities: [User, Order, Product],
              synchronize: false,
            });
            return dataSource.initialize();
          },
          inject: [ConfigService],
        },
        {
          provide: `TENANT_REPO_${tenantId}`,
          useFactory: (ds: DataSource) => ds.getRepository(User),
          inject: [dataSourceToken],
        },
      ],
      exports: [dataSourceToken, `TENANT_REPO_${tenantId}`],
    };
  }
}

테스트 전략

Dynamic Module의 테스트는 설정 주입을 모킹하는 것이 핵심입니다:

describe('NotificationService', () => {
  let service: NotificationService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [
        // 테스트용 설정으로 Dynamic Module 초기화
        NotificationModule.forRoot({
          apiKey: 'test-key',
          baseUrl: 'http://localhost:3001',
          timeout: 1000,
          retryCount: 0,
        }),
      ],
    }).compile();

    service = module.get(NotificationService);
  });

  it('should send notification', async () => {
    const result = await service.send('user@test.com', 'Hello');
    expect(result).toBeDefined();
  });
});

관련 글: NestJS Testing 실전 가이드에서 모킹 전략을, NestJS Pipe 유효성 검증 심화에서 커스텀 프로바이더 패턴을 함께 확인하세요.

마무리

Dynamic Module은 NestJS 모듈 시스템의 핵심입니다. forRoot로 글로벌 설정, forRootAsync로 비동기 의존성 주입, forFeature로 도메인별 커스터마이징 — 이 세 가지 패턴을 마스터하면 어떤 서드파티 통합이든 깔끔한 모듈로 캡슐화할 수 있습니다. NestJS 9 이상이라면 ConfigurableModuleBuilder로 보일러플레이트를 줄이세요.

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