ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Batch 대신 @Scheduled 활용해보기
    Spring Framework 2024. 5. 8. 00:58

    Spring Batch와 Scheduler

    Spring Batch와 @Scheduled는 모두 스프링 프레임워크에서 시간 또는 이벤트 기반의 작업을 스케줄링하기 위해 사용됩니다.

     

    다만 Spring Batch는 스케줄러를 대체하는 것이 아니라 스케줄러와 함께 작동하도록 고안되었습니다.

    예를 들어 Quartz, Jenkins,  Tivoli, Control-M 등과 함께 활용해야 합니다.

     

    주로 데이터 처리, 데이터베이스 작업, 파일 처리 등과 같은 대량의 작업을 일괄적으로 처리하는 데 Spring Batch를 활용합니다.

     

    예를 들어 파일을 다운로드하고, 파일을 전송하고 삭제하는 기능을 구현할 때는 Spring Batch를 선택해서 활용하였습니다.

     

    그렇다면 언제 Spring Batch 대신 @Scheduled를 사용해야 할까?

    1. 별도의 스케줄러와 함께 동작해야하기 때문에 배치 관련 설정이 없을 때 고려해 볼 수 있습니다.

     

    2. 실패가 발생하더라도 중간 지점부터 재시작할 필요가 없을 때 Scheduler를 고려해 볼 수 있습니다.

     

    3. Spring Batch에 대한 Job, Meta Table, Step 등의 개념을 학습하기 부담스러울 때 활용해 볼 수 있습니다.

     

    4. @Scheduled 의 경우 실제 Application이 실행되는 환경과 같이 실행될 수 있기 때문에 대용량 데이터를 다루는 것이 아닐 때 사용해 볼 수 있습니다. 너무 많은 데이터를 실제 운용되는 서버에 같이 동작시키면 컴퓨팅 자원을 너무 많이 소모해서 실제 서버에 문제가 발생할 수도 있습니다.

     

    @Scheduled 적용해보기

    @SpringBootApplication
    @EnableScheduling
    class SpringSchedulerApplication
    
    fun main(args: Array<String>) {
    	runApplication<SpringSchedulerApplication>(*args)
    }

    @Scheduled 어노테이션을 사용하기 위해 다음과 같이 Application Class에 @EnableScheduling을 추가해야 합니다.

     

    @Component
    class ScheduledTask {
        @Scheduled(fixedDelay = 1000)
        fun run(){
            logger.info { "Hello ScheduledTask!" }
        }
    }

    이후에는 @Scheduled 어노테이션을 활용하여 1초 간격으로 작업을 수행해 보는 예제를 작성해 보았습니다.

    fixedDelay 대신 cron, fixedRate를 활용해 볼 수도 있습니다.

     

    1초마다 Hello ScheduledTask가 출력되며 scheduling-1이라는 스레드에서 동작하는 것을 확인할 수 있습니다.

    매우 간단한 작업이지만 고려해 볼 법한 주의사항 3가지가 존재합니다.

     

    주의사항 1 - 여러 개의 Scheduling이 동작하는 경우

    By default, Spring will search for an associated scheduler definition:
    either a unique org.springframework.scheduling.TaskScheduler bean in the context,
    or a TaskScheduler bean named "taskScheduler" otherwise;
    the same lookup will also be performed for a java.util.concurrent.ScheduledExecutorService bean. 
    If neither of the two is resolvable, a local single-threaded default scheduler will be created and used within the registrar.

    @EnableScheduling 어노테이션의 Java Doc를 살펴보면 위와 같은 설명이 적혀있습니다.

    간단히 요약해 보자면 TaskSchedeuler의 Bean과 ScheduledExecutorService Bean을 찾아보고 찾지 못한다면 single-thread로 작업이 기본적으로 수행됩니다.

     

    Single Thread 사용하면 안 되나요?

    @Scheduled(fixedDelay = 1000)
    fun run2(){
    	logger.info { "Hello ScheduledTask2!" }
    }

    위의 run2() 메서드 작업을 추가해 주어도 scheduling-1라는 single-thread에서 잘 동작합니다.

     

    다만 아래와 같이 변경되면 어떨까요?

    @Scheduled(fixedDelay = 1000)
    fun run2(){
    	sleep(3000)
    	logger.info { "Hello ScheduledTask2!" }
    }

    run2() 작업에 3초의 sleep()을 주고 다시 애플리케이션을 실행해 보면 기존의 run() 작업도 3초의 딜레이가 같이 걸리는 것을 확인할 수 있습니다.

     

    즉, single-thread에서 동작하기 때문에 run2()에서 작업이 오래 걸리는 경우 run1() 작업은 blocking 되어 실행되지 못하는 상태가 돼버립니다.

     

    해결법 - TaskScheduler Bean 등록

    @Configuration
    class ScheduledConfig {
        @Bean
        fun scheduledThreadPool(): TaskScheduler{
            val taskScheduler = ThreadPoolTaskScheduler()
            taskScheduler.poolSize = 2
            taskScheduler.setThreadNamePrefix("custom-name")        
            return taskScheduler
        }
    }

    TaskScheduler를 Bean으로 등록해 주면 2개의 스레드에서 스케쥴링 작업을 수행하기 때문에 더 이상 blokcing 되지 않습니다.

    다만 스케쥴링 작업이 3개로 늘어나는 경우 여전히 문제가 발생할 수 있습니다.

    이 문제를 해결하기 위해서는 poolSize의 개수와 스케쥴링 작업의 개수가 동일하거나 poolSize가 더 많아야 합니다.

     

    스레드 이름으로 정의한 custom-name이 출력되며 2개의 스케쥴링 작업이 독립적으로 잘 수행되는 것을 확인할 수 있습니다.

     

    주의사항 2 - Graceful Shutdown 고려

    애플리케이션을 배포하다 보면 @Scheduled가 동작하는 시점과 겹쳐서 수행 중인 작업에 예상치 못한 오류가 발생할 수 있습니다.

     

    @Scheduled(fixedDelay = 1000)
    fun run3(){
        logger.info { "Hello ScheduledTask3 - processing!" }
        val result = RestTemplate().getForEntity<String>("http://httpbin.org/delay/10")
        logger.info { "Hello ScheduledTask3 - done!" }
    }

    예를 들어 10초가 걸리는 스케쥴링작업이 구동되고 애플리케이션에 SIGTERM 요청을 받아 종료된다고 가정해 보겠습니다.

     

    Thread.sleep()을 활용하면 InterruptedException이 발생하기 때문에 10초간의 지연이 발생하는 HTTP 호출을 보내볼 수 있습니다.

     

    httpbin.org/delay/ 를 활용하면 최대 10초의 N초간 지연이 발생하는 HTTP 호출을 테스트해 볼 수 있습니다.

     

    실제 동작중인 애플리케이션을 종료하는 테스트를 수행해 보면 processing으로 처리 중이지만 done 없이 애플리케이션이 종료되었습니다.

     

     

    위의 문제를 해결하기 위해서는 스레드풀의 설정을 활용해 볼 수 있습니다.

    • setAwaitTerminationSeconds : 적절한 시간 설정하여 특정시간 동안 작업이 끝나기를 기다림

     

    @Configuration
    class ScheduledConfig {
    
        @Bean
        fun scheduledThreadPool(): TaskScheduler{
            val taskScheduler = ThreadPoolTaskScheduler()
            taskScheduler.poolSize = 3
            taskScheduler.setThreadNamePrefix("custom-name")
            taskScheduler.setAwaitTerminationSeconds(20)
            return taskScheduler
        }
    }

     

    20초 동안 작업이 끝나기를 기다리는 설정을 추가한 뒤 테스트해 보면 Application을 종료하더라도 done이 출력되는 것을 확인할 수 있습니다.

     

     

    주의사항 3 - 가용성 및 성능을 위해 Multi Instance 환경의 경우

    가용성 및 성능을 위해 동일한 서버를 3대 띄우는 경우 스케쥴러 작업도 3번 실행될 수 있습니다.

    이를 해결하기 위해서는 ShedLock 라이브러리를 활용하여 중복 실행을 방지할 수 있습니다.

    외부 저장소(MongoDB, Redis, MySQL 등)를 사용하여 잠금 상태를 관리하므로, 여러 인스턴스 간에 잠금 정보를 공유할 수 있습니다.

     

    예를 들어 8080, 8081 포트로 서버를 2대 실행하게 되면 스케쥴링작업이 동시에 실행되는 것을 확인할 수 있습니다.

     

     

    외부 저장소중 Redis를 채택하여 ShedLock 라이브러리를 사용해 보겠습니다.

    docker run --name my-redis-container -d -p 6379:6379 redis
    
    docker exec -it [CONTAINER_NAME_OR_ID] redis-cli
    
    KEYS *

    docker를 활용하여 redis를 구동합니다.

    이후 redis-cli에 container_id를 기반으로 접근하여 KEYS로 key를 조회해 볼 수 있습니다.

     

    Gradle

    implementation("net.javacrumbs.shedlock:shedlock-spring:5.13.0")
    implementation("net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.13.0")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")

    JDK 17 이상을 활용하기 때문에 5.13.0 현시점기준의 최신버전을 사용해 보았습니다.

    JDK 17 미만의 경우  4.44.0 버전을 사용해야 합니다.

     

    Redis Config

    @Configuration
    class RedisConfig {
        
        fun redisConnectionFactory(): RedisConnectionFactory {
            return LettuceConnectionFactory("localhost", 6379)
        }
    
    }

     

    LockProvider Config

    @Configuration
    class RedisLockProviderConfig {
    
        @Value("\${spring.profiles.active}")
        private lateinit var activeProfile: String
    
    
    
        @Bean
        fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider {
            return RedisLockProvider(connectionFactory, activeProfile)
        }
    
    }

    lockProvider 구성으로 환경별로 Lock을 잡아줄 수 있습니다.

    예를 들어 Lock Name이 run3 라면 "job-lock:local:run3" Lock이 잡히게 됩니다.

     

    @SchedulerLock 활용

    @Scheduled(fixedDelay = 1000 * 11)
    @SchedulerLock(name = "run3", lockAtLeastFor = "PT10S", lockAtMostFor = "PT10S")
    fun run3(){
        LockAssert.assertLocked();
        logger.info { "Hello ScheduledTask3 - processing!" }
        val result = RestTemplate().getForEntity<String>("http://httpbin.org/delay/10")
        logger.info { "Hello ScheduledTask3 - done!" }
    }
    
    @Scheduled(fixedDelay = 1000* 11)
    @SchedulerLock(name = "run3", lockAtLeastFor = "PT10S", lockAtMostFor = "PT10S")
    fun run4(){
        LockAssert.assertLocked();
        logger.info { "Hello ScheduledTask4 - processing!" }
        val result = RestTemplate().getForEntity<String>("http://httpbin.org/delay/10")
        logger.info { "Hello ScheduledTask4 - done!" }
    }

    @SchedulerLock의 name을 run3()로 동일하게 두면 2개의 메서드 중 하나만 실행됩니다.

    LockAssert의 경우 Lock을 잡고 진입하지 않는 경우를 막아줄 수 있습니다.

     

    Not executing 'run3'. It's locked. 로그와 함께 진입되지 않는 모습을 확인할 수 있습니다.

     

    Lock을 잡아서 구동하던 서버가 갑자기 죽어버리는 경우는 어떻게 될까?

    그 뒤에 동작하는 스케줄러가 이전에 처리하지 못하는 작업을 처리할 수 있는 구조로 만들어야 합니다.

    Lock은 TTL(Time to Live) 시간이 지나면 자동으로 해제됩니다.

     

    찜찜한 이슈 하나

    graceful shutdown과 @SchedulerLock를 동시에 활용하면 작업이 모두 끝나고 Lock을 반납하다가 LockException이 발생합니다.

     

    lettuce.core.protocol.AsyncCommand.await 메서드에서 InterruptedException 이 발생하고 스레드가 중단되었기 때문에 발생합니다.

     

    실제 redis에서 key를 조회해 보면 expire는 정상적으로 수행되기 때문에 에러로그 이외의 문제는 발생하지 않습니다.

     

    Shedlock에 이슈 올려보기

    https://github.com/lukas-krecan/ShedLock/issues/1932

     

    Support for Graceful Shutdown in @SchedulerLock · Issue #1932 · lukas-krecan/ShedLock

    Is your feature request related to a problem? Please describe. When utilizing @SchedulerLock, we registered TaskScheduler as a Spring Bean and set setAwaitTerminationSeconds. Upon receiving the SIG...

    github.com

    관련해서 ShedLock 라이브러리에 이슈를 올려보면서 마무리해 보겠습니다.

     

     

     

     

    참고자료

    https://docs.spring.io/spring-batch/reference/spring-batch-intro.html

    https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/scheduling.html

    https://www.baeldung.com/spring-task-scheduler

    https://seriouskang.tistory.com/2

    https://dev-coco.tistory.com/176

    https://dkswnkk.tistory.com/731

    https://github.com/lukas-krecan/ShedLock

    https://github.com/spring-projects/spring-data-redis/issues/2501

     

    댓글

Designed by Tistory.