NestJS Task Scheduling: @Cron

NestJS Task Scheduling이란? — 반복 작업의 선언적 관리

만료된 세션 정리, 일일 리포트 생성, 외부 API 데이터 동기화, 미결제 주문 자동 취소 — 이런 반복 작업은 모든 백엔드에 존재한다. NestJS는 @nestjs/schedule 패키지로 Cron JobInterval/Timeout을 데코레이터 기반으로 선언적으로 관리할 수 있다. 내부적으로 Node.js의 node-cron 라이브러리를 사용한다.

단, Kubernetes 환경에서 Pod이 여러 개일 때 동시 실행 문제가 발생한다. 3개 Pod이 돌면 같은 Cron Job이 3번 실행된다. 이 글에서는 기본 사용법부터 동적 스케줄 등록, MikroORM의 RequestContext 통합, 분산 락으로 중복 실행 방지, 그리고 K8s CronJob과의 역할 분담까지 운영 패턴을 총정리한다.

1. 기본 설정과 데코레이터 3가지

# 의존성 설치
npm install @nestjs/schedule
npm install -D @types/cron
// app.module.ts
import { ScheduleModule } from '@nestjs/schedule';

@Module({
  imports: [
    ScheduleModule.forRoot(),    // 스케줄러 활성화
    // ...
  ],
})
export class AppModule {}

1-1. @Cron — 크론 표현식 기반 스케줄링

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

@Injectable()
export class TaskService {
  private readonly logger = new Logger(TaskService.name);

  // 매일 자정에 실행
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async handleDailyCleanup() {
    this.logger.log('Starting daily cleanup...');
    await this.cleanExpiredSessions();
    await this.archiveOldOrders();
  }

  // 커스텀 크론 표현식 — 매주 월요일 오전 9시 (KST)
  @Cron('0 0 9 * * 1', {
    name: 'weeklyReport',              // 스케줄 이름 (동적 제어용)
    timeZone: 'Asia/Seoul',            // 타임존 지정
  })
  async generateWeeklyReport() {
    this.logger.log('Generating weekly report...');
    await this.reportService.generateAndSend();
  }

  // 매 30분마다
  @Cron('0 */30 * * * *')
  async syncExternalData() {
    this.logger.log('Syncing external data...');
    await this.syncService.run();
  }
}

크론 표현식 필드 (6자리 — 초 포함)

위치 필드 범위 예시
1 초(second) 0-59 0, */10
2 분(minute) 0-59 0, */30
3 시(hour) 0-23 9, 0-6
4 일(day of month) 1-31 1, 15
5 월(month) 0-11 또는 JAN-DEC *, 1-6
6 요일(day of week) 0-6 또는 SUN-SAT 1 (월요일)

주의: NestJS의 크론은 6자리(초 포함)다. Linux crontab의 5자리와 다르다. 0 9 * * 1(Linux)은 NestJS에서 0 0 9 * * 1이다.

1-2. @Interval — 고정 간격 반복

@Injectable()
export class HealthCheckService {
  // 30초마다 실행 (앱 시작 후 즉시 첫 실행)
  @Interval('healthCheck', 30_000)
  async checkDependencies() {
    await this.checkDatabase();
    await this.checkRedis();
    await this.checkExternalApi();
  }
}

1-3. @Timeout — 일회성 지연 실행

@Injectable()
export class StartupService {
  // 앱 시작 5초 후 한 번만 실행
  @Timeout('warmup', 5_000)
  async warmUpCache() {
    this.logger.log('Warming up cache...');
    await this.cacheService.preload();
  }
}

2. 동적 스케줄 등록·수정·삭제 — SchedulerRegistry

데코레이터는 컴파일 타임에 고정된다. 런타임에 스케줄을 추가하거나 변경하려면 SchedulerRegistry를 사용한다.

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

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

  // 동적으로 Cron Job 추가
  addCronJob(name: string, cronExpression: string, callback: () => void) {
    const job = new CronJob(cronExpression, callback, null, false, 'Asia/Seoul');

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

    this.logger.log(`Cron job "${name}" added with expression: ${cronExpression}`);
  }

  // 기존 Cron Job 일시 정지
  pauseCronJob(name: string) {
    const job = this.schedulerRegistry.getCronJob(name);
    job.stop();
    this.logger.log(`Cron job "${name}" paused`);
  }

  // Cron Job 재개
  resumeCronJob(name: string) {
    const job = this.schedulerRegistry.getCronJob(name);
    job.start();
    this.logger.log(`Cron job "${name}" resumed`);
  }

  // Cron Job 삭제
  deleteCronJob(name: string) {
    this.schedulerRegistry.deleteCronJob(name);
    this.logger.log(`Cron job "${name}" deleted`);
  }

  // 등록된 모든 Cron Job 목록
  listCronJobs(): Map<string, CronJob> {
    return this.schedulerRegistry.getCronJobs();
  }

  // Interval 동적 추가/삭제
  addInterval(name: string, ms: number, callback: () => void) {
    const intervalId = setInterval(callback, ms);
    this.schedulerRegistry.addInterval(name, intervalId);
  }

  deleteInterval(name: string) {
    this.schedulerRegistry.deleteInterval(name);
  }
}

// API로 스케줄 제어 (관리자용)
@Controller('admin/schedules')
@UseGuards(AdminGuard)
export class ScheduleAdminController {
  constructor(private readonly dynamicSchedule: DynamicScheduleService) {}

  @Get()
  listJobs() {
    const jobs = this.dynamicSchedule.listCronJobs();
    return Array.from(jobs.entries()).map(([name, job]) => ({
      name,
      running: job.running,
      nextDate: job.nextDate()?.toISO(),
      lastDate: job.lastDate()?.toISO?.() ?? null,
    }));
  }

  @Post(':name/pause')
  pause(@Param('name') name: string) {
    this.dynamicSchedule.pauseCronJob(name);
    return { status: 'paused' };
  }

  @Post(':name/resume')
  resume(@Param('name') name: string) {
    this.dynamicSchedule.resumeCronJob(name);
    return { status: 'resumed' };
  }
}

3. MikroORM RequestContext 통합 — 크론에서 DB 접근

NestJS + MikroORM RequestContext 심화에서 다룬 것처럼, HTTP 요청 외부(크론, 큐)에서 MikroORM을 사용하면 Identity Map이 공유되어 메모리 누수와 데이터 오염이 발생한다. @CreateRequestContext()로 반드시 격리해야 한다.

import { CreateRequestContext } from '@mikro-orm/core';

@Injectable()
export class OrderCleanupService {
  constructor(
    private readonly orm: MikroORM,
    private readonly orderRepository: OrderRepository,
  ) {}

  @Cron(CronExpression.EVERY_HOUR)
  @CreateRequestContext()              // 독립된 EntityManager 컨텍스트 생성
  async cancelExpiredOrders() {
    const expiredOrders = await this.orderRepository.find({
      status: OrderStatus.PENDING,
      createdAt: { $lt: subHours(new Date(), 24) },    // 24시간 이상 미결제
    });

    for (const order of expiredOrders) {
      order.status = OrderStatus.CANCELLED;
      order.cancelledAt = new Date();
    }

    await this.orm.em.flush();         // Unit of Work — 변경분만 커밋
    this.logger.log(`Cancelled ${expiredOrders.length} expired orders`);
  }

  // TypeORM 사용 시에는 별도 처리 불필요 (각 쿼리가 독립적)
}

함정: @CreateRequestContext() 없이 크론에서 em.find()를 호출하면, 글로벌 EntityManager의 Identity Map에 엔티티가 계속 쌓여 메모리가 증가하고, 이전 크론 실행의 캐시된 엔티티가 반환될 수 있다.

4. 에러 처리와 모니터링 — 조용히 실패하지 않게

크론 작업은 HTTP 요청과 달리 실패해도 클라이언트에게 에러가 전달되지 않는다. 로깅과 알림 없이는 실패를 영원히 모를 수 있다.

// 방법 1: 각 핸들러에서 try-catch
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleDailyCleanup() {
  const startTime = Date.now();
  try {
    const result = await this.cleanExpiredSessions();
    this.logger.log(`Daily cleanup completed in ${Date.now() - startTime}ms, ` +
                    `cleaned: ${result.count}`);

    // 메트릭 기록
    this.metricsService.recordCronExecution('dailyCleanup', true, Date.now() - startTime);

  } catch (error) {
    this.logger.error(`Daily cleanup failed: ${error.message}`, error.stack);
    this.metricsService.recordCronExecution('dailyCleanup', false, Date.now() - startTime);

    // Sentry/Slack 알림
    this.alertService.sendAlert({
      title: 'Daily Cleanup Failed',
      message: error.message,
      severity: 'warning',
    });
  }
}

// 방법 2: 데코레이터로 공통 에러 처리 추출
function CronSafe(jobName: string) {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const logger = new Logger(`Cron:${jobName}`);
      const startTime = Date.now();

      try {
        logger.log(`Starting ${jobName}...`);
        const result = await original.apply(this, args);
        logger.log(`${jobName} completed in ${Date.now() - startTime}ms`);
        return result;
      } catch (error) {
        logger.error(`${jobName} failed after ${Date.now() - startTime}ms: ${error.message}`);
        // Sentry, Slack 알림 등
        throw error;    // 재throw하여 상위에서도 인지 가능
      }
    };

    return descriptor;
  };
}

// 사용
@Cron(CronExpression.EVERY_HOUR)
@CreateRequestContext()
@CronSafe('cancelExpiredOrders')
async cancelExpiredOrders() {
  // 순수 비즈니스 로직만
  const count = await this.orderService.cancelExpired();
  return { count };
}

5. 분산 환경 — 중복 실행 방지 (분산 락)

Kubernetes에서 Pod 3개가 동일한 @Cron 데코레이터를 가지면, 같은 시각에 3번 실행된다. 중복 실행을 방지하려면 분산 락이 필요하다.

5-1. Redis 기반 분산 락 (Redlock 패턴)

import Redis from 'ioredis';

@Injectable()
export class DistributedLockService {
  constructor(@InjectRedis() private readonly redis: Redis) {}

  /**
   * 락 획득 시도
   * @returns unlock 함수 (null이면 락 획득 실패)
   */
  async acquireLock(
    key: string,
    ttlMs: number = 60_000,
  ): Promise<(() => Promise<void>) | null> {
    const lockValue = `${process.pid}:${Date.now()}:${Math.random()}`;
    const lockKey = `lock:${key}`;

    // SET NX EX — 원자적 락 획득
    const result = await this.redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX');

    if (result !== 'OK') {
      return null;    // 다른 인스턴스가 이미 락을 보유
    }

    // unlock 함수 반환 — Lua 스크립트로 원자적 해제
    return async () => {
      const script = `
        if redis.call("get", KEYS[1]) == ARGV[1] then
          return redis.call("del", KEYS[1])
        else
          return 0
        end
      `;
      await this.redis.eval(script, 1, lockKey, lockValue);
    };
  }
}

// 크론에서 분산 락 사용
@Injectable()
export class OrderCleanupService {
  constructor(
    private readonly lockService: DistributedLockService,
    private readonly orderService: OrderService,
  ) {}

  @Cron(CronExpression.EVERY_HOUR)
  @CreateRequestContext()
  async cancelExpiredOrders() {
    // 락 획득 시도 — TTL 5분 (작업 최대 소요 시간보다 길게)
    const unlock = await this.lockService.acquireLock('cron:cancelExpiredOrders', 300_000);

    if (!unlock) {
      this.logger.debug('Another instance is already running this job, skipping');
      return;
    }

    try {
      const count = await this.orderService.cancelExpired();
      this.logger.log(`Cancelled ${count} expired orders`);
    } finally {
      await unlock();    // 반드시 해제
    }
  }
}

5-2. 커스텀 데코레이터로 추상화

// 분산 락 데코레이터
function WithDistributedLock(lockKey: string, ttlMs: number = 60_000) {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const lockService: DistributedLockService = this.lockService;  // DI된 서비스
      const unlock = await lockService.acquireLock(lockKey, ttlMs);

      if (!unlock) {
        return;    // 다른 인스턴스가 처리 중 — 조용히 스킵
      }

      try {
        return await original.apply(this, args);
      } finally {
        await unlock();
      }
    };

    return descriptor;
  };
}

// 깔끔한 사용
@Cron(CronExpression.EVERY_HOUR)
@CreateRequestContext()
@CronSafe('cancelExpiredOrders')
@WithDistributedLock('cron:cancelExpiredOrders', 300_000)
async cancelExpiredOrders() {
  return await this.orderService.cancelExpired();
}

6. NestJS @Cron vs Kubernetes CronJob — 언제 무엇을 쓸까?

기준 NestJS @Cron K8s CronJob
실행 환경 기존 Pod 내부 (상시 실행) 별도 Pod 생성 후 종료
리소스 기존 Pod 리소스 공유 독립된 리소스 할당
중복 실행 Pod 수만큼 중복 → 분산 락 필요 concurrencyPolicy로 제어
실패 처리 직접 구현 backoffLimit 자동 재시도
콜드 스타트 없음 (이미 실행 중) Pod 생성 시간 (수 초~수십 초)
적합한 작업 짧고 가벼운 주기 작업 (캐시 갱신, 상태 체크) 무거운 배치 (리포트, 마이그레이션, 대량 처리)
최소 주기 초 단위 분 단위

실무 가이드라인:

  • 30초 이하 경량 작업: NestJS @Cron + 분산 락
  • 수 분~수 시간 배치: K8s CronJob이 더 적합 (독립 리소스, 자동 재시도, 실행 이력)
  • 초 단위 폴링: @Interval (K8s CronJob은 분 단위가 최소)

7. 테스트 전략

describe('OrderCleanupService', () => {
  let service: OrderCleanupService;
  let orderRepository: jest.Mocked<OrderRepository>;
  let lockService: jest.Mocked<DistributedLockService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        OrderCleanupService,
        {
          provide: OrderRepository,
          useValue: {
            find: jest.fn(),
          },
        },
        {
          provide: DistributedLockService,
          useValue: {
            acquireLock: jest.fn(),
          },
        },
        {
          provide: MikroORM,
          useValue: { em: { flush: jest.fn() } },
        },
      ],
    }).compile();

    service = module.get(OrderCleanupService);
    orderRepository = module.get(OrderRepository);
    lockService = module.get(DistributedLockService);
  });

  it('만료된 주문을 CANCELLED로 변경', async () => {
    const unlock = jest.fn();
    lockService.acquireLock.mockResolvedValue(unlock);

    const expiredOrders = [
      { id: '1', status: OrderStatus.PENDING, createdAt: subHours(new Date(), 25) },
      { id: '2', status: OrderStatus.PENDING, createdAt: subHours(new Date(), 30) },
    ];
    orderRepository.find.mockResolvedValue(expiredOrders as any);

    await service.cancelExpiredOrders();

    expect(expiredOrders[0].status).toBe(OrderStatus.CANCELLED);
    expect(expiredOrders[1].status).toBe(OrderStatus.CANCELLED);
    expect(unlock).toHaveBeenCalled();
  });

  it('락 획득 실패 시 작업 스킵', async () => {
    lockService.acquireLock.mockResolvedValue(null);    // 락 실패

    await service.cancelExpiredOrders();

    expect(orderRepository.find).not.toHaveBeenCalled();
  });

  it('작업 실패해도 락은 반드시 해제', async () => {
    const unlock = jest.fn();
    lockService.acquireLock.mockResolvedValue(unlock);
    orderRepository.find.mockRejectedValue(new Error('DB connection failed'));

    await expect(service.cancelExpiredOrders()).rejects.toThrow('DB connection failed');
    expect(unlock).toHaveBeenCalled();    // finally 블록에서 해제
  });
});

8. 실전 패턴 종합 — 완성된 크론 서비스

@Injectable()
export class ScheduledTasksService {
  private readonly logger = new Logger(ScheduledTasksService.name);

  constructor(
    private readonly orm: MikroORM,
    private readonly lockService: DistributedLockService,
    private readonly orderService: OrderService,
    private readonly reportService: ReportService,
    private readonly cacheService: CacheService,
    private readonly metricsService: MetricsService,
    private readonly schedulerRegistry: SchedulerRegistry,
  ) {}

  // ── 경량 작업: 분산 락 + RequestContext ──

  @Cron('0 */10 * * * *')            // 10분마다
  @CreateRequestContext()
  async refreshProductCache() {
    const unlock = await this.lockService.acquireLock('cron:refreshCache', 120_000);
    if (!unlock) return;

    try {
      const count = await this.cacheService.refreshTopProducts();
      this.metricsService.record('cron.cache_refresh', count);
    } catch (error) {
      this.logger.error('Cache refresh failed', error.stack);
    } finally {
      await unlock();
    }
  }

  // ── 상태 체크: 분산 락 불필요 (읽기 전용, 멱등) ──

  @Interval(60_000)                   // 60초마다
  async checkExternalServices() {
    const results = await Promise.allSettled([
      this.checkPaymentGateway(),
      this.checkInventoryService(),
      this.checkShippingService(),
    ]);

    const failed = results.filter(r => r.status === 'rejected');
    if (failed.length > 0) {
      this.logger.warn(`${failed.length} external services unhealthy`);
    }
  }

  // ── 앱 시작 시 워밍업 ──

  @Timeout(3_000)
  async onStartup() {
    this.logger.log('Application started, warming up...');
    await this.cacheService.preload();
  }

  // ── 동적 제어 ──

  pauseAllJobs() {
    const jobs = this.schedulerRegistry.getCronJobs();
    jobs.forEach((job, name) => {
      job.stop();
      this.logger.log(`Paused: ${name}`);
    });
  }

  resumeAllJobs() {
    const jobs = this.schedulerRegistry.getCronJobs();
    jobs.forEach((job, name) => {
      job.start();
      this.logger.log(`Resumed: ${name}`);
    });
  }
}

9. 운영 체크리스트

항목 권장 사항 위반 시 증상
크론 표현식 6자리(초 포함) 확인, timeZone 명시 예상과 다른 시간에 실행
RequestContext MikroORM 사용 시 @CreateRequestContext() 필수 메모리 누수, 데이터 오염
분산 락 K8s 다중 Pod 환경에서 쓰기 작업 크론에 적용 중복 실행 (이메일 2번 전송 등)
에러 처리 try-catch + 로깅 + 알림 실패를 모르고 지나감
실행 시간 모니터링 메트릭으로 소요 시간 기록 크론 간격보다 오래 걸려 중첩 실행
무거운 배치 K8s CronJob으로 분리 서비스 Pod 리소스 고갈

마무리 — 크론은 단순하지만 운영은 단순하지 않다

NestJS의 @Cron·@Interval·@Timeout은 사용법 자체는 간단하다. 하지만 프로덕션에서는 MikroORM의 @CreateRequestContext()로 DB 컨텍스트를 격리하고, Redis 분산 락으로 다중 인스턴스 중복 실행을 방지하며, 에러 처리와 메트릭으로 작업 상태를 관측해야 한다.

경량 주기 작업은 @Cron, 무거운 배치는 K8s CronJob, 초 단위 폴링은 @Interval로 역할을 분담하라. 그리고 SchedulerRegistry를 활용한 동적 스케줄 제어 API를 만들어두면, 배포 없이 크론을 일시 정지하거나 재개할 수 있어 운영 유연성이 크게 향상된다.

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