목록으로

@Async + CompletableFuture.runAsync = 이중 비동기의 함정

8

@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를 직접 넘겨야 함
둘 다 같이이중 스레드 홉, 예외 처리 복잡

규칙: 하나만 선택하고 섞지 말 것.


배운 것

  1. @AsyncCompletableFuture.runAsync()를 같이 쓰지 말자. 비동기 메커니즘은 하나만.
  2. Spring @Async의 빈 이름은 대소문자를 구분한다. 못 찾으면 에러 없이 기본 executor로 폴백한다.
  3. 사일런트 폴백은 디버깅의 적이다. 의도한 스레드 풀에서 실행되고 있는지 스레드 이름 로깅으로 확인하자.