Spring Batch 대신 @Scheduled 활용해보기
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
관련해서 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