안녕하세요. 오늘은 NestJS CronJob이 중복으로 등록되는 현상 해결법을 알아보도록 하겠습니다.
아래처럼 CronOption에 있는 name을 설정하면 중복으로 등록되는 현상이 해결됩니다.
@Cron(CronExpression.EVERY_MINUTE, { name: cronJob1' })
감사합니다.
라고 끝내면 안되겠죠?
원인 분석부터 근본적인 해결 방법까지 살펴보도록 해보겠습니다.
개발 중에 하나의 CronJob이 중복 실행되는 현상을 발견했습니다.
CronJob이 등록되는 함수의 동작은 간략하게 아래와 같습니다.
@Cron(CronExpression.EVERY_MINUTE)
async cronFunction1() {
const dataList = await this.repository.find({
skip: 0,
take: 1
});
if (!dataList.length) {
return;
}
@nestjs/schedule 패키지에서는 스케쥴링 작업을 CRD할 수 있는 SchedulerRegistry
Provider가 존재하여 이를 이용하여 확인해봤을 때 마찬가지로 총 4개의 CronJob이 등록되어 있었습니다. (해당 프로젝트는 3개의 CronJob만 존재합니다.)
그래서 검색 후 name을 지정하면 해결된다는 내용대로 수정했을 때 정상적으로 CronJob이 3개만 등록되었습니다.
문제가 해결되었으니 끝내야겠다 생각했지만 자세한 원인을 알고 싶었습니다.
원인 분석을 위해서는 CronJob이 정확히 어떻게 등록되어야하는지 알아야 했고 @nestjs/schedule
패키지를 더 면밀하게 살펴보기로 했습니다.
아래는 ScheduleModule
코드입니다.
@Module({
imports: [DiscoveryModule],
providers: [SchedulerMetadataAccessor, SchedulerOrchestrator],
})
export class ScheduleModule {
static forRoot(options?: ScheduleModuleOptions): DynamicModule {
const optionsWithDefaults = {
cronJobs: true,
intervals: true,
timeouts: true,
...options,
};
return {
global: true,
module: ScheduleModule,
providers: [
ScheduleExplorer,
SchedulerRegistry,
{
provide: SCHEDULE_MODULE_OPTIONS,
useValue: optionsWithDefaults,
},
],
exports: [SchedulerRegistry],
};
}
}
SchedulerMetadataAccessor
, SchedulerOrchestrator
, ScheduleExplorer
, SchedulerRegistry
4개의 provider가 IoC Container에 등록됩니다.
클래스명만 추측해보면 ScheduleExplorer
가 가장 우리가 찾는 것과 비슷해보였습니다.
아래는 ScheduleExplorer
코드의 일부입니다.
@Injectable()
export class ScheduleExplorer implements OnModuleInit {
...
onModuleInit() {
this.explore();
}
explore() {
const instanceWrappers: InstanceWrapper[] = [
...this.discoveryService.getControllers(),
...this.discoveryService.getProviders(),
];
instanceWrappers.forEach((wrapper: InstanceWrapper) => {
const { instance } = wrapper;
...
const processMethod = (name: string) =>
wrapper.isDependencyTreeStatic()
? this.lookupSchedulers(instance, name)
: this.warnForNonStaticProviders(wrapper, instance, name);
// TODO(v4): remove this after dropping support for nestjs v9.3.2
if (!Reflect.has(this.metadataScanner, 'getAllMethodNames')) {
this.metadataScanner.scanFromPrototype(
instance,
Object.getPrototypeOf(instance),
processMethod,
);
return;
}
this.metadataScanner
.getAllMethodNames(Object.getPrototypeOf(instance))
.forEach(processMethod);
});
}
lookupSchedulers(instance: Record<string, Function>, key: string) {
const methodRef = instance[key];
const metadata = this.metadataAccessor.getSchedulerType(methodRef);
switch (metadata) {
case SchedulerType.CRON: {
if (!this.moduleOptions.cronJobs) {
return;
}
const cronMetadata = this.metadataAccessor.getCronMetadata(methodRef);
const cronFn = this.wrapFunctionInTryCatchBlocks(methodRef, instance);
return this.schedulerOrchestrator.addCron(cronFn, cronMetadata!);
}
case SchedulerType.TIMEOUT: {
...
}
}
해당 Provider의 작동 방식을 요약하면 아래와 같습니다.
ModuleInit
Lifecycle 에서 explore
함수를 실행합니다.
discoveryService
를 이용하여 IoC Container에 등록된 Controller
, Provider
를 조회합니다.
정적 바인딩된 Provider의 Metadata 중 ScheduerType(CRON
, TIMEOUT
, INTERVAL
)가 존재하는 지 확인
해당 Metadata가 존재할 경우 SchedulerOrchestrator
에 의해 해당 Function을 Job에 등록합니다.
SchedulerOrchestrator
에서는 Record 방식으로 관리하고 있었고 name 속성값이 없을 경우 uuid 패키지의 v4를 사용하여 등록하고 있었습니다.
@Injectable()
export class SchedulerOrchestrator
implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly cronJobs: Record<string, CronJobOptions> = {};
...
addCron(
methodRef: Function,
options: CronOptions & Record<'cronTime', CronJobParams['cronTime']>,
) {
const name = options.name || v4();
this.cronJobs[name] = {
target: methodRef,
options,
};
}
...
위 작동 방식으로 보았을 경우 name이 없는 CronJob은 Provider가 여러개 등록되어 있을 경우 중복으로 등록될 수 있다는 정보를 알게 되었고 name을 설정할 경우 문제가 해결이 되는 이유 또한 알게 되었습니다.
그리고 문제의 함수를 포함하고 있는 Service Class가 중복으로 등록되어있는지 확인해보았고 실제로 2개의 모듈에서 Provider로 등록하고 있었습니다.
코드를 살펴보았을 때 참조하는 Module에서는 Service를 직접적으로 등록할 이유는 없었기에 Module import 방식으로 변경하였습니다.
Provider 중복 등록을 없앤 후 Module import 방식으로 변경하였을 경우 name 설정할 필요 없이 정상적으로 3개의 CronJob이 등록되는 것을 확인했습니다.
결론
provider를 잘 관리할 경우 이러한 일이 생기진 않겠지만 프로젝트가 커질 경우 이를 지키기가 쉽지 않을 수 있기에 name을 설정하는 것이 side-effect를 줄이는 가장 효과적인 방법인 것을 다시 한번 확인하게 되었습니다.
참고
https://stackoverflow.com/questions/74355633/cron-job-executing-twice-at-scheduled-time-nestjs