Spring/Spring Boot

[Spring Boot] 다중 인스턴스에서 스케줄링 중복 실행 제어 하기 (@Scheduled Lock - shed lock)

민돌v 2024. 10. 10. 21:08
다중 인스턴스 환경에서 스케줄링 중복 실행을 제어하는 @ShedLock 에 대해 정리하는 글입니다.
코드는 깃허브에 있습니다. 

Spring 에서는 정해진 시간마다 지정한 메소드가 실행되도록 스케줄링 기능을 지원하는 @Scheduled 어노테이션이 존재합니다.

이 스케줄링 기능을 Spring 에서 구현하였을 때, 단일 인스턴스 (1개 서버) 배포환경에서는 신경써줘야할게 없지만 다중 인스턴스 (n 개의 서버, scale-out) 배포 환경일 경우

n 개의 인스턴스 환경에 배포되어있는
n 개의 프로그램이 특정 시간에
n 번의 스케줄링을 체크하여
n 번의 기능을 수행합니다.

즉, 중복이 일어날 수 있습니다.

이러한 문제점을 해결하기 위해서는 아래와 같은 처리를 해주어야합니다.

  1. 1번만 작업을 해도 된다.
  2. 혹은 특정 스레드에서 내가 작업하고 있으니 건들지 말아라.

 

친절하고 다행이도 Spring Boot 에서는

다중 인스턴스 환경에서 스케줄러가 1번만 돌게 하기 위해 락을 거는 행위 "lukas krecan" 이라는 사람이 만들어둔 오픈소스인 @ShedLock 어노테이션으로 테이블 락을 이용해 "건들지 말아라" 를 구현할 수 있습니다.

 


ShedLock 이란

  • 예약된 작업이 동시에 1번만 실행되도록 합니다.
  • 작업이 시작될때 별도의 테이블에서 lock 을 획득합니다.
  • Jdbc 템플릿으로 구현되기 때문에 DB에 종속적이지 않습니다. 다만 shedlock 이라는 테이블이 필요합니다.
  • 다른 노드(스레드)에서는 한 작업이 이미 다른 노드에서 실행 중이라면(lock 점유) 다른 노드에서의 실행은 기다리지 않고 건너 뜁니다.
if one task is already being executed on one node, execution on other nodes does not wait, it is simply skipped.

 


💡 ShedLock 은 분산 스케줄러가 아닙니다.

  • ShedLock 은 단지 lock 을 이용할 뿐 동기화를 지원하지 않습니다.
  • 따라서 ShedLock 은 병렬로 실행할 준비가 되지 않았지만, 안전하고 반복적으로 실행할 수 있는 Scheduled 된 작업에서만 사용해야합니다.
  • 분산 스케줄러가 필요한 경우 db-sxheduler, JobRunr 등을 사용해야 합니다.

 

💡 또한 ShedLock 의 Lock은 시간기반이며, 노드들의 시간이 동기화된다고 가정합니다.

따라서 ShedLock 의 LockProvider 를 구성할 때
공식 라이브러리 가이드에서 usingDbTime을 활성화 하는 것을 강력하게 권장합니다.
이 옵션이 활성화 되지 않으면 서버의 시간을 기준으로 Lock의 시계가 동작하기 때문에 서버간 시계가 동기화되어 있지 않은 상황을 방지할 수 있습니다.
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;

...
@Bean
public LockProvider lockProvider(DataSource dataSource) {
            return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime() // Works on Postgres, MySQL, MariaDb, MS SQL, Oracle, DB2, HSQL and H2
                .build()
            );
}

 


ShedLock 사용하기

ShedLock 은 @Scheduled 어노테이션과 함께 @SchedulerLock 어노테이션이 붙은 메소드에 구현됩니다.

Dependcy

//shedlock
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0'

Config

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10S")
public class ScheduledConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withTableName("shed_lock_t")
                .withColumnNames(new ColumnNames("task_name", "lock_until_tmstamp", "locked_at_tmstamp", "locked_by"))
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        );
    }

    //스케줄러 멀티 스레드로 실행
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();

        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("my-scheduler-thread-");
        scheduler.initialize();

        return scheduler;
    }
}

Scheduled Task

@Component
@Slf4j
public class ScheduledTask {

    @Scheduled(fixedRate = 1000)
    @SchedulerLock(name = "scheduledTaskName")
    public void taskOne() throws InterruptedException {
        log.info("{}, Task one start. ⚙️", LocalDateTime.now());
        sleep(2000);
        log.info("{}, Task one done. ⚙️⚙️", LocalDateTime.now());
    }

    @Scheduled(fixedRate = 1000)
    @SchedulerLock(name = "scheduledTaskName")
    public void taskTwo() throws InterruptedException {
        log.info("{}, Task two start️. ⭐️", LocalDateTime.now());
        sleep(2000);
        log.info("{}, Task two done. ⭐⭐️️", LocalDateTime.now());
    }
}

'

→ ShedLock 테이블에 name 레코드가 존재한다면 기존의 동작은 중복 실행되지 않습니다.


Not Use ShedLock

  • 다중 인스턴스는 아니지만 굳이 가정하고 본다면 - Shed Lock 을 사용하지 않는다면, 아래의 사진과 같이 n 개의 스레드가 중복 실행될 것 입니다.
@Component
@Slf4j
public class ScheduledTask {

    @Scheduled(fixedRate = 1000)
    public void taskOne() throws InterruptedException {
        log.info("{}, Task one start. ⚙️", LocalDateTime.now());
        sleep(2000);
        log.info("{}, Task one done. ⚙️⚙️", LocalDateTime.now());
    }

    @Scheduled(fixedRate = 1000)
    public void taskTwo() throws InterruptedException {
        log.info("{}, Task two start️. ⭐️", LocalDateTime.now());
        sleep(2000);
        log.info("{}, Task two done. ⭐⭐️️", LocalDateTime.now());
    }
}

 


ShedLock 동작 과정

LockProvider 구현체인 JdbcTemplateStorageAccessor.class 에 구현되어 있습니다.

https://songjb.tistory.com/36 → 블로그 내용 요약 정리

  1. 스케줄러가 실행되면 executeWithLock 메서드를 실행하고 먼저 Provider를 통해서 lock을 획득하게 됩니다.
    1. shad lock 테이블의 key 값인 name 컬럼으로 lock 을 걸 컬럼 name 이 존재하는지 확인합니다. (조회)
    2. 존재하지 않는다면 lock 레코드를 Insert 합니다.
    3. INSERT IGNORE INTO 문을 사용하여 레코드를 생성합니다.
    4. 만약 두 개의 인스턴스에서 동일한 스케줄러를 실행한다면, 두 번째로 실행한 스케줄러에서 삽입된 중복된 레코드는 무시됩니다.
  2. 처음 락 레코드를 삽입한다면 TRUE 반환, 기존 락 레코드가 존재하는경우 해당 레코드의 락 종료시간을 확입합니다.
    1. 락 "종료 시간 <= 현재 시간 인 경우"
    2. "lock_until" 컬럼 시간 변경 (Update)
    3. True 를 반환함으로써 lock 획득
  3. 만약 동일 스케줄러가 실행된다면 락 "종료 시간 > 현재 시간"이 작기 때문에 UPDATE 되지 않고 0 반환 (수정 쿼리 결과 값)
  4. 0이 반환된다면 updateRecord가 false를 반환하게되고 false가 반환되면 lock메서드가 Optional.empty()를 반환하게 되면서 스케줄러가 실행되지 않게된다.

 


→ 처음에는 Lock 이고 별도의 테이블과 컬럼을 사용한다고 하여 Mysql 의 Named Lock 과 동일한거 아닌가? 라는 생각도 했습니다.

Shed Lock 의 동작과정을 보니, Lock 을 획득하기위해 대기하지 않는다는 점과 READ 와 (INSERT or UPDTE) 2개의 쿼리로 스케줄러의 중복을 방지할 수 있다는 점에서 큰 성능저하를 야기시키지도 않을 것 같네요

너무 잦은 테이블 접근은 지양해야겠지만 간단하게 다중 인스턴스에서 중복 Task 실행을 막기에는 좋은 선택지가 되어줄 것 같습니다.

 

끝!

 


참고