1. 서론: 동기식 호출의 늪과 비동기(Async)의 필요성
웹 애플리케이션에서 회원가입이나 주문 완료 API를 호출했을 때, 화면이 3~5초간 멈칫거리며 로딩되는 경험을 누구나 해보았을 것입니다.
대부분의 원인은 메인 비즈니스 로직 뒤에 붙어 있는 부가적인 작업들 때문입니다:
- 신규 가입 축하 이메일 / 카카오 알림톡 발송 (외부 SMTP / API 연동: 1~3초 소요)
- 마케팅 포인트 적립 및 슬랙 알림 전송 (외부 네트워크 I/O)
- 빅데이터 분석을 위한 감사 로그(Audit Log) 적재
사용자가 실제로 원하는 것은 “회원가입이 완료되었습니다"라는 즉각적인 피드백입니다. 이메일 전송이 2초 뒤에 끝나든 3초 뒤에 끝나든 사용자는 기다릴 이유가 없습니다.
이처럼 즉각적인 응답이 필요하지 않은 부가 작업을 백그라운드 작업 스레드로 격리하여 비동기(Asynchronous)로 처리하면, 사용자의 체감 응답 시간을 수 초에서 수십 밀리초(ms) 단위로 단축시킬 수 있습니다. 이번 포스팅에서는 Spring Boot의 @Async 동작 원리, 기본 풀의 위험성과 커스텀 ThreadPoolTaskExecutor, MDC/트랜잭션 함정, 그리고 Java 21 가상 스레드(Virtual Threads) 연동까지 완벽히 정리합니다.
2. 동기(Sync) vs 비동기(Async) 실행 흐름 비교
동기식 처리와 비동기식 처리에서 사용자 응답 시간과 스레드 점유의 차이를 시각화한 다이어그램입니다.
rect rgb(255, 235, 238)
Note over User,Ext: 동기식 처리 (총 2,150ms 대기)
User->>Tom: 회원가입 요청
Tom->>DB: 회원 정보 INSERT (50ms)
Tom->>Ext: 이메일 발송 대기 (2,100ms 블로킹!)
Ext-->>Tom: 발송 완료
Tom-->>User: 200 OK (사용자는 2초 이상 대기)
end
rect rgb(232, 245, 233)
Note over User,Ext: 비동기 @Async 처리 (총 55ms 만에 즉시 응답)
User->>Tom: 회원가입 요청
Tom->>DB: 회원 정보 INSERT (50ms)
Tom->>Worker: 이메일 전송 비동기 위임 (fire-and-forget: 5ms)
Tom-->>User: 200 OK (즉시 가입 완료 응답!)
Worker->>Ext: 백그라운드에서 이메일 발송 실행 (2,100ms)
Ext-->>Worker: 백그라운드 완료
end
3. 기본 @Async의 치명적 함정: SimpleAsyncTaskExecutor
Spring Boot에서 @EnableAsync만 선언하고 별도의 스레드 풀 빈(TaskExecutor)을 등록하지 않으면, 스프링은 기본적으로 **SimpleAsyncTaskExecutor**를 사용합니다.
[!CAUTION] SimpleAsyncTaskExecutor는 스레드를 절대 재사용(Pool)하지 않습니다! 요청이 들어올 때마다 매번 새로운 OS 네이티브 스레드를
new Thread()로 생성하고, 작업이 끝나면 스레드를 파괴합니다. 트래픽이 갑자기 몰릴 경우 수천 개의 스레드가 일시에 생성되어 **java.lang.OutOfMemoryError: unable to create new native thread**가 터지며 JVM 전체가 셧다운됩니다. 프로덕션 환경에서는 반드시 커스텀ThreadPoolTaskExecutor를 명시적으로 등록해야 합니다.
4. 엔터프라이즈급 ThreadPoolTaskExecutor 커스텀 설정
안정적인 큐 크기, 스레드 수, 우아한 종료(Graceful Shutdown), 그리고 큐 포화 시의 거절 정책(RejectedExecutionHandler)을 정의한 표준 설정 클래스입니다.
@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {
@Bean(name = "mailTaskExecutor")
public Executor mailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 1. 기본 상시 유지 스레드 수
executor.setCorePoolSize(5);
// 2. 최대 생성 가능 스레드 수
executor.setMaxPoolSize(20);
// 3. 작업 대기 큐 용량
executor.setQueueCapacity(200);
// 4. 스레드 이름 접두사 (로그 추적용)
executor.setThreadNamePrefix("MailAsync-");
// 5. 큐와 최대 스레드가 모두 꽉 찼을 때의 거절 정책: 호출자 스레드가 직접 실행
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 6. 스프링 종료 시 실행 중인 작업이 끝날 때까지 대기
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
// void 비동기 메서드에서 예외 발생 시 전역 로깅
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
log.error("[Async Exception] 메서드: {}, 원인: {}", method.getName(), throwable.getMessage(), throwable);
};
}
}
ThreadPool 동작 순서의 진실 (중요!)
자바의 ThreadPoolExecutor는 개발자의 일반적인 직관과 다르게 동작합니다:
- 작업 인입 ➔
corePoolSize(5개)까지 스레드를 먼저 생성함. - 스레드가 5개 찼을 때 새 작업 인입 ➔
maxPoolSize로 늘어나는 것이 아니라,queueCapacity(대기열 200개)를 먼저 채움! - 큐 200개가 꽉 찬 이후에 비로소 ➔
maxPoolSize(20개)까지 스레드를 추가 생성함. - 스레드 20개와 큐 200개가 모두 꽉 차면 ➔
RejectedExecutionHandler(CallerRunsPolicy)가 발동됨.
5. 결과 반환 비동기 처리: CompletableFuture
반환값이 필요 없는 작업은 void로 선언하지만, 비동기 연산의 결과값(HTTP 외부 조회 등)을 받아 메인 로직에서 조합해야 할 때는 Java 8의 **CompletableFuture<T>**를 사용합니다.
@Service
@Slf4j
public class OrderNotificationService {
// 1. 반환값이 없는 단순 알림 (Fire-and-Forget)
@Async("mailTaskExecutor")
public void sendWelcomeEmail(String email, String username) {
log.info("[{}] 이메일 발송 시작 -> {}", Thread.currentThread().getName(), email);
try {
Thread.sleep(2000); // 2초 걸리는 무거운 SMTP 전송
log.info("[{}] 이메일 발송 성공", Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 2. 비동기 결과를 반환하는 메서드
@Async("mailTaskExecutor")
public CompletableFuture<CreditScoreResponse> fetchCreditScoreAsync(String userId) {
log.info("외부 신용평가원 API 비동기 조회 시작: {}", userId);
// 외부 API 호출 시뮬레이션
CreditScoreResponse result = new CreditScoreResponse(userId, 850);
return CompletableFuture.completedFuture(result);
}
}
여러 외부 API를 병렬로 호출하여 한 번에 취합할 때 CompletableFuture.allOf()를 활용하면 전체 응답 속도가 비약적으로 향상됩니다.
CompletableFuture<CreditScoreResponse> futureA = service.fetchCreditScoreAsync("user1");
CompletableFuture<CreditScoreResponse> futureB = service.fetchCreditScoreAsync("user2");
// 두 작업이 모두 끝날 때까지 논블로킹 대기 후 결합
CompletableFuture.allOf(futureA, futureB).join();
6. 실무에서 반드시 겪는 @Async 4대 함정 (Gotchas)
1) 자가 호출(Self-Invocation) 문제
동일한 빈 클래스 내부에서 this.sendEmail() 형태로 비동기 메서드를 호출하면, Spring AOP 프록시를 거치지 않고 순수 자바 인스턴스 메서드가 직접 호출되므로 비동기가 적용되지 않고 동기로 실행됩니다. 반드시 별도의 서비스 빈으로 분리해야 합니다.
2) public 메서드만 지원
스프링 프록시 메커니즘 특성상 private이나 protected 메서드 위에 @Async를 붙여도 무시됩니다.
3) 트랜잭션(@Transactional) 불일치
메인 메서드에 @Transactional이 걸려 있고, 내부에서 호출한 @Async 메서드에도 @Transactional이 걸려 있더라도, 두 메서드는 서로 다른 물리 스레드에서 실행되므로 트랜잭션이 전혀 공유되지 않습니다. 메인 트랜잭션이 롤백되어도 이미 비동기 스레드에서 커밋된 DB 작업은 롤백되지 않습니다.
4) MDC 로깅 및 SecurityContext 유실
로그 추적용 traceId(MDC)나 인증 세션(SecurityContextHolder)은 ThreadLocal에 저장됩니다. 스레드가 바뀌면 이전 스레드의 MDC 정보가 사라지므로, TaskDecorator를 구현하여 컨텍스트를 복사해 넘겨주어야 합니다.
executor.setTaskDecorator(runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear();
}
};
});
7. 최신 트렌드: Java 21 가상 스레드(Virtual Threads)와의 결합
Java 21과 Spring Boot 3.2+ 환경에서는 복잡한 스레드 풀 사이징 고민 없이 단 한 줄의 프로퍼티로 비동기 작업을 수백만 개까지 경량화할 수 있습니다.
# application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
가상 스레드가 활성화되면 Spring Boot의 모든 @Async 작업은 무거운 OS 스레드 대신 초경량 가상 스레드 위에서 실행되어, I/O 블로킹 대기 시에도 OS 자원을 전혀 낭비하지 않는 혁신적인 동시성을 누릴 수 있습니다.
8. 결론
Spring Boot의 @Async는 복잡한 멀티스레드 코드를 어노테이션 하나로 캡슐화해 주는 강력한 도구입니다.
하지만 기본 SimpleAsyncTaskExecutor의 위험성을 인지하고, 서비스 성격에 맞춘 커스텀 ThreadPoolTaskExecutor와 적절한 거절 정책(CallerRunsPolicy), 그리고 AOP 프록시 제약 사항을 준수할 때 비로소 장애 없는 고성능 비동기 파이프라인을 구축할 수 있습니다.