[SpringBoot] Scale-out 환경에서 발생하는 Scheduler 중복 실행 문제 Shedlock으로 해결하기

2025. 4. 21. 15:44SpringBoot

 

Scale-out 환경에서 스케줄러(Scheduler)를 이용해 특정 작업을 주기적으로 수행할 경우, 동일한 작업이 여러 인스턴스에서 동시에 실행되는 문제가 발생할 수 있습니다.

이번 포스팅에서는 이런 상황을 방지하기 위한 방법으로 ShedLock 라이브러리에 대해 알아보고 이용하여 스케줄러 중복 실행 문제를 해결해보겠습니다.

 

문제 상황

@Component
@Slf4j
public class Scheduler {
    @Scheduled(zone = "Asia/Seoul", fixedDelay = 1000) // 타임존 서울, 이전 작업이 끝난 후 1초마다 실행
    public void run1() {
        log.info("Scheduler 실행1 : {}", Thread.currentThread().getName());
    }
}

 

위와 같이 1초마다 작업을 수행하는 스케줄러를 작성한 후 scale-out 환경을 만들기 위해 2대의 스프링 서버를 띄워서 실행시켜 보았습니다.

2대의 서버에서 스케줄러가 모두 돌아가는 모습을 볼 수 있습니다.

만약 이메일 발송 스케줄러였다면 사용자는 같은 이메일을 2번 받는 상황이 발생합니다.

 

 

Shedlock 이란?

 

ShedLock은 분산 환경에서 스케줄러의 중복 실행을 방지하기 위한 라이브러리입니다. 다중 인스턴스 환경에서도 특정 작업이 한 번만 실행되도록 보장해주는 역할을 합니다.

 

 

Shedlock은 DB, Redis 등 공유 리소스를 이용해 락을 관리합니다.

지정한 시간 동안 다른 인스턴스는 스케줄러를 실행하지 못하도록 막습니다.

또한 락은 TTL(Time To Live) 설정을 통해 자동 만료되어, 스케줄러가 멈추는 것도 방지합니다.

 

Shedlock 구성 요소

Lock Provider 락을 저장할 위치 (예: DB, Redis 등)
Lock 이름 스케줄 작업을 식별하는 키
TTL (lockAtMostFor) 락이 최대 유지될 시간
Minimum Lock Duration (lockAtLeastFor) 락이 최소 유지되어야 하는 시간 (작업이 빨리 끝나도 유지됨)

 

구현

Shedlock을 사용하기 위해서 다음과 같이 의존성을 추가합니다.

implementation 'net.javacrumbs.shedlock:shedlock-spring:6.2.0'

 

 

Lock provider로 redis를 사용할 예정입니다.

따라서 다음과 같은 의존성을 추가합니다.

implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:6.2.0'

 

SchedulerConfig 파일을 다음과 같이 작성합니다.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT5M")
public class SchedulerConfig {
    @Bean
    public RedisLockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) {
        return new RedisLockProvider(redisConnectionFactory);
    }
}

 

ShedLock이 Redis를 통해 lock을 관리할 수 있도록 설정하는 Bean을 등록합니다.

 

RedisLockProvider Redis를 사용해 분산 락을 제공하는 클래스입니다.

redisConnectionFactory는 SpringBoot에서 Redis와의 연결을 관리하는 객체입니다. application.yml에서 설정된 Redis 정보를 사용하여 자동으로 연결 객체가 주입됩니다.

 

@EnableSchedulerLock는 ShedLock을 활성화하고, 기본 락 설정값을 지정할 수 있는 어노테이션입니다.

해당 어노테이션을 Config파일에 달아줍니다.

 

defaultLockAtMostFor = "PT5M"

  • PT5M은 ISO-8601 duration format이며, 5분을 의미합니다.
  • 즉, 락이 최대 5분 동안 유지됩니다.
  • 작업이 5분 안에 끝나거나 끝나지 않아도, 5분이 지나면 락이 자동으로 풀립니다.

서버가 다운되거나 예외가 발생했을 때도 락이 계속 걸려 있으면 다음 작업이 실행되지 못하기 때문에 락이 무한정 유지되지 않게게 반드시 lockAtMostFor를 설정해야 합니다.

개별 작업마다 다르게 지정하고 싶다면 @SchedulerLock에서 설정합니다.

 

@Component
@RequiredArgsConstructor
@Slf4j
public class SeatStatusScheduler {
    private final SeatRepository seatRepository;
    private final StringRedisTemplate redisTemplate;

    @Scheduled(fixedDelay = 300000) // 5분마다 실행
    @SchedulerLock(name = "SEATSTATUS-SCHEDULER",
            lockAtLeastFor = "PT1M", lockAtMostFor = "PT5M")
    public void cleanUpOccupiedSeats() {

        log.info("[SEAT CLEANUP] 예약된 좌석 상태 정리 작업 시작");

        List<Seat> occupiedSeats = seatRepository.findAllBySeatStatus(SeatStatus.OCCUPIED);

        log.info("[SEAT CLEANUP] 점유된 좌석 수: {}", occupiedSeats.size());

        List<Seat> updatedSeats = new ArrayList<>();

        for (Seat seat : occupiedSeats) {
            String redisSeatKey = "SEAT-HOLDING-LOCK:SEAT:" + seat.getId();

            Boolean exists = redisTemplate.hasKey(redisSeatKey);
            if (exists == null || !exists) {
                seat.updateSeatStatus(SeatStatus.AVAILABLE);
                updatedSeats.add(seat);
                log.info("[SEAT CLEANUP] Redis 키 없음 → 좌석 상태 AVAILABLE로 변경 (seatId: {})", seat.getId());
            }
        }

        seatRepository.saveAll(updatedSeats);

        log.info("[SEAT CLEANUP] 상태 변경된 좌석 수: {}", updatedSeats.size());
        log.info("[SEAT CLEANUP] 예약된 좌석 상태 정리 작업 종료");
    }
}

 

스케줄러 작업은 Shedlock을 적용하기 전과 같지만, 다음 어노테이션이 추가되었습니다.

@SchedulerLock(name = "SEATSTATUS-SCHEDULER",
            lockAtLeastFor = "PT1M", lockAtMostFor = "PT5M")

 

 

name 락을 구분하는 고유한 이름입니다. DB나 Redis 등에 저장될 때 이 name이 락의 키로 사용됩니다.
lockAtLeastFor 락을 최소한 유지할 시간입니다.
작업이 빨리 끝나도, 락은 최소 1분(PT1M) 동안 유지됩니다.
lockAtMostFor 락을 최대 유지할 수 있는 시간입니다.
만약 작업 중에 예외가 발생하거나 서버가 다운되더라도, 이 시간이 지나면 락이 자동 해제됩니다.

 

결과

 

동시에 서버2대를 띄운 환경에서 PicketApplication2 서버에선 스케줄러가 실행되지 않고  PicketApplication 서버에서만 스케줄러가 동작하는 것을 확인할 수 있습니다.

 

 

다음과 같이 스케줄러가 실행될 때, 지정해준 key로 Redis에 lock이 존재하는지 확인합니다.

lock이 없으면 현재 실행중인 서버가 lock을 획득하고 스케줄러를 실행합니다.

lock이 이미 존재하면 다른 서버에서는 스케줄러 실행이 되지 않습니다.

작업이 완료되면 lock이 해제되며, lockAtMostFor(최대 유지 시간)이 지나면 강제로 해제됩니다.