Dev/Java

@Async와 스레드 풀 - ThreadPoolTaskExecutor가 기본 설정이다!

frog-in-well 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를 생성하고 메소드를 이용해 설정을 변경하면 된다.