NestJS 중복 CronJob 등록 현상 원인 분석 및 해결

Jeerryy·2024년 9월 25일
0

NestJS

목록 보기
3/4

안녕하세요. 오늘은 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;
    }

조회가 2번되는 현상 발견

@nestjs/schedule 패키지에서는 스케쥴링 작업을 CRD할 수 있는 SchedulerRegistry Provider가 존재하여 이를 이용하여 확인해봤을 때 마찬가지로 총 4개의 CronJob이 등록되어 있었습니다. (해당 프로젝트는 3개의 CronJob만 존재합니다.)

4개의 CronJob이 등록된 모습

그래서 검색 후 name을 지정하면 해결된다는 내용대로 수정했을 때 정상적으로 CronJob이 3개만 등록되었습니다.

정상적으로 3개의 CronJob 등록
문제가 해결되었으니 끝내야겠다 생각했지만 자세한 원인을 알고 싶었습니다.

원인 분석

원인 분석을 위해서는 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의 작동 방식을 요약하면 아래와 같습니다.

  1. ModuleInit Lifecycle 에서 explore 함수를 실행합니다.

  2. discoveryService를 이용하여 IoC Container에 등록된 Controller, Provider를 조회합니다.

  3. 정적 바인딩된 Provider의 Metadata 중 ScheduerType(CRON, TIMEOUT, INTERVAL)가 존재하는 지 확인

  4. 해당 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이 등록되는 것을 확인했습니다.

정상적으로 3개의 CronJob이 등록됨

결론
provider를 잘 관리할 경우 이러한 일이 생기진 않겠지만 프로젝트가 커질 경우 이를 지키기가 쉽지 않을 수 있기에 name을 설정하는 것이 side-effect를 줄이는 가장 효과적인 방법인 것을 다시 한번 확인하게 되었습니다.

참고
https://stackoverflow.com/questions/74355633/cron-job-executing-twice-at-scheduled-time-nestjs

https://github.com/nestjs/schedule#readme

profile
다양한 경험을 해보고자 하는 Backend-Engineer.

0개의 댓글