
크론 대신 앱 안에서 스케줄을 돌릴 때 재시작 중복 실행을 막으려면, 스케줄러 라이브러리가 아니라 디스크나 데이터베이스처럼 프로세스 바깥에 남는 곳에 “이미 실행했다”는 흔적을 남겨야 합니다. node-cron 같은 라이브러리는 프로세스 메모리 안에서 타이머를 돌릴 뿐이라, 앱이 재시작되는 순간 그 상태가 통째로 사라지기 때문입니다. 특히 저장소를 1분마다 pull 받아 자동 배포하면서 프로세스를 재시작하는 서버라면, 배포 타이밍과 스케줄 실행 타이밍이 겹칠 확률이 낮지 않아 이 설계를 미리 챙겨야 합니다.
왜 크론 대신 앱 안에서 스케줄을 돌리는가
리눅스나 macOS 서버라면 OS의 크론(cron)에 작업을 등록하는 편이 가장 단순합니다. 하지만 실제로는 앱 프로세스 안에 스케줄러를 심는 경우가 많습니다. 이유는 크게 두 가지입니다.
첫 번째는 상태 공유 문제입니다. 별도의 크론 작업은 앱 프로세스와 다른 프로세스이기 때문에, 앱이 메모리에 들고 있는 설정값이나 DB 커넥션 풀을 그대로 쓸 수 없습니다. 매번 새 프로세스를 띄워 초기화 비용을 치러야 합니다. 두 번째는 실행 환경 문제입니다. Windows 서버에는 crontab 자체가 없고 작업 스케줄러(Task Scheduler)를 따로 등록해야 하는데, 배포 스크립트만으로 관리하기 번거로워서 node-cron 같은 라이브러리를 앱 코드 안에 넣는 쪽을 택하는 팀이 많습니다.
문제는 이렇게 앱 안에서 스케줄을 돌리는 구조로 바꾸는 순간, 크론이 공짜로 보장해 주던 것 하나를 잃어버린다는 점입니다. 바로 “프로세스가 죽었다 살아나도 스케줄 상태는 그대로”라는 보장입니다.
크론과 앱 내장 스케줄러, 재시작 앞에서 다르게 행동합니다
POSIX 표준의 crontab 명세에서 정의하는 분 단위 크론은 OS의 cron 데몬이 별도 프로세스로 관리합니다. 앱이 죽어도 cron 데몬은 살아 있고, 정해진 시각이 되면 새 프로세스를 fork해서 작업을 실행합니다. 앱 안에 넣는 스케줄러는 이 구조가 정반대입니다.
| 항목 | OS 크론(crontab) | 앱 내장 스케줄러(node-cron 등) |
|---|---|---|
| 실행 주체 | cron 데몬이 별도 프로세스 fork | 앱 프로세스 내부 타이머 |
| 앱 재시작 영향 | 영향 없음(독립 실행) | 앱이 죽으면 스케줄도 같이 사라짐 |
| 상태 공유 | 어려움(앱 메모리 접근 불가) | 쉬움(같은 프로세스, 같은 메모리) |
| 배포 중 중복 실행 위험 | 낮음 | 재시작 타이밍과 겹치면 발생 |
| 최소 실행 단위 | 보통 1분 | 코드로 초 단위까지 지정 가능 |
표에서 보듯 앱 내장 스케줄러는 재시작 내구성이 없다는 대가로 유연성과 상태 공유를 얻습니다. 이 트레이드오프를 이해하지 못한 채 크론 대신 앱 안에서 스케줄을 그대로 옮기면, 배포할 때마다 같은 작업이 두 번 실행되는 버그를 만나게 됩니다.
재시작 중복 실행이 실제로 발생하는 시점

예를 들어 매 1분마다 git pull로 최신 코드를 받아 앱을 재시작하는 자동 배포 구조를 생각해 보겠습니다. 이 앱이 node-cron으로 “매 정각마다 글 1건 발행” 작업을 돌리고 있다면, 다음과 같은 순서로 중복 실행이 생길 수 있습니다.
정각 직전에 배포 스크립트가 새 코드를 받아 프로세스를 재시작하면, 새로 뜬 프로세스는 스케줄을 처음부터 다시 계산합니다. 이때 재시작 직후 타이밍이 정각과 맞물리면 스케줄러가 “지금이 실행 시각”이라고 판단해 작업을 한 번 더 돌립니다. 기존 프로세스가 이미 그 작업을 실행 중이었거나 막 끝낸 상태였어도, 새 프로세스 입장에서는 그 사실을 알 방법이 없습니다. 스케줄 상태가 메모리에만 있었기 때문입니다.
발행 주기가 하루 1회처럼 긴 작업일수록 이 문제는 더 위험합니다. 하루에 같은 글이 두 번 올라가는 사고로 바로 이어지기 때문입니다.
디스크 잠금 파일로 중복 실행 막아보기
가장 간단한 해법은 디스크에 잠금 표시를 남기는 것입니다. Node.js의 fs.mkdirSync는 디렉터리가 이미 있으면 EEXIST 에러를 던지는데, 이 동작 자체가 원자적(atomic)이라서 별도 라이브러리 없이도 잠금으로 쓸 수 있습니다.
const cron = require('node-cron');
const fs = require('fs');
const path = require('path');
const LOCK_DIR = path.join(__dirname, 'daily-post.lock');
function acquireLock() {
try {
fs.mkdirSync(LOCK_DIR);
return true;
} catch (err) {
if (err.code === 'EEXIST') return false;
throw err;
}
}
function releaseLock() {
fs.rmSync(LOCK_DIR, { recursive: true, force: true });
}
cron.schedule('0 * * * *', async () => {
if (!acquireLock()) {
console.log('[skip] 이미 실행 중이거나 처리된 작업이라 건너뜁니다.');
return;
}
try {
console.log('[run] 포스팅 작업 시작', new Date().toISOString());
await runPostingJob();
} finally {
releaseLock();
}
});
배포 타이밍과 스케줄이 겹쳐 프로세스가 재시작돼도, daily-post.lock 디렉터리는 디스크에 그대로 남아 있습니다. 새로 뜬 프로세스가 같은 코드를 실행하면 mkdirSync가 실패하고, 아래처럼 건너뜁니다.
[run] 포스팅 작업 시작 2026-08-23T09:00:03.512Z
[skip] 이미 실행 중이거나 처리된 작업이라 건너뜁니다.
[skip] 이미 실행 중이거나 처리된 작업이라 건너뜁니다.
다만 이 방식은 작업이 끝났을 때 releaseLock()으로 잠금을 지워야 다음 실행이 정상적으로 돌아간다는 전제가 있습니다. 이 전제가 깨지는 상황은 바로 다음 항목에서 다룹니다.
락 파일보다 DB 레코드가 나은 경우는 언제인가요?

하루 1회처럼 실행 빈도가 낮고 “오늘 이미 했는가”만 확인하면 되는 작업이라면, 파일 잠금보다 DB의 유니크 제약을 쓰는 편이 안전합니다. SQLite를 예로 들면 이렇습니다.
CREATE TABLE job_runs (
job_name TEXT NOT NULL,
run_date TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (job_name, run_date)
);
-- 실행 전에 먼저 삽입을 시도합니다
INSERT INTO job_runs (job_name, run_date, created_at)
VALUES ('daily_post', date('now'), datetime('now'));
-- 오늘 이미 실행했다면 PRIMARY KEY 충돌로
-- SQLITE_CONSTRAINT 에러가 나면서 삽입이 거부됩니다
이 방식은 “먼저 확인하고 나중에 실행”하는 순서 대신 “삽입을 시도하고, 실패하면 중복”이라는 순서를 씁니다. 확인과 실행 사이에 다른 프로세스가 끼어들 틈이 없어서, 별도의 락 없이도 경쟁 조건을 막을 수 있습니다. 실행 이력이 테이블에 그대로 쌓이니 “언제 몇 번 돌았는지” 나중에 조회하기도 쉽습니다.
반대로 하루에도 여러 번, 짧은 주기로 도는 작업이라면 매번 DB에 쓰는 비용이 부담될 수 있습니다. 이런 경우엔 앞서 본 파일 잠금 쪽이 더 가볍습니다.
이 설계가 안 통하는 경우
파일 잠금과 DB 유니크 제약 모두 만능은 아닙니다. 짚어야 할 한계가 있습니다.
가장 흔한 함정은 스테일 락(stale lock)입니다. 프로세스가 작업 도중 강제 종료되면 releaseLock()이 호출되지 못하고, 잠금 디렉터리가 영영 남아 이후 실행이 계속 건너뛰어지는 상태가 됩니다. 이걸 막으려면 잠금 파일에 타임스탬프를 같이 기록해 두고, 예상 실행 시간보다 훨씬 오래된 잠금은 강제로 지우는 보완 로직이 필요합니다.
또 다른 한계는 다중 인스턴스 환경입니다. 서버를 여러 대로 수평 확장하거나 PM2를 클러스터 모드로 돌리면, 파일 잠금은 각 서버·프로세스의 로컬 디스크에만 걸리기 때문에 서로 다른 인스턴스의 중복 실행을 막지 못합니다. 이런 구조에서는 Redis의 SET key value NX 같은 공유 저장소 기반 락이나, 여러 인스턴스 중 하나만 스케줄러를 켜는 리더 선출 방식이 필요합니다. 지금 설명한 파일 잠금·SQLite 방식은 단일 프로세스로 도는 노트북 서버나 단일 인스턴스 배포처럼, 스케줄러가 딱 하나만 존재하는 환경에서 성립하는 이야기입니다.
재배포 주기가 짧다면 잠금 로직부터 넣어 보세요
크론 대신 앱 안에서 스케줄을 돌리기로 했다면, 그 순간부터 재시작 중복 실행은 “혹시 생길 수도 있는 버그”가 아니라 “배포 주기가 짧을수록 반드시 마주치는 문제”로 봐야 합니다. 앱 메모리에만 의존하는 스케줄 상태는 재시작과 동시에 사라지기 때문입니다.
지금 바로 할 수 있는 다음 단계는 두 가지입니다. 실행 빈도가 낮고 이력 조회가 필요한 작업이면 SQLite의 유니크 제약으로 중복 삽입을 막고, 짧은 주기로 자주 도는 작업이면 fs.mkdirSync 기반 잠금 디렉터리를 앞단에 붙여 보는 것입니다. 그다음엔 실제로 정각 근처에 배포를 한 번 실행시켜 보고, 로그에 [skip] 메시지가 정상적으로 찍히는지 확인하는 것으로 검증을 마칠 수 있습니다.
