> 학습 목표: 단일 서버에서 잘 돌던 @Scheduled 작업이 인스턴스를 2대 이상으로 늘리는 순간 왜 중복 실행되는지 이해하고, ShedLock·분산 락·리더 선출·외부 스케줄러 같은 선택지를 trade-off와 함께 고를 수 있게 한다. > > 한 줄 결론: @Scheduled는 JVM 하나 안에서만 동작하므로 인스턴스 간 조율 장치가 전혀 없다....
학습 목표: 단일 서버에서 잘 돌던
@Scheduled작업이 인스턴스를 2대 이상으로 늘리는 순간 왜 중복 실행되는지 이해하고, ShedLock·분산 락·리더 선출·외부 스케줄러 같은 선택지를 trade-off와 함께 고를 수 있게 한다.한 줄 결론:
@Scheduled는 JVM 하나 안에서만 동작하므로 인스턴스 간 조율 장치가 전혀 없다. 같은 잡을 한 번만 돌리려면 외부 저장소 기반의 잠금(또는 단 하나의 실행 주체)을 별도로 붙여야 한다.
처음 서비스를 띄울 때는 인스턴스가 한 대다. 매일 새벽 정산을 돌리는 @Scheduled, 만료 쿠폰을 정리하는 배치, 외부 API로 상태를 동기화하는 폴링 작업이 모두 그 한 대에서 한 번씩 돈다. 문제가 없다.
트래픽이 늘어 인스턴스를 3대로 스케일 아웃하는 순간 조용히 망가진다. 같은 코드가 3대에 똑같이 배포되므로, 새벽 정산이 3번 실행되고, 결제 알림 메일이 3통 나가고, 외부 API 호출이 3배가 된다. 컴파일 에러도, 런타임 예외도 없이 "그냥 결과가 이상한" 형태로 드러나기 때문에 운영에서 가장 늦게 발견되는 부류의 버그다.
핵심은 @Scheduled가 분산 환경을 전혀 모른다는 사실이다. 이 문서는 그 한계를 이해하고, 중복 실행을 막는 표준 패턴들을 정리한다.
Spring의 @Scheduled는 ScheduledAnnotationBeanPostProcessor가 빈을 스캔해서, cron/fixedDelay/fixedRate 메타데이터를 읽고 TaskScheduler(기본 구현은 ThreadPoolTaskScheduler)에 등록하는 구조다.
@Component
public class SettlementJob {
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void runDailySettlement() {
// 정산 로직
}
}여기서 중요한 사실 두 가지:
ThreadPoolTaskScheduler는 로컬 스레드 풀일 뿐, 다른 인스턴스의 스케줄러와 통신하지 않는다.즉 N개 인스턴스 = N개의 독립된 스케줄러 = 같은 잡이 N번 실행. 이것은 버그가 아니라 @Scheduled의 설계 그대로의 동작이다.
fixedRate/fixedDelay/cron 모두 로컬 트리거 방식이다. 트리거 방식만 다를 뿐, 어느 것도 인스턴스 간 조율을 하지 않는다. 셋 다 똑같이 중복 실행된다.
트랜잭션은 같은 행을 동시에 수정할 때의 격리를 보장할 뿐, "이 잡을 한 번만 실행"을 보장하지 않는다. 3개 인스턴스가 각자 트랜잭션을 열고 각자 정산 행을 INSERT하면, 트랜잭션은 모두 정상 커밋되고 중복 데이터가 3건 남는다.
락은 동시 실행을 줄여줄 뿐 완벽한 단일 실행을 보장하지 못한다. 락 만료(TTL)가 잡 실행 시간보다 짧거나, 시계 차이(clock skew)가 있거나, GC 멈춤으로 락을 늦게 해제하면 두 인스턴스가 겹칠 수 있다. 락은 1차 방어선이고, 잡 로직 자체의 멱등성이 최종 안전망이다.
ShedLock은 "스케줄 잡 한정 분산 락" 라이브러리다. 범용 분산 락이 아니라 스케줄 중복 실행 방지라는 단일 목적에 최적화돼 있다.
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(
name = "dailySettlement",
lockAtMostFor = "10m", // 잡이 죽어도 최대 10분 뒤 락 강제 해제
lockAtLeastFor = "1m" // 최소 1분은 락 유지 (빠른 재획득/시계차 방어)
)
public void runDailySettlement() {
LockAssert.assertLocked(); // 락 없이 호출되면 예외
// 정산 로직
}동작 원리:
INSERT 또는 조건부 UPDATE로 잡으려 한다.JDBC 락 저장소 예시 스키마:
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);name이 PK라는 점이 핵심이다. 같은 잡 이름으로 두 인스턴스가 동시에 INSERT를 시도하면 PK 제약으로 하나만 성공한다. 데이터베이스의 원자성에 조율을 위임하는 셈이다.
이 둘을 헷갈리면 운영 사고가 난다.
이미 Redis 분산 락 인프라가 있다면 ShedLock 없이 직접 잠글 수도 있다. 상세 구현은 Redis 분산 락 문서를 참고한다.
@Scheduled(cron = "0 0 3 * * *")
public void runDailySettlement() {
String lockKey = "lock:job:dailySettlement";
// SET key value NX EX 600 — 원자적 획득 + TTL
boolean acquired = redis.opsForValue()
.setIfAbsent(lockKey, instanceId, Duration.ofMinutes(10));
if (!Boolean.TRUE.equals(acquired)) {
return; // 다른 인스턴스가 이미 실행 중
}
try {
// 정산 로직
} finally {
// 본인이 건 락만 해제 (Lua로 owner 확인 후 DEL)
releaseIfOwner(lockKey, instanceId);
}
}ShedLock 대비 차이: 직접 구현은 유연하지만 락 만료·소유권 확인·fencing token 같은 디테일을 스스로 책임져야 한다. 스케줄 중복 방지만이 목적이라면 ShedLock이 보일러플레이트를 줄여준다.
"모든 인스턴스가 잡을 경합"하는 대신, 클러스터에서 리더 하나만 스케줄 잡을 돌린다는 접근이다.
Lease 오브젝트 기반 리더 선출LeaderInitiator (ZooKeeper/Hazelcast/etcd 등)@Scheduled 잡을 활성화하고, 리더가 죽으면 다른 인스턴스가 리더를 이어받는다.락 방식과의 차이: 락은 "매 실행마다 경합"이고, 리더 선출은 "한 번 리더를 정해두고 그 인스턴스가 계속 실행"이다. 잡이 많고 자주 도는 환경에서는 매번 락을 잡는 비용을 줄일 수 있다.
조율 자체를 애플리케이션 밖으로 빼는 방법.
scheduler 역할을 켜고 나머지는 끈다 (@Profile("scheduler")). 단순하지만 그 한 대가 죽으면 잡이 멈추는 단일 장애점이 된다.| 상황 | 권장 패턴 |
|---|---|
일반적인 Spring @Scheduled 중복 방지 | ShedLock (JDBC 또는 Redis 락 저장소) |
| 이미 Redis 분산 락 인프라 보유 | 직접 락 또는 ShedLock Redis provider |
| 잡이 많고 자주 돌아 매번 락 경합이 부담 | 리더 선출 |
| 애플리케이션과 무관하게 1회 실행 보장 | Kubernetes CronJob 등 외부 스케줄러 |
| 복잡한 잡 스케줄·재시도·이력 관리 필요 | Quartz 클러스터 모드 |
대부분의 백엔드 서비스에서 첫 선택은 ShedLock이다. 추가 인프라 없이 기존 DB만으로 시작할 수 있고, 목적이 "스케줄 중복 방지" 하나로 명확하기 때문이다.
@Scheduled(cron=...) 잡은 몇 번 실행되는가? 왜 그런가?lockAtMostFor와 lockAtLeastFor는 각각 어떤 장애 시나리오를 막기 위한 값인가?@Scheduled 대비 무엇을 얻고 무엇을 잃는가?@Scheduled(fixedRate=5000) 잡에 인스턴스 ID와 실행 시각을 로그로 찍어 중복 실행을 눈으로 확인한다.shedlock 테이블의 locked_by를 관찰하며, 매 실행마다 어느 인스턴스가 락을 잡는지 추적한다.lockAtMostFor를 잡 실행 시간보다 짧게 일부러 설정해 두 인스턴스가 겹쳐 실행되는 상황을 재현하고, 값을 늘려 해소되는지 확인한다.