“알림 발송이 느리니 비동기로 돌리자.” 메서드에 @Async 하나 붙이고 배포했습니다. 며칠 뒤 이상한 신고가 들어왔습니다. 어떤 주문은 알림이 아예 안 갔는데, 에러 로그에는 아무것도 남아 있지 않았습니다. 게다가 비동기로 만들었다는 그 메서드가 여전히 요청을 붙잡고 느리게 돌기도 했습니다.
증상은 셋이었지만, 뿌리는 하나였습니다. @Async가 스프링 AOP 프록시 위에서, 그리고 별도 스레드 위에서 동작한다는 사실을 제대로 이해하지 못한 것입니다. 이 한 가지를 놓치면 트랜잭션은 경계를 못 넘어 새어나가고, 예외는 조용히 삼켜져 사라집니다.
이 글은 @Async를 붙였을 때 실제로 겪은 세 가지 증상과 그 원인, 그리고 기본 스레드풀이라는 숨은 지뢰까지 정리한 기록입니다. 마지막에는 안전하게 쓰기 위한 체크리스트로 마무리합니다.
증상 1: @Async를 붙였는데 여전히 동기로 돈다
가장 먼저 발견한 건, 비동기여야 할 메서드가 호출자를 그대로 붙잡고 있다는 점이었습니다. 원인은 같은 클래스 안에서의 자기 호출(self-invocation)이었습니다.
@Service
public class NotifyService {
public void handleOrder(Order order) {
sendAsync(order); // 같은 클래스 내부 호출 → 프록시를 거치지 않음
}
@Async
public void sendAsync(Order order) { /* ... */ }
}
@Async는 스프링이 만든 프록시 객체가 가로채야 비동기로 실행됩니다. 그런데 같은 클래스 안에서 sendAsync()를 직접 부르면, 프록시를 거치지 않고 원본 메서드를 그대로 호출합니다. 결과적으로 애너테이션이 무시되고 동기로 돕니다. 이건 @Transactional이 자기 호출에서 안 걸리는 것과 정확히 같은 원리입니다.
해결은 호출을 프록시 경계 밖으로 빼는 것입니다. 비동기 메서드를 별도 빈으로 분리해 주입받아 호출하면, 프록시를 정상적으로 거칩니다. 의존성을 분리해 이런 경계 문제를 푸는 접근은 스프링 순환참조, 이벤트 리스너로 끊어낸 극복기에서도 같은 결로 다룬 바 있습니다.
증상 2: 비동기 작업이 실패해도 에러가 안 남는다
두 번째 증상이 더 위험했습니다. 알림 발송이 실패했는데 로그에 흔적조차 없었습니다. 반환 타입이 void인 @Async 메서드에서 예외가 아무에게도 전달되지 않고 사라진 것입니다.
@Async
public void sendAsync(Order order) {
throw new RuntimeException("발송 실패"); // 호출자에 전파 안 됨, 로그도 없이 소멸
}
이유는 단순합니다. 호출자는 이미 자기 갈 길을 갔기 때문에, 별도 스레드에서 뒤늦게 터진 예외를 받아줄 대상이 없습니다. 반환 타입이 void면 이 예외는 그대로 유실됩니다. 만약 CompletableFuture를 반환하면 예외가 그 안에 담겨 나중에 확인할 수 있지만, void는 그렇지 않습니다.
void 비동기 메서드의 예외를 붙잡으려면 AsyncUncaughtExceptionHandler를 등록해야 합니다. 이걸 걸어두면 삼켜질 뻔한 예외를 한곳에서 로깅하고 알림을 보낼 수 있습니다. “비동기니까 실패해도 조용히 넘어가겠지”가 아니라, “실패를 어디서 감지할 것인가”를 반드시 설계해야 합니다.
증상 3: 비동기 안에서 트랜잭션과 지연 로딩이 깨진다
세 번째는 가장 헷갈리는 증상이었습니다. 비동기 메서드 안에서 엔티티를 다루다 LazyInitializationException이 터지거나, 방금 저장한 데이터가 조회되지 않는 일이 벌어졌습니다.
@Transactional
public void placeOrder(OrderRequest req) {
Order order = orderRepository.save(new Order(req));
asyncService.afterOrder(order); // 새 스레드 → 이 트랜잭션에 묶이지 않음
}
핵심은 트랜잭션이 스레드에 묶여 있다는 점입니다. 스프링의 트랜잭션은 스레드 로컬에 저장되는데, @Async 메서드는 완전히 다른 스레드에서 돕니다. 그래서 호출자의 트랜잭션은 비동기 스레드로 전파되지 않습니다. 비동기 메서드는 트랜잭션이 없는 상태로 시작하고, 그 안에서 지연 로딩을 시도하면 영속성 컨텍스트가 없어 예외가 납니다.
여기에 타이밍 문제까지 겹칩니다. 위 코드에서 비동기 작업이 호출자의 커밋보다 먼저 실행되면, 아직 커밋 전인 주문을 조회하려다 실패할 수 있습니다. 커밋 이후에 후속 작업을 확실히 태우려면, 단순 @Async보다 커밋 시점을 인지하는 방식이 안전합니다. 이 조합은 @TransactionalEventListener, 코드가 진화하는 4단계에서 자세히 정리했습니다. 비동기 메서드가 DB 작업을 한다면, 그 메서드에 별도의 @Transactional을 직접 부여해 자기만의 트랜잭션을 갖게 하는 것도 방법입니다.
기본 스레드풀이라는 숨은 지뢰
증상들을 고치고 나서도 부하 테스트에서 스레드 수가 비정상적으로 치솟는 걸 발견했습니다. 원인은 전용 Executor를 지정하지 않았다는 것이었습니다.
@Async에 실행기를 명시하지 않으면 스프링이 기본 실행기를 씁니다. 이 기본값은 상황에 따라 작업마다 새 스레드를 만드는 식으로 동작할 수 있어, 요청이 몰리면 스레드가 무한정 늘어나거나 큐 관리가 되지 않습니다. 스레드가 폭증하면 결국 자원이 고갈되는데, 이건 외부 호출이 스레드를 잠가 서버가 멈췄던 RestTemplate 타임아웃 누락, 전 서버가 멈춘 날의 상황과 원인은 달라도 결말이 닮아 있습니다.
그래서 경계가 분명한 전용 스레드풀을 직접 정의했습니다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("appTaskExecutor")
public Executor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("app-async-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()); // 큐가 차면 호출 스레드가 직접 처리
executor.initialize();
return executor;
}
}
그리고 비동기 메서드에는 @Async("appTaskExecutor")처럼 실행기를 명시해, 이 작업이 어느 풀에서 도는지 분명히 했습니다. 스레드 이름 접두어를 지정해 두면 스레드 덤프에서 추적하기도 쉬워집니다.
제대로 쓰는 @Async 체크리스트
지금까지의 시행착오를 실전 점검 항목으로 정리하면 다음과 같습니다. 새로 @Async를 붙일 때마다 이 목록을 훑으면 대부분의 사고를 예방할 수 있습니다.
- 프록시 경계: 같은 클래스 내부에서 호출하지 않는다. 비동기 메서드는 별도 빈으로 분리한다.
- 예외 처리:
void라면AsyncUncaughtExceptionHandler를 등록한다. 결과나 실패를 다뤄야 하면CompletableFuture를 반환한다. - 트랜잭션: 호출자의 트랜잭션은 전파되지 않는다. 비동기 메서드가 DB를 다루면 자체
@Transactional을 부여하고, 커밋 이후 실행이 중요하면 이벤트 방식을 검토한다. - 스레드풀: 기본 실행기에 의존하지 말고
ThreadPoolTaskExecutor를 정의해 풀 크기·큐·거부 정책을 명시한다. - 컨텍스트 전파: 보안 컨텍스트, MDC 로그 추적값 등은 비동기 스레드로 자동 전파되지 않으므로 필요하면 명시적으로 넘긴다.
자주 묻는 질문 (FAQ)
Q1. @Async 메서드는 왜 같은 클래스에서 부르면 동작하지 않나요?
@Async는 스프링 프록시가 호출을 가로채야 비동기로 실행됩니다. 같은 클래스 내부 호출은 프록시를 거치지 않고 원본 메서드를 직접 부르기 때문에 애너테이션이 무시됩니다. 비동기 메서드를 별도 빈으로 분리해 주입받아 호출하면 해결됩니다.
Q2. 비동기 메서드의 예외를 어떻게 잡나요?
반환 타입이 void면 AsyncUncaughtExceptionHandler를 등록해 한곳에서 처리합니다. 실패 여부나 결과를 호출 측에서 다뤄야 한다면 CompletableFuture를 반환해 예외를 그 안에서 확인하는 방식이 낫습니다.
Q3. 호출자의 트랜잭션이 비동기 메서드까지 이어지나요?
이어지지 않습니다. 트랜잭션은 스레드에 묶여 있는데 @Async는 별도 스레드에서 돌기 때문입니다. 비동기 메서드가 DB 작업을 한다면 그 메서드에 자체 @Transactional을 부여해야 하며, 지연 로딩에 의존하는 코드는 특히 주의해야 합니다.
돌이켜보면
@Async는 애너테이션 하나로 비동기를 얻는 편리한 기능이지만, 그 편리함이 오히려 함정입니다. “붙였으니 됐겠지”라고 믿는 순간, 프록시를 거치지 못해 동기로 돌거나, 예외가 조용히 사라지거나, 트랜잭션이 경계를 못 넘어 데이터가 어긋납니다. 모두 컴파일 에러 없이, 평소엔 멀쩡해 보이다가 특정 상황에서만 터지는 종류라 더 까다롭습니다.
한 줄로 남기면,
@Async는 “프록시를 거치는가”와 “별도 스레드에서 도는가” 두 가지만 항상 의식하면, 트랜잭션과 예외가 새어나가는 대부분의 사고를 막을 수 있습니다.