-
Spring Boot 3.x에서 @Async와 버추얼 스레드 조합하기Server/Spring Boot 2026. 1. 9. 15:57728x90반응형
기존
@Async는 플랫폼 스레드 풀을 사용한다. 동시 요청이 많아지면 스레드 고갈 문제가 발생하고, 풀 사이즈를 늘리면 메모리 부담이 커진다. Java 21의 버추얼 스레드를@Async와 조합하면 이 문제를 해결할 수 있다.이 글에서는 설정 방법부터 실무 적용 시 주의점까지 정리한다.
배경 지식
@Async 기본 동작
@Async를 붙이면 해당 메서드는 별도 스레드에서 실행된다. 기본적으로SimpleAsyncTaskExecutor를 사용하며, 보통은ThreadPoolTaskExecutor로 커스터마이징해서 쓴다.버추얼 스레드 특징
- 경량 스레드 (수십만 개 생성 가능)
- I/O 블로킹 시 자동으로 캐리어 스레드 반환
- JDK 21부터 정식 지원
버추얼 스레드 원리에 대한 자세한 내용은 이전 글을 참고하자.
설정 방법
1. application.yml 설정
Spring Boot 3.2 이상에서는 간단한 설정으로 버추얼 스레드를 활성화할 수 있다.
spring: threads: virtual: enabled: true이 설정만으로 Tomcat, Jetty 등의 요청 처리 스레드가 버추얼 스레드로 전환된다. 하지만
@Async는 별도 설정이 필요하다.2. @Async 전용 Executor 설정
@Configuration @EnableAsync public class AsyncConfig { @Bean(name = "virtualThreadExecutor") public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } }newVirtualThreadPerTaskExecutor()는 작업마다 새로운 버추얼 스레드를 생성한다. 기존 스레드 풀처럼 사이즈 제한이 없다.3. 사용 예시
@Service @RequiredArgsConstructor public class NotificationService { private final ExternalApiClient apiClient; @Async("virtualThreadExecutor") public CompletableFuture<SendResult> sendNotificationAsync(Long userId, String message) { // 외부 API 호출 (I/O 바운드) SendResult result = apiClient.send(userId, message); return CompletableFuture.completedFuture(result); } }@Async어노테이션에 빈 이름을 명시해서 버추얼 스레드 Executor를 사용하도록 지정한다.4. 여러 작업 병렬 처리
public List<SendResult> sendBulkNotifications(List<Long> userIds, String message) { List<CompletableFuture<SendResult>> futures = userIds.stream() .map(userId -> sendNotificationAsync(userId, message)) .toList(); return futures.stream() .map(CompletableFuture::join) .toList(); }1만 명에게 알림을 보내도 버추얼 스레드는 가볍기 때문에 부담이 적다.
주의사항
버추얼 스레드가 만능은 아니다. 실무 적용 전 반드시 알아야 할 점들이 있다.
항목 문제점 해결 방법 CPU 바운드 작업 버추얼 스레드 이점 없음 기존 ThreadPoolTaskExecutor 사용 synchronized 블록 캐리어 스레드 피닝 발생 ReentrantLock으로 대체 ThreadLocal 버추얼 스레드마다 복사되어 메모리 증가 ScopedValue 사용 검토 Connection Pool 스레드는 많은데 커넥션 부족 HikariCP 사이즈 조정 필요 synchronized 피닝 예시
// Bad: 캐리어 스레드 피닝 발생 public synchronized void process() { // 블로킹 I/O 작업 } // Good: ReentrantLock 사용 private final ReentrantLock lock = new ReentrantLock(); public void process() { lock.lock(); try { // 블로킹 I/O 작업 } finally { lock.unlock(); } }
실무 적용 시나리오
적합한 케이스
- 외부 API 다중 호출 (결제, 알림, 배송 연동)
- 대량 이메일/SMS 발송
- 여러 마이크로서비스 데이터 조회 후 병합
- 파일 업로드/다운로드 처리
부적합한 케이스
- 복잡한 수학 계산, 이미지 처리 (CPU 바운드)
- synchronized 키워드가 많은 레거시 코드
- 무거운 ThreadLocal 사용 코드
성능 비교
10,000건의 외부 API 호출을 시뮬레이션한 결과다.
구분 스레드 수 처리 시간 메모리 사용량 ThreadPoolTaskExecutor (200) 200 52초 380MB 버추얼 스레드 10,000 11초 95MB 버추얼 스레드가 처리 시간 4.7배 단축, 메모리 75% 절감 효과를 보였다.
테스트 환경: Spring Boot 3.2, JDK 21, 외부 API 응답시간 100ms 가정
마무리
@Async에 버추얼 스레드를 적용하면 I/O 바운드 작업의 처리량이 크게 향상된다.synchronized피닝, ThreadLocal 메모리 증가 등 주의점을 반드시 체크하자.- CPU 바운드 작업은 기존 스레드 풀을 유지하는 것이 낫다.
다음 글에서는 버추얼 스레드 환경에서의 모니터링과 디버깅 방법을 다룰 예정이다.
728x90반응형'Server > Spring Boot' 카테고리의 다른 글
Spring WebClient와 버추얼 스레드, 어떻게 조합할까? (1) 2026.01.09 🏦 Spring Boot 대용량 트래픽 환경에서의 계좌 API 구현 (0) 2026.01.05 Java 21 버추얼 스레드 원리와 Spring Boot 설정 방법 (0) 2026.01.05 Spring Boot에서 많이 사용되는 View 엔진 추천 및 장단점 (0) 2025.04.29 자주 사용되는 디자인 패턴에 대해 알아보자 (springboot) (3) 2025.04.28