Vercel에서 node-cron과 setInterval이 안 도는 이유

Next.js 앱을 Vercel에 올려두고 “매일 아침에 메일 보내기” 같은 걸 붙이려다 이런 코드를 짜본 적이 있으실 겁니다.

import cron from 'node-cron';

cron.schedule('0 9 * * *', () => {
  sendDailyReport();
});

로컬에서는 완벽하게 돕니다. 배포하면 아무 일도 일어나지 않습니다. 에러도 안 납니다. 그냥 조용히 실행되지 않습니다.

왜 안 되나

버그가 아니라 구조입니다.

node-cron이나 setInterval은 “계속 살아 있으면서 시계를 세는 프로세스”를 전제합니다. 9시가 될 때까지 누군가는 깨어서 기다려야 하니까요.

서버리스에는 그 프로세스가 없습니다. 요청이 들어오면 함수 인스턴스가 뜨고, 응답을 돌려주면 종료됩니다. 실행 시간이 끝나면 그 안에 등록해둔 타이머도 인스턴스와 함께 사라집니다. 9시까지 기다려줄 주체가 존재하지 않는 겁니다.

setInterval도 같습니다. 등록은 성공하지만, 함수가 종료되는 순간 같이 없어집니다.

정리하면 이렇습니다.

상주 서버서버리스
프로세스 수명계속 살아 있음요청 처리 후 종료
setInterval유지됨함수와 함께 소멸
node-cron정상 동작등록만 되고 실행 안 됨
웹소켓 유지가능불가

흔한 오해: “요청이 오면 깨어나니까 괜찮지 않나”

깨어나긴 합니다. 문제는 누가 깨우느냐입니다.

스케줄러의 본질은 “아무도 부르지 않아도 정해진 시각에 스스로 실행되는 것”입니다. 그런데 서버리스는 외부에서 요청이 와야만 실행됩니다. 즉 깨워줄 외부 주체가 반드시 따로 필요합니다. 그게 바로 크론 서비스가 하는 일이고, 그래서 node-cron을 코드에 넣는 것으로는 해결이 안 됩니다.

선택지 세 가지

1. Vercel Cron Jobs

Vercel이 공식으로 제공하는 방법입니다. vercel.json에 스케줄을 적어두면 Vercel이 정해진 시각에 지정한 경로를 호출해줍니다.

{
  "crons": [{ "path": "/api/daily-report", "schedule": "0 0 * * *" }]
}

앱을 옮기지 않아도 되니 가장 자연스러운 답입니다. 다만 제약이 있습니다.

  • 실행 빈도가 요금제에 묶여 있습니다. 무료 플랜은 촘촘한 주기를 쓸 수 없습니다. 정확한 한도는 Vercel 요금제 문서에서 현재 값을 확인하세요 — 정책이 바뀌는 항목이라 여기 숫자를 적어두면 금방 틀립니다.
  • 여전히 함수 실행 시간 제한을 받습니다. 오래 걸리는 배치 작업에는 맞지 않습니다.
  • 상주가 필요한 일은 여전히 불가능합니다. 웹소켓을 붙들거나, 큐를 계속 소비하거나, 메모리에 상태를 유지하는 작업은 해결되지 않습니다.

몇 시간에 한 번 짧게 도는 작업이라면 이걸로 충분합니다. 여기서 끝나는 경우가 실제로 많으니 먼저 검토해보세요.

2. 외부 크론 서비스로 API 호출

cron-job.org 같은 외부 서비스가 주기적으로 여러분의 API를 호출하게 두는 방식입니다. Vercel Cron과 원리는 같고, 빈도 제약을 우회할 수 있습니다.

주의할 점은 그 엔드포인트가 인터넷에 열려 있다는 것입니다. 누구나 호출할 수 있으면 정산이나 메일 발송이 아무 때나 돌 수 있으니, 비밀 토큰을 헤더나 쿼리로 확인하는 절차를 반드시 넣으세요.

3. 크론 워커만 따로 분리

프론트엔드는 Vercel에 그대로 두고, 상주가 필요한 부분만 별도의 작은 서버로 빼는 구성입니다.

[ Next.js — Vercel ]        [ 크론 워커 — 상주 서버 ]
   사용자 화면                node-cron 이 시각을 셈
   API 라우트                 DB 작업, 메일 발송, 크롤링
         └──── 같은 DB ────┘

이 방법의 장점은 기존 스택을 하나도 안 버려도 된다는 겁니다. Vercel의 배포 경험과 CDN은 그대로 쓰고, 서버리스가 구조적으로 못 하는 일만 떼어냅니다.

워커는 화면을 그리지 않으니 자원도 거의 안 씁니다. 크론 몇 개 도는 워커라면 메모리 300MB로 충분하고, 인바운드 트래픽도 사실상 0입니다.

워커를 분리할 때 반드시 챙길 것: 시간대

이건 실제로 많이들 당하는 부분입니다.

대부분의 서버는 시스템 시간이 UTC입니다. 그래서 '0 9 * * *'라고 적으면 한국 시간 오전 9시가 아니라 오후 6시에 돕니다.

node-cron은 시간대 옵션을 지원하니 명시적으로 지정하세요.

import cron from 'node-cron';

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

이렇게 두면 서버 시간이 UTC든 무엇이든 항상 한국 시간 9시에 돕니다. 서버를 옮겨도 코드가 그대로 동작한다는 점에서, 시간대를 코드에 박아두는 편이 서버 시간을 KST로 바꾸는 것보다 안전합니다.

정리

  • node-cron·setInterval이 Vercel에서 안 도는 건 버그가 아니라 서버리스의 정의입니다
  • 몇 시간에 한 번이면 Vercel Cron Jobs로 충분합니다. 먼저 이걸 보세요
  • 촘촘한 주기나 상주가 필요하면 워커만 분리하세요. 프론트는 옮길 필요 없습니다
  • 워커를 만들었다면 시간대를 반드시 명시하세요

참고로 이 글을 쓴 곳은 이런 워커를 올려두기 위한 Node.js 호스팅을 운영합니다. 슬립이 없어서 node-cron이 계속 시각을 셉니다.

다만 서버가 유럽에 있고, 자동 백업이 없으며, 장애 보상을 약속하지 않습니다. 크론 워커는 지연이 문제되지 않는 워크로드라 잘 맞지만, 정확한 시각 실행을 보장한다고는 말씀드리지 않겠습니다 — 저희도 SLA가 없습니다. 분 단위 정확도가 사업에 중요하다면 관리형 스케줄러를 쓰시는 편이 맞습니다.

← 글 목록으로