@Async + CompletableFuture.runAsync = 이중 비동기의 함정
@Async + CompletableFuture.runAsync = 이중 비동기의 함정
2026-02-19
발단
에러 핸들러가 이상하게 동작했다. FCM 발송 실패 시 텔레그램으로 알림을 보내는 로직인데, 가끔 알림이 안 왔다.
코드를 보니 이랬다:
@Async("errorhandlerExecutor")
public void handleError(Exception e) {
CompletableFuture.runAsync(() -> {
telegramBot.sendAlert("FCM 발송 실패: " + e.getMessage());
}, alertExecutor);
}
@Async로 비동기 + CompletableFuture.runAsync()로 또 비동기. 이중 비동기다.
@Async가 뭔데?
Spring에서 메서드를 비동기(호출한 쪽이 결과를 기다리지 않고 바로 다음 작업으로 넘어가는 방식)로 실행하게 해주는 어노테이션이다.
@Async("myExecutor")
public void doSomething() {
// 이 메서드는 myExecutor 스레드 풀에서 실행됨
// 호출자는 기다리지 않고 바로 리턴
}
CompletableFuture.runAsync()도 비슷하다:
CompletableFuture.runAsync(() -> {
// 이 블록은 별도 스레드에서 실행됨
}, executor);
둘 다 "다른 스레드에서 실행하라"는 뜻이다.
문제 1: 이중 스레드 홉
호출 스레드
└── @Async → errorhandlerExecutor 스레드 (1차 점프)
└── runAsync → alertExecutor 스레드 (2차 점프)
└── 텔레그램 전송
스레드를 두 번 갈아탄다. 불필요한 오버헤드(실제 작업 외에 추가로 드는 비용)이고, 예외가 발생하면 어디서 잡아야 할지도 애매하다.
문제 2: @Async 빈 이름이 틀렸다
@Async("errorhandlerExecutor") // 소문자 h
실제 빈 이름은:
@Bean("errorHandlerExecutor") // 대문자 H
대소문자가 달랐다. Spring @Async는 빈을 못 찾으면 에러를 던지지 않고 기본 executor로 폴백(원래 목표가 실패했을 때 대체 동작으로 넘어가는 것)한다. 조용히.
즉, errorhandlerExecutor라는 빈이 없으니 Spring의 기본 SimpleAsyncTaskExecutor(요청마다 새 스레드를 만드는 단순한 executor로, 스레드 풀을 재사용하지 않아 운영 환경에서는 비효율적이다)가 쓰였다. 그 안에서 또 alertExecutor로 넘기니까 결국 의도와 완전히 다른 스레드에서 실행되고 있었다.
해결
@Async를 제거했다. CompletableFuture.runAsync()가 이미 비동기 실행을 해주니까 하나만 쓰면 된다.
// After — 하나의 비동기 메커니즘만 사용
public void handleError(Exception e) {
CompletableFuture.runAsync(() -> {
telegramBot.sendAlert("FCM 발송 실패: " + e.getMessage());
}, alertExecutor);
}
호출 스레드
└── runAsync → alertExecutor 스레드
└── 텔레그램 전송
깔끔하게 한 번만 점프한다.
비동기 실행, 뭘 써야 하나?
| 방식 | 장점 | 주의점 |
|---|---|---|
| @Async | 선언적, 간단 | 프록시 기반이라 self-invocation 안 됨. 빈 이름 틀려도 에러 안 남 |
| CompletableFuture.runAsync() | 명시적, 체이닝(여러 비동기 작업을 순서대로 연결하는 것) 가능 | executor를 직접 넘겨야 함 |
| 둘 다 같이 | ❌ | 이중 스레드 홉, 예외 처리 복잡 |
규칙: 하나만 선택하고 섞지 말 것.
배운 것
@Async와CompletableFuture.runAsync()를 같이 쓰지 말자. 비동기 메커니즘은 하나만.- Spring
@Async의 빈 이름은 대소문자를 구분한다. 못 찾으면 에러 없이 기본 executor로 폴백한다. - 사일런트 폴백은 디버깅의 적이다. 의도한 스레드 풀에서 실행되고 있는지 스레드 이름 로깅으로 확인하자.