junyeokk
Blog
RSS Feed·2025. 03. 05

node-schedule 기반 Crawler 스케줄링

서버에서 주기적으로 실행해야 하는 작업이 있다. 예를 들어 외부 데이터를 30분마다 수집하거나, 1분마다 큐에 쌓인 작업을 처리하거나, 5분마다 전체 동기화를 도는 식이다. 가장 단순한 방법은 setInterval을 쓰는 것이다.

typescript
setInterval(() => {
  crawlFeeds();
}, 30 * 60 * 1000); // 30분

동작은 하지만 문제가 많다. "매시 0분과 30분에 실행"처럼 정확한 시각 기반 스케줄링이 안 된다. setInterval은 프로세스 시작 시점 기준으로 간격만 보장하기 때문에, 서버를 13:17에 재시작하면 13:47, 14:17에 실행된다. 시스템 전체에서 "매시 정각"에 맞춰 동작해야 하는 경우 이건 쓸 수 없다.

또한 cron 표현식 같은 복잡한 스케줄(*/5 * * * *, 0,30 * * * *)을 표현할 방법이 없고, 작업에 이름을 붙이거나 특정 작업만 취소하는 것도 불가능하다.

node-schedule은 이런 문제를 해결하는 Node.js 스케줄링 라이브러리다. cron 표현식을 지원하고, 각 작업을 Job 객체로 관리할 수 있으며, 날짜 기반 일회성 실행도 가능하다.

설치와 기본 사용법

bash
npm install node-schedule

가장 기본적인 사용법은 scheduleJob에 cron 표현식과 콜백을 넘기는 것이다.

typescript
import * as schedule from 'node-schedule';

// 매 30분마다 실행 (매시 0분, 30분)
const job = schedule.scheduleJob('0,30 * * * *', () => {
  console.log('피드 수집 시작');
});

setInterval과의 결정적 차이는 시각 기반이라는 점이다. 0,30 * * * *은 "매시 0분과 30분"을 의미하므로, 서버를 몇 시에 시작하든 항상 정각과 30분에 실행된다.

Cron 표현식

cron 표현식은 다섯 개(또는 여섯 개) 필드로 구성된다.

* * * * * * ┬ ┬ ┬ ┬ ┬ ┬ │ │ │ │ │ │ │ │ │ │ │ └── 요일 (0-7, 0과 7은 일요일) │ │ │ │ └────── 월 (1-12) │ │ │ └─────────── 일 (1-31) │ │ └──────────────── 시 (0-23) │ └───────────────────── 분 (0-59) └────────────────────────── 초 (0-59, 선택사항)

node-schedule은 표준 5필드 cron에 초 단위를 추가로 지원한다. 6필드를 쓰면 맨 앞이 초가 된다.

자주 쓰는 패턴을 정리하면:

표현식의미
* * * * *매분 실행
*/5 * * * *5분마다
0,30 * * * *매시 0분, 30분
0 */2 * * *2시간마다 (정시)
0 9 * * 1-5평일 오전 9시
30 4 1 * *매월 1일 새벽 4:30
*/10 * * * * *10초마다 (6필드)

*/n은 "n 간격으로"를 의미한다. */5 * * * *이면 0분, 5분, 10분, ... 55분에 실행된다. 쉼표(,)는 특정 값들을 나열하고, 하이픈(-)은 범위를 지정한다.

Job 이름 지정

scheduleJob의 첫 번째 인자로 이름을 넘길 수 있다. 디버깅과 로깅에서 어떤 작업이 실행 중인지 식별하는 데 유용하다.

typescript
*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    │
│    │    │    │    │    └── 요일 (0-7, 0과 7은 일요일)
│    │    │    │    └────── 월 (1-12)
│    │    │    └─────────── 일 (1-31)
│    │    └──────────────── 시 (0-23)
│    └───────────────────── 분 (0-59)
└────────────────────────── 초 (0-59, 선택사항)

이름을 붙이면 나중에 schedule.scheduledJobs 객체에서 이름으로 특정 작업을 찾을 수 있다.

typescript
schedule.scheduleJob('FEED CRAWLING', '0,30 * * * *', () => {
  console.log('피드 수집 시작');
});

schedule.scheduleJob('AI BATCH PROCESS', '*/1 * * * *', () => {
  console.log('AI 배치 처리 시작');
});

schedule.scheduleJob('FULL SYNC', '*/5 * * * *', () => {
  console.log('전체 동기화 시작');
});

RecurrenceRule

cron 표현식 대신 RecurrenceRule 객체를 사용할 수도 있다. 코드에서 스케줄을 프로그래밍적으로 조합해야 할 때 유용하다.

typescript
const job = schedule.scheduledJobs['FEED CRAWLING'];
if (job) {
  job.cancel(); // 이 작업만 중지
}

RecurrenceRule의 장점은 타임존 지정이 가능하다는 것이다. cron 표현식만으로는 타임존을 표현할 수 없는데, RecurrenceRule에서는 tz 속성으로 명시할 수 있다. 서버가 UTC로 돌아가더라도 한국 시간 기준 매일 오전 9시에 실행하고 싶다면:

typescript
const rule = new schedule.RecurrenceRule();
rule.hour = [0, 6, 12, 18]; // 0시, 6시, 12시, 18시
rule.minute = 0;
rule.tz = 'Asia/Seoul'; // 타임존 지정 가능

schedule.scheduleJob('PERIODIC REPORT', rule, () => {
  console.log('리포트 생성');
});

날짜 기반 일회성 실행

특정 시각에 한 번만 실행할 수도 있다.

typescript
const rule = new schedule.RecurrenceRule();
rule.hour = 9;
rule.minute = 0;
rule.tz = 'Asia/Seoul';

schedule.scheduleJob(rule, () => {
  // KST 09:00에 실행
});

이건 setTimeout으로도 할 수 있지만, setTimeout은 약 24.8일(2^31 - 1 밀리초)이 최대 지연 시간이라서 그보다 먼 미래는 처리할 수 없다. node-schedule은 이 제한이 없다.

Graceful Shutdown

스케줄러가 돌아가는 프로세스를 종료할 때는 등록된 모든 작업을 정리해야 한다. 그렇지 않으면 프로세스가 종료되지 않을 수 있다.

typescript
const futureDate = new Date(2025, 11, 31, 23, 59, 59);

schedule.scheduleJob(futureDate, () => {
  console.log('한 번만 실행되고 끝');
});

gracefulShutdown()은 현재 실행 중인 작업이 완료될 때까지 기다린 후 모든 작업을 취소한다. 개별 작업을 취소하려면 job.cancel()을 사용하면 된다.

실제 프로덕션에서는 스케줄러뿐 아니라 DB 연결, Redis 연결 등도 함께 정리해야 한다.

typescript
async function handleShutdown(signal: string) {
  console.log(`${signal} 수신, 종료 중...`);
  
  // 모든 스케줄 작업 취소
  schedule.gracefulShutdown().then(() => {
    console.log('모든 스케줄 작업 취소됨');
    process.exit(0);
  });
}

process.on('SIGINT', () => handleShutdown('SIGINT'));
process.on('SIGTERM', () => handleShutdown('SIGTERM'));

process.on 콜백에서 void를 붙이는 이유는 async 함수가 반환하는 Promise를 명시적으로 무시하기 위해서다. 이렇게 하지 않으면 TypeScript에서 floating promise 경고가 발생한다.

여러 스케줄러를 함께 등록하는 패턴

실무에서는 하나의 프로세스에서 여러 주기적 작업을 함께 돌리는 경우가 많다. 이때 스케줄러 등록을 함수로 분리하면 코드가 깔끔해진다.

typescript
async function handleShutdown(signal: string) {
  console.log(`${signal} 수신, 종료 중...`);
  
  await dbConnection.end();
  await redisConnection.quit();
  
  console.log('모든 리소스 정리 완료');
  process.exit(0);
}

process.on('SIGINT', () => void handleShutdown('SIGINT'));
process.on('SIGTERM', () => void handleShutdown('SIGTERM'));

핵심 포인트:

  1. 의존성을 주입받는 구조: registerSchedulers가 필요한 서비스를 인자로 받으므로 테스트가 쉽고, 서비스 초기화와 스케줄 등록이 분리된다.
  2. 콜백 내 void 키워드: 각 작업의 start()가 async라면 void를 붙여서 floating promise를 방지한다.
  3. 진입점의 에러 처리: main().catch()로 초기화 단계의 에러를 잡아서 프로세스를 안전하게 종료한다.

node-schedule vs node-cron vs setInterval

Node.js에서 주기적 작업을 실행하는 선택지가 여럿 있다. 각각의 차이를 정리한다.

기능setIntervalnode-cronnode-schedule
cron 표현식✅ (5필드)✅ (5-6필드)
초 단위 스케줄❌ (ms 단위)
날짜 기반 실행
RecurrenceRule
타임존 지원
Job 이름/관리
외부 의존성없음있음있음

setInterval은 외부 의존성 없이 간단한 반복만 필요할 때 적합하다. node-cron은 가볍고 cron 표현식만 있으면 충분할 때 좋다. node-schedule은 Job 관리, 날짜 기반 실행, RecurrenceRule 등 고급 기능이 필요할 때 선택한다.

OS cron vs node-schedule

또 하나 비교할 것은 OS 레벨의 crontab이다. Linux의 crontab -e로 등록하는 cron job은 프로세스 외부에서 관리되므로, Node.js 프로세스가 죽어도 다음 스케줄에 맞춰 새로 실행된다.

반면 node-schedule은 프로세스 내부에서 돌아가므로, 프로세스가 죽으면 스케줄도 사라진다. 대신 같은 프로세스의 메모리(DB 연결 풀, Redis 클라이언트 등)를 공유할 수 있어서, 매번 새 프로세스를 띄우는 오버헤드가 없다.

OS cron이 적합한 경우:

  • 독립적인 스크립트를 주기적으로 실행
  • 프로세스 장애 시에도 다음 스케줄은 보장되어야 할 때
  • 시스템 관리 작업 (로그 로테이션, 백업 등)

node-schedule이 적합한 경우:

  • 이미 떠 있는 서버 프로세스에서 주기적 작업을 함께 돌릴 때
  • DB/Redis 등 기존 연결을 재사용해야 할 때
  • 작업 등록/취소를 런타임에 동적으로 해야 할 때

주의사항

작업 실행 시간이 간격보다 길면? node-schedule은 이전 작업이 끝났는지 확인하지 않는다. 1분마다 실행하는 작업이 2분 걸리면 작업이 겹쳐서 동시에 돌아간다. 이를 방지하려면 작업 내부에서 잠금을 직접 구현해야 한다.

typescript
import * as schedule from 'node-schedule';

interface Dependencies {
  feedCrawler: FeedCrawler;
  batchProcessor: BatchProcessor;
  syncWorker: SyncWorker;
}

function registerSchedulers(deps: Dependencies) {
  // 30분마다 데이터 수집
  schedule.scheduleJob('DATA COLLECTION', '0,30 * * * *', () => {
    console.log(`수집 시작: ${new Date().toISOString()}`);
    void deps.feedCrawler.start(new Date());
  });

  // 1분마다 배치 처리
  schedule.scheduleJob('BATCH PROCESS', '*/1 * * * *', () => {
    console.log(`배치 시작: ${new Date().toISOString()}`);
    void deps.batchProcessor.start();
  });

  // 5분마다 전체 동기화
  schedule.scheduleJob('FULL SYNC', '*/5 * * * *', () => {
    console.log(`동기화 시작: ${new Date().toISOString()}`);
    void deps.syncWorker.start();
  });
}

async function main() {
  console.log('[Scheduler Start]');

  const deps = initializeDependencies();
  registerSchedulers(deps);

  process.on('SIGINT', () => void handleShutdown(deps, 'SIGINT'));
  process.on('SIGTERM', () => void handleShutdown(deps, 'SIGTERM'));
}

main().catch((error) => {
  console.error('스케줄러 시작 실패:', error);
  process.exit(1);
});

프로세스 재시작 시 놓친 스케줄은? node-schedule은 놓친 스케줄을 다시 실행하지 않는다. 프로세스가 10시에 죽었다가 11시에 다시 뜨면, 10시~11시 사이 실행되었어야 할 작업은 그냥 건너뛴다. 놓친 작업을 보상해야 한다면 별도의 로직이 필요하다.

관련 문서