node-cron 스크립트를 서버에 올려 매일 정해진 시각에 돌리기

node-cron으로 매일 아침 크롤링하거나, 정산을 돌리거나, 알림을 보내는 스크립트를 짜는 것 자체는 쉽습니다. 라이브러리 사용법을 다룬 글도 많습니다.

정작 막히는 건 그다음입니다. 이걸 어디에 올려야 계속 도나요?

검색해보면 대부분 cron.schedule() 문법까지만 설명하고 끝납니다. 그래서 이 글은 문법이 아니라 배포 위치 선택과, 올리고 나서 반드시 겪게 되는 시간대 문제를 다룹니다.

크론 워커가 다른 앱과 다른 점

배포 위치를 고르기 전에, 크론 워커가 어떤 성격의 앱인지부터 정리하면 선택이 쉬워집니다.

요청을 받지 않습니다. 웹서버가 아니라서 인바운드 트래픽이 사실상 0입니다. 대신 스스로 시각을 세야 하므로 프로세스가 계속 살아 있어야 합니다.

자원을 거의 안 씁니다. 대부분의 시간을 그냥 기다리며 보냅니다. 작업이 도는 순간에만 잠깐 CPU와 메모리를 쓰죠. 크론 몇 개 도는 워커라면 메모리 300MB 정도로 충분합니다.

응답 속도가 중요하지 않습니다. 서버가 어디에 있든, 9시에 시작한 작업이 9시에 시작되기만 하면 됩니다. 왕복 지연 300ms는 아무 의미가 없습니다.

이 세 가지 때문에 크론 워커는 가장 싼 곳에 올려도 되는 대신, 안 꺼지는 곳이어야 하는 특이한 워크로드입니다.

올릴 곳 고르기

서버리스 (Vercel, Lambda 등) — 안 됩니다

node-cron을 코드에 넣어도 실행되지 않습니다. 요청을 처리하면 함수가 종료되고 타이머도 같이 사라지기 때문입니다. 대신 각 플랫폼이 제공하는 크론 기능(Vercel Cron Jobs, EventBridge 등)으로 외부에서 깨우는 방식을 써야 합니다.

짧고 드물게 도는 작업이라면 이게 제일 편합니다.

GitHub Actions schedule — 무료지만 시각이 밀립니다

.github/workflowsschedule을 적으면 무료로 주기 실행이 됩니다. 간단한 작업에는 훌륭한 선택입니다.

다만 두 가지를 알고 쓰셔야 합니다. 정확한 시각을 보장하지 않습니다 — 러너가 혼잡하면 수십 분 밀리거나 건너뛸 수 있습니다. 그리고 공개 저장소는 60일 동안 저장소 활동이 없으면 스케줄이 자동으로 비활성화됩니다(비공개 저장소는 해당 없습니다).

“하루에 한 번 돌기만 하면 되고 몇 분 밀려도 상관없다”면 충분합니다. “정각에 알림이 나가야 한다”면 맞지 않습니다.

무료 PaaS — 슬립 때문에 안 됩니다

무료 티어는 대부분 유휴 시 컨테이너를 내립니다. 크론 워커는 인바운드 요청이 없으니 항상 유휴로 판정되고, 잠든 동안의 스케줄은 통째로 건너뜁니다. 그리고 깨워줄 요청도 오지 않습니다.

상주 서버 — 되긴 되는데 관리가 따라옵니다

VPS를 빌리면 확실합니다. 대신 OS 업데이트, 재부팅 시 자동 시작, 프로세스가 죽었을 때 되살리기(pm2·systemd)를 직접 챙겨야 합니다.

슬립 없는 컨테이너 호스팅

서버 관리 없이 프로세스만 계속 켜두는 방식입니다. 크론 워커에는 이게 가장 결이 맞습니다 — 필요한 게 “안 꺼지는 프로세스” 하나뿐이니까요.

반드시 겪게 되는 함정: 서버는 UTC입니다

배포하고 나서 가장 많이 당하는 부분입니다.

대부분의 서버는 시스템 시간이 UTC입니다. 한국 시간보다 9시간 느리죠. 그래서 로컬에서 잘 돌던 이 코드를 그대로 올리면,

cron.schedule('0 9 * * *', sendMorningReport);

한국 시간 오후 6시에 돕니다. 아침 리포트가 저녁에 나가는 겁니다.

해결은 간단합니다. 시간대를 명시하세요.

import cron from 'node-cron';

cron.schedule('0 9 * * *', sendMorningReport, {
  timezone: 'Asia/Seoul',
});

서버 시간을 KST로 바꾸는 것보다 이 방법을 권합니다. 서버를 옮기거나 다른 곳에 다시 올려도 코드가 그대로 동작하고, 코드만 읽어도 의도가 드러나기 때문입니다.

참고로 new Date()로 찍는 로그도 UTC로 나옵니다. 로그 시각이 9시간 어긋나 보여서 “안 돌았나” 착각하기 쉬우니, 로그를 남길 때도 시간대를 맞춰두면 디버깅이 편합니다.

const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
console.log(`[${now}] 리포트 발송 완료`);

올리기 전에 챙기면 좋은 것들

작업이 겹치지 않게 하세요. 10분마다 도는 크론인데 작업이 15분 걸리면 이전 작업이 끝나기 전에 다음 작업이 시작됩니다. 실행 중 플래그를 하나 두는 것만으로 대부분 막힙니다.

let running = false;

cron.schedule('*/10 * * * *', async () => {
  if (running) {
    console.log('이전 작업이 아직 도는 중 — 이번 회차 건너뜀');
    return;
  }
  running = true;
  try {
    await doWork();
  } catch (error) {
    console.error('작업 실패', error);
  } finally {
    running = false;
  }
});

작업 안에서 예외를 삼키세요. 위 예시처럼 try/catch로 감싸지 않으면, 한 번의 실패가 프로세스를 죽이고 이후 모든 스케줄이 사라집니다. 크론 워커에서 이건 치명적입니다.

실패해도 다음 회차에 복구되게 짜세요. “이번에 실패하면 다음 실행 때 밀린 것까지 처리한다”는 구조로 만들어두면, 잠깐의 장애가 데이터 구멍으로 남지 않습니다.

데이터를 서버에 쌓지 마세요. 크론 결과를 컨테이너 안 파일에만 저장하면, 재배포나 컨테이너 교체 때 사라질 수 있습니다. 결과는 외부 DB나 스토리지에 두세요.

정리

  • 크론 워커에 필요한 건 성능이 아니라 안 꺼지는 프로세스 하나입니다
  • 하루 한 번, 몇 분 밀려도 되면 GitHub Actions로 충분합니다
  • 정각이 중요하거나 자주 돌아야 하면 상주 프로세스가 필요합니다
  • 올렸다면 timezone: 'Asia/Seoul'을 반드시 명시하세요
  • 중복 실행 방지와 예외 처리는 올리기 전에 넣어두세요

이 글을 쓴 곳은 이런 워커를 올려두기 위한 Node.js 호스팅을 운영합니다. 슬립이 없어서 프로세스가 계속 시각을 셉니다. 가장 싼 스타터 플랜(메모리 300MB, 월 트래픽 30GB)이면 크론 워커에는 충분하고, 지금은 출시 기념으로 무료입니다.

먼저 밝혀둘 것 — 서버는 유럽(핀란드·독일)에 있고, 자동 백업이 없으며, SLA를 제공하지 않습니다. 위에 쓴 대로 결과를 외부에 저장하는 구조로 만드시길 권합니다. 그리고 정각 실행을 보장한다고는 말씀드리지 않겠습니다. 다만 “내가 띄운 프로세스가 직접 타이머를 센다”는 점은 외부에서 깨우는 방식과 분명히 다릅니다.

← 글 목록으로