개요
NestJS을 다루다 보면 ConfigModule.forRoot(), TypeOrmModule.forRoot() 같은 코드를 보게 됨
보통 “이건 전역 설정이니까 한 번만 하면 끝이고 알아서 싱글톤 유지되겠지?“라고 생각하기 쉬움
하지만 forRoot()를 호출한다고 프레임워크가 알아서 물리적인 싱글톤 인스턴스를 강제하는 건 아님
특히 ScheduleModule처럼 사이드 이펙트(이벤트 리스너, 타이머 등)를 유발하는 모듈을 잘못 다루면, 기능이 중복 실행되는 심각한 버그가 터질 수 있음
이 글에서는 forRoot()의 진짜 의미와 내부 동작, 그리고 ScheduleModule 중복 실행 문제가 왜 생기는지 코드로 뜯어보겠음
forRoot()란 무엇인가
forRoot()는 NestJS의 동적 모듈(Dynamic Module)을 생성하기 위해 관례적으로 쓰는 메서드 이름임
정적 모듈 vs 동적 모듈
정적 모듈 (일반적인 import) 메타데이터(설정)가 바뀔 일 없는 모듈임. 그냥
imports배열에 클래스 이름만 넣으면 끝남동적 모듈 (forRoot) 모듈을 쓰는 쪽에서 설정을 주입해줘야 할 때 씀 DB 접속 정보, API 키, 전역 옵션 같은 걸 인자로 넘겨주면, 그에 맞춰 모듈 정의를 동적으로 만들어서 반환함
forRoot()와 forFeature()의 차이
- forRoot()
앱 전체에서 단 한 번만 설정해야 하는 전역 설정용임
주로 루트 모듈(
AppModule)에서 호출하고 내부적으로 DB 커넥션 풀을 만들거나 스케줄러를 초기화함 - forFeature()
이미
forRoot로 설정된 모듈의 기능을 특정 도메인(Feature)에서 갖다 쓸 때 씀 엔티티 등록이나 리포지토리 주입을 위해 여러 번 호출해도 됨
문제 상황: forRoot()는 싱글톤 보장 안 함
제일 큰 오해가 “forRoot()는 전역 설정이니까 여러 번 불러도 NestJS가 알아서 합쳐주겠지"라고 믿는 거임
하지만 NestJS 모듈 시스템에서 forRoot()는 호출할 때마다 새로운 동적 모듈 객체를 반환함
만약 ScheduleModule.forRoot()를 루트 모듈에서도 부르고 자식 모듈에서도 부르면, NestJS는 이걸 서로 다른 두 개의 모듈로 인식하고 로딩해버림
예제 코드 (잘못된 사용)
// AppModule (루트)
@Module({
imports: [
ScheduleModule.forRoot(), // 1. 여기서 한 번 생성됨
BatchModule,
],
})
export class AppModule {}
// BatchModule (자식)
@Module({
imports: [
ScheduleModule.forRoot(), // 2. 여기서 또 생성됨 (중복!)
],
})
export class BatchModule {}이렇게 짜면 스케줄러가 2개 돔. @Cron 붙은 작업이 1초에 2번씩 실행되는 대참사가 일어남
심층 분석: 왜 중복 실행되는가
이걸 이해하려면 @nestjs/schedule 패키지 내부를 봐야 함
ScheduleModule 소스 코드 분석
// @nestjs/schedule/lib/schedule.module.ts (단순화됨)
@Module({
imports: [DiscoveryModule],
providers: [SchedulerMetadataAccessor, SchedulerOrchestrator],
})
export class ScheduleModule {
static forRoot(options?: ScheduleModuleOptions): DynamicModule {
return {
global: true, // 전역 모듈로 선언
module: ScheduleModule,
providers: [
ScheduleExplorer, // 핵심: 스케줄링 탐색기
SchedulerRegistry,
{
provide: SCHEDULE_MODULE_OPTIONS,
useValue: options,
},
],
exports: [SchedulerRegistry],
};
}
}여기서 눈여겨볼 건 딱 두 가지임
global: true이건 이 모듈이 제공하는 프로바이더(providers)를 다른 모듈에서imports없이도 쓸 수 있게 해준다는 뜻임. 모듈 인스턴스를 딱 하나만 만들겠다는 뜻이 아님ScheduleExplorer프로바이더providers에 있는ScheduleExplorer는OnModuleInit생명주기를 가짐. 앱 켜질 때 전체를 스캔해서@Cron데코레이터를 찾고 작업을 등록하는 놈임
중복 호출 시 일어나는 일
AppModule에서forRoot()호출 → DynamicModule A 생성- 내부의
ScheduleExplorerA가 생성됨 - A가
@Cron메서드 싹 긁어서 스케줄러에 등록함
- 내부의
BatchModule에서forRoot()호출 → DynamicModule B 생성forRoot()는 팩토리 메서드라 그냥 새 객체를 뱉음- 내부의
ScheduleExplorerB가 또 생성됨 - B가 다시
@Cron메서드 싹 긁어서 스케줄러에 또 등록함
결과적으로 탐색기가 2명이라 일도 2배로 함. global: true가 있어도 모듈 객체 참조값이 다르니 NestJS는 별개로 처리함
올바른 사용 패턴
forRoot() 쓰는 모듈은 무조건 루트 모듈(AppModule)이나 CoreModule에서 딱 한 번만 호출한다는 관례를 지켜야 함
예제 코드 (올바른 구조)
// app.module.ts
@Module({
imports: [
// 1. 루트에서 한 번만 설정 및 초기화
ScheduleModule.forRoot(),
BatchModule,
HttpModule,
],
})
export class AppModule {}
// batch.module.ts
@Module({
imports: [
// 2. ScheduleModule을 다시 import 하지 않음!
// global: true 덕분에 SchedulerRegistry 같은 건 그냥 주입받아 쓸 수 있음
],
providers: [BatchService],
})
export class BatchModule {}요약
forRoot()는 단순 팩토리 메서드임 부를 때마다 새 설정 객체 뱉음. 프레임워크가 알아서 중복 막아주지 않음global: true는 싱글톤 보장이 아님 프로바이더를 전역 공유하게 해줄 뿐, 모듈 초기화 로직이 두 번 도는 걸 막지는 못함- 결국 개발자 책임임
ScheduleModule,TypeOrmModule같은 건 앱의 최상위 진입점(AppModule)에서만 명시적으로 부르자. 하위에서 필요하면forFeature를 쓰거나 그냥 주입받아 쓰면 됨