junyeokk
Blog
NestJS·2025. 03. 05

NestJS Schedule

서버를 운영하다 보면 주기적으로 실행해야 하는 작업이 반드시 생긴다. 매일 자정에 임시 데이터를 정리하거나, 30초마다 랭킹을 갱신하거나, 만료된 세션을 주기적으로 삭제하는 작업 같은 것들이다.

가장 원시적인 방법은 setInterval을 쓰는 것이다.

typescript
setInterval(() => {
  cleanupExpiredSessions();
}, 60 * 60 * 1000); // 1시간마다

간단하지만 문제가 많다. 서버가 재시작되면 타이머가 초기화되고, "매일 자정"처럼 특정 시각에 실행하려면 직접 시간 계산 로직을 짜야 한다. 그리고 이런 코드가 여기저기 흩어지면 어떤 스케줄 작업이 돌고 있는지 파악하기 어려워진다.

리눅스의 crontab을 쓸 수도 있지만, 이건 애플리케이션 외부에서 관리되기 때문에 NestJS의 DI 컨테이너에 접근할 수 없다. 데이터베이스에서 뭔가 조회해서 처리하는 스케줄 작업이라면, 별도의 스크립트를 만들어서 DB 연결부터 다시 설정해야 한다.

@nestjs/schedule은 이 문제를 해결한다. 크론 표현식이나 인터벌을 데코레이터로 선언하면, NestJS가 애플리케이션 컨텍스트 안에서 해당 메서드를 주기적으로 실행해준다. DI가 그대로 동작하니까 서비스, 레포지토리, Redis 클라이언트 등을 자유롭게 주입받아 쓸 수 있다.

내부적으로는 Node.js의 node-cron 라이브러리를 래핑하고 있다.


설치와 설정

bash
npm install @nestjs/schedule

모듈 등록은 ScheduleModule.forRoot()를 사용한다.

typescript
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';

@Module({
  imports: [ScheduleModule.forRoot()],
})
export class AppModule {}

forRoot()를 호출하면 NestJS가 애플리케이션 내의 모든 @Cron(), @Interval(), @Timeout() 데코레이터를 스캔해서 자동으로 등록한다. 주의할 점은 ScheduleModule.forRoot()를 여러 모듈에서 중복 호출하면 각각 독립적인 스케줄러 인스턴스가 생성된다는 것이다. 일반적으로는 루트 모듈이나 공통 모듈에서 한 번만 등록하는 게 맞지만, 모듈별로 분리 등록하는 것도 동작은 한다.


@Cron 데코레이터

가장 많이 쓰는 방식이다. 크론 표현식으로 실행 시점을 정의한다.

typescript
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';

@Injectable()
export class TaskScheduler {
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async handleMidnightTask() {
    console.log('자정에 실행됩니다');
  }

  @Cron('*/30 * * * * *')
  async handleEvery30Seconds() {
    console.log('30초마다 실행됩니다');
  }
}

크론 표현식

@nestjs/schedule의 크론 표현식은 6자리다. 표준 Unix cron의 5자리(분 시 일 월 요일)와 달리 필드가 맨 앞에 추가된다.

text
*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    │
│    │    │    │    │    └ 요일 (0-7, 0과 7은 일요일)
│    │    │    │    └──── 월 (1-12)
│    │    │    └───────── 일 (1-31)
│    │    └────────────── 시 (0-23)
│    └─────────────────── 분 (0-59)
└──────────────────────── 초 (0-59) ← 추가 필드

자주 쓰는 표현식 예시:

표현식의미
0 0 0 * * *매일 자정
*/30 * * * * *30초마다
0 */5 * * * *5분마다
0 0 9 * * 1매주 월요일 오전 9시
0 0 */2 * * *2시간마다

매번 표현식을 직접 쓰면 실수하기 쉽기 때문에, @nestjs/scheduleCronExpression enum을 제공한다.

CronExpression

자주 쓰는 패턴을 미리 정의해둔 enum이다.

typescript
import { CronExpression } from '@nestjs/schedule';

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)    // 매일 자정
@Cron(CronExpression.EVERY_30_SECONDS)          // 30초마다
@Cron(CronExpression.EVERY_HOUR)                // 매시간
@Cron(CronExpression.EVERY_WEEK)                // 매주
@Cron(CronExpression.EVERY_10_MINUTES)          // 10분마다
@Cron(CronExpression.EVERY_DAY_AT_1AM)          // 매일 새벽 1시
@Cron(CronExpression.EVERY_QUARTER)             // 분기마다

직접 표현식을 쓰는 것보다 가독성이 훨씬 좋고, 오타 위험도 없다. 다만 제공되는 패턴이 한정적이라서 "매주 화, 목 오후 3시"같은 복잡한 스케줄은 직접 작성해야 한다.

@Cron 옵션

두 번째 인자로 옵션 객체를 전달할 수 있다.

typescript
@Cron('0 0 0 * * *', {
  name: 'midnight-cleanup',
  timeZone: 'Asia/Seoul',
})
async handleCleanup() {
  // ...
}
옵션설명
name작업 이름. 런타임에 동적으로 제어할 때 사용
timeZoneIANA 타임존. 지정하지 않으면 서버 시스템 타임존 기준
utcOffsetUTC 오프셋 (timeZone과 동시 사용 불가)
disabledtrue로 설정하면 등록은 되지만 실행되지 않음
unrefTimeouttrue면 Node.js 이벤트 루프가 이 타이머 때문에 유지되지 않음

timeZone은 배포 환경에서 중요하다. 서버가 UTC로 동작하는데 "한국 시간 자정"에 작업을 돌려야 한다면 timeZone: 'Asia/Seoul'을 명시해야 한다.


@Interval과 @Timeout

크론 표현식 없이 단순한 반복이나 지연 실행이 필요할 때 사용한다.

@Interval

typescript
@Interval('polling-task', 5000)
async handlePolling() {
  // 5초마다 실행
}

setInterval과 동일하게 동작하지만, NestJS의 라이프사이클에 통합된다. 첫 번째 인자는 작업 이름(선택), 두 번째는 밀리초 단위 간격이다.

@Timeout

typescript
@Timeout('delayed-init', 3000)
async handleDelayedInit() {
  // 애플리케이션 시작 3초 후 1회 실행
}

setTimeout과 동일하게 1회만 실행된다. 애플리케이션 시작 직후 초기화 작업을 약간 지연시킬 때 유용하다.


동적 스케줄 제어

등록된 스케줄 작업을 런타임에 시작, 중지, 삭제할 수 있다. SchedulerRegistry를 주입받아서 사용한다.

typescript
import { Injectable } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';

@Injectable()
export class DynamicScheduleService {
  constructor(private schedulerRegistry: SchedulerRegistry) {}

  // 등록된 크론 작업 조회
  getCronJobs() {
    const jobs = this.schedulerRegistry.getCronJobs();
    jobs.forEach((value, key) => {
      const next = value.nextDate().toJSDate();
      console.log(`Job: ${key} -> Next: ${next}`);
    });
  }

  // 크론 작업 중지
  stopCronJob(name: string) {
    const job = this.schedulerRegistry.getCronJob(name);
    job.stop();
    console.log(`${name} 중지됨`);
  }

  // 크론 작업 재시작
  startCronJob(name: string) {
    const job = this.schedulerRegistry.getCronJob(name);
    job.start();
    console.log(`${name} 재시작됨`);
  }

  // 런타임에 새 크론 작업 동적 등록
  addCronJob(name: string, cronExpression: string) {
    const job = new CronJob(cronExpression, () => {
      console.log(`동적으로 추가된 작업 ${name} 실행`);
    });

    this.schedulerRegistry.addCronJob(name, job);
    job.start();
  }

  // 크론 작업 삭제
  deleteCronJob(name: string) {
    this.schedulerRegistry.deleteCronJob(name);
    console.log(`${name} 삭제됨`);
  }
}

SchedulerRegistry는 크론 작업뿐 아니라 인터벌(getIntervals, getInterval)과 타임아웃(getTimeouts, getTimeout)도 동일한 방식으로 관리한다.

동적 제어가 유용한 시나리오:

  • 관리자가 API로 특정 스케줄 작업을 일시 중지/재개
  • 설정값이 바뀌면 기존 작업을 삭제하고 새 스케줄로 다시 등록
  • 사용자별로 다른 주기의 알림 스케줄을 동적으로 생성

실행 겹침 방지

30초마다 실행되는 작업이 있는데, 어떤 실행이 30초 이상 걸리면 이전 실행이 끝나기 전에 다음 실행이 시작된다. @nestjs/schedule은 이런 겹침을 자동으로 방지해주지 않는다.

직접 플래그로 제어해야 한다.

typescript
@Injectable()
export class SafeScheduler {
  private isRunning = false;

  @Cron(CronExpression.EVERY_30_SECONDS)
  async handleTask() {
    if (this.isRunning) {
      return; // 이전 실행이 아직 진행 중이면 스킵
    }

    this.isRunning = true;
    try {
      await this.doHeavyWork();
    } finally {
      this.isRunning = false;
    }
  }
}

이 패턴은 단일 인스턴스에서는 잘 동작한다. 하지만 서버가 여러 대로 스케일 아웃된 환경에서는 각 인스턴스가 독립적으로 스케줄을 실행하기 때문에, 분산 락(Redis 기반 등)이 필요하다.


에러 처리

스케줄 작업 내에서 예외가 발생하면 NestJS의 글로벌 예외 필터가 잡아주지 않는다. HTTP 요청 컨텍스트가 아니기 때문이다. 따라서 스케줄 작업 내부에서 직접 try-catch로 에러를 처리해야 한다.

typescript
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async resetExpiredStreaks() {
  try {
    const expiredUsers = await this.userRepository.find({
      where: { currentStreak: Not(0) },
    });

    // ... 처리 로직

    this.logger.log(`${usersToUpdate.length}명의 streak 업데이트 완료`);
  } catch (error) {
    this.logger.error(`streak 업데이트 중 오류 발생: ${error}`);
    // 에러가 발생해도 다음 실행에는 영향 없음
  }
}

에러가 발생해도 스케줄 자체가 멈추지는 않는다. 다음 실행 시점이 되면 정상적으로 다시 호출된다. 다만 에러를 잡지 않으면 unhandled rejection으로 로그에 남거나, 심한 경우 프로세스가 죽을 수 있으므로 반드시 감싸야 한다.


스케줄러 구조 설계

스케줄 작업을 어디에 배치할지도 중요하다. 크게 두 가지 접근이 있다.

1. 기능 모듈에 배치

각 도메인 모듈 안에 해당 도메인의 스케줄러를 두는 방식이다.

text
src/
├── feed/
│   ├── module/feed.module.ts
│   ├── service/feed.service.ts
│   └── scheduler/feed.scheduler.ts    ← 피드 관련 스케줄
├── chat/
│   ├── module/chat.module.ts
│   └── scheduler/chat.scheduler.ts    ← 채팅 관련 스케줄
└── user/
    ├── module/user.module.ts
    └── scheduler/user.scheduler.ts    ← 사용자 관련 스케줄

장점은 관련 로직이 한 곳에 모여 있어서 응집도가 높다는 것이다. 피드 스케줄러는 피드 서비스를 바로 주입받아 쓸 수 있고, 피드 모듈을 삭제하면 관련 스케줄도 함께 사라진다.

2. 공통 스케줄 모듈로 분리

모든 스케줄 작업을 하나의 모듈에 모으는 방식이다.

text
src/
├── schedule/
│   ├── schedule.module.ts
│   ├── feed.scheduler.ts
│   ├── chat.scheduler.ts
│   └── user.scheduler.ts

전체 스케줄을 한눈에 파악할 수 있다는 장점이 있지만, 다른 모듈의 서비스를 모두 import해야 해서 의존성이 복잡해질 수 있다.

어느 쪽이든 ScheduleModule.forRoot() 등록은 필요하다. 기능 모듈에 배치하는 경우, 각 모듈에서 ScheduleModule.forRoot()를 import하거나, 루트 모듈에서 한 번만 등록하면 된다.


@Cron vs @Interval 선택 기준

기준@Cron@Interval
"매일 자정" 같은 특정 시각
"30초마다" 같은 단순 반복✅ (가능하지만 과함)
복잡한 스케줄 (매주 월/수/금)
타임존 지정
첫 실행 시점다음 매칭 시각지정한 간격 후

대부분의 경우 @Cron을 쓰면 된다. @Interval은 크론 표현식이 과할 정도로 단순한 반복(헬스체크 폴링 등)에 적합하다.


정리

  • @Cron은 크론 표현식으로 특정 시각/주기 실행을 선언하고, DI 컨텍스트 안에서 서비스와 레포지토리를 자유롭게 주입받아 사용할 수 있다
  • 실행 겹침은 자동 방지되지 않으므로 플래그 기반 가드가 필요하고, 멀티 인스턴스 환경에서는 분산 락(Redis 등)을 추가해야 한다
  • 스케줄 작업의 예외는 글로벌 예외 필터가 잡지 않으므로 반드시 try-catch로 감싸야 하며, SchedulerRegistry로 런타임 동적 제어가 가능하다

관련 문서