TL;DR

  • ShedLock은 클러스터에서 같은 스케줄 작업이 여러 인스턴스에서 중복 실행되는 문제를 막는 경량 분산 락 도구다.
  • 기존 Spring @Scheduled 코드에 @SchedulerLock을 덧붙이는 방식이라 도입 비용이 낮고, DB나 Redis 같은 기존 저장소를 활용할 수 있다.
  • 다만 완전한 스케줄러나 exactly-once 보장은 아니므로, 재시도와 상태 추적이 필요하면 더 무거운 대안을 검토해야 한다.

1. 개념

ShedLock은 여러 애플리케이션 인스턴스가 동시에 실행되는 환경에서 특정 스케줄 작업을 한 번만 실행되도록 제한하는 분산 락 라이브러리다. Spring의 @Scheduled와 함께 사용해 기존 코드 구조를 크게 바꾸지 않고 중복 실행을 방지한다.

2. 배경

클라우드와 Kubernetes 환경에서는 동일 서비스가 여러 인스턴스로 확장되는 것이 일반적이다. 이때 단순한 @Scheduled 작업은 각 인스턴스에서 모두 실행되어 중복 메일 발송, 배치 중복 처리, 데이터 충돌 같은 문제가 생기기 쉽다.

3. 이유

분산 환경의 스케줄 작업은 단순히 “주기적으로 실행한다”를 넘어서 “한 번만 실행되어야 한다”는 요구가 자주 붙는다. ShedLock은 기존 Spring 생태계를 유지하면서도 낮은 복잡도로 이 문제를 해결해, 운영 리스크를 줄이고 도입 장벽을 낮춘다.

4. 특징

ShedLock은 1개 락 테이블 또는 외부 저장소를 기반으로 동작하고, 설정이 비교적 단순하며, JDBC·Redis·MongoDB 등 다양한 LockProvider를 지원한다. 대신 작업 상태 추적, 재시도, 워크플로 관리 같은 기능은 제공하지 않아 경량 락 도구라는 성격이 분명하다.

5. 상세 내용

ShedLock 분산 스케줄링 완전 가이드

작성일: 2026-04-15 카테고리: Backend / Distributed Systems / Scheduling / Spring Boot 키워드: ShedLock, Distributed Lock, @Scheduled, @SchedulerLock, LockProvider, JDBC Lock, Redis Lock, Quartz, db-scheduler, JobRunr, Temporal, Clock Skew, lockAtMostFor, lockAtLeastFor


목차

  1. 개요
  2. 용어 사전
  3. 등장 배경과 이유
  4. 역사적 기원과 진화
  5. 학술적/이론적 배경
  6. 내부 아키텍처와 동작 원리
  7. 대안 비교
  8. 상황별 최적 선택
  9. 설계 원칙과 베스트 프랙티스
  10. 안티패턴과 함정
  11. 빅테크 실전 사례
  12. 참고 자료

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, Scheduler API)
  • 노드 간 시계 동기화 필수 (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 Google 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.

대안 솔루션

빅테크 사례

기술 심화