@Async로 보낸 작업의 예외가 조용히 사라지던 문제

회원 가입 시 환영 메일을 보내는 코드를 @Async로 감쌌다. 메일이 느려도 가입 응답은 빨라야 하니 비동기로 뺀 거다. 로컬에서 잘 됐고, 운영에서도 메일은 잘 나갔다. 문제는 실패했을 때였다.

@Async
public void sendWelcomeMail(Long memberId) {
    Member m = memberRepository.findById(memberId).orElseThrow();
    mailSender.send(m.getEmail(), welcomeTemplate(m));
}

어느 날 메일 서버가 30분쯤 죽었다. 그동안 가입한 사람들은 환영 메일을 못 받았는데, 정작 우리 로그엔 아무 에러도 없었다. 예외가 어디에도 안 찍히고 조용히 증발한 거다. 장애를 고객 문의로 처음 알았다. 이게 제일 무서운 종류의 버그다. 터지는데 안 보이니까.

@Async가 실제로 하는 일

@Async@Transactional처럼 프록시가 가로채는 방식이다. 프록시가 메서드 호출을 받으면 진짜 실행을 TaskExecutor에 작업으로 던지고, 호출한 쪽엔 즉시 제어를 돌려준다. 그래서 호출자는 메서드 안에서 무슨 일이 나든 기다리지 않고 다음 줄로 넘어간다. 여기서 두 가지 함정이 파생된다. 첫째, 별도 스레드에서 도니 예외가 호출자에게 전달될 길이 없다. 둘째, @Async도 프록시 기반이라 같은 클래스 안에서 자기 메서드를 직접 부르면 비동기가 안 걸린다. 트랜잭션의 self-invocation 함정과 완전히 같은 원리다.

void냐 Future냐에 따라 예외 운명이 갈린다

리턴 타입에 따라 예외가 어디로 가는지가 다르다.

반환 타입 예외가 나면
void AsyncUncaughtExceptionHandler로 감(기본은 로그만)
Future / CompletableFuture Future 안에 담김 → get()·exceptionally()로 잡힘

우리는 void였다. 기본 핸들러가 로그라도 남겨 주는데 왜 안 보였을까. 성능 때문에 커스텀 ThreadPoolTaskExecutor를 등록해 쓰고 있었고, 그 과정에서 예외 핸들러를 따로 지정하지 않았기 때문이다. 커스텀 executor를 쓰면서 핸들러를 안 붙이면 그 기본 로그마저 사라진다. 예외는 스레드 풀 안에서 조용히 죽고 아무 기록도 안 남았다.

먼저 예외를 수면 위로 끌어올렸다

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        var ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(4);
        ex.setMaxPoolSize(8);
        ex.setQueueCapacity(200);
        ex.setThreadNamePrefix("async-");
        ex.initialize();
        return ex;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("async 실패: {} args={}", method.getName(), params, ex);
    }
}

핸들러를 구현해 등록하니, 그동안 사라지던 예외가 전부 에러 로그로 올라오기 시작했다. 그제야 메일 서버 장애가 알림으로 잡혔다. 참고로 기본값인 SimpleAsyncTaskExecutor는 요청마다 새 스레드를 만들어 스레드 수 제한이 없다. 운영에서 이걸 그대로 쓰면 폭주할 수 있어, 큐 용량이 있는 풀로 바꾸는 것도 이 김에 같이 했다.

결과를 꼭 받아야 하는 작업이라면

환영 메일은 실패해도 흐름을 막을 필요가 없어 로그와 재시도로 충분했다. 하지만 호출자가 결과나 실패 여부를 꼭 알아야 한다면 void 대신 CompletableFuture를 반환하게 바꾸는 게 맞다.

@Async
public CompletableFuture<Void> send(Long memberId) { ... }

// 호출부
send(id).exceptionally(ex -> { alert(ex); return null; });

덤으로 만난 함정 — 로그의 traceId가 끊겼다

비동기로 빼고 나니 async 스레드의 로그에서 요청 추적용 traceId(MDC 값)가 사라져 있었다. MDC는 스레드 로컬이라 새 스레드로 넘어가면 복사가 안 되기 때문이다. TaskDecorator로 부모 스레드의 MDC를 자식 스레드에 복사해 주도록 executor를 감싸서 해결했다.

정리

동기 코드는 예외를 던지면 위에서 누군가 받는다는 가정이 통한다. 비동기에선 그 가정이 깨진다. 받을 사람이 이미 떠났으니까. 그래서 새 프로젝트에 @Async를 처음 켤 때 나는 이제 기능보다 예외 핸들러부터 먼저 붙인다. 안 보이는 실패만큼 비싼 건 없다.