Spring의 @Scheduled는 단일 인스턴스 환경에서는 아무런 문제가 없다. 하지만 ECS Fargate처럼 여러 인스턴스가 동시에 뜰 수 있는 환경에서는 모든 인스턴스가 같은 시각에 스케줄러를 실행한다.
예를 들어 매일 자정에 목표 상태를 업데이트하는 스케줄러가 있을 때, 인스턴스가 2개라면 동일한 쿼리가 두 번 실행된다. 단순 UPDATE나 DELETE라면 멱등성이 보장되어 결과는 같겠지만, 알림 발송처럼 외부 시스템을 호출하는 경우에는 중복 전송이 발생한다.
ShedLock은 분산 환경에서 스케줄러가 정확히 한 번만 실행되도록 보장하는 Java/Kotlin 라이브러리이다. 핵심 동작 원리는 간단하다.
스케줄러가 실행되기 전에 공유 저장소(Redis, DB 등)에 락을 획득하려 시도한다. 락 획득에 성공한 인스턴스만 작업을 수행하고, 나머지 인스턴스는 락 획득에 실패하여 해당 실행을 건너뛴다. 작업이 완료되면 락을 해제한다.
인스턴스 A ──── 락 획득 성공 ──── 스케줄러 실행 ──── 락 해제
인스턴스 B ──── 락 획득 실패 ──── 실행 건너뜀
ShedLock은 핵심 모듈과 저장소 프로바이더를 분리하여 제공한다. 이 프로젝트는 Redis를 이미 사용하고 있으므로 Redis 프로바이더를 선택한다.
스케줄러 코드가 있는 application 모듈에는 어노테이션을 사용하기 위한 핵심 모듈을, Redis 연결 설정이 있는 infrastructure 모듈에는 Redis 프로바이더를 추가한다.
// application/build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.0.2'
// infrastructure/build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:6.0.2'
LockProvider는 ShedLock이 락을 저장할 저장소를 지정하는 빈이다. @EnableSchedulerLock을 함께 선언하여 ShedLock을 활성화한다.
defaultLockAtMostFor는 작업이 비정상 종료되어 락 해제가 되지 않을 때를 대비한 최대 락 보유 시간이다. 이 시간이 지나면 ShedLock이 자동으로 락을 해제하여 다음 인스턴스가 실행할 수 있게 된다.
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
@Configuration
class ShedLockConfig {
@Bean
fun lockProvider(redisConnectionFactory: RedisConnectionFactory): LockProvider {
return RedisLockProvider(redisConnectionFactory)
}
}
@SchedulerLock의 name은 락의 식별자로, 반드시 고유해야 한다. 같은 이름을 가진 락이 이미 존재하면 해당 스케줄러는 실행되지 않는다.
@Scheduled(cron = "0 0 0 * * *")
@SchedulerLock(name = "goalSchedulerProcessDaily", lockAtMostFor = "10m")
@Transactional
fun processDaily() {
// 단 하나의 인스턴스만 실행됨
}
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "deleteOldNotifications", lockAtMostFor = "10m")
@Transactional
fun deleteOldNotifications() {
val threshold = LocalDateTime.now().minusDays(RETENTION_DAYS)
val deleted = notificationRepository.deleteCreatedBefore(threshold)
logger.info { "오래된 알림 삭제 완료: ${deleted}건" }
}
ShedLock에는 두 가지 시간 설정이 있다.
lockAtMostFor: 락을 보유할 수 있는 최대 시간이다. 인스턴스가 작업 도중 크래시되어 락을 해제하지 못한 경우, 이 시간이 지나면 락이 자동으로 만료된다. 작업 실행 시간보다 충분히 길게 설정해야 한다.
lockAtLeastFor: 락을 보유할 최소 시간이다. 작업이 매우 빠르게 끝나더라도 이 시간 동안은 락을 유지한다. 클럭 동기화 문제로 인해 여러 인스턴스가 거의 동시에 실행되는 경우를 방지한다. 보통 짧은 작업에는 몇 초 정도로 설정한다.
이 프로젝트의 스케줄러는 하루에 한 번 실행되므로 lockAtLeastFor 없이 lockAtMostFor만으로 충분하다.
중복 실행 방지: ECS 오토스케일링으로 인스턴스가 2개로 늘어나거나, 배포 중 Blue/Green 전환 시점에 두 인스턴스가 겹치더라도 스케줄러는 단 한 번만 실행된다.
안전한 장애 복구: lockAtMostFor로 인해 인스턴스가 비정상 종료되어도 지정한 시간 이후에는 다른 인스턴스가 다음 스케줄에 정상적으로 실행된다. 락이 영구적으로 남아 스케줄러가 영원히 실행되지 않는 상황을 방지한다.
인프라 추가 없이 적용: 이미 사용 중인 Redis를 그대로 락 저장소로 활용하므로 별도의 인프라 변경 없이 분산 락을 구현할 수 있다.