왜 내 크론은 두 번 실행될까?
멀티 프로세스 환경에서 리더 선출로 해결한 이야기
Node.js로 만든 정책 업데이트 서버를 운영하고 있었다. 구조는 단순했다.
- 단일 Node.js 프로세스
- 내부에서 여러 개의
cron잡이 돌아가는 구조
정책의 수가 많다 보니, 정책 업데이트를 위한 크론 잡도 상당히 많았다. 그래도 프로세스가 1개일 때는 아무 문제가 없었다. 크론은 예상한 대로, 한 번만, 제때 실행되었다.
성능 문제, 그리고 멀티 프로세스로의 전환
회사가 성장하면서 정책 개수와 트래픽이 동시에 늘었다.
- 쿼리 튜닝
- 인덱스 추가
- 데이터 모델링 수정
- 벌크 처리 도입
등 할 수 있는 최적화는 웬만큼 다 해봤다. 그럼에도 단일 Node.js 프로세스만으로는 목표 성능에 도달하지 못했다.
- 정책 수: 수백 ~ 수 기가바이트
- 정책을 받아가는 서버 수: 수십 대
- 결과: 정기적으로 서버가 버티지 못하고 장애가 났다.
결론은 하나였다.
이제는 멀티 프로세스로 가야 했다.
멀티 프로세스 구성, 그리고 예상치 못한 크론 지옥
이미 서버 앞단에는 nginx가 붙어 있었다. 그래서 선택지는 자연스럽게 이렇게 정리되었다.
- Node.js 클러스터를 쓰기보다는
- 포트만 다르게 여러 Node.js 프로세스를 띄우고
nginx로 리버스 프록시 + 로드 밸런싱
이 방식은 장점도 분명했다.
- 배포/패치 시 무중단 배포가 쉽고
- 각 프로세스를 독립적으로 관리할 수 있었다.
그래서 프로세스를 여러 개 띄웠는데… 그때 문제가 터졌다.
각 프로세스 안에 있던 cron이 모두 실행되기 시작한 것.즉, 원래는 1번만 돌던 크론이, 프로세스 수만큼 실행되기 시작했다.
- 기존: 프로세스 1개 → 크론 1번 실행
- 변경 후: 프로세스 4개 → 크론 4번 실행
정책 업데이트 잡이 대표적인 예였다. 하나의 서버에 같은 정책이 여러 번 업데이트되는 버그가 발생했다.
동일한 시각에 여러 프로세스가 동시에 같은 작업을 수행하고 있었다.
멀티 프로세스를 도입하면서, 크론이 “N번 실행되는 시스템” 이 되어버린 것이다.
문제 정의: “여러 프로세스 중 딱 하나만 돌아야 한다”
이 문제의 본질은 단순했다.
여러 프로세스가 떠 있지만, 크론은 딱 하나의 프로세스에서만 실행돼야 한다.
이걸 다른 표현으로 말하면:
- 여러 인스턴스 중에서
- 동일 작업을 담당하는 리더를 하나만 선출하고
- 그 리더만 크론을 실행하게 만들면 된다.
즉, 리더 선출(Leader Election) 문제였다.
리더 선출을 어떻게 할까?
여러 가지 방법을 고민했다. 대표적으로 떠올렸던 것들:
- DB를 이용한 락
- 전용 테이블을 만들고 “이 작업을 담당하는 주인”을 한 줄로 관리
UPDATE ... WHERE owner IS NULL같은 방식으로 선점
- Redis 분산 락 (SETNX)
- 키 하나를 두고, 먼저 잡는 프로세스가 리더
- TTL을 두어 비정상 종료에도 자동 복구 가능
- ZooKeeper / Consul 같은 전용 코디네이션 시스템
- 안정적이고 검증된 방식이지만, 인프라 오버헤드가 큼
우리 시스템은 이미 Redis를 쓰고 있었고, 복잡한 인프라를 더 늘리고 싶지 않았다.
그래서 Redis 기반의 간단한 리더 선출을 도입했다.
리더 선출 알고리즘 (간단 버전)
아이디어는 매우 단순하다.
- 각 프로세스는 크론이 돌기 전에 Redis에 특정 키를
SETNX로 잡으려고 시도한다. - 성공한 프로세스만 “리더”가 되어 크론을 실행한다.
- 키에는 TTL을 설정해서, 리더 프로세스가 비정상 종료되더라도 일정 시간 후 자동으로 락이 해제되게 한다.
- 다른 프로세스는 키 획득에 실패하면 이번 턴에는 아무것도 하지 않는다.
의사 코드는 대략 이런 느낌이다.
// pseudo
const isLeader = redis.setnx("cron:policy-update:lock", myProcessId, {
EX: 60,
});
if (isLeader) {
// 여기서만 크론 실행
} else {
// 리더가 아니므로 스킵
}
이렇게 하면, 동시에 여러 프로세스가 락을 잡으려고 해도 오직 한 프로세스만 성공한다.
그 프로세스가 해당 주기 동안의 리더가 된다.
실제 구현 예시 (Node.js + node-cron + ioredis)
간단한 예제를 하나 만들어 보면 다음과 같다.
// leader-cron.js
const cron = require("node-cron");
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
const PROCESS_ID = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
const LOCK_KEY = "cron:policy-update:leader";
const LOCK_TTL_SEC = 55; // 크론 주기(예: 1분)보다 약간 짧게
async function tryAcquireLeadership() {
// ioredis 기준: set(key, value, 'NX', 'EX', ttl)
const result = await redis.set(
LOCK_KEY,
PROCESS_ID,
"NX",
"EX",
LOCK_TTL_SEC,
);
return result === "OK";
}
async function runPolicyUpdate() {
console.log(
`[${new Date().toISOString()}] [${PROCESS_ID}] 정책 업데이트 시작`,
);
// 실제 정책 업데이트 로직...
// await updatePolicies();
console.log(
`[${new Date().toISOString()}] [${PROCESS_ID}] 정책 업데이트 완료`,
);
}
// 매 분 0초마다 실행된다고 가정
cron.schedule("0 * * * * *", async () => {
try {
const isLeader = await tryAcquireLeadership();
if (!isLeader) {
console.log(
`[${new Date().toISOString()}] [${PROCESS_ID}] 리더가 아님 → 스킵`,
);
return;
}
await runPolicyUpdate();
} catch (err) {
console.error("크론 실행 중 오류", err);
}
});
핵심은 두 가지다.
SET NX EX조합으로 락 + TTL을 동시에 걸어준다.- 락 획득에 성공한 프로세스만 크론 로직을 돈다.
적용 후, 로그가 어떻게 바뀌었는가
- 동시에 여러 프로세스가 크론을 “시도”하지만,
- 실제로 정책 업데이트를 실행하는 건 딱 한 프로세스뿐이 된다.
문제였던 “같은 서버에 업데이트가 여러 번 되는 버그”는 이 방식으로 해결할 수 있었다.
마무리: 멀티 프로세스 + 크론이면, 리더를 꼭 생각하자
이번 경험에서 얻은 교훈은 명확했다.
- 단일 프로세스에서 잘 돌던
cron이라도 - 멀티 프로세스 / 멀티 인스턴스 환경에서는 절대 같은 방식으로 두면 안 된다.
특히 다음과 같은 특징을 가진 작업이라면:
- idempotent 하지 않은 작업
- 외부 시스템에 부하를 많이 주는 작업
- 중복 실행 시 장애나 데이터 불일치가 생기는 작업
반드시 아래 중 하나를 고려해야 한다.
- 리더 선출(Leader Election)
- 분산 락(Distributed Lock)
- 워커 큐/잡 큐로의 분리 (예: Bull, Sidekiq 스타일)
나는 비교적 가벼운 선택지인 Redis 기반 리더 선출을 도입해서 문제를 해결했다.
멀티 프로세스 환경에서 “왜 내 크론은 두 번(혹은 N번) 실행될까?”라는 의문이 든다면,
아마도 리더가 없어서일 가능성이 크다.
그럴 땐, 이제 망설이지 말고 리더부터 뽑자.