본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[Spring Scheduler] 서버 이중화 또는 증설 시 ShedLock을 사용하여 스케쥴러 중복 실행 방지하기

by 개발하는 곰돌이 2024. 9. 9.

목차

    들어가기 전에

    여러가지 이유로 구동되고 있는 서버를 증설해야 하는 경우가 있습니다.

     

    이렇게 서버가 증설되어 여러 대의 서버 인스턴스에서 동일한 서버 애플리케이션이 구동되고 있다면 스케쥴러를 통해 정해진 시간에 주기적으로 동작하는 로직은 여러 서버에서 동시에 실행되었을 때 문제가 발생할 수 있기 때문에 오직 하나의 서버에서만 스케쥴러 작업이 수행되도록 하는 것이 좋습니다.

     

    이번 포스트에서는 오픈 소스 라이브러리인 ShedLock을 사용하여 스프링 애플리케이션의 스케쥴러가 중복으로 실행되지 않도록 방지하는 내용을 다루고자 합니다.

    ShedLock?

    ShedLock은 MongoDB, Redis, RDBMS 등의 외부 저장소를 사용하여 다른 노드나 스레드에서 동일한 스케쥴러 작업이 중복 실행되지 않도록 해주는 오픈소스 라이브러리이며, 간단한 설정만 해주면 손쉽게 적용할 수 있다는 장점이 있습니다.

     

    다만 시간을 기반으로 동작되도록 설계되어 있기 때문에 각 서버 노드의 시간이 동기화되어 있어야 의도한대로 동작한다는 한계가 있습니다.

    기본 설정

    의존성 추가

    먼저 의존성을 추가해줍니다.

     

    스프링 또는 스프링부트 버전에 따라 추가해야 하는 의존성의 버전이 다르며, 구현하기 위해 사용하는 외부 저장소의 종류에 따라 추가해야 하는 의존성이 다르다는 점을 주의해야 합니다.

    이 포스트에서는 JDBC를 사용합니다.

     

    공통 의존성(build.gradle)

    // Spring Framework 6 이상 또는 Spring Boot 3 이상인 경우
    implementation 'net.javacrumbs.shedlock:shedlock-spring:5.15.1'
    
    // Spring Framework 6 미만 또는 Spring Boot 3 미만인 경우
    implementation 'net.javacrumbs.shedlock:shedlock-spring:4.44.0'

     

    공통 의존성(build.gradle.kts)

    // Spring Framework 6 이상 또는 Spring Boot 3 이상인 경우
    implementation("net.javacrumbs.shedlock:shedlock-spring:5.15.1")
    
    // Spring Framework 6 미만 또는 Spring Boot 3 미만인 경우
    implementation("net.javacrumbs.shedlock:shedlock-spring:4.44.0")

     

    추가 의존성(build.gradle)

    // JDBC 사용 시
    implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.1'
    
    // MongoDB 사용 시
    implementation 'net.javacrumbs.shedlock:shedlock-provider-mongo:5.15.1'

     

    추가 의존성(build.gradle.kts)

    // JDBC 사용 시
    implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.1")
    
    // MongoDB 사용 시
    implementation("net.javacrumbs.shedlock:shedlock-provider-mongo:5.15.1")

    추가 의존성도 공통 의존성과 마찬가지로 스프링 또는 스프링부트 버전에 따라 의존성 버전을 변경해야 하며, 그 외의 다양한 외부 저장소별 의존성은 공식 깃허브 레포지토리에서 확인하실 수 있습니다.

     

    GitHub - lukas-krecan/ShedLock: Distributed lock for your scheduled tasks

    Distributed lock for your scheduled tasks. Contribute to lukas-krecan/ShedLock development by creating an account on GitHub.

    github.com

    스케쥴러 잠금을 위한 테이블 생성

    ShedLock에서 스케쥴러가 잠겨있는지 체크하려면 스케쥴러를 잠그기 위한 테이블이 필요합니다.

     

    RDBMS의 종류에 따라서 아래 DDL을 실행하여 테이블을 생성해줍니다.

    # MySQL, MariaDB
    CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
        locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
    
    # Postgres
    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));
    
    # Oracle
    CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
        locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
    
    # MS SQL
    CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until datetime2 NOT NULL,
        locked_at datetime2 NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
    
    # DB2
    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);

    각 컬럼이 나타내는 값은 다음과 같습니다.

    • name : 어떤 작업에 대한 잠금인지 구분하기 위한 이름
    • lock_until : 잠금이 풀리는 시간
    • locked_at : 잠금이 시작된 시간
    • locked_by : 스케쥴러를 잠근 주체(기본값 = 인스턴스의 호스트명)

    위 DDL에 작성된 테이블 이름과 컬럼 이름은 기본값이며 커스텀이 가능합니다.

    LockProvider 스프링 빈 등록

    ShedLock이 정상적으로 동작하려면 LockProvider 객체가 스프링 빈으로 등록되어 있어야 합니다. 아래와 같이 스프링 빈 등록을 해줍니다.

    @Configuration
    class ShedLockConfig {
        @Bean
        fun lockProvider(dataSource: DataSource) = JdbcTemplateLockProvider(dataSource)
    }

     

    만약 ShedLock을 구현하기 위한 테이블 이름이나 컬럼 이름을 커스텀하였다면 아래와 같이 커스텀을 할 수도 있습니다.

    @Configuration
    class ShedLockConfig(
        @Value("\${server.port}") private val port: String
    ) {
        @Bean
        fun lockProvider(dataSource: DataSource) = JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(JdbcTemplate(dataSource))
                .withTableName("custom_shedlock")
                .withColumnNames(
                    JdbcTemplateLockProvider.ColumnNames(
                        "custom_name",
                        "custom_lock_until",
                        "custom_locked_at",
                        "custom_locked_by"
                    )
                )
                .withLockedByValue("${InetAddress.getLocalHost().hostName}:$port")
                .build()
        )
    }

    withTableName의 인자에 커스텀한 테이블의 이름을, JdbcTemplateLockProvide.ColumnNames의 인자에 차례대로 name, lock_until, locked_at, locked_by에 해당하는 커스텀한 컬럼의 이름을 입력해주면 됩니다.

    withLockedByValue에는 locked_by에 기록할 잠금을 얻은 주체를 설정해주면 됩니다. 기본값은 인스턴스의 호스트 이름이며, 위 예시에서는 호스트 이름 뒤에 애플리케이션이 가동되고 있는 포트를 추가로 붙여서 기록하도록 설정하고 있습니다.

     

    이 때 DB에서 테이블 이름과 컬럼 이름의 대소문자를 구분한다면 해당 설정에서도 마찬가지로 대소문자를 구분하여야 합니다.

     

    마지막으로 메인 클래스 또는 잠그고자 하는 스케쥴러가 있는 클래스에 @EnableSchedulerLock를 설정하여 ShedLock 설정을 마무리해줍니다.

    @EnableSchedulerLock(defaultLockAtMostFor = "30s")
    @EnableScheduling
    @SpringBootApplication
    class DemoApplication
    
    fun main(args: Array<String>) {
    	runApplication<DemoApplication>(*args)
    }

    여기서 defaultLockAtMostFor의 값에는 스케쥴러 잠금이 유지될 최대 시간을 입력해줍니다. 위 예시는 모든 스케쥴러 잠금에 대해 기본값으로 최대 잠금 유지시간을 30초로 설정하여 스케쥴러 작업이 종료되지 않더라도 30초가 지나면 잠금을 해제하는 설정입니다.

     

    이외에도 defaultLockAtLeastFor의 값을 설정하여 잠금이 반드시 유지되는 최소 시간을 설정할 수도 있습니다. 이 값은 서버가 구동되는 인스턴스들의 시간이 완전히 동기화되지 않는 경우를 고려하여 어느정도 값을 정해주는 것이 좋습니다.

    ShedLock으로 스케쥴러 잠그기

    설정이 끝났으니 이제 스케쥴러에 잠금을 걸어봅시다.

     

    스케쥴러를 잠그는 방법은 간단합니다. 스케쥴 작업이 설정된 메소드에 @SchedulerLock을 달아주기만 하면 됩니다.

    @Component
    class DemoScheduler {
        private val log = LoggerFactory.getLogger(this.javaClass)!!
    
        @Scheduled(cron = "0/5 * * * * *")
        @SchedulerLock(name = "loggingScheduler", lockAtLeastFor = "1s")
        fun loggingScheduler() {
            log.info("Logging every 5 seconds")
        }
    }

    여기서 name은 각 스케쥴러 작업을 구분하기 위한 고유 값, lockAtLeastFor는 잠금을 유지할 최소 시간을 설정합니다.

     

    lockAtLeastFor의 기본값은 0초이며 위와 같이 설정할 경우 작업이 1초 이내에 끝나더라도 스케쥴러 잠금은 1초동안 유지됩니다.

     

    이렇게 설정하고 두 개의 애플리케이션을 실행해봅시다.

    스케쥴러가 동시에 실행되는 일 없이 둘 중 하나의 애플리케이션에서만 실행되는 모습을 볼 수 있습니다.

     

    실제로 ShedLock 설정에서 지정한 테이블을 확인해보면 이렇게 나옵니다.

    어노테이션을 사용하지 않고 잠그는 방법

    @SchedulerLock을 사용하지 않고 직접 잠금을 설정하는 방법도 있습니다.

     

    이 방법을 사용할 경우 스프링 빈으로 등록한 LockProvider를 주입받아야 합니다. 

    @Component
    class DemoScheduler(
        private val lockProvider: LockProvider
    ) {
        private val log = LoggerFactory.getLogger(this.javaClass)!!
    
        @Scheduled(cron = "\${task.scheduler-cron}")
        fun loggingScheduler() {
            val lockConfig = LockConfiguration(
                Instant.now(),
                "loggingScheduler",
                Duration.ofSeconds(30),
                Duration.ofSeconds(1)
            )
            val lock = lockProvider.lock(lockConfig)
    
            lock.ifPresent {
                try {
                    log.info("Logging every 5 seconds")
                } finally {
                    it.unlock()
                }
            }
        }
    }

    LockConfiguration 생성자는 다음과 같이 정의되어 있습니다.

    createdAt은 스케쥴러를 잠그는 시간이므로, 스케쥴러가 실행되는 시점의 현재 시간을 설정하기 위해 Instant.now()로 설정합니다.

     

    name@SchedulerLock에서의 name과 동일합니다.

     

    lockAtMostForlockAtLeastFor@SchedulerLock에서의 값과 비슷하지만 문자열이 아닌 Duration 객체를 전달해야 합니다. 위 예시코드에서는 각각 30초, 1초에 해당하는 Duration 객체를 전달하고 있습니다.

     

    이후 LockProvider의 lock을 통해 위에서 생성한 LockConfiguration 객체에 대한 잠금을 획득하고 잠금을 획득한 경우에만 메소드의 로직이 실행되도록 설정하면 됩니다.

     

    위 예시 코드에서는 finally를 사용해서 로직의 성공 여부와 관계 없이 반드시 잠금을 해제하도록 했습니다. 

    안타깝게도 LockProvider의 lock으로 얻은 SimpleLock 객체는 AutoCloseable의 구현체가 아니어서 자바의 try-with-resources나 코틀린의 use 메소드를 사용할 수 없습니다.

    이렇게 실행했을 때도 정상적으로 스케쥴러가 잠기는 것을 확인할 수 있습니다.

     

    잠금을 획득했을 때의 로직을 조금 수정하여, 이미 스케쥴러가 잠겨있는 경우에 대한 로직을 작성할 수도 있습니다.

    lock.ifPresentOrElse({
        try {
            log.info("Logging every 5 seconds")
        } finally {
            it.unlock()
        }
    }, {
        log.warn("Scheduler is locked")
    })

    위와 같이 변경해놓고 애플리케이션을 실행해보면 한쪽은 잠금을 획득해서 Logging every 5 seconds가 출력되고 다른쪽은 스케쥴러가 잠겨있어서 Scheduler is locked가 출력되는 것을 볼 수 있습니다.

    참조 링크

     

    GitHub - lukas-krecan/ShedLock: Distributed lock for your scheduled tasks

    Distributed lock for your scheduled tasks. Contribute to lukas-krecan/ShedLock development by creating an account on GitHub.

    github.com

     

    댓글