Sidekiq-scheduler 중복 실행? cron과 every 차이를 코드로 정리
Rails에서 Sidekiq로 백그라운드 작업을 돌리다 보면 “같은 잡이 왜 두 번 실행되지?”라는 미스터리가 가끔 생깁니다. 특히 sidekiq-scheduler로 정기 작업을 걸어두면, 설정은 멀쩡해 보이는데 알림이 두 번 가거나 API 호출이 두 번 찍히는 식으로 사고가 나죠.
이 글에서는 실제로 every: "10m" 설정이 Sidekiq 프로세스 수만큼 중복 실행되던 사례를 출발점으로, 왜 cron으로 바꾸면 문제를 멈출 수 있었는지 그 이유를 내부 동작(Redis sorted set, rufus-scheduler, “실행 시각 계산 방식”) 관점에서 쉽게 풀어봅니다. 마지막에는 “그럼 우리는 뭘 써야 하는가”까지 실전 기준으로 정리합니다.
sidekiq-scheduler 중복 실행이 실제로 생기는 순간
상황은 단순했습니다. 신규 매물 알림을 보내는 NotifyJob을 10분마다 실행하고 싶었고, 설정도 딱 한 줄이었죠.
:schedule:
notify_job:
every: "10m"
class: NotifyJob검증 환경에서 Sidekiq 프로세스를 2개 띄워둔 상태였는데, 어느 순간부터 같은 알림이 “10분마다 1번”이 아니라 “거의 동시에 2번” 도착했습니다.
여기서 많은 사람이(저 포함) 자연스럽게 이렇게 생각합니다.
“정기 실행 스케줄링은 한 군데에서만 큐에 넣고, 워커들이 알아서 나눠 먹는 거 아니야?”
그런데 sidekiq-scheduler에서 every는 그 기대와 다르게 동작할 수 있습니다. 스케줄 등록이 프로세스마다 일어나고, 프로세스마다 “다음 실행 시각”이 달라질 수 있기 때문입니다.
반면 같은 잡을 cron으로 바꾸자 중복이 즉시 사라졌습니다.
:schedule:
notify_job:
cron: '0 */10 * * * *'
class: NotifyJob여기서 핵심 질문이 생깁니다. 설정은 “every냐 cron이냐” 차이뿐인데, 왜 결과는 프로세스 수만큼 갈릴까요?
cron으로 바꾸면 중복이 멈추는 이유: “한 번만 enqueue” 장치가 있다
sidekiq-scheduler는 멀티 프로세스 환경에서 정기 작업이 중복으로 큐에 들어가는 걸 막기 위해 Redis를 사용합니다. 그중에서도 Redis sorted set(ZSET) 을 활용해 “이번 실행은 이미 등록됐는지”를 체크합니다.
ZSET은 점수(score)와 값(member)을 함께 저장하는 자료구조인데, 중요한 특징이 하나 있습니다. 같은 member를 또 넣으면 요소가 늘지 않고 갱신됩니다. 즉 “중복 없는 목록”처럼 쓸 수 있죠.
sidekiq-scheduler는 이 성질을 이용해 대략 이런 흐름으로 움직입니다.
스케줄 시간이 되면, “이번 실행 시각(time)”을 Redis ZSET에 등록 시도합니다.
등록이 “새로 추가”로 성공한 프로세스만 실제로 Sidekiq 큐에 enqueue 합니다.
이미 같은 실행 시각이 등록되어 있으면 “누가 먼저 넣었구나”로 판단하고 enqueue를 건너뜁니다.
여기까지 들으면 오히려 안심됩니다. “그럼 every여도 한 번만 들어가야 하는 거 아닌가?”라고요.
맞습니다. 관건은 딱 하나, Redis에 등록할 때 쓰는 “실행 시각(time)”이 모든 프로세스에서 동일하게 계산되느냐입니다.
cron vs every 핵심 차이: 절대시간(정렬) vs 상대시간(각자 기준)
sidekiq-scheduler는 실제 스케줄 계산을 직접 하지 않고, 내부적으로 rufus-scheduler에 위임합니다. 그리고 rufus가 “지금 실행할 타이밍이야”라고 판단하면 블록이 호출됩니다.
그런데 이때 sidekiq-scheduler는 cron 잡과 every 잡을 똑같이 취급하지 않습니다.
cron 잡일 때는 rufus가 넘겨준 실행 시각을 한 번 “정규화”합니다. 예를 들어 10:10:00에 실행해야 하는데 시스템 부하로 10:10:01에 깨어났다면, Redis에는 10:10:00으로 맞춰 넣는 식입니다.
반면 every 잡일 때는 rufus가 준 시간을 거의 그대로 씁니다. 이게 문제의 시작점입니다.
cron은 “매 시각의 0초, 10분마다”처럼 기준이 명확한 절대시간 기반이라, 프로세스가 여러 개여도 결국 같은 슬롯(예: 10:10:00)을 바라보게 됩니다.
하지만 every는 “프로세스가 시작된 시점으로부터 10분마다”라는 성격이 강해서, 프로세스 기동 시각이 달라지면 다음 실행 시각도 달라집니다.
즉, 프로세스 1이 09:58에 떠 있으면 10:08에 실행을 예약하고, 프로세스 2가 10:01:30에 떠 있으면 10:11:30에 실행을 예약하는 식으로 엇갈립니다.
그리고 Redis ZSET 입장에서는 “실행 시각이 다르니” 서로 다른 member로 저장됩니다. 그 결과 “각 실행 시각마다 한 번씩” enqueue가 일어나며, 사용자 입장에서는 “같은 잡이 중복 실행된다”로 관측되는 거죠.
‘10분마다 실행’ cron 표현, 그리고 운영에서 더 안전한 이유
cron을 쓸 때 가장 자주 하는 실수는 “표현이 헷갈려서” 매번 검색하는 겁니다. 하지만 자주 쓰는 패턴은 정해져 있습니다.
전통적인 crontab에서는 */10 * * * *가 “매 10분”의 대표 표현입니다1. (분, 시, 일, 월, 요일 5필드)
sidekiq-scheduler는 초 단위까지 받는 6필드 cron도 자주 사용합니다. 그래서 0 */10 * * * *처럼 “0초에, 10분마다”로 쓰는 패턴이 흔합니다.
운영 관점에서 cron이 유리한 지점은 또 있습니다. 주기 작업이 촘촘할수록 서버 부하가 튀고, 실행이 겹치면(오버랩) 지연이 지연을 낳습니다. 작업 시간이 3분인데 5분마다 돌리면 작은 지연에도 겹치기 시작한다는 식이죠2. 이런 상황에서 “모든 프로세스가 각자 10분마다”로 움직이면 더 쉽게 충돌합니다.
cron은 적어도 “언제 실행되어야 하는지”를 공통 시간축에 고정해두기 때문에, 멀티 프로세스에서 예상 가능한 운영을 만들기가 훨씬 쉽습니다.
실전 선택 가이드: 어떤 경우에 cron, 어떤 경우에 every?
비즈니스 잡(알림 발송, 결제 후처리, 외부 API 호출, 정산/집계 등)은 대개 “전 시스템에서 딱 한 번”이 중요합니다. 이때는 cron을 기본값으로 잡는 편이 안전합니다. 멀티 프로세스/멀티 서버로 갈수록 더욱 그렇습니다.
반대로 every가 어울리는 작업도 있긴 합니다. 예를 들어 프로세스마다 자신의 상태를 주기적으로 찍는다거나, 각 프로세스가 독립적으로 캐시를 워밍업한다거나, “중복되어도 상관없고 오히려 분산되면 좋은” 종류의 작업은 every가 단순하고 편합니다.
다만 이 결론에는 단서가 붙습니다. 스케줄러 설정이 무엇이든, 장애나 재시도 같은 변수 때문에 잡은 언제든 두 번 실행될 수 있습니다. 그래서 중요한 잡일수록 “중복 실행돼도 결과가 안전한가?”를 먼저 물어야 합니다. 멱등성(idempotency)은 선택이 아니라 보험에 가깝습니다2.
시사점 내용 (핵심 포인트 정리 + 개인적인 생각 또는 실용적 조언)...
정기 작업이 중복 실행될 때, 의외로 범인은 “큐”가 아니라 “스케줄의 시간 계산 방식”인 경우가 많습니다. sidekiq-scheduler에서 cron은 여러 프로세스가 같은 시간 슬롯을 공유하도록 시간을 정렬해주지만, every는 각 프로세스의 시작 시점을 기준으로 돌아가며 서로 다른 실행 시각을 만들 수 있습니다.
팀에서 sidekiq-scheduler를 쓴다면, 우선 sidekiq.yml의 스케줄을 훑어보면서 “중복되면 곤란한 잡이 every로 되어 있진 않은지”부터 점검해보면 좋습니다. 그리고 어떤 스케줄을 쓰든, 최종적으로는 잡 로직 자체를 멱등적으로 설계해 “두 번 돌아도 안전한 시스템”으로 만드는 게 가장 오래가는 해결책입니다.
참고
1How to Run Cron Jobs Every 5, 10, 15, or 30 Minutes - UptimeRobot Knowledge Hub
2Cronjob intervals: Optimizing effects on server load - web hosting
