Spring Framework

Spring Batch 대신 @Scheduled 활용해보기

Junuuu 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