본문 바로가기

Spring Framework/Spring

[Spring] Spring의 비동기 처리 — @Async부터 Virtual Thread 통합까지

들어가며

이전 포스트에서 자바의 네 가지 동시성 구현 메커니즘(Thread, Executor, Fork/Join, Virtual Thread)을 살펴보았습니다. 이번 포스트에서는 Spring Framework가 이 위에서 비동기 처리를 어떻게 추상화하는지 정리합니다.

 

Spring은 @Async 어노테이션 하나로 메서드를 비동기로 실행할 수 있게 해줍니다. 편리하지만, 내부 동작 원리를 이해하지 못하면 "분명히 @Async를 붙였는데 동기로 실행된다"는 문제를 마주치게 됩니다. 이 포스트에서는 설정부터 동작 원리, 흔한 함정, 그리고 Spring Boot 3.2+에서의 Virtual Thread 통합까지 다룹니다.

 


1. @Async와 ThreadPoolTaskExecutor 설정

1-1. @EnableAsync 활성화

Spring에서 비동기 처리를 사용하려면 먼저 @EnableAsync를 선언해야 합니다.

@Configuration
@EnableAsync
public class AsyncConfig {
}

이것만으로도 @Async가 붙은 메서드는 비동기로 실행됩니다.

 

다만 기본 Executor는 환경에 따라 다릅니다.

Spring Framework 단독 사용 시에는 별도 Executor 빈이 없으면 SimpleAsyncTaskExecutor가 폴백으로 사용되는데, 이 Executor는 매 호출마다 새 스레드를 생성하므로 스레드 풀링이 되지 않습니다.

 

Spring Boot를 사용하는 경우에는 ThreadPoolTaskExecutor를 자동 구성해주지만, 기본값(core 8, 무제한 큐 등)이 애플리케이션 특성에 맞지 않을 수 있습니다. 어느 쪽이든 운영 환경에서는 스레드 풀을 직접 구성하는 것이 좋습니다.

 

1-2. ThreadPoolTaskExecutor 빈 등록

운영 환경에서는 ThreadPoolTaskExecutor를 직접 빈으로 등록하여 스레드 풀을 관리해야 합니다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

 

각 설정 항목의 의미는 다음과 같습니다.

설정 설명
corePoolSize 기본적으로 유지되는 스레드 수
maxPoolSize 큐가 가득 찼을 때 확장 가능한 최대 스레드 수
queueCapacity 코어 스레드가 모두 사용 중일 때 작업이 대기하는 큐의 크기
keepAliveSeconds 코어 수를 초과한 유휴 스레드가 제거되기까지의 대기 시간
threadNamePrefix 스레드 이름 접두사 (로그에서 비동기 스레드를 식별할 때 유용)
rejectedExecutionHandler 큐와 스레드 모두 가득 찼을 때의 거부 정책

ThreadPoolTaskExecutor는 내부적으로 자바의 ThreadPoolExecutor를 감싸고 있습니다. 작업 처리 흐름(core → 큐 → max → 거부 정책)은 동일하며, Spring 생명주기와 통합되어 애플리케이션 종료 시 shutdown()이 자동 호출됩니다.

 

1-3. 여러 Executor 사용하기

용도별로 서로 다른 스레드 풀을 구성할 수 있습니다. @Asyncvalue 속성에 빈 이름을 지정하면 됩니다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("mailExecutor")
    public ThreadPoolTaskExecutor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("mail-");
        executor.initialize();
        return executor;
    }

    @Bean("reportExecutor")
    public ThreadPoolTaskExecutor reportExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("report-");
        executor.initialize();
        return executor;
    }
}
@Service
public class NotificationService {

    @Async("mailExecutor")
    public void sendMail(String to, String content) {
        // mailExecutor 스레드 풀에서 실행
    }

    @Async("reportExecutor")
    public CompletableFuture<Report> generateReport(Long id) {
        // reportExecutor 스레드 풀에서 실행
        return CompletableFuture.completedFuture(report);
    }
}

이렇게 하면 메일 발송과 리포트 생성이 서로 다른 스레드 풀에서 독립적으로 실행되므로, 한쪽의 부하가 다른 쪽에 영향을 주지 않습니다.

 


2. @Async 반환 타입별 사용법

@Async 메서드의 반환 타입에 따라 사용 방식이 달라집니다.

 

2-1. void — Fire and Forget

결과가 필요 없는 경우입니다. 호출 즉시 반환되며, 비동기 작업은 백그라운드에서 실행됩니다.

@Async
public void sendNotification(String userId) {
    // 알림 발송 — 호출자는 완료를 기다리지 않음
    notificationClient.send(userId, "새 메시지가 도착했습니다.");
}

 

주의할 점은, void 반환 시 메서드 내부에서 발생한 예외가 호출자에게 전파되지 않는다는 것입니다. 예외는 기본적으로 로그에만 출력됩니다. 예외를 처리하려면 AsyncUncaughtExceptionHandler를 구현하면 됩니다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            // 예외 로깅, 알림 등
            log.error("비동기 메서드 예외 - {}: {}", method.getName(), ex.getMessage(), ex);
        };
    }
}

 

2-2. Future / ListenableFuture — 레거시 방식

Java 5의 Future를 반환할 수 있지만, get() 호출 시 블로킹되므로 비동기의 이점이 반감됩니다.

@Async
public Future<String> fetchData() {
    String result = externalApi.call();
    return new AsyncResult<>(result);  // Spring이 제공하는 헬퍼
}

// 호출 측
Future<String> future = service.fetchData();
String result = future.get(); // 블로킹

ListenableFuture는 Spring 4에서 콜백을 지원하기 위해 도입되었지만, Spring 6에서 deprecated 되었습니다. 새 코드에서는 사용할 이유가 없습니다.

 

2-3. CompletableFuture — 권장 방식

Java 8+의 CompletableFuture를 반환하는 것이 현재 권장되는 방식입니다. 논블로킹 콜백 체이닝과 작업 조합이 가능합니다.

@Async
public CompletableFuture<User> findUser(Long id) {
    User user = userRepository.findById(id).orElseThrow();
    return CompletableFuture.completedFuture(user);
}

@Async
public CompletableFuture<List<Order>> findOrders(Long userId) {
    List<Order> orders = orderRepository.findByUserId(userId);
    return CompletableFuture.completedFuture(orders);
}

 

호출 측에서는 두 작업을 병렬로 실행하고 논블로킹으로 조합할 수 있습니다.

CompletableFuture<User> userFuture = userService.findUser(userId);
CompletableFuture<List<Order>> ordersFuture = orderService.findOrders(userId);

CompletableFuture.allOf(userFuture, ordersFuture).thenAccept(v -> {
    User user = userFuture.join();
    List<Order> orders = ordersFuture.join();
    // user와 orders를 결합하여 처리
});

 

예외 처리도 체이닝으로 할 수 있습니다.

CompletableFuture<User> result = userService.findUser(userId)
    .exceptionally(ex -> {
        log.error("사용자 조회 실패: {}", ex.getMessage());
        return User.unknown();  // 대체 값 반환
    });

 

반환 타입 비교 요약

반환 타입 결과 수신 예외 처리 작업 조합 권장 여부
void 불가 AsyncUncaughtExceptionHandler 불가 결과 불필요 시 사용
Future get() — 블로킹 ExecutionException 불가 비권장 (레거시)
CompletableFuture 논블로킹 콜백 exceptionally, handle allOf, thenCombine 권장

 


3. 프록시 기반 동작 원리

3-1. @Async는 어떻게 동작하는가?

@AsyncSpring AOP 프록시를 통해 동작합니다. @EnableAsync를 선언하면 Spring은 @Async가 붙은 메서드를 가진 빈에 대해 프록시 객체를 생성합니다. 이 프록시가 원래 메서드 호출을 가로채서 Executor에 작업으로 제출합니다.

 

즉, 호출자가 보는 것은 프록시 객체이며, 프록시가 실제 메서드 호출을 별도 스레드에서 실행되도록 중개합니다.

 

3-2. 같은 클래스 내부 호출 문제

이 프록시 기반 구조 때문에 발생하는 가장 흔한 함정이 있습니다. 같은 클래스 내에서 @Async 메서드를 호출하면 비동기로 동작하지 않습니다.

@Service
public class OrderService {

    public void createOrder(OrderRequest request) {
        // ... 주문 생성 로직
        sendNotification(request.getUserId()); // ❌ 비동기로 동작하지 않음!
    }

    @Async
    public void sendNotification(String userId) {
        // 이 메서드는 동기로 실행됨
        notificationClient.send(userId, "주문이 완료되었습니다.");
    }
}

 

왜 이런 일이 발생할까요? createOrder()에서 sendNotification()을 호출할 때, 이 호출은 프록시를 거치지 않고 this(실제 객체)를 직접 호출합니다. 프록시를 거치지 않으므로 @Async 가로채기가 작동하지 않습니다. 위 다이어그램의 하단에서 보듯이, 내부 호출은 프록시와 Executor에 도달하지 못하고 호출자 스레드에서 그대로 동기 실행됩니다.

 

3-3. 해결 방법

방법 1: 별도 빈으로 분리 (권장)

가장 깔끔한 방법은 비동기 메서드를 별도의 빈으로 분리하는 것입니다.

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void createOrder(OrderRequest request) {
        // ... 주문 생성 로직
        notificationService.sendNotification(request.getUserId()); // O 프록시 통과
    }
}

@Service
public class NotificationService {

    @Async
    public void sendNotification(String userId) {
        notificationClient.send(userId, "주문이 완료되었습니다.");
    }
}

외부 빈을 통해 호출하므로 프록시를 거치게 되어 @Async가 정상 동작합니다.

 

방법 2: 자기 자신 주입 (Self-Injection)

빈 분리가 어려운 경우, 자기 자신의 프록시를 주입받아 호출할 수 있습니다.

@Service
public class OrderService {

    @Lazy
    @Autowired
    private OrderService self;  // 프록시 객체가 주입됨

    public void createOrder(OrderRequest request) {
        // ... 주문 생성 로직
        self.sendNotification(request.getUserId()); // O 프록시 통과
    }

    @Async
    public void sendNotification(String userId) {
        notificationClient.send(userId, "주문이 완료되었습니다.");
    }
}

@Lazy를 붙이는 이유는 순환 참조를 방지하기 위함입니다. 하지만 이 방식은 코드의 의도가 명확하지 않으므로, 가능하면 방법 1(빈 분리)을 사용하는 것이 좋습니다.

 


4. Virtual Thread 통합

4-1. Spring Boot 3.2+에서의 설정

Spring Boot 3.2(Java 21+)부터는 단 한 줄의 설정으로 Virtual Thread를 활성화할 수 있습니다.

# application.properties
spring.threads.virtual.enabled=true

이 설정을 활성화하면 Spring Boot의 동작이 여러 부분에서 변경됩니다. @Async 작업의 기본 Executor가 ThreadPoolTaskExecutor 대신 Virtual Thread 기반의 SimpleAsyncTaskExecutor로 교체됩니다. Tomcat, Jetty 등 내장 웹 서버의 요청 처리 스레드도 Virtual Thread로 전환되며, @Scheduled 작업의 스케줄러도 Virtual Thread를 사용하는 SimpleAsyncTaskScheduler로 교체됩니다.

 

4-2. 커스텀 Executor와의 관계

주의할 점은, 커스텀 Executor 빈을 등록하면 해당 빈이 우선된다는 것입니다. 즉, ThreadPoolTaskExecutor를 직접 빈으로 등록한 상태에서 spring.threads.virtual.enabled=true를 설정하더라도, @Async는 여전히 커스텀 ThreadPoolTaskExecutor를 사용합니다.

 

Virtual Thread를 사용하면서 커스텀 설정도 하고 싶다면, Virtual Thread 기반의 Executor를 직접 등록하면 됩니다.

@Bean
public Executor taskExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

 

4-3. 기존 ThreadPoolTaskExecutor와 비교

항목 ThreadPoolTaskExecutor Virtual Thread
스레드 모델 플랫폼 스레드 풀링 작업마다 새 가상 스레드 생성
동시 처리 수 maxPoolSize에 제한 사실상 무제한
적합한 작업 CPU 바운드, 정밀한 풀 제어 필요 시 I/O 바운드 (DB 조회, API 호출, 파일 I/O)
풀 크기 설정 corePoolSize, maxPoolSize 튜닝 필요 설정 불필요
모니터링 스레드 풀 메트릭 활용 가능 전통적 풀 메트릭 해당 없음

 

4-4. 주의사항

Virtual Thread를 Spring에서 사용할 때도 자바 수준의 주의사항은 동일하게 적용됩니다.

 

synchronized 블록 내 블로킹 I/O는 Pinning을 유발합니다. 이 경우 가상 스레드가 캐리어 스레드에 고정되어 Virtual Thread의 이점이 사라집니다. synchronized 대신 ReentrantLock을 사용하면 해결됩니다. 서드파티 라이브러리(JDBC 드라이버 등)가 내부적으로 synchronized를 사용하는 경우도 있으므로, JDK Flight Recorder 등으로 Pinning을 모니터링하는 것이 좋습니다.

 

CPU 바운드 작업에는 이점이 없습니다. Virtual Thread는 블로킹 대기 시간에 캐리어 스레드를 재활용하는 것이 핵심이므로, CPU를 계속 사용하는 작업에서는 ThreadPoolTaskExecutor가 더 적합합니다.

 

ThreadLocal 사용에 주의해야 합니다. 요청마다 가상 스레드가 생성되므로, ThreadLocal에 무거운 객체를 저장하면 메모리가 급증할 수 있습니다.

 


5. 정리

항목 설정/방식 비고
비동기 활성화 @EnableAsync 필수
스레드 풀 ThreadPoolTaskExecutor 빈 등록 운영 환경 권장
반환 타입 CompletableFuture 논블로킹, 조합 가능
주의사항 같은 클래스 내부 호출 시 @Async 미동작 빈 분리로 해결
Virtual Thread spring.threads.virtual.enabled=true Spring Boot 3.2+, Java 21+

 

Spring의 @Async는 편리하지만, 프록시 기반 동작 원리를 이해하지 않으면 예상치 못한 동기 실행에 당황할 수 있습니다. 운영 환경에서는 반드시 ThreadPoolTaskExecutor를 명시적으로 구성하거나, Java 21 이상이라면 Virtual Thread 활성화를 고려해 보시기 바랍니다.

 


참고 자료