Spring @Async를 쓰면서 생각해볼 것들

kindof

·

2026. 2. 13. 20:32

Tomcat Thread Pool과 Async Thread Pool

Spring 기반 애플리케이션을 운영하면 아래와 같은 구조를 기반으로 쓰레드풀을 관리하여 클라이언트 응답을 처리한다.

[클라이언트 요청]
      ↓
[Nginx / Load Balancer]
      ↓
[Tomcat 쓰레드풀]  ← HTTP 요청 수신, 응답 반환
      ↓
[Spring MVC Controller → Service]
      │
      ├── 동기 처리 → Tomcat 쓰레드에서 직접 실행, 응답 후 반납
      │
      └── @Async 호출 → Spring Async 쓰레드풀로 작업 위임
                         Tomcat 쓰레드는 즉시 응답 반환 후 반납

Tomcat 쓰레드풀은 HTTP 요청을 받아서 응답을 돌려주는 역할로 쓰레드 하나가 요청 하나를 처리하며 Async 쓰레드풀은 @Async로 위임된 비동기 작업을 실행하는 역할을 한다.

메일 발송, 알림 전송, 로그 적재처럼 클라이언트 응답에 포함할 필요 없는 작업은 Tomcat 쓰레드에서 처리할 이유가 없다. 이런 작업을 별도 쓰레드풀로 위임하면 Tomcat 쓰레드를 빨리 반납할 수 있다.

 

 

@Async 쓰레드풀의 분리(AsyncConfigurer)

Async 쓰레드 풀은 AsyncConfigurer를 구현하여 기본 설정한다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("DEFAULT-ASYNC-");
        executor.setDaemon(true);
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new SimpleAsyncUncaughtExceptionHandler();
    }
}

참고로 Spring Boot의 @EnableAsync 기본 Executor는 SimpleAsyncTaskExecutor인데, 이건 매 호출마다 새 쓰레드를 생성한다. 반드시 위처럼 ThreadPoolTaskExecutor를 설정해야 한다.

 

하지만 서비스 운영에서는 용도에 따라 Executor를 분리하는 패턴을 사용한다.

@Configuration
public class AsyncExecutors {

    @Bean("mailSendExecutor")
    public Executor mailSendExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("MAIL-SEND-");
        return executor;
    }

    @Bean("notificationExecutor")
    public Executor notificationExecutor() {
        return Executors.newCachedThreadPool(
            new CustomizableThreadFactory("NOTIFY-")
        );
    }
}

DEFAULT-ASYNC 쓰레드 풀 하나만 두고 모든 비동기 작업을 처리하면 한 종류의 비동기 처리 작업에 장애가 나면 쓰레드 풀 전체에 영향이 가버리게 된다.

 

예를 들어, 알림을 보내는 서버가 느려져서 알림 하나 보내는 데 10초 걸리게 되면 비동기 쓰레드풀 전체가 점유되게 되어 다른 종류의 비동기 작업에 모두 장애가 발생하게 된다.

 

그래서 사용 목적에 따라 Executor를 반드시 분리해야 한다. 다만, 풀이 많아지면 전체 쓰레드 수가 늘어 메모리 사용량이 증가하고, 각 풀의 크기를 개별 튜닝해야 하는 관리 부담도 생긴다. 사용량이 적은 풀은 쓰레드가 놀고 있어 자원이 낭비되기도 한다. 그래서 모든 @Async를 분리할 필요는 없고, 외부 의존성이 다르거나 장애 격리가 필요한 핵심 작업만 분리하고 나머지는 기본 풀을 공유하는 것이 현실적이다.

 

 

@Async + @Transactional 같이 쓸 때

아래와 같이 @Async + @Transactional을 함께 사용하는 경우 Async 쓰레드 1개 + DB 커넥션 1개가 동시에 점유된다.

@Async("mailSendExecutor")
@Transactional(readOnly = true)
public void sendNotification(Long userId) {
    User user = userRepository.findById(userId);  // DB 커넥션 점유
    mailService.send(user.getEmail(), ...);       // 외부 API 호출 (느림)
}

메일 발송이 느려지면 DB 커넥션이 계속 잡혀있게 되고 커넥션 풀 문제가 생길 수 있다.

 

이런 사태를 방지하기 위해 실제 서비스에서는 대부분 조회와 비동기 작업은 분리해서 사용한다.

// Controller 또는 동기 Service에서 먼저 조회
User user = userRepository.findById(userId);
asyncMailService.send(user.getEmail(), ...);  // DB 커넥션 불필요한 데이터만 전달

// Async 메서드에서는 DB 접근 없이 처리
@Async("mailSendExecutor")
public void send(String email, String content) {
    mailService.send(email, content);  // DB 커넥션 불필요
}

 

 

Async 쓰레드풀 개수는 DB 커넥션 풀을 확인하고...

Async 쓰레드풀의 maxPoolSize를 50으로 두고 HikariCP maximumPoolSize를 10으로 두면 어떻게 될까? Async 쓰레드 50개가 동시에 DB 작업을 하면, 40개는 커넥션을 기다리며 블로킹된다. 쓰레드풀 크기를 정할 때는 DB 커넥션 풀 크기를 함께 고려해야 한다.

 

 

@Async 쓰는거랑 Kafka 이벤트 분리하는거랑 무슨 차이?

문득 @Async 쓰는거랑 Kafka 이벤트 분리하는거랑 무슨 차이인지 궁금한 적이 있다.

 

@Async는 같은 프로세스(JVM) 내에서 특정 작업을 비동기로 처리하여 응답 시간을 줄이는 데 목적이 있다. 서버가 죽으면 @Async 작업도 당연히 실패하고 재처리도 불가능하다. 그리고 @Async 전용 쓰레드풀을 사용하기 때문에 스케일업이 쉽지도 않다. 하지만 그냥 코드 구현을 통해 비동기 작업을 처리하는 것이기 때문에 가벼운 느낌이다.

Kafka 이벤트로 분리하는 것은 좀 더 본격적이다. 메시지 유실이 허용되지 않고, Produce/Consume 속도도 다를 수 있다. Consumer 자체를 스케일아웃하는 경우도 많고 재처리가 필요한 경우도 많다.

 

예를 들어 주문 완료 후 메일 한 통 보내는 정도면 @Async로 충분할 것 같다. 하지만 주문 이벤트를 메일 서비스, 포인트 서비스, 정산 서비스가 각각 소비해야 하고, 하루 수백만 건에 피크 시 급증하며, 서비스가 잠시 죽어도 복구 후 밀린 메시지를 처리해야 한다면 Kafka가 적합하겠다.

 

 

@Async와 Future는 페어인가?

async 메서드를 소개하는 스프링 공식 문서를 보면 @Async와 CompletableFuture를 함께 사용하는 코드를 소개하고 있다.

@Async
public CompletableFuture<User> findUser(String user) throws InterruptedException {
    logger.info("Looking up " + user);
    String url = String.format("https://api.github.com/users/%s", user);
    User results = restTemplate.getForObject(url, User.class);
    // Artificial delay of 1s for demonstration purposes
    Thread.sleep(1000L);
    return CompletableFuture.completedFuture(results);
}

 

뭔가 @Async와 Future는 페어의 느낌이 난다. 하지만 @Async와 Future는 항상 같이 써야만 하는 조합이 아니다. 오히려 결과 반환이 필요없는 void 타입의 @Async 메서드가 더 많이 쓰인다.

 

Future가 필요한 것은 비동기로 실행하되, 결과가 필요한 때다. 그리고 공식 문서의 코드처럼 CompletableFuture가 유용한 것은 여러 외부 API를 병렬로 호출할 때다. 

// 순차 호출: 3 + 2 + 2 = 7초
Profile profile = userService.getProfile(userId);
List<Order> orders = orderService.getOrders(userId);
int point = pointService.getPoint(userId);

// 병렬 호출: max(3, 2, 2) = 3초
CompletableFuture<Profile> profileF = userService.getProfile(userId);
CompletableFuture<List<Order>> ordersF = orderService.getOrders(userId);
CompletableFuture<Integer> pointF = pointService.getPoint(userId);

CompletableFuture.allOf(profileF, ordersF, pointF).join();
return new UserDetail(profileF.get(), ordersF.get(), pointF.get());

이런 경우가 CompletableFuture와 @Async를 함께 쓰는 가장 실용적인 패턴이다.