ShedLock 분산 스케줄링 완전 가이드
TL;DR
- 참고 자료 ShedLock은 클러스터 환경에서 Spring @Scheduled 작업의 중복 실행을 방지하는 Java 분산 락 라이브러리다.
- 원문 전체는 아래 상세 내용에 그대로 포함했다.
1. 개념
참고 자료 ShedLock은 클러스터 환경에서 Spring @Scheduled 작업의 중복 실행을 방지하는 Java 분산 락 라이브러리다.
2. 배경
3. 이유
4. 특징
5. 상세 내용
ShedLock 분산 스케줄링 완전 가이드
목차
- 개요
- 용어 사전
- 등장 배경과 이유
- 역사적 기원과 진화
- 학술적/이론적 배경
- 내부 아키텍처와 동작 원리
- 대안 비교
- 상황별 최적 선택
- 설계 원칙과 베스트 프랙티스
- 안티패턴과 함정
- 빅테크 실전 사례
- 참고 자료
1. 개요
1.1 ShedLock이란 무엇인가
ShedLock은 클러스터 환경에서 Spring @Scheduled 작업의 중복 실행을 방지하는 Java 분산 락 라이브러리다. 외부 저장소(DB, Redis, MongoDB 등)에 락 레코드를 기록하여, 동일 시점에 하나의 인스턴스만 특정 작업을 실행하도록 보장한다.
┌──────────────────────────────────────────────────────────────┐
│ ShedLock 핵심 동작 │
├──────────────────────────────────────────────────────────────┤
│ │
│ [Instance A] ──@Scheduled──► lock("myTask") ──► ✅ 실행 │
│ [Instance B] ──@Scheduled──► lock("myTask") ──► ❌ Skip │
│ [Instance C] ──@Scheduled──► lock("myTask") ──► ❌ Skip │
│ │
│ ┌─────────────┐ │
│ │ shedlock │ │
│ │ (DB Table) │ │
│ │ │ │
│ │ name: myTask│ │
│ │ lock_until │ │
│ │ locked_at │ │
│ │ locked_by │ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
1.2 ShedLock이 아닌 것
ShedLock 공식 README에서 직접 명시하는 제약:
“ShedLock is not and will never be a full-fledged scheduler, it’s just a lock.”
- 분산 스케줄러가 아니다: 작업 큐잉, 재시도, 상태 추적 기능이 없다
- 리더 선출이 아니다: 락을 획득한 노드를 다른 노드가 인지하지 못한다
- Exactly-once 보장이 아니다: “at most once per scheduling window” 보장이다
1.3 ShedLock의 적용 스펙트럼
┌─────────────────────────────────────────────────────────────────────┐
│ 분산 스케줄링 복잡도 스펙트럼 │
├──────────┬──────────────┬───────────────┬──────────────────────────┤
│ 단순 │ 중간 │ 복잡 │ 엔터프라이즈 │
├──────────┼──────────────┼───────────────┼──────────────────────────┤
│ShedLock │ db-scheduler │ Quartz Cluster│ Temporal/Cadence │
│ │ JobRunr │ │ Airflow │
├──────────┼──────────────┼───────────────┼──────────────────────────┤
│중복 방지 │ 작업 큐잉 │ Job/Trigger │ Workflow State Machine │
│락만 제공 │ 재시도 │ Misfire 정책 │ Saga, 보상 트랜잭션 │
│설정 최소 │ 대시보드 │ Calendar │ 이벤트 소싱 기반 내구성 │
├──────────┼──────────────┼───────────────┼──────────────────────────┤
│ 테이블 1 │ 테이블 1~2 │ 테이블 11 │ 전용 인프라 클러스터 │
└──────────┴──────────────┴───────────────┴──────────────────────────┘
2. 용어 사전
2.1 ShedLock 핵심 용어
| 용어 | 정의 |
|---|---|
| ShedLock | Scheduled + Lock의 혼성어(portmanteau). “Sched”에서 “c”를 탈락시켜 “Shed”로 축약한 것. 작성자 Lukáš Křečan이 공식 유래를 문서화하진 않았으나, 패키지명 net.javacrumbs.shedlock과 어노테이션 @SchedulerLock에서 의도가 명확하다. |
| LockProvider | 분산 락의 획득(acquire)과 해제(release)를 담당하는 핵심 인터페이스. 각 백엔드(JDBC, Redis, MongoDB 등)별 구현체가 존재한다. 단일 메서드 Optional<SimpleLock> lock(LockConfiguration) 만 정의한다. |
| SimpleLock | 획득된 락을 표현하는 인터페이스. unlock()과 extend() 두 메서드를 제공한다. 일단 해제(unlock)된 후에는 재사용할 수 없다. |
| LockConfiguration | 락의 이름, lockAtMostFor, lockAtLeastFor 등 동작 파라미터를 담는 값 객체(value object). 생성자에서 lockAtLeastFor <= lockAtMostFor을 검증한다. |
| lockAtMostFor | 락 최대 보유 시간. 노드가 crash해도 이 시간이 지나면 자동으로 락이 만료된다. Deadlock safety net으로, 예상 최대 실행 시간보다 충분히 길게 설정해야 한다. |
| lockAtLeastFor | 락 최소 보유 시간. 작업이 아무리 빨리 끝나도 이 시간 동안은 다른 노드의 실행을 차단한다. Clock skew로 인한 중복 실행 방지가 주 목적이다. |
| LockAssert | 락이 올바르게 걸려 있는지 런타임에 검증하는 유틸리티. LockAssert.assertLocked()를 메서드 내부에서 호출하면 AOP 미설정 시 즉시 IllegalStateException을 던진다. |
| KeepAliveLock | 장시간 실행 작업을 위한 래퍼. lockAtMostFor 간격의 중간 시점마다 락 TTL을 자동 연장한다. 최소 30초 이상의 lockAtMostFor 필요. |
| @SchedulerLock | @Scheduled 메서드에 함께 붙이는 어노테이션. 락 이름, lockAtMostFor, lockAtLeastFor를 선언한다. |
| @EnableSchedulerLock | Spring 설정 클래스에 붙여 ShedLock을 활성화하는 어노테이션. defaultLockAtMostFor 등 전역 기본값을 설정한다. |
2.2 관련 개념 용어
| 용어 | 정의 | ShedLock과의 관계 |
|---|---|---|
| Distributed Lock | 여러 노드가 공유 자원에 동시 접근하지 못하도록 하나의 노드만 잠금을 획득하는 메커니즘 | ShedLock은 이 개념을 scheduled task에 특화하여 구현 |
| Leader Election | 분산 시스템에서 노드들 중 하나를 “리더”로 선출하고 모든 노드가 리더를 인식하는 메커니즘 | ShedLock은 Leader Election이 아니다. 락 획득 노드를 다른 노드가 인지하지 못한다 |
| Fencing Token | 락 획득 시마다 단조 증가하는 숫자로, 스토리지 시스템이 구식 요청을 거부하는 메커니즘 | ShedLock은 fencing token을 지원하지 않는다 (의도적 설계 결정) |
| Advisory Lock | DB 엔진이 직접 관리하는 애플리케이션 정의 락 (예: PostgreSQL pg_advisory_lock) |
ShedLock은 advisory lock 대신 테이블 row의 UPDATE 경쟁으로 락을 구현 |
| Clock Skew | 분산 환경에서 노드 간 시계의 차이 | ShedLock의 시간 기반 만료 방식에 직접적 영향을 미치는 핵심 변수 |
3. 등장 배경과 이유
3.1 Spring @Scheduled의 클러스터 문제
Spring의 @Scheduled는 단일 JVM에서는 완벽하게 작동하지만, 클러스터 인식(cluster-aware)이 아니다. 각 JVM 프로세스가 독립적으로 스케줄을 실행하므로, 3개 인스턴스가 실행 중이면 동일 작업이 3번 동시에 실행된다.
┌──────────────────────────────────────────────────────────┐
│ @Scheduled의 클러스터 환경 문제 │
├──────────────────────────────────────────────────────────┤
│ │
│ 매 5분마다 실행되는 sendReport() 메서드 │
│ │
│ [Pod-1] 10:00:00 → sendReport() 실행 → 이메일 발송 ✉️ │
│ [Pod-2] 10:00:00 → sendReport() 실행 → 이메일 발송 ✉️ │
│ [Pod-3] 10:00:00 → sendReport() 실행 → 이메일 발송 ✉️ │
│ │
│ 결과: 고객이 동일 리포트를 3통 수신 😱 │
│ │
└──────────────────────────────────────────────────────────┘
Kubernetes 환경이 일반화되면서 이 문제가 훨씬 빈번해졌다.
3.2 ShedLock 이전의 해결 방식
1) Quartz Scheduler Cluster Mode
Java 분산 스케줄링의 사실상 표준(de facto standard)이었다.
- 11개 DB 테이블 생성 필요 (
QRTZ_JOB_DETAILS,QRTZ_TRIGGERS,QRTZ_LOCKS등) - 기존
@Scheduled방식과 완전히 다른 프로그래밍 모델 (Job,Trigger,SchedulerAPI) - 노드 간 시계 동기화 필수 (1초 이내)
- 마지막 stable release: 2019년 10월 (사실상 유지보수 중단)
2) DB Flag 수동 구현
별도 DB 테이블에 “현재 실행 중” 플래그를 두고 작업 전에 체크하는 방식.
- Race condition 처리가 어려움
- 작업 실패 시 플래그가 영구 잠김
- 각 팀이 자체 구현 → 표준화 부재
3) 단일 인스턴스 강제
“스케줄러용 인스턴스는 1개만 실행”하는 운영 정책.
- 확장성 없음
- 해당 인스턴스 장애 시 스케줄 작업 전체 중단
3.3 ShedLock이 해결한 것
| 문제 | Quartz Cluster | DB Flag 수동 구현 | ShedLock |
|---|---|---|---|
| 설정 복잡도 | 11개 테이블 + 전용 API | 직접 구현 부담 | 1개 테이블 + 2개 어노테이션 |
| 기존 코드 변경 | 전면 교체 | 중간 | @SchedulerLock 추가만 |
| 장애 시 락 해제 | Heartbeat 기반 | 수동 해제 필요 | TTL 자동 만료 |
| 학습 곡선 | 높음 | 중간 | 매우 낮음 |
| 백엔드 선택지 | JDBC만 | JDBC | JDBC, Redis, Mongo, DynamoDB, ZooKeeper 등 |
4. 역사적 기원과 진화
4.1 기원
- 작성자: Lukáš Křečan (체코 개발자, GitHub: lukas-krecan)
- 시작: 2017년경, Spring @Scheduled의 클러스터 환경 문제 해결을 위해 시작
- 설계 철학: 최소한의 설정으로 기존 @Scheduled 코드에 분산 락을 덧씌우는 비침습적(non-invasive) 접근
- 패키지:
net.javacrumbs.shedlock(javacrumbs.net은 Křečan의 개인 도메인)
4.2 진화 타임라인
┌─────────────────────────────────────────────────────────────────┐
│ ShedLock 진화 타임라인 │
├─────┬───────────────────────────────────────────────────────────┤
│ │ │
│2017 │ ● 0.x 시리즈 시작 │
│ │ 초기 LockProvider: JDBC, MongoDB │
│ │ LockProvider 인터페이스 중심 플러그인 아키텍처 확립 │
│ │ │
│2018 │ ● Redis, Hazelcast, ZooKeeper LockProvider 추가 │
│ │ Spring Boot 2.x 통합 안정화 │
│ │ │
│2019 │ ● 1.x → 2.x → 3.x 빠른 메이저 버전 진행 │
│ │ Couchbase, Elasticsearch LockProvider 추가 │
│ │ DynamoDB, Cassandra 지원 │
│ │ │
│2020 │ ● 4.x 시리즈 │
│ │ Java 8+ 최소 요구사항 확립 │
│ │ usingDbTime() 도입 — clock skew 문제 해결의 전환점 │
│ │ │
│2021 │ ● LockAssert 강화 │
│ ~ │ KeepAliveLock 도입 (장시간 작업 지원) │
│2022 │ Micronaut, Quarkus 통합 추가 │
│ │ │
│2023 │ ● 5.x 시리즈 │
│ │ Spring Boot 3.x / Jakarta EE 대응 │
│ │ R2DBC (Reactive) LockProvider 실험적 추가 │
│ │ jOOQ LockProvider 추가 │
│ │ │
│2024 │ ● 6.x 시리즈 │
│ ~ │ OpenTelemetry 통합 │
│2026 │ LockExtender 자동 연장 메커니즘 강화 │
│ │ 현재 버전: 7.7.0 │
│ │ │
└─────┴───────────────────────────────────────────────────────────┘
4.3 LockProvider 지원 히스토리
| LockProvider | 도입 시기 | 비고 |
|---|---|---|
| JDBC (JdbcTemplate) | 초기 (~2017) | 가장 널리 사용 |
| MongoDB | 초기 (~2017) | |
| Redis (Jedis/Lettuce) | 2018 | |
| Hazelcast | 2018 | |
| ZooKeeper (Curator) | 2018 | |
| Couchbase | 2019 | |
| Elasticsearch | 2019 | |
| DynamoDB (AWS SDK v1 → v2) | 2019 | |
| Cassandra | 2019 | |
| Micronaut Data JDBC | 2021 | |
| R2DBC | 2023 | 실험적 |
| jOOQ | 2023 | REQUIRES_NEW 미지원 주의 |
| OpenSearch | 2023 |
5. 학술적/이론적 배경
5.1 Lamport의 분산 시스템 이론
Leslie Lamport의 1978년 논문 “Time, Clocks, and the Ordering of Events in a Distributed System”은 분산 락의 이론적 토대다.
- 분산 시스템에서 이벤트 순서는 partial ordering만 정의 가능
- Logical Clock: 각 프로세스가 정수형 카운터를 유지하며 메시지 전송/수신 시 동기화
- 이 개념으로 mutual exclusion 알고리즘을 분산 방식으로 구현 가능함을 증명
ShedLock과의 관계: ShedLock은 Lamport의 논리 시계를 직접 구현하지 않고, 대신 물리적 클럭 동기화(NTP) 또는 DB 서버 시간에 의존하는 시간 기반 만료(time-based expiry) 방식을 채택했다. 이것이 ShedLock의 가장 근본적인 설계 가정이자 한계점이다.
5.2 CAP Theorem과 ShedLock
┌─────────────────────────────────────────────────────────┐
│ CAP Theorem과 ShedLock의 위치 │
├─────────────────────────────────────────────────────────┤
│ │
│ Consistency │
│ ▲ │
│ / \ │
│ / \ │
│ / CP \ ← ZooKeeper, etcd 기반 락 │
│ / \ (파티션 시 락 획득 거부) │
│ / \ │
│ / ShedLock \ ← LockProvider에 따라 │
│ / (JDBC=CP) \ CP/AP 경계에서 이동 │
│ / (Redis=AP) \ │
│ / \ │
│ ──────────────────── │
│ Availability Partition │
│ Tolerance │
└─────────────────────────────────────────────────────────┘
ShedLock은 CP/AP 선택을 LockProvider에 위임한다:
- JDBC 기반: RDBMS 트랜잭션 보장에 의존 (CP 성향)
- Redis 기반: 속도 우선, 파티션 시 동시성 위반 가능 (AP 성향)
5.3 Kleppmann의 분산 락 분석
Martin Kleppmann의 2016년 글 “How to do distributed locking”은 분산 락의 두 가지 목적을 구분한다:
1) 효율성 목적 (Efficiency)
- 같은 작업의 중복 수행 방지
- 락 실패 시 결과: 불필요한 비용 발생 (이메일 2번 발송 등)
- Redis 단순 구현도 수용 가능
2) 정확성 목적 (Correctness)
- 데이터 손상 방지
- 락 실패 시 결과: 데이터 정합성 깨짐
- Fencing token 필수
ShedLock의 위치: Kleppmann 기준으로 ShedLock은 효율성 목적의 락에 속한다. Fencing token을 지원하지 않으므로, 데이터 정합성이 critical한 시나리오에는 추가적인 안전장치가 필요하다.
5.4 Advisory Lock vs Application-level Lock
| 구분 | Advisory Lock (DB 네이티브) | Application-level Lock (ShedLock) |
|---|---|---|
| 구현 주체 | DB 엔진 (pg_advisory_lock) |
애플리케이션 코드 (테이블 row UPDATE) |
| 락 범위 | 세션 또는 트랜잭션 | 시간 기반 (lock_until) |
| DB 의존성 | PostgreSQL 등 특정 DB | 범용 (어떤 RDBMS든 가능) |
| 이식성 | 낮음 | 높음 |
| I/O | 적음 (인메모리 관리) | 상대적으로 많음 (테이블 UPDATE) |
ShedLock이 advisory lock 대신 테이블 row 방식을 택한 이유: DB 엔진 종속성 없이 어떤 저장소든 동일한 인터페이스로 사용하기 위함이다.
6. 내부 아키텍처와 동작 원리
6.1 핵심 인터페이스 설계
┌─────────────────────────────────────────────────────────────┐
│ ShedLock 핵심 아키텍처 │
├─────────────────────────────────────────────────────────────┤
│ │
│ @SchedulerLock │
│ │ │
│ ▼ │
│ ScheduledLockConfiguration (AOP Proxy) │
│ │ │
│ ▼ │
│ DefaultLockingTaskExecutor │
│ │ │
│ ├── LockConfiguration (name, lockAtMost, lockAtLeast) │
│ │ │
│ ▼ │
│ LockProvider.lock(config) │
│ │ │
│ ├── Optional.empty() → Skip (락 획득 실패) │
│ │ │
│ └── SimpleLock → 작업 실행 → unlock() │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ LockProvider 구현체 │ │
│ ├──────────────────────────────────────┤ │
│ │ JdbcTemplateLockProvider (RDBMS) │ │
│ │ RedisLockProvider (Redis) │ │
│ │ MongoLockProvider (Mongo) │ │
│ │ DynamoDBLockProvider (AWS) │ │
│ │ ZookeeperCuratorLockProvider(ZK) │ │
│ │ ... │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
6.2 DefaultLockingTaskExecutor 실행 흐름
// 핵심 실행 흐름 (의사 코드)
public TaskResult executeWithLock(Runnable task, LockConfiguration config) {
// 1. 이미 locked 상태인지 확인
// 2. lockProvider.lock(config) 호출
Optional<SimpleLock> lock = lockProvider.lock(config);
if (lock.isEmpty()) {
// 3a. 락 획득 실패 → Skip (대기하지 않음, 즉시 반환)
return TaskResult.notExecuted();
}
try {
// 3b. 락 획득 성공
LockAssert.startLock(); // 현재 스레드에 락 등록
task.run(); // 실제 @Scheduled 메서드 실행
return TaskResult.executed();
} finally {
lock.get().unlock(); // 예외 발생해도 반드시 unlock
LockAssert.endLock(); // 스레드 락 해제
}
}
핵심 특성:
- 락 획득 실패 시 대기(wait)하지 않는다 — 즉시 skip
- checked exception은 RuntimeException으로 래핑
finally블록에서 반드시 unlock 수행
6.3 JDBC LockProvider 상세
테이블 스키마
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
| 컬럼 | 역할 |
|---|---|
name |
태스크 식별자 (유니크 락 키). PK로 동시 INSERT 방지 |
lock_until |
이 시각까지 락 유지. lock_until > now() = 락 활성 |
locked_at |
락 획득 시각. 감사/디버깅 용도 |
locked_by |
락 보유 인스턴스 식별자 (hostname 등) |
락 획득 알고리즘 (2-phase)
┌─────────────────────────────────────────────────────────────┐
│ JDBC 락 획득 알고리즘 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: INSERT 시도 │
│ ┌─────────────────────────────────────────────┐ │
│ │ INSERT INTO shedlock (name, lock_until, ...) │ │
│ │ VALUES ('myTask', now()+lockAtMost, ...) │ │
│ └───────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ 성공 (첫 실행) 실패 (PK 중복) │
│ │ │ │
│ 락 획득 완료 Phase 2: UPDATE 시도 │
│ │ │
│ ┌───────────────────────┴──────────────────────┐ │
│ │ UPDATE shedlock │ │
│ │ SET lock_until = now()+lockAtMost, ... │ │
│ │ WHERE name = 'myTask' │ │
│ │ AND lock_until <= now() ← 핵심 조건 │ │
│ └───────────────────────┬──────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ affected=1 affected=0 │
│ (만료된 락) (아직 유효한 락) │
│ │ │ │
│ 락 획득 성공 락 획득 실패 │
│ (Skip) │
└─────────────────────────────────────────────────────────────┘
DB별 SQL 차이
PostgreSQL (usingDbTime() 사용 시 — 단일 atomic 쿼리):
INSERT INTO shedlock(name, lock_until, locked_at, locked_by)
VALUES(?, timezone('utc', CURRENT_TIMESTAMP) + cast(? as interval),
timezone('utc', CURRENT_TIMESTAMP), ?)
ON CONFLICT (name) DO UPDATE
SET lock_until = timezone('utc', CURRENT_TIMESTAMP) + cast(? as interval),
locked_at = timezone('utc', CURRENT_TIMESTAMP),
locked_by = ?
WHERE shedlock.lock_until <= timezone('utc', CURRENT_TIMESTAMP)
MySQL/MariaDB:
-- Phase 1
INSERT IGNORE INTO shedlock(name, lock_until, locked_at, locked_by)
VALUES(?, ?, ?, ?)
-- Phase 2 (INSERT IGNORE 후 별도 UPDATE)
UPDATE shedlock
SET lock_until = ?, locked_at = ?, locked_by = ?
WHERE name = ? AND lock_until <= ?
MySQL의
INSERT ON DUPLICATE KEY UPDATE는 데드락 위험이 있어 사용하지 않고,INSERT IGNORE+ 별도UPDATE패턴을 채택했다.
Oracle:
MERGE INTO shedlock USING dual ON (name = ?)
WHEN NOT MATCHED THEN
INSERT (name, lock_until, locked_at, locked_by) VALUES (?, ?, ?, ?)
WHEN MATCHED THEN
UPDATE SET lock_until = ?, locked_at = ?, locked_by = ?
WHERE lock_until <= ?
SqlStatementsSource 아키텍처
SqlStatementsSource (interface)
├── getInsertStatement()
├── getUpdateStatement()
├── getExtendStatement()
└── getUnlockStatement()
구현체 자동 선택 (JDBC metadata에서 DB 제품명 감지):
├── PostgresSqlStatementsSource
├── MySqlServerTimeStatementsSource
├── OracleStatementsSource
└── GenericSqlStatementsSource (fallback)
6.4 Redis LockProvider 상세
Redis LockProvider는 SET NX EX 패턴을 사용한다:
SET shedlock:myTask <value> NX EX <lockAtMostFor_seconds>
NX: 키가 존재하지 않을 때만 설정 (atomic lock 획득)EX: 만료 시간 설정 (TTL 기반 자동 해제)
Redis Cluster 주의사항: Redis Master 장애 후 Replica 승격 사이에 락이 유실될 수 있다. 이는 Redis의 비동기 복제 특성 때문이며, Kleppmann이 지적한 Redlock의 근본 문제와 동일하다.
6.5 트랜잭션과 ShedLock
JDBC LockProvider는 REQUIRES_NEW 트랜잭션 전파를 사용한다:
[Lock 획득 트랜잭션 — COMMIT]
↓
[비즈니스 로직 트랜잭션]
↓
[Lock 해제 트랜잭션 — COMMIT]
락 획득/해제는 비즈니스 트랜잭션과 독립적이다. 비즈니스 트랜잭션이 롤백되더라도 락은 정상 해제된다.
⚠️ jOOQ 예외: jOOQ LockProvider는
REQUIRES_NEW를 지원하지 않는다. ShedLock DB 작업이 외부(enclosing) 트랜잭션에 포함될 수 있으므로 주의가 필요하다.
7. 대안 비교
7.1 근본적 아키텍처 차이: Lock 방식 vs Task Scheduling 방식
| 구분 | Lock 방식 (ShedLock 계열) | Task Scheduling 방식 (Quartz, JobRunr 계열) |
|---|---|---|
| 철학 | “누가 실행하든, 중복 실행만 막자” | “어떤 인스턴스가 이 작업을 실행할지를 시스템이 결정” |
| Trigger 관리 | 외부 스케줄러(@Scheduled)에 위임 |
라이브러리 내부가 스케줄 전체 관리 |
| Missed execution | 보장 없음 (skipped) | 영속성 기반 재실행 보장 가능 |
| Job 상태 추적 | 없음 | 있음 (SUCCESS, FAILED, ENQUEUED 등) |
| 복잡도 | 낮음 | 높음 |
7.2 솔루션별 비교
Quartz Scheduler (Cluster Mode)
[Node A: Quartz] ─┐
[Node B: Quartz] ─┼──► [Shared RDBMS (11 tables)] ◄── QRTZ_LOCKS (SELECT FOR UPDATE)
[Node C: Quartz] ─┘
- 2001년부터의 성숙한 생태계
- 11개 DB 테이블, Job/Trigger 분리 모델
- 마지막 stable release: 2019년 10월 (사실상 유지보수 중단)
- Misfire 처리 정책 다양하지만 설정이 복잡
db-scheduler (by Gustav Karlsson)
- ShedLock과 유사한 경량 접근, 하지만 완전한 스케줄러
- 테이블 1개, 폴링 기반
- Missed execution 재실행 지원
- 작업 상태 추적 및 재시도 가능
- ShedLock이 “Lock만 제공”이라면, db-scheduler는 “Lock + Scheduler”
JobRunr
- 대시보드 UI 제공 (실시간 job 모니터링)
- Java 8 lambda 기반 API
- Background job, scheduled job, recurring job 모두 지원
- 무료 OSS + Pro 버전 (고급 기능)
Spring Integration LockRegistry
- Spring 생태계의 범용 분산 락 추상화
- JdbcLockRegistry, RedisLockRegistry, ZookeeperLockRegistry 등
@Scheduled와 독립적으로 사용 가능- 어노테이션 기반 자동화 없음 — 직접 lock/unlock 코드 작성 필요
Redisson Distributed Lock
- Redis 위의
java.util.concurrent.locks.Lock완전 구현 - Watchdog 메커니즘: 30초마다 TTL 자동 갱신 (crash 시 자동 해제)
- RLock, RFairLock, RReadWriteLock, RSemaphore 등 다양한 락 유형
- 스케줄링 전용이 아닌 범용 분산 락
Temporal/Cadence
- Workflow State Machine 기반 — 근본적으로 다른 패러다임
- Durable execution: 어떤 노드가 죽어도 다른 노드가 정확히 이어서 실행
- Fencing 불필요 (event sourcing 기반)
- 인프라 복잡도 매우 높음
7.3 종합 비교표
| 기준 | ShedLock | Quartz | db-scheduler | JobRunr | Temporal |
|---|---|---|---|---|---|
| 설정 복잡도 | ⭐ 매우 낮음 | ⭐⭐⭐⭐ 매우 높음 | ⭐⭐ 낮음 | ⭐⭐ 낮음 | ⭐⭐⭐⭐⭐ 매우 높음 |
| DB 테이블 수 | 1 | 11 | 1 | 1~3 | 전용 DB |
| 대시보드 UI | ❌ | ❌ | ❌ (별도 추가 가능) | ✅ 내장 | ✅ 내장 |
| Missed execution 재실행 | ❌ | ✅ | ✅ | ✅ | ✅ |
| 작업 상태 추적 | ❌ | ✅ | ✅ | ✅ | ✅ |
| 기존 @Scheduled 호환 | ✅ 그대로 유지 | ❌ 전면 교체 | ❌ 자체 API | ❌ 자체 API | ❌ 전면 교체 |
| 백엔드 선택지 | JDBC, Redis, Mongo 등 다수 | JDBC만 | JDBC | JDBC | 전용 서버 |
| 유지보수 상태 | ✅ 활발 | ⚠️ 정체 | ✅ 활발 | ✅ 활발 | ✅ 매우 활발 |
| GitHub Stars | ~4k | ~6k | ~1k | ~2k | ~12k |
8. 상황별 최적 선택
8.1 선택 플로우차트
┌─────────────────────────────────────────────────────────────┐
│ 분산 스케줄링 솔루션 선택 가이드 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 기존 @Scheduled 코드가 있는가? │
│ │ │
│ ┌──┴──┐ │
│ Yes No ──► 새로 설계한다면 db-scheduler 또는 JobRunr │
│ │ │
│ ▼ │
│ 작업 실패 시 재실행/재시도가 필요한가? │
│ │ │
│ ┌──┴──┐ │
│ Yes No ──► ShedLock ✅ │
│ │ (가장 가벼운 솔루션) │
│ ▼ │
│ 대시보드/모니터링 UI가 필요한가? │
│ │ │
│ ┌──┴──┐ │
│ Yes No ──► db-scheduler │
│ │ │
│ ▼ │
│ JobRunr (대시보드 내장) │
│ │
│ │
│ 복잡한 워크플로(Saga, 보상 트랜잭션)가 필요한가? │
│ │ │
│ ▼ │
│ Temporal / Cadence │
│ │
└─────────────────────────────────────────────────────────────┘
8.2 시나리오별 최적 선택
| 시나리오 | 최적 선택 | 이유 |
|---|---|---|
기존 @Scheduled 코드 + 중복 방지만 필요 |
ShedLock | 최소 변경으로 적용 가능 |
| 새 프로젝트 + 작업 상태 추적 필요 | db-scheduler | 경량이면서 완전한 스케줄러 |
| 비개발자도 모니터링해야 함 | JobRunr | 대시보드 UI 내장 |
| 마이크로서비스 간 오케스트레이션 | Temporal | Durable execution, Saga 패턴 |
| 레거시 시스템과의 호환 필요 | Quartz | 광범위한 통합 지원 (단, 유지보수 정체) |
| Redis만 있고 DB 없음 | ShedLock (Redis) 또는 Redisson | Redis 네이티브 지원 |
| 범용 분산 락 (스케줄 외 용도) | Redisson 또는 Spring LockRegistry | 스케줄링에 국한되지 않는 락 필요 |
8.3 ShedLock이 빛나는 시나리오
- 이미
@Scheduled로 구현된 코드가 있고, 인프라 변경 최소화를 원할 때 - 작업이 단순하고 멱등적(idempotent)이어서, 가끔 누락되어도 다음 주기에 실행하면 되는 경우
- 추가 인프라(별도 스케줄러 서버) 없이 기존 DB/Redis만으로 해결하고 싶을 때
8.4 ShedLock이 부족한 시나리오
- Missed execution이 절대 허용되지 않는 결제/정산 작업
- 작업 실패 시 자동 재시도와 백오프 전략이 필요한 경우
- 관리자 대시보드를 통한 작업 모니터링/수동 재실행이 필요한 경우
- 작업 간 의존성(DAG)이 존재하는 복잡한 파이프라인
9. 설계 원칙과 베스트 프랙티스
9.1 lockAtMostFor / lockAtLeastFor 설정 원칙
┌─────────────────────────────────────────────────────────────┐
│ lockAtMostFor / lockAtLeastFor 설정 원칙 │
├─────────────────────────────────────────────────────────────┤
│ │
│ [──────── cron 주기 (예: 15분) ─────────] │
│ [── lockAtLeastFor ──] │
│ [──────── lockAtMostFor ────────] │
│ │
│ 원칙: │
│ 1. lockAtLeastFor <= cron 주기 │
│ 2. lockAtLeastFor <= lockAtMostFor │
│ 3. lockAtMostFor >> 예상 최대 실행 시간 │
│ 4. lockAtMostFor < cron 주기 (다음 주기 실행 보장) │
│ │
└─────────────────────────────────────────────────────────────┘
실제 설정 예시 (15분 주기, 평균 실행 2분, 최대 5분):
@Scheduled(cron = "0 */15 * * * *")
@SchedulerLock(
name = "reportTask",
lockAtMostFor = "14m", // 안전망: 최대 실행시간(5분)보다 충분히 길게
lockAtLeastFor = "14m" // 한 주기 내 정확히 1번만 실행 보장
)
public void generateReport() {
LockAssert.assertLocked();
// 비즈니스 로직
}
9.2 LockProvider 설정
JDBC (권장 설정):
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // ⭐ 핵심: DB 서버 시간 사용 (clock skew 해결)
.build()
);
}
usingDbTime()은 반드시 사용해야 한다. 이것 없이는 각 애플리케이션 서버의 시계를 사용하게 되어 clock skew 문제에 노출된다.
9.3 LockAssert 활용
// 프로덕션 코드: AOP 미설정 감지
@Scheduled(cron = "...")
@SchedulerLock(name = "myTask")
public void myTask() {
LockAssert.assertLocked(); // AOP 프록시 미적용 시 즉시 실패
// 비즈니스 로직
}
// 단위 테스트: assert 비활성화
@Test
void shouldExecuteTask() {
LockAssert.TestHelper.makeAllAssertsPass(true);
scheduler.myTask();
// 검증
}
9.4 Clock Skew 대응
| 방법 | 설명 | 권장도 |
|---|---|---|
usingDbTime() |
모든 노드가 DB 서버 시간 기준 사용 | ⭐⭐⭐ 최우선 권장 |
NTP 동기화 + lockAtLeastFor 여유 |
서버 시계 동기화 + 안전 마진 | ⭐⭐ 보조 |
| Redis LockProvider | TTL 기반으로 clock skew 영향 적음 | ⭐⭐ 상황에 따라 |
9.5 Spring Boot 설정 체크리스트
@SpringBootApplication
@EnableScheduling // 1. 스케줄링 활성화
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S") // 2. ShedLock 활성화 + 기본 TTL
public class Application { }
# application.yml — SpEL로 외부화 가능
app:
lock:
default-most: "PT10M"
@SchedulerLock(
name = "myTask",
lockAtMostFor = "${app.lock.default-most}" // SpEL 지원
)
10. 안티패턴과 함정
10.1 lockAtMostFor를 너무 짧게 설정
// ❌ 위험: 작업이 5분 걸리는데 lockAtMostFor를 1분으로
@SchedulerLock(name = "heavyTask", lockAtMostFor = "1m")
public void heavyTask() {
// 실행 시간: 5분
}
1분 후 락이 자동 해제되어 다른 노드가 동일 작업을 시작한다. 두 노드에서 동시에 실행되는 결과를 낳는다.
실제로 GitHub issue #1968에서 MySQL 환경에서 이 문제가 보고되었다.
10.2 lockAtMostFor를 너무 길게 설정
// ❌ 주의: lockAtMostFor를 24시간으로 설정
@Scheduled(cron = "0 */5 * * * *") // 5분 주기
@SchedulerLock(name = "task", lockAtMostFor = "24h")
public void task() { ... }
노드가 crash하면 24시간 동안 해당 작업이 전혀 실행되지 않는다.
10.3 ShedLock을 Leader Election으로 오용
// ❌ 안티패턴: ShedLock으로 "이 인스턴스가 리더인지" 판단
if (tryAcquireShedLock("leaderElection")) {
// 리더로서의 지속적 작업 수행
}
ShedLock은 일시적인 작업 실행 권한을 제공할 뿐, 지속적인 리더십을 보장하지 않는다. Leader Election이 필요하면 ZooKeeper, etcd, 또는 Spring Integration의 LeaderInitiator를 사용해야 한다.
10.4 shedlock 테이블 row 직접 삭제
-- ❌ 절대 하지 마세요
DELETE FROM shedlock WHERE name = 'myTask';
ShedLock은 첫 실행 시 INSERT하고 이후 UPDATE만 한다. Row를 삭제하면 다음 실행 시 모든 노드가 동시에 INSERT를 시도하여 경쟁 상태가 발생할 수 있다.
10.5 usingDbTime() 미사용
// ❌ clock skew에 취약
new JdbcTemplateLockProvider(dataSource);
// ✅ DB 서버 시간 사용
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build();
usingDbTime() 없이는 각 애플리케이션 서버의 System.currentTimeMillis()를 사용하게 되어, 노드 간 시계 차이만큼 중복 실행 위험이 증가한다.
10.6 @SchedulerLock name 중복
// ❌ 두 메서드가 같은 lock name을 공유
@SchedulerLock(name = "task")
public void taskA() { ... }
@SchedulerLock(name = "task") // 같은 이름!
public void taskB() { ... }
두 작업이 상호 배타적으로 실행되어, taskB는 taskA가 끝날 때까지 항상 skip된다. 각 작업에는 고유한 name을 부여해야 한다.
10.7 @Scheduled 없이 @SchedulerLock만 사용
// ❌ @Scheduled 없으면 AOP 프록시가 인터셉트하지 않음
@SchedulerLock(name = "task")
public void task() {
LockAssert.assertLocked(); // 항상 실패!
}
@SchedulerLock은 Spring의 스케줄링 AOP 인프라를 통해 동작한다. @Scheduled 없이 직접 호출하면 락이 걸리지 않는다.
11. 빅테크 실전 사례
11.1 Google: Paxos 기반 분산 Cron
Google의 분산 Cron 서비스 (SRE Book에 문서화):
┌──────────────────────────────────────────────────────┐
│ Google 분산 Cron 아키텍처 │
├──────────────────────────────────────────────────────┤
│ │
│ [Cron Replica 1] ─┐ │
│ [Cron Replica 2] ─┼── Paxos Consensus ──► Leader │
│ [Cron Replica 3] ─┘ │
│ │
│ Leader만 Job Launch 가능 │
│ Double-launch 방지: 시작/완료 각각 Paxos quorum 확인 │
│ 장애 복구: ~1분 내 새 Leader 선출 │
│ Thundering Herd 방지: '?' 연산자로 launch 분산 │
│ │
└──────────────────────────────────────────────────────┘
vs ShedLock: Google은 Paxos 기반 강한 일관성을 보장하지만, ShedLock 대비 인프라 복잡도가 극도로 높다. Google 규모가 아니면 과잉 설계(over-engineering)다.
11.2 Netflix: Meson → Maestro
Netflix의 워크플로 플랫폼 진화:
- Maestro (현재): 70,000+ workflows, 하루 500,000 jobs
- “at-least-once triggering” + 중복 제거 = 실질적 exactly-once
- Stateless microservice + CockroachDB + 분산 큐
- 전임자 Meson의 “midnight UTC thundering herd” 문제를 해결
11.3 Uber: Cadence/Temporal
Uber의 접근법: 분산 락 대신 Workflow State Machine
// Temporal의 cron scheduling — 놀라울 정도로 단순한 API
WorkflowOptions options = WorkflowOptions.newBuilder()
.setCronSchedule("0 * * * *")
.build();
- 월 270억 actions 처리, 1,000+ 서비스 지원
- 1억+ 병렬 cron job 운용 가능
- Lock TTL 관리 불필요 — workflow state가 DB에 영구 저장
- ShedLock의 “lock 못 잡으면 skip”과 달리 “언젠가 반드시 실행”을 보장
11.4 LinkedIn: Azkaban
- 2009년부터 운영, 하루 25,000+ flows 실행
- Web Server (스케줄링, 인증) + Executor Server (실행) 분리
- ShedLock이 “단일 작업 중복 방지”에 집중하는 반면, Azkaban은 수천 개 job의 의존성 DAG 관리
11.5 Pinterest: Pinball → Spinner (Airflow 기반)
- Spinner (현재): 4,000+ workflows, 하루 10,000 flow/38,000 job 실행
- Apache Airflow 기반 + Multi-partition scheduler로 단일 스케줄러 병목 제거
- Custom Kubernetes executor: 각 task가 독립 pod에서 실행
11.6 분산 Lock 서비스 비교
| 서비스 | 조직 | Consensus | ShedLock 대비 |
|---|---|---|---|
| Chubby | Paxos | 강한 일관성, 인프라 의존 높음 | |
| ZooKeeper | Apache | ZAB (Paxos 변형) | Ephemeral node로 자동 해제, 전용 클러스터 필요 |
| etcd | CNCF | Raft | Lease 기반 TTL, Kubernetes 생태계 통합 |
| DynamoDB Lock Client | AWS | DynamoDB 내부 | Heartbeat 기반, AWS 종속 |
핵심 교훈: 빅테크는 ShedLock 같은 경량 솔루션 대신 전용 분산 시스템을 구축하지만, 이는 수만~수십만 규모의 job을 다루기 때문이다. 일반적인 Spring Boot 애플리케이션에서 ShedLock은 복잡도 대비 충분한 가치를 제공한다.
12. 참고 자료
공식 문서
학술/이론
- Lamport, L. (1978). “Time, Clocks, and the Ordering of Events in a Distributed System.” Communications of the ACM, 21(7), 558-565.
- Kleppmann, M. (2016). How to do distributed locking
- Gilbert, S. & Lynch, N. (2002). “Brewer’s conjecture and the feasibility of consistent, available, partition-tolerant web services.” ACM SIGACT News, 33(2), 51-59.
대안 솔루션
빅테크 사례
- Google SRE Book: Distributed Periodic Scheduling
- Netflix TechBlog: Maestro
- Uber Blog: Cadence
- Pinterest Engineering: Spinner
- LinkedIn Engineering: Azkaban