(Spring) @Scheduled를 수행하는 가장 효율적인 방법 (virtual thread, coroutine, thread pool)
Spring에서 주기적으로 호출되어야 하는 메서드는 @Scheduled
처리한다.
@Scheduled
붙은 메서드는 어떤 스레드에서 돌릴지 구성하는 방법은 크게 3가지가 있는데,
- ThreadPool (default)
- coroutine (Spring 6.1.0 부터 지원)
- Vitrual Thread (java 21 부터 지원)
각 방법을 비교해보면 아래와 같다.
ThreadPool
- 아무런 설정을 하지 않는다면, 기본적으로
ThreadPoolTaskScheduler
bean이 생성되고 기본 taskScheduler가 된다.TaskSchedulingConfigurations
통해서 생성한다.- Scheduled 작업은 이 기본 taskScheduler가 제공하는 스레드 위에서 실행된다.
ThreadPoolTaskScheduler
의 기본 풀 사이즈는 1이다. (싱글 스레드)- 따라서 어떤 Task B의 실행 주기가 도래했는데 현재 실행 중인 Task A가 끝나지 않았다면 Task B는 pending 상태로 대기한다.
- Task A가 끝나면, pending 상태였던 Task B가 실행된다.
- 작업이 밀리지 않게끔, 보통 아래와 같이 설정해서 이용해 풀 사이즈를 늘려 사용한다.
1
spring.task.scheduling.pool.size=8
단점
- 호출 되자 마자 비동기로 작업 던지고 끝내면 좋겠지만, 작업이 중복 실행되지 않도록 하려면 반드시 종료를 기다려야만 한다.
- e.g. 직접 코루틴 호출한다 해도
runBlocking
해주어야 작업 중복 실행이 방지된다. - 특히
fixedDelay
는 끝난 시점 부터 시간을 재기 때문에 반드시 종료를 기다려야 한다.
- e.g. 직접 코루틴 호출한다 해도
- 작업이 밀리지 않도록 하려면 thread pool size를 충분히 넉넉하게 줘야 하는데, 이 과정에서 스레드 낭비가 발생하기 쉽다.
- 간헐적으로 오래걸릴 수 있는 작업이 있다면, 그 작업을 위한 스레드 개수 만큼 풀을 늘려서 가지고 있어야 하는데, 평시에는 그 만큼 낭비가 발생한다.
- 충분히 큰 pool size를 확보하지 않는 경우 전체 작업이 딜레이 될 수 있다.
- 초단위로 주기적 실행 해야되는 작업이 많은 시스템에서는 pool size scaling도 간헐적 지연의 원인이 된다. (== max pool size를 미리 계산해서 할당해두어야 하니 낭비가 심할 수 밖에 없다.)
coroutine
- Spring 6.1.0 부터 suspend 함수에 대한
@Scheduled
가 가능하다. link - 호출은 ThreadPoolTaskScheduler 위에서 하지만, pool size = 1 이어도 비동기로 돌아간다.
- Dispatchers.Unconfined 에 작업을 던지는 것 같다.
단점
- 🔴 다른 스케줄링은 문제가 없는데,
fixedDelay
쓰는 경우에는 비동기로 동작하지 않고, 동기식으로 동작한다. (thread가 block된다.)ScheduledAnnotationReactiveSupport.createSubscriptionRunnable
참고.
- 🔴
@Scheduled suspend
에 대한GlobalExceptionHandler
는 별도로 따로 등록해줘야 하는데, 등록 가능한 인터페이스가 현재로서는 없다.
Virtual Thread
- java 21 부터 스케줄링 작업이 호출 될 때 마다 VT를 생성하고 VT에서 돌릴 수 있다.
- 아래 설정 주면,
@ConditionalOnThreading(Threading.VIRTUAL)
이 붙은 Bean들이 자동 생성된다.- enum Threading 참고
1
spring.threads.virtual.enabled=true
- enum Threading 참고
TaskSchedulingConfigurations
확인해보면,virtual.enable
여부에 따라 bean을 생성하고 있다.false
이면,ThreadPoolTaskScheduler
생성 후 기본 taskScheduler로.true
이면,SimpleAsyncTaskScheduler
생성 후 기본 taskScheduler로.
SimpleAsyncTaskScheduler
는 스케줄링 작업을 실행 해야 할 때 마다 새로운 스레드를 생성하는 스케줄러인데,virtual.enable=ture
이면 VT를 생성한다.
단점
[!danger] NOTE: Scheduling with a fixed delay enforces execution on the single scheduler thread, in order to provide traditional fixed-delay semantics!
- 🔴 다른 스케줄링은 문제가 없는데,
fixedDelay
쓰는 경우에는 thread pinning 된다. (coroutine과 동일하게, 동기식으로 동작한다.) - (Spring 6.1 버전 기준)
SimpleAsyncTaskScheduler
에GlobalExceptionHandler
를 등록 할 수 있는 인터페이스가 없다.- customizer에서 설정이 불가능하고, 무조건 상속 받아서 처리하거나 decorator를 쓰는 등 번거롭게 처리해야만 한다.
- ( > Spring 6.2 버전) 인터페이스 추가 되어서 ExceptionHandler 등록 가능해질 것으로 보인다. link
결론
- ThreadPool은 상황에 따라 자원 낭비가 발생할 가능성이 커보인다.
- coroutine, VT는 비슷하게 효율적일 것 같은데, coroutine은
GlobalExceptionHandler
등록 가능한 인터페이스가 현재로서는 없다. fixedDelay
로 인한 pinning 문제가 해결되기 전까지@Scheduled(fixedDelay)
는 쓰지 않는게 좋아보인다.- 애너테이션 대신 람다를 정의해서 쓰는 방법도 괜찮아 보인다.
[VT + fixedDelay
용 람다]로 처리하는 것이 현재 가능한 최선 인 것 같다.
6.1 버전 이하에서 globalExceptionHandler
는 직접 처리해야 하는데, AOP로 처리하는게 그나마 깔끔해보인다.
This post is licensed under CC BY 4.0 by the author.