-
@Async와 스레드 풀 - ThreadPoolTaskExecutor가 기본 설정이다!Dev/Java 2023. 12. 12. 23:55
회사에서 프로젝트를 진행하며 @Async를 사용할 일이 있었다. 기존 프로젝트의 설정이 완료된 상태였기 때문에 비동기로 동작해야 하는 메소드 위에 @Async만 붙이기만 하면 간단하게 지나갈 수 있었지만 의문이 생겼다. 비동기로 메소드가 실행되면 스레드의 개수는 누가 관리하는 거지? 자바 커뮤니티에서 유명한 baeldung의 글을 포함해서 여러 블로그들을 찾을 수 있었다. 기본적으로 SimpleAsyncTaskExecutor을 사용하기 때문에 ThreadPoolTaskExecutor을 사용하도록 하는 추가적인 설정을 하지 않으면 스레드의 개수가 무한대로 늘어날 수 있다. 결론부터 말하면 이것은 틀렸다.
1. @Async 적용하기
우선 Spring에서 @Async 어노테이션을 이용한 비동기 처리 방법은 매우 간단하다. Async의 경우 AOP로 동작하기 때문에 public 메소드 사용과 자가 호출이 불가능하다는 점만 유의하면 된다.
- SpringBootApplication에 @EnableAsync 적용
- 적용할 메소드에 @Async 추가
2. Async와 Thread Pool
이제 메소드는 비동기로 동작한다. 동시에 동작하는 비동기 메소드가 늘어나면 스레드는 어떻게 관리될 지 알아보자. @Async를 사용할 때, Executor를 Bean에서 등록하지 않으면 Spring에서는 AsyncTaskExecutor을 사용해서 스레드를 알아서 관리한다. 이 때 AsyncTaskExecutor의 기본 설정은 SimpleAsyncTaskExecutor가 아닌 ThreadPoolTaskExecutor이다. 만약 가상 스레드를 사용하고 있는 경우라면(Java 21 이상,
spring.threads.virtual.enabled
설정이true
인 경우) SimpleAsyncTaskExecutor을 기본 설정으로 한다.ThreadPoolTaskExecutor vs SimpleAsyncTaskExecutor
- ThreadPoolTaskExecutor가 auto-configured 된 경우에 스레드 풀은 8개의 코어 스레드까지 사용하고 더 이상 늘리지 않는다. 이러한 기본 설정은 yaml 파일에서 변경하거나 Configuration 클래스에서 변경할 수 있다.
- SimpleAsyncTaskExecutor을 사용하는 경우 스레드를 재사용하지 않는다. 매번 새로운 스레드를 생성하기 때문에 비효율적이다.
//ThreadPoolTaskExecutor 설정 - Configuration @Configuration @EnableAsync public class AsyncConfig { @Bean public Executor taskExecutor() { SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(2); executor.setQueueCapacity(500); executor.setThreadNamePrefix("Rab-"); executor.initialize(); return executor; } } //ThreadPoolTaskExecutor 설정 - application.yml spring.task.execution.pool.max-size=16 spring.task.execution.pool.queue-capacity=100 spring.task.execution.pool.keep-alive=10s
3. 코드 예제
3.1 기본 설정으로 실행
- setTimer 메소드를 비동기로 100번 실행
@Configuration @EnableAsync public class AsyncConfig { @Bean public Executor taskExecutor() { SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(2); executor.setQueueCapacity(500); executor.setThreadNamePrefix("Rab-"); executor.initialize(); return executor; } }
public class AsyncTimer { @Async public void setTimer(int minutes, int count) { try { Thread.sleep(minutes*60000); log.info("timer over : {}", count); } catch (InterruptedException e) { log.info("timer error : {}", e); } } }
public class AsynRabRunner implements ApplicationRunner { private final AsyncTimer asyncTimer; @Override public void run(ApplicationArguments args) throws Exception { for (int i = 0; i < 100; i++) { asyncTimer.setTimer(1,i); log.info("run() - 현재 스레드 개수 : {}", Thread.activeCount()); } } }
- 결과
- 스레드의 개수가 28개를 넘어가지 않는다.(Spring의 기본 스레드 풀 개수인 20개에 ThreadPoolTaskExecutor에서 설명한 최대 스레드의 개수 8개를 더한 값)
- timeOut으로 설정된 1분이 지난 시점부터 먼저 종료된 결과값이 출력된다.
//기본 설정 결과 출력 run() - 현재 스레드 개수 : 21 run() - 현재 스레드 개수 : 22 run() - 현재 스레드 개수 : 23 run() - 현재 스레드 개수 : 24 run() - 현재 스레드 개수 : 25 run() - 현재 스레드 개수 : 26 run() - 현재 스레드 개수 : 27 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 ... run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 timer over : 2 timer over : 0 timer over : 6 timer over : 1 timer over : 3 ...
3.2 Configuration에서 ThreadPoolTaskExecutor으로 설정
@Configuration @EnableAsync public class AsyncConfig { @Bean public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); return executor; } }
- 결과
- 기본 설정과 동일하게 스레드의 개수가 28개를 넘어가지 않는다.
//ThreadPoolTaskExecutor으로 설정 결과 출력 run() - 현재 스레드 개수 : 21 run() - 현재 스레드 개수 : 22 run() - 현재 스레드 개수 : 23 run() - 현재 스레드 개수 : 24 run() - 현재 스레드 개수 : 25 run() - 현재 스레드 개수 : 26 run() - 현재 스레드 개수 : 27 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28 ... run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 28
3.3 Configuration에서 SimpleAsyncTaskExecutor으로 설정
Configuraion에서 TaskExecutor을 SimpleAsyncTaskExecutor으로 직접 설정하고 동일한 메소드를 실행해보자.
@Configuration @EnableAsync public class AsyncConfig { @Bean public Executor taskExecutor() { SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); return executor; } }
- 결과
- 메소드가 실행될 때마다 스레드의 개수가 늘어난다.
//SimpleAsyncTaskExecutor으로 설정 후 결과 출력 run() - 현재 스레드 개수 : 21 run() - 현재 스레드 개수 : 22 run() - 현재 스레드 개수 : 23 run() - 현재 스레드 개수 : 24 run() - 현재 스레드 개수 : 25 run() - 현재 스레드 개수 : 26 run() - 현재 스레드 개수 : 27 run() - 현재 스레드 개수 : 28 run() - 현재 스레드 개수 : 29 run() - 현재 스레드 개수 : 30 run() - 현재 스레드 개수 : 31 ... run() - 현재 스레드 개수 : 119 run() - 현재 스레드 개수 : 120 timer over : 5 timer over : 6 timer over : 18 timer over : 25 timer over : 7 ...
많은 글에서 SimpleAsyncTaskExecutor을 기본 Executor로 설정한다고 설명했기에 Spring 버전이 업그레이드 되며 생긴 변화인 줄 알았으나 Spring Boot 2.1 버전부터 ThreadPoolTaskExecutor을 기본으로 설정하고 있었다. @Async를 사용하기 위해서 따로 설정을 하지 않아도 되며 만약 스레드 풀의 Limit을 늘리고 싶은 경우에 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 을 통해 executor를 생성하고 메소드를 이용해 설정을 변경하면 된다.