티스토리 뷰

반응형

스프링의 Task Executor 설정을 특별히 건드리지 않았더니 요청이 몰려드는 시점에 @Async를 붙인 메서드들의 처리가 느려지는 것을 확인했다. 서버 설정을 수정하는 김에 Task Executor 관련 주요 개념과 설정을 정리하려 한다.

 


 

Task Executor

 

스프링 어플리케이션에서 @EnableAsync 설정을 추가하고 @Async가 붙은 메서드를 런타임에 호출 시 Runnable 혹은 Callable의 형태로 스레드 풀의 Blocking Queue에 작업을 등록한 뒤 비동기로 처리된다.

 

비동기 처리 시 작업을 등록할 스레드 풀이 필요한데, 스프링 부트가 아닌 순수 스프링 환경에서는 별도의 설정이 없다면  AsyncExecutionInterceptor에 의해 요청마다 스레드를 새로 생성하는 SimpleAsyncTaskExecutor 를 Executor로 등록한다.

 

스프링 부트에서는 JAVA 21 이상을 사용해 가상 스레드를 사용하도록 설정하는 경우에만(spring.threads.virtual.enabled = true) 스레드의 생성 작업이 비교적 가벼워 SimpleAsyncTaskExecutor를 기본 Task Executor로 등록, 이외에는 설정에 따라 풀링을 하는 ThreadPoolTaskExecutor를 등록한다.  (이외에도 WebFlux 혹은 GraphQL 사용 과정에서 Task Executor를 사용한다)

 

 

ThreadPoolTaskExecutor

 

스프링에서 제공하는 Task Executor 클래스이나 내부적으로는 JDK의 스레드 풀인 ThreadPoolExecutor를 사용하며 부가 기능을 일부 포함한다. 실제 풀링 기능의 동작 방식은 JDK의 ThreadPoolExecutor를 따른다.

 

protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
    ...
    executor = new ThreadPoolExecutor(this.corePoolSize, this.maxPoolSize, (long)this.keepAliveSeconds, TimeUnit.SECONDS, queue, threadFactory, rejectedExecutionHandler);
    ...
}

 

 

 

기본 설정과 동작 방식

 

Spring Boot를 통해 기본 생성되는 ThreadPoolTaskExecutor의 주요 풀링 관련 설정(=ThreadPoolExecutor 설정)은 아래와 같다

  • corePoolSize: 8
  • maxPoolSize: Integer.MAX_VALUE
  • queueCapacity: Integer.MAX_VALUE
  • keepAliveSeconds: 60

이 환경에서의 동작 방식은 ThreadPoolExecutor의 JavaDoc을 보면 자세히 알 수 있다.

  1. 런타임에서는 최초 크기 0의 스레드 풀로 시작한다.
  2. 스레드 풀에 작업이 등록되면 corePoolSize까지의 요청은 바로 신규 스레드가 생성되어 처리된다.
  3. corePoolSize 이상의 요청이 동시 등록되면 Blocking Queue에 우선 작업이 쌓인다.
  4. Blocking Queue 크기 이상의 작업이 요청되었다면 순차적으로 maxPoolSize 크기 이하의 스레드를 생성하여 작업들을 할당한다.
  5. 설정된 한도만큼의 스레드를 사용 중이며 Blocking Queue도 가득 찼으나 새로운 작업이 등록되면 RejectedExecutionHandler에 의해 처리된다(기본: RejectedExecutionException 발생)
  6. corePoolSize 이상으로 늘어난 스레드는 이후 요청이 감소하면 keepAliveSeconds만큼 대기했다가 정리된다.

 

유의할 점은 corePoolSize만큼의 스레드가 동시 처리를 하는 중 신규 작업이 추가되었을 때 maxPoolSize까지 신규 스레드를 생성하는 것이 아닌, Blocking Queue에 작업을 우선적으로 등록한다는 것.

하지만 queueCapacity가 기본 Integer.MAX_VALUE이므로 기본 설정에서는 corePoolSize 크기 이상의 스레드가 할당될 일이 사실상 없고 모두 Blocking Queue에서 대기하게 된다.

 

이로 인해 기본 설정에서는 동시 요청이 많은 순간 @Async 메서드 콜이 몰려도 8개의 스레드에서만 작업을 처리해 비동기 작업이 지연될 수 있다.

 

 

설정 변경

 

두 가지 방법이 존재한다.

application.yaml 혹은 application.properties의 spring.task.execution.pool 설정을 바꿔 spring boot가 지정한 설정의 pool을 생성하도록 할 수 있다.

 

# application.yaml
spring:
  task:
    execution:
      pool:
        max-size: 16
        queue-capacity: 100
        keep-alive: "10s"

 

 

혹은 설정 파일에서 Executor 관련 Bean을 직접 주입하면 @Async 처리 과정에서 이 스레드 풀을 사용한다.

아래의 방법으로는 스레드 풀을 여럿 설정하여 @Async 호출 시 원하는 스레드 풀을 사용하도록 선택할 수도 있다.

 

@Config
public class BeanConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(8);
        taskExecutor.setQueueCapacity(Integer.MAX_VALUE);
        taskExecutor.setMaxPoolSize(Integer.MAX_VALUE);
        taskExecutor.initialize();
        return taskExecutor;
    }
}

 

 

만약 요청이 급증할 때 Blocking Queue를 거치지 않고 바로 신규 스레드를 생성하여 비동기 작업들을 빠르게 처리해야 한다면 queueCapacity를 0으로 설정하면 된다.

 

 

 

추가적인 설정

 

이외에도 부가적인 ThreadPoolTaskExecutor 설정 몇 개를 소개한다.

 

setTaskDecorator()

TaskDecorator 설정을 추가하면 스레드 풀을 통해 작업이 처리될 때 decorator의 runnable이 추가적으로 수행된다. (기본 null)

 

protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
    ...
    if (this.taskDecorator != null) {
        executor = new ThreadPoolExecutor(this.corePoolSize, this.maxPoolSize, (long)this.keepAliveSeconds, TimeUnit.SECONDS, queue, threadFactory, rejectedExecutionHandler) {
            public void execute(Runnable command) {
                Runnable decorated = ThreadPoolTaskExecutor.this.taskDecorator.decorate(command);
                if (decorated != command) {
                    ThreadPoolTaskExecutor.this.decoratedTaskMap.put(decorated, command);
                }

                super.execute(decorated);
            }
        };
    }
    ...
}

 

일례로, 아래와 같이 사용 시 스레드 풀의 작업 처리 과정에서 스레드 별 로그가 출력된다.

 

TaskDecorator taskDecorator = runnable -> () -> log.debug("decorator");
taskExecutor.setTaskDecorator(taskDecorator);

 

 

setAllowCoreThreadTimeOut()

기본 옵션 false에서 corePool 크기 이상으로 만들어진 스레드는 유휴 상태에서 keepAliveSeconds(기본 60초) 이후 자동으로 정리되나,

corePool 크기 만큼의 스레드들(Core Thread)은 생성 이후 정리되지 않는다.

이 옵션을 true로 설정 시 Core Thread 또한 유휴 상태가 지속되면 정리된다.

 

 

setPrestartAllCoreThreads()

위에서 설명했듯, 기본 옵션 false에서는 최초 런타임에서는 비동기 요청이 시작되어야 Core Thread들이 생성된다.

true로 설정 시 마치 HikariCP와 같은 Connection Pool이 미리 pool size만큼의 커넥션을 최초 런타임에 한 번에 맺듯,

런타임 초기에 Core Thread들을 corePoolSize만큼 바로 생성해 서버의 warm up 영향을 줄일 수 있다.

 

 

setWaitForTasksToCompleteOnShutDown()

true로 설정 시 애플리케이션을 TaskExecutor는 graceful shutdown을 수행한다. 새로운 작업을 시작하지는 않으나, 대기 중인 작업들이 완료될 때까지 기다린 뒤 닫힌다.

 

ThreadPoolTaskExecutor가 사용하는 ThreadPoolExecutor의 종료 메서드는 shutdown()과 shutdownNow() 두 가지가 존재하는데, 기본 설정(false)에서는 shutdownNow()를 선택해 종료하여 처리 중인 작업들도 interrupt한다.

 


사견

 

기본 설정에서 ThreadPoolTaskExecutor가 8개 스레드로만 동작하고 이외에는 추가 스레드 할당 없이 Blocking Queue에 작업만 예약한다는 사실을 알았을 때 '스프링 부트가 왜 기본 설정에서 추가 스레드를 할당하도록 하지 않았지?' 라는 고민을 했다. 서버의 램 상황에 크게 제약이 있지 않다면 queue size를 0으로 하여 신규 스레드를 바로 할당하는 방식이 @Async 메서드 호출이 쌓여도 요청의 전체적인 처리 자체는 빠를 것이기 때문이다.

 

8이라는 기본 설정에 대한 근거를 따로 찾지는 못했으나, 생각해보니 전통적인 Spring MVC로 웹앱 설계를 한다면 @Async를 사용하여 별도 스레드에서 처리되는 작업은 요청 별 메인 스레드의 작업과는 별개의 부관심사인 경우가 많을 것이다. 부관심사 처리 과정에서 별도의 풀에서 부가적인 스레드의 생성/삭제로 주관심사 처리의 성능적 영향을 주기보다, 한정된 자원만을 할당하고자 이렇게 설정한 듯 하다.

 

@Async 처리하는 구간이 많고 이 로직이 비교적 처리 시간이 길거나 빠르게 처리되어야 한다면 Core Thread 수를 늘리거나, Queue 크기를 많이 줄여 바로 추가 스레드를 생성하도록 하는 편이 적절해 보인다. 부가적으로 스레드 생성을 지연 처리할 일이 없다면 prestart core thread 설정을, shutdown시의 안정성을 위해 waitForTasksToCompleteOnShutDown 설정 정도는 고려할 만 한 것 같다.

 

 

 


Reference

https://github.com/spring-projects/spring-framework/blob/5e808ad0183a63944ab08017a13cbc7ed75bc581/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java#L156

https://docs.spring.io/spring-framework/reference/integration/scheduling.html

반응형