@Async와 ThreadPoolTaskExecutor를 활용한 Spring 비동기 처리

쩡log·2025년 5월 29일

Spring에서는 @Async 어노테이션을 사용하면 간단하게 비동기 작업을 구현할 수 있습니다. 하지만 아무런 설정 없이 기본값만 사용할 경우, 쓰레드가 과도하게 생성되어 시스템 리소스를 급격히 소모하고, 최악의 경우 애플리케이션이 다운될 위험도 있습니다.

이러한 문제를 방지하고 안정적이며 효율적인 비동기 처리를 구현하기 위해, Spring에서는 ThreadPoolTaskExecutor를 활용한 별도의 스레드 풀 설정을 지원합니다.

ThreadPoolTaskExecutor는 Spring에서 제공하는 TaskExecutor의 구현체로, Java의 java.util.concurrent.ThreadPoolExecutor를 감싸서 Spring 친화적으로 사용할 수 있도록 만든 클래스입니다.

모든 비동기 작업에 하나의 executor만 사용하는 것은 지양하는 것이 좋습니다. 업무 성격에 따라 executor를 나누면 더욱 유연하게 운영할 수 있습니다. 저는 비즈니스 로직과 알림 처리를 분리하기 위해 비즈니스용과 알림용 두 가지 ThreadPool을 설정해 사용하고 있습니다. 아래는 전체 설정 코드입니다.


import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

    private static final int coreCount = Runtime.getRuntime().availableProcessors();

    @Bean(name = "businessExecutor")
    public ThreadPoolTaskExecutor taskAsyncExecutor() {
        var executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(coreCount);
        executor.setMaxPoolSize(coreCount * 2);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("BusinessAsyncExecutor-");
        executor.setKeepAliveSeconds(60);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);

        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        executor.initialize();
        return executor;
    }

    @Bean(name = "alarmExecutor")
    public ThreadPoolTaskExecutor alarmAsyncExecutor() {
        var executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(coreCount);
        executor.setMaxPoolSize(coreCount * 2);
        executor.setQueueCapacity(250);
        executor.setThreadNamePrefix("AlarmAsyncExecutor-");
        executor.setKeepAliveSeconds(30);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);

        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        executor.initialize();
        return executor;
    }

}

코드설명

1. 시스템 코어 수 기반 설정

    private static final int coreCount = Runtime.getRuntime().availableProcessors();
  • 현재 시스템에서 사용 가능한 CPU 코어 수를 기준으로 쓰레드 풀 크기를 동적으로 설정합니다.

2. 비즈니스 로직 처리용 Executor (businessExecutor)

@Bean(name = "businessExecutor")
public ThreadPoolTaskExecutor taskAsyncExecutor() {
    var executor = new ThreadPoolTaskExecutor();

    executor.setCorePoolSize(coreCount);
    executor.setMaxPoolSize(coreCount * 2);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("BusinessAsyncExecutor-");
    executor.setKeepAliveSeconds(60);
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60);
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

    executor.initialize();
    return executor;
}
  • corePoolSize: 최소 유지할 쓰레드 수. CPU 코어 수 만큼 설정하여 CPU 사용을 최적화 합니다.

  • maxPoolSize: 최대 쓰레드 수. 일반적으로 CPU는 코어 수 이상으로 스레드를 실행할 수 있지만, 너무 많으면 오히려 컨텍스트 스위칭 비용(스레드 전환 비용)이 증가해 성능이 저하됩니다. coreCount * 2는 병렬성과 유연성을 모두 확보하면서 과도한 스레드 경쟁을 피할 수 있는 안정적인 상한선입니다.

  • ueueCapacity: 작업 큐 크기 (500개의 작업을 큐에서 대기 가능) 유휴 쓰레드가 없으면 큐에서 대기합니다.

  • keepAliveSeconds: 유휴 쓰레드의 생존 시간 (기본은 60초)

  • threadNamePrefix: 디버깅에 유용한 쓰레드 이름 프리픽스

  • waitForTasksToCompleteOnShutdown:애플리케이션 종료 시 남은 작업이 끝날 때까지 대기 여부

  • awaitTerminationSeconds: 종료 대기 최대 시간

  • rejectedExecutionHandler: 작업 거부 시 정책 (여기서는 AbortPolicy → 예외 발생)


3. 알림 처리용 Executor (alarmExecutor)

@Bean(name = "alarmExecutor")
public ThreadPoolTaskExecutor alarmAsyncExecutor() {
    var executor = new ThreadPoolTaskExecutor();

    executor.setCorePoolSize(coreCount);
    executor.setMaxPoolSize(coreCount * 2);
    executor.setQueueCapacity(250);
    executor.setThreadNamePrefix("AlarmAsyncExecutor-");
    executor.setKeepAliveSeconds(30);
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(30);
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

    executor.initialize();
    return executor;
}
  • alarmExecutor는 알림 등 비교적 짧고 빈번한 작업 처리에 적합한 설정입니다.
  • queueCapacity와 keepAliveSeconds 값을 businessExecutor보다 작게 설정하여 자원 점유를 줄입니다.

4. 실제 사용 예시

@Component
@RequiredArgsConstructor
public class StaffActionLogHelper {

    private final StaffActionLogProvider staffActionProvider;

    @Async("businessExecutor")
    public void create(StaffActionLog request) {
        staffActionProvider.save(request);
    }

}

@Async("businessExecutor")를 통해 지정한 executor에서 비동기 작업이 수행됩니다.

executor 이름은 @Bean(name = "...")에서 정의한 이름과 일치해야 합니다.


결론

@EnableAsync와 ThreadPoolTaskExecutor를 함께 사용하면 Spring 애플리케이션에서 효율적이고 안정적인 비동기 처리가 가능합니다.

특히, 서로 다른 executor를 나누어 구성하면 작업 간의 간섭을 방지하고, 한 executor가 과부하로 인해 실패하더라도 다른 executor로의 장애 전파를 차단할 수 있습니다.

0개의 댓글