[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파
![[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파](https://blog.kakaocdn.net/dna/qOXgk/dJMcaiQ6h1y/AAAAAAAAAAAAAAAAAAAAAHikrPgeI_Rh-NoaQi2GzexI2myv84E0YSQ68E171L5G/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=RGu7r7ExU3aiR9xCyGP%2Bs70dIQo%3D)
[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파
@Async 한 줄이면 비동기가 되니 가볍게 쓰기 쉬운데, 이 애너테이션은 함정 밀도가 유난히 높아요. 기본 스레드풀은 운영에 부적합하고, 예외는 조용히 사라져요. 트랜잭션·인증·로그 추적은 끊겨요. 셋 다 "장애 났는데 아무 데도 안 보이는" 종류라 미리 알아야 해요.
@Async의 동작(역시 프록시예요)에서 출발해, 기본 풀의 무한 큐 함정, void 예외 증발, ThreadLocal 미전파(트랜잭션·SecurityContext·MDC)를 하나씩 헤집어볼게요. 앞 편 말미의 "AFTER_COMMIT + @Async" 조합을 쓰려면 이 글이 선행이에요.
01. @Async도 프록시예요
먼저 익숙한 이야기부터 — @Async도 프록시 기반이에요. 자기호출·private에서 조용히 동기로 실행돼요. "비동기인 줄 알았는데 응답이 느려요"의 1번 점검 항목이에요. @EnableAsync를 빼먹어도 마찬가지로 조용히 동기예요.
02. 기본 스레드풀, 큐가 무한이라 8개에서 멈춰요

스프링 부트는 @Async용으로 ThreadPoolTaskExecutor를 자동 구성해주는데, 기본값이 core 8, 큐 무제한이에요. 여기에 스레드풀의 동작 규칙이 겹치면 함정이 완성돼요 — 풀은 core가 다 차면 큐부터 채우고 큐가 가득 차야 max까지 스레드를 늘려요. 큐가 무한이면? 영원히 안 차니 스레드는 8개 고정이에요.
결과적으로 작업이 몰리면 수만 개가 큐에서 대기해요. "비동기로 보냈는데 한참 뒤에 실행돼요"와 큐 메모리 증가가 그 증상이에요. 그래서 @Async를 운영에 쓰려면 풀 정의가 사실상 필수예요.
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500); // 유한해야 max가 의미 있다
executor.setThreadNamePrefix("notify-"); // 스레드 덤프에서 식별
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()); // 넘치면 호출자가 실행 (백프레셔)
executor.initialize();
return executor;
}
@Async("notificationExecutor") // 풀 이름 명시
public void sendPush(Long orderId) { ... }
주의할 게, 사용자 정의 Executor 빈이 하나라도 생기면 Boot의 기본 applicationTaskExecutor 자동구성이 물러나요. 한정자 없는 @Async까지 전부 이 풀로 흘러가니, 기본 풀을 유지하려면 spring.task.execution.*도 같이 챙겨요.
두 가지를 챙겨요. 첫째, 큐는 유한하게 + 넘칠 때의 정책을 명시해요. CallerRunsPolicy는 "넘치면 호출자 스레드가 직접 실행"이라 자연스러운 속도 조절(백프레셔)이 되고, 유실은 없어요. 다만 executor가 shutdown 중이면 조용히 버려질 수 있고, 호출자가 요청 스레드면 그 요청의 응답이 느려지는 비용이 있어요. 둘째, 용도별로 풀을 분리해요. 알림용과 정산용이 한 풀을 쓰면, 느린 정산 작업이 풀을 점거해 알림까지 굶어요. @Async("풀이름")으로 격리하는 게 운영의 기본이에요.
03. 예외 증발, void 비동기의 침묵

동기 호출의 예외는 콜스택을 타고 호출자에게 가요. 그런데 @Async void 메서드의 예외는 받을 사람이 없어요 — 호출자는 이미 자기 갈 길을 갔으니까요. 스프링이 기본 로그를 남기긴 하지만 에러 응답도 알림도 없이 조용해요. "푸시가 안 나가는데 에러도 없어요"의 정체예요.
대응은 반환 타입에 따라 둘이에요.
- 결과가 필요하면
CompletableFuture반환 — 호출자가exceptionally()·join()으로 예외를 받아 처리해요. - fire-and-forget이면 전역 핸들러 등록 —
AsyncConfigurer의getAsyncUncaughtExceptionHandler()로 공통 처리(에러 로그 + 알림 + 메트릭)를 박아요. 최소한 모니터링에는 보이게요.
04. 컨텍스트 미전파, 새 스레드는 빈손이에요

스프링의 많은 것들이 ThreadLocal에 살아요 — 트랜잭션, SecurityContext(인증 정보), MDC(로그 traceId). @Async로 넘어간 작업은 다른 스레드라 이게 전부 없어요.
- 트랜잭션 — 호출자의 트랜잭션과 완전히 별개예요. 호출자가 롤백해도 비동기 작업은 이미 실행됐고, 같이 묶을 방법도 없어요. 비동기 작업은 자체 트랜잭션(
@Transactional을 비동기 메서드에)으로 설계하고, "커밋 확정 후 실행"이 필요하면 AFTER_COMMIT과 조합해요. - SecurityContext — 비동기 안에서 인증 정보가 null이에요. 사용자 ID가 필요하면 파라미터로 넘기는 게 명시적이고 안전해요. 자동 전파가 꼭 필요하면
DelegatingSecurityContextAsyncTaskExecutor로 풀을 감싸요. - MDC — traceId가 끊겨 비동기 로그가 추적에서 빠져요.
TaskDecorator로 제출 시점의 MDC를 복사해주는 게 정석이에요.
executor.setTaskDecorator(runnable -> {
Map<String, String> mdc = MDC.getCopyOfContextMap(); // 제출 시점 복사
return () -> {
if (mdc != null) MDC.setContextMap(mdc);
try { runnable.run(); } finally { MDC.clear(); }
};
});
원칙은 하나예요 — 암묵적 컨텍스트에 기대지 말고 필요한 값은 파라미터로. 자동 전파 장치들은 그게 번거로울 때의 보조예요.
05. 자주 만나는 문제
@Async가 동기로 실행돼요
자기호출이거나 @EnableAsync 누락이에요. 스레드 이름 로그로 확인하면 1분이면 갈려요(Thread.currentThread().getName()).
비동기 작업이 한참 뒤에 실행돼요
기본 풀의 무한 큐 적체예요. 풀을 정의하고 큐를 유한하게, 풀 지표(active·queue size)를 모니터링에 올려요.
비동기 안에서 LazyInitializationException이 나요
호출자의 영속성 컨텍스트가 새 스레드에 없어서예요(지연 로딩의 그 예외). 엔티티를 넘기지 말고 ID나 DTO를 넘긴 뒤, 비동기 쪽에서 자체 트랜잭션으로 다시 조회해요.
비동기 로그가 traceId 없이 찍혀요
MDC 미전파예요. TaskDecorator를 등록해요.
정리
@Async를 운영에 쓰려면 세 가지가 세트로 따라와야 해요. 이 셋 없이 붙인 @Async는 "평소엔 되는데 장애 때 아무것도 안 보이는" 코드가 돼요.
- ① 풀 직접 정의 — 유한 큐 + 거부 정책 + 용도별 분리. 기본 풀의 무한 큐를 피해요.
- ② 예외 처리 명시 — CompletableFuture 반환 또는 전역 핸들러. void 예외가 증발하지 않게요.
- ③ 컨텍스트는 파라미터로 — 트랜잭션은 자체로, MDC는 TaskDecorator로. ThreadLocal은 스레드를 못 넘어요.
다음은 의존성 주입의 발밑이에요 — 순환 참조와 빈 초기화 함정을 이어지는 글에서 다뤄요.
출처: Spring Framework — @Async · Spring Boot — Task Execution
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
|---|---|
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
| [Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결 (0) | 2026.07.30 |
| [Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW (0) | 2026.07.29 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."