-
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
'Spring Framework' 카테고리의 다른 글
Spring Boot Distributed Scheduling (0) 2024.06.30 프로젝트에 Feature Flag 적용하기 (0) 2024.06.01 Adaptor 패턴으로 호출가능한 Local 환경 만들기 (0) 2024.03.08 JsonTypeInfo, JsonSubTypes 어노테이션 (0) 2024.02.27 Spring 단일 Endpoint에 여러 요청 처리하기 (0) 2024.02.13