• ShedLock: 분산 환경에서 @Scheduled 작업이 동시에 두 번 이상 실행되지 않도록 보장하는 라이브러리. net.javacrumbs.shedlock 패키지에서 제공한다.
    • 핵심 애노테이션이 @SchedulerLock이라, 흔히 SchedulerLock으로도 불린다.
    • 스케줄러 자체를 대체하지 않는다. 스프링의 @Scheduled는 그대로 두고, 그 위에 “한 번만 실행” 제약만 얹는 동시성 제어 도구다.

왜 필요한가

  • 무상태 애플리케이션을 여러 인스턴스로 띄우면, 각 인스턴스가 자기 스케줄러를 갖는다.
  • @Scheduled(cron = "0 0 * * * *") 하나만 붙여두면 인스턴스가 3대일 때 매시 정각에 같은 작업이 3번 실행된다.
    • 결제 정산, 배치 집계, 알림 발송 같은 작업이 중복 실행되면 데이터가 어긋난다.
  • 스케줄러를 한 인스턴스에만 켜는 방법도 있지만, 그 인스턴스가 죽으면 작업이 아예 멈춘다. ShedLock은 모든 인스턴스에 스케줄러를 켜두되 실행 권한만 한 번에 하나로 제한한다.

동작 원리

  • 작업이 트리거되면 외부 저장소(DB·Redis 등)에 name을 키로 락 레코드를 쓰려고 시도한다.
    • 락 획득 성공 → 작업을 실행한다.
    • 락 획득 실패 → 기다리지 않고 그냥 건너뛴다(skip). 이미 다른 인스턴스가 실행 중이라는 뜻이므로 대기할 이유가 없다.
  • 작업이 끝나면 락을 해제한다.
  • 비차단(non-blocking) 방식이다. 같은 락으로 보호되는 다른 작업은 블로킹되지 않고 단순히 건너뛰어진다.
  • 모든 노드의 시계가 동기화되어 있어야 한다. 락 만료 판단이 시각 비교에 기반하기 때문이다.

사용 방법

의존성

implementation("net.javacrumbs.shedlock:shedlock-spring:7.7.0")
// LockProvider 별로 추가 의존성이 필요하다 (예: shedlock-provider-redis-spring)

활성화

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
class SchedulerConfig {

    @Bean
    fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider =
        RedisLockProvider(connectionFactory, "nomat")
}
  • @EnableScheduling(스프링 기본)과 @EnableSchedulerLock(ShedLock)을 함께 붙인다.
  • LockProvider 빈을 하나 등록해 어떤 저장소로 락을 잡을지 지정한다.

작업에 락 걸기

@Scheduled(cron = "0 */15 * * * *")
@SchedulerLock(name = "settleDaily", lockAtMostFor = "14m", lockAtLeastFor = "1m")
fun settleDaily() {
    LockAssert.assertLocked() // 락을 잡은 상태로 실행 중인지 방어적으로 확인
    // ...
}
  • name은 락을 식별하는 고유 키다. 서로 다른 작업이면 반드시 다른 이름을 써야 한다.
  • LockAssert.assertLocked()는 이 메서드가 정말 락을 획득한 채 실행되는지 검증한다. 설정 누락으로 락 없이 실행되는 실수를 조기에 잡아준다.

lockAtMostFor 와 lockAtLeastFor

  • lockAtMostFor: 락을 잡은 노드가 죽거나 응답이 없어도 이 시간이 지나면 락이 강제로 풀린다.
    • 안전장치다. 이 값이 없으면 노드가 죽었을 때 락이 영원히 안 풀려 작업이 멈춘다.
    • 실제 실행 시간보다 훨씬 길게 잡아야 한다. 작업이 아직 도는 중인데 락이 풀리면 다른 노드가 끼어들어 중복 실행된다.
  • lockAtLeastFor: 작업이 아무리 빨리 끝나도 최소 이 시간 동안은 락을 유지한다.
    • 작업이 몇 밀리초 만에 끝나는 아주 짧은 경우를 위한 장치다. 노드 간 시계 오차 때문에 여러 노드가 거의 동시에 락을 잡아 중복 실행되는 상황을 막는다.
  • 표기는 "10m", "5s" 같은 단순 문자열이나 ISO-8601("PT10M", "PT5S") 둘 다 된다.

LockProvider 종류

  • 락 상태를 저장할 백엔드를 고르는 부분이다. 20종 이상 지원한다.

  • SQL(JdbcTemplateLockProvider): shedlock 테이블에 락을 기록한다. 아래 스키마가 필요하다.

    CREATE TABLE shedlock(
        name VARCHAR(64) NOT NULL PRIMARY KEY,
        lock_until TIMESTAMP NOT NULL,
        locked_at TIMESTAMP NOT NULL,
        locked_by VARCHAR(255) NOT NULL
    );
    
    • .usingDbTime()을 켜면 애플리케이션 서버 시계 대신 DB 서버 시계를 기준으로 만료를 판단한다. 노드 간 시계 오차 문제를 피할 수 있어 권장된다.
  • Redis(RedisLockProvider): RedisConnectionFactory만 있으면 된다. 이미 Redis를 쓰는 프로젝트라면 별도 테이블 없이 가장 가볍게 붙는다.

  • 그 외 MongoDB, Hazelcast, ZooKeeper, DynamoDB, Elasticsearch, Cassandra 등.

주의사항

  • @SchedulerLock이 붙은 메서드는 public이어야 하고 final이면 안 된다. AOP 프록시로 동작하기 때문이다.
  • lockAtMostFor를 실제 실행 시간보다 넉넉히 크게 잡는다. 짧게 잡으면 락이 조기 해제돼 중복 실행이 발생한다.
  • 애노테이션이 붙은 메서드는 스케줄러가 부르든 코드에서 직접 부르든 락이 걸린다.
  • 자바의 Lock이나 분산 락 자체 개념은 Redis Lock 사용하기 참고. ShedLock은 그 분산 락을 “스케줄러 중복 실행 방지"라는 목적에 특화해 애노테이션으로 감싼 것으로 이해하면 된다.

참고 자료