NestJS Task Scheduling이란? — 반복 작업의 선언적 관리
만료된 세션 정리, 일일 리포트 생성, 외부 API 데이터 동기화, 미결제 주문 자동 취소 — 이런 반복 작업은 모든 백엔드에 존재한다. NestJS는 @nestjs/schedule 패키지로 Cron Job과 Interval/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를 만들어두면, 배포 없이 크론을 일시 정지하거나 재개할 수 있어 운영 유연성이 크게 향상된다.