Post

(Spring) @Scheduled를 수행하는 가장 효율적인 방법 (virtual thread, coroutine, thread pool)

Spring에서 주기적으로 호출되어야 하는 메서드는 @Scheduled 처리한다.
@Scheduled 붙은 메서드는 어떤 스레드에서 돌릴지 구성하는 방법은 크게 3가지가 있는데,

  1. ThreadPool (default)
  2. coroutine (Spring 6.1.0 부터 지원)
  3. 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는 끝난 시점 부터 시간을 재기 때문에 반드시 종료를 기다려야 한다.
  • 작업이 밀리지 않도록 하려면 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들이 자동 생성된다.
  • TaskSchedulingConfigurations 확인해보면, virtual.enable 여부에 따라 bean을 생성하고 있다.
    • false이면, ThreadPoolTaskScheduler 생성 후 기본 taskScheduler로.
    • true이면, SimpleAsyncTaskScheduler 생성 후 기본 taskScheduler로.
  • SimpleAsyncTaskScheduler는 스케줄링 작업을 실행 해야 할 때 마다 새로운 스레드를 생성하는 스케줄러인데, virtual.enable=ture 이면 VT를 생성한다.
    • 따라서 스케줄링 작업은 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 버전 기준) SimpleAsyncTaskSchedulerGlobalExceptionHandler를 등록 할 수 있는 인터페이스가 없다.
    • 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.