왜 @Async를 제대로 설정해야 하는가
Spring Boot에서 @Async는 메서드를 비동기로 실행하는 가장 간편한 방법이다. 이메일 발송, 로그 적재, 외부 API 호출처럼 요청 스레드를 블로킹하면 안 되는 작업에 광범위하게 쓰인다. 그러나 기본 설정만으로 운영에 투입하면 스레드 폭증, 예외 소실, self-invocation 무시 같은 함정에 빠진다.
이 글은 Spring Framework 공식 문서(Task Execution and Scheduling)와 Spring Boot 공식 문서(Task Execution and Scheduling)를 근거로, @Async의 내부 동작·스레드 풀 설계·예외 처리·운영 패턴을 실무 수준으로 정리한다.
1. @EnableAsync와 프록시 모드의 동작 원리
1-1. 기본 동작: proxy 모드
@EnableAsync를 선언하면 Spring은 @Async가 붙은 메서드를 가진 빈을 프록시로 래핑한다. 외부에서 해당 메서드를 호출하면 프록시가 가로채어 TaskExecutor에 작업을 위임한다.
@Configuration
@EnableAsync
public class AsyncConfig {
// 기본 proxy 모드: advice mode = PROXY
}
핵심 제약 — self-invocation: 같은 클래스 내에서 this.asyncMethod()를 호출하면 프록시를 거치지 않으므로 동기로 실행된다. 이것은 @Transactional의 self-invocation 함정과 동일한 메커니즘이다.
@Service
public class OrderService {
// ❌ self-invocation — 동기 실행됨
public void placeOrder(Order order) {
save(order);
this.sendNotification(order); // 프록시를 타지 않음
}
@Async
public void sendNotification(Order order) {
// 비동기를 기대했지만 호출 스레드에서 실행됨
}
}
1-2. self-invocation 해결 3가지
| 방법 | 설명 | 장단점 |
|---|---|---|
| 별도 빈 분리 | @Async 메서드를 다른 @Service로 이동 | 가장 단순하고 권장되는 방법. 역할 분리도 자연스러움 |
| ApplicationContext에서 자기 자신 주입 | @Lazy @Autowired private OrderService self; |
순환 참조 주의. @Lazy로 회피 가능하나 코드 가독성 저하 |
| AspectJ 모드 | @EnableAsync(mode = AdviceMode.ASPECTJ) |
컴파일/로드타임 위빙 필요. 설정 복잡도 증가 |
2. Spring Boot의 자동 구성 TaskExecutor
2-1. 자동 구성 동작 (Spring Boot 3.x)
Spring Boot는 컨텍스트에 Executor 빈이 없으면 ThreadPoolTaskExecutor를 자동 구성한다. Java 21+에서 spring.threads.virtual.enabled=true이면 SimpleAsyncTaskExecutor(가상 스레드)가 대신 사용된다.
자동 구성 기본값:
| 프로퍼티 | 기본값 | 설명 |
|---|---|---|
spring.task.execution.pool.core-size |
8 | 항상 유지되는 코어 스레드 수 |
spring.task.execution.pool.max-size |
Integer.MAX_VALUE | 최대 스레드 수 |
spring.task.execution.pool.queue-capacity |
Integer.MAX_VALUE | 큐 용량 |
spring.task.execution.pool.keep-alive |
60s | 코어 초과 유휴 스레드 제거 시간 |
spring.task.execution.thread-name-prefix |
task- | 스레드 이름 접두사 |
spring.task.execution.pool.allow-core-thread-timeout |
true | 코어 스레드도 타임아웃 적용 여부 |
spring.task.execution.shutdown.await-termination |
false | 종료 시 작업 완료 대기 여부 |
spring.task.execution.shutdown.await-termination-period |
— | 대기 최대 시간 |
2-2. 기본값의 함정: 무제한 큐
queue-capacity가 Integer.MAX_VALUE이므로 max-size는 사실상 무의미하다. ThreadPoolExecutor의 동작 방식상, 큐가 가득 차야 코어 이상의 스레드를 생성하기 때문이다. 즉 기본 설정에서는 코어 8개 + 무제한 큐로 동작하며, 작업이 폭주하면 큐만 무한히 쌓여 메모리 부족(OOM)이 발생한다.
3. 운영 수준 ThreadPoolTaskExecutor 설계
3-1. 직접 빈 등록
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
3-2. 풀 사이징 설계 원칙
| 파라미터 | 설계 기준 |
|---|---|
corePoolSize |
평상시 동시 작업 수. CPU 바운드: CPU 코어 수. I/O 바운드: 코어 수 × (1 + 대기/처리 비율) |
maxPoolSize |
피크 시 허용 최대 스레드. 무제한 금지. 일반적으로 core의 2–4배 |
queueCapacity |
반드시 유한값으로 설정. 100~500 범위. 큐가 차면 max까지 스레드 증가 → 그래도 차면 rejection |
rejectedExecutionHandler |
CallerRunsPolicy 권장: 호출 스레드가 직접 실행하여 백프레셔 역할. AbortPolicy는 RejectedExecutionException 발생 |
waitForTasksToComplete |
Graceful Shutdown 시 큐 잔여 작업 완료 후 종료. Kubernetes 환경 필수 |
3-3. 용도별 Executor 분리
하나의 풀로 모든 비동기 작업을 처리하면, 느린 작업(외부 API)이 빠른 작업(로그 적재)의 스레드를 잠식한다. 용도별 Executor를 분리하고 @Async("executorName")으로 지정한다.
@Async("emailExecutor")
public void sendEmail(String to, String body) { ... }
@Async("auditExecutor")
public void writeAuditLog(AuditEvent event) { ... }
@Async에 이름을 지정하지 않으면 기본 Executor가 사용된다. 빈이 여러 개일 때 어떤 것이 기본인지 혼란이 생기므로, 항상 명시적으로 지정하는 것을 권장한다.
4. 반환 타입: void vs CompletableFuture
4-1. void 반환
호출자가 결과를 기다리지 않는 fire-and-forget 패턴. 예외가 발생해도 호출자에게 전파되지 않는다.
@Async("emailExecutor")
public void sendWelcomeEmail(User user) {
emailClient.send(user.getEmail(), "Welcome!");
}
4-2. CompletableFuture 반환
결과를 받거나 여러 비동기 작업을 조합할 때 사용한다.
@Async("apiExecutor")
public CompletableFuture<ExchangeRate> fetchRate(String currency) {
ExchangeRate rate = externalApi.getRate(currency);
return CompletableFuture.completedFuture(rate);
}
// 호출부: 여러 API 동시 호출 후 합산
CompletableFuture<ExchangeRate> usd = rateService.fetchRate("USD");
CompletableFuture<ExchangeRate> eur = rateService.fetchRate("EUR");
CompletableFuture.allOf(usd, eur).join();
// usd.get(), eur.get() 사용
주의: CompletableFuture 반환 시 예외는 get()이나 join() 호출 시점에 ExecutionException/CompletionException으로 전파된다. get()을 호출하지 않으면 예외가 소실된다.
4-3. 반환 타입별 예외 전파 비교
| 반환 타입 | 예외 전파 | 적합한 용도 |
|---|---|---|
void |
AsyncUncaughtExceptionHandler로만 전달 | fire-and-forget (알림, 로그) |
Future<T> / CompletableFuture<T> |
get()/join() 시 ExecutionException으로 전파 |
결과 필요, 작업 조합 |
5. 예외 처리: AsyncUncaughtExceptionHandler
5-1. void 메서드의 예외 소실 문제
void 반환 @Async 메서드에서 예외가 발생하면 기본적으로 WARN 로그만 남고 무시된다. 운영 환경에서 이메일 발송 실패를 모르고 넘어갈 수 있다.
5-2. 커스텀 핸들러 등록
AsyncConfigurer를 구현하여 전역 예외 핸들러를 등록한다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
log.error("Async 예외 — method={}, params={}",
method.getName(), Arrays.toString(params), throwable);
// Sentry, Slack 알림 등 운영 알림 연동
};
}
}
주의: AsyncConfigurer를 구현하면 Spring Boot의 자동 구성 Executor가 비활성화된다. getAsyncExecutor()에서 직접 설정해야 한다.
6. Executor 빈 탐색 우선순위
@Async에 Executor 이름을 지정하지 않았을 때 Spring이 사용하는 Executor 탐색 순서:
AsyncConfigurer.getAsyncExecutor()반환값- 타입이
TaskExecutor인 빈 (유일한 경우) - 이름이
taskExecutor인 빈 - Spring Boot 자동 구성
ThreadPoolTaskExecutor(위 모두 없을 때)
빈이 여러 개인데 이름을 지정하지 않으면 NoUniqueBeanDefinitionException이 발생할 수 있다. 반드시 @Async("name")으로 명시하거나 @Primary를 지정한다.
7. Java 21 가상 스레드와 @Async
Spring Boot 3.2+에서 spring.threads.virtual.enabled=true를 설정하면 자동 구성 Executor가 SimpleAsyncTaskExecutor(가상 스레드)로 교체된다.
| 항목 | ThreadPoolTaskExecutor (플랫폼 스레드) | SimpleAsyncTaskExecutor (가상 스레드) |
|---|---|---|
| 스레드 생성 비용 | 높음 (OS 스레드) | 극히 낮음 (JVM 관리) |
| 풀 사이징 | 필수 (core/max/queue) | 불필요 (작업당 새 가상 스레드) |
| I/O 대기 중 스레드 점유 | 점유 (블로킹) | 자동 언마운트 |
| CPU 바운드 작업 | 적합 (스레드 수 제한으로 경합 제어) | 주의 필요 (과도한 병렬화 가능) |
| ThreadLocal 호환 | 완전 호환 | ScopedValue 전환 권장 (ThreadLocal pinning 주의) |
실무 판단: I/O 바운드 비동기 작업이 대부분이면 가상 스레드가 유리하다. CPU 바운드나 ThreadLocal 의존 코드(MDC, SecurityContext 등)가 많으면 플랫폼 스레드 풀을 유지하되, 점진적으로 전환한다.
8. Graceful Shutdown과 Kubernetes 연동
Pod 종료 시 SIGTERM을 받으면 Spring Boot는 Graceful Shutdown을 시작한다. 이때 비동기 작업이 큐에 남아있으면 유실될 수 있다.
# application.yml
spring:
task:
execution:
shutdown:
await-termination: true
await-termination-period: 30s
lifecycle:
timeout-per-shutdown-phase: 30s
server:
shutdown: graceful
설계 포인트:
await-termination-period는 KubernetesterminationGracePeriodSeconds(기본 30s)보다 짧게 설정한다.- 큐에 남은 작업이 타임아웃 내에 끝나지 않으면
InterruptedException이 발생한다. - 중요 작업은 DB에 상태를 기록하고 재시도 메커니즘(BullMQ, SQS 등)을 병행한다.
9. 운영 체크리스트
- self-invocation 확인: 같은 클래스에서 @Async 메서드를 호출하지 않는지 코드 리뷰.
- queue-capacity 유한 설정: 기본 무제한을 반드시 변경. OOM 방지의 핵심.
- rejectedExecutionHandler 지정: CallerRunsPolicy로 백프레셔 확보.
- AsyncUncaughtExceptionHandler 등록: void 메서드 예외를 모니터링 시스템에 연동.
- Executor 이름 명시: @Async(“name”)으로 항상 지정. 다중 풀 환경에서 혼란 방지.
- 스레드 이름 접두사 설정: 로그에서 어떤 풀의 스레드인지 즉시 식별.
- Graceful Shutdown 설정: await-termination + Kubernetes terminationGracePeriodSeconds 정합.
- 스레드 풀 모니터링: Micrometer + Prometheus로 active/queue-size/pool-size 메트릭 수집.
마무리
@Async는 단순해 보이지만 프록시 모드, 스레드 풀 사이징, 예외 전파, 종료 시 작업 유실까지 고려할 요소가 많다. 핵심을 정리하면:
- self-invocation은 proxy 모드의 근본적 제약. 빈 분리로 해결한다.
- queue-capacity를 유한값으로 설정해야 max-size가 의미를 갖고 OOM을 방지한다.
- void 반환 시 예외가 소실되므로 AsyncUncaughtExceptionHandler를 반드시 등록한다.
- CompletableFuture로 반환하면 호출부에서 예외를 받을 수 있고 작업 조합이 가능하다.
- Java 21 가상 스레드는 I/O 바운드 시 풀 사이징 고민을 없애지만 ThreadLocal pinning에 주의한다.
참고 문서: