@Async 예외 처리와 스레드풀

실패가 호출자에게 바로 오지 않는 이유

@Async는 호출한 스레드를 기다리게 하지 않고 작업을 TaskExecutor에 넘긴다. 메일 발송, 알림 전송처럼 응답과 분리해도 되는 작업에 좋지만, 실패도 별도 스레드에서 발생한다. 요청은 이미 끝났는데 백그라운드 작업만 실패할 수 있다는 뜻이다.

따라서 반환 타입부터 나눠 봐야 한다. void 메서드의 예외는 호출자에게 직접 전달되지 않으므로 AsyncUncaughtExceptionHandler에서 기록한다. CompletableFuture나 Future를 반환하면 결과 객체로 성공과 실패를 확인한다.

실행자에 이름을 붙여 분리한다

기본 실행자에 모든 비동기 작업을 몰아넣으면 느린 메일 작업이 다른 알림까지 막을 수 있다. 큐 크기, 스레드 수, 이름 접두사를 정해 관찰 가능한 실행자로 분리한다.

@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
    @Bean("mailExecutor")
    Executor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("mail-async-");
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
                LoggerFactory.getLogger(method.getDeclaringClass())
                        .error("async failed: {}", method.getName(), ex);
    }
}

@EnableAsync가 프록시 기반 비동기 실행을 켜고, mailExecutor라는 이름은 메서드의 @Async 값과 연결된다. 핸들러는 void 메서드에서 놓치기 쉬운 예외를 로그나 모니터링으로 보내는 마지막 방어선이다.

반환 타입에 맞춰 실패를 본다

실패 후 보상 처리나 사용자 알림이 필요하다면 void보다 CompletableFuture가 낫다. 예를 들어 @Async("mailExecutor")가 붙은 sendWelcomeMail은 실패 시 uncaught handler가 기록하고, sendAndReport처럼 Future를 반환하는 메서드는 호출부에서 whenComplete, exceptionally, join으로 실패를 확인한다.

검증은 실행 경계까지 확인한다

비동기 코드는 "호출했다"가 아니라 "다른 실행자에서 끝났다"를 확인해야 한다. 테스트에서는 짧은 대기 도구로 결과 생성을 확인하고, 스레드 이름 접두사를 로그나 저장된 값으로 검증하면 잘못된 실행자 연결을 빨리 찾을 수 있다.

같은 클래스 내부에서 자기 메서드를 호출하면 프록시를 거치지 않아 @Async가 적용되지 않을 수 있다. 비동기 경계는 별도 빈으로 분리하고, 큐가 꽉 찰 때의 거절 정책과 실패 로그 형식까지 운영 기준으로 정한다. 그러면 @Async 작업은 조용히 사라지는 일이 아니라 추적 가능한 백그라운드 흐름이 된다.

댓글