이 시리즈는 Spring의 비동기 처리를 처음부터 단계별로 다룬다.
2. ThreadPoolTaskExecutor 설정과 스레드 풀 관리
4. 비동기 예외 처리와 재시도 전략 ← 현재 문서
비동기 메서드에서 예외가 터지면 어떻게 될까? 동기 메서드라면 호출한 쪽에서 try-catch로 잡으면 된다. 하지만 비동기 메서드는 다른 스레드에서 실행되기 때문에, 호출한 스레드에서 예외를 직접 잡을 수 없다. 예외가 조용히 사라지는 상황이 발생한다.
void 반환 @Async의 예외 처리
@Async 메서드가 void를 반환하면, 그 안에서 터진 예외는 호출한 쪽으로 전파되지 않는다. 로그도 없이 조용히 삼켜진다.
@Async("paymentExecutor")
public void processPayment(String orderId, String customerName, int amount) {
log.info("[{}] 결제 처리 시작: 주문번호={}", Thread.currentThread().getName(), orderId);
sleep(2000);
if (amount > 100000) {
throw new IllegalArgumentException("결제 금액이 한도를 초과했습니다." + amount + "원");
}
log.info("[{}] 결제 처리 완료!: 주문번호={}", Thread.currentThread().getName(), orderId);
}
이 메서드에서 예외가 터져도, 컨트롤러에서 호출한 쪽은 이미 "접수 완료"를 응답한 상태다. 예외가 어디로도 전달되지 않고 사라진다.
해결책은 AsyncUncaughtExceptionHandler를 구현하는 것이다.
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("======================================================");
log.error("비동기 작업 중 예외 발생!");
log.error("예외 메세지: {}", ex.getMessage());
log.error("메서드 이름: {}", method.getName());
log.error("파라미터: {}", Arrays.toString(params));
log.error("======================================================");
}
}
handleUncaughtException()— void 반환@Async메서드에서 잡히지 않은 예외가 여기로 전달된다. 예외 정보, 메서드 이름, 파라미터를 모두 받을 수 있다.- 이 핸들러 안에서 로그를 남기거나, 슬랙/이메일 알림을 보내거나, 모니터링 시스템에 기록할 수 있다.
이 핸들러를 AsyncConfig에 등록한다.
@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
// ... Executor 빈 정의들
}
AsyncConfigurer— Spring이 제공하는 비동기 설정 인터페이스다.getAsyncUncaughtExceptionHandler()를 오버라이드해서 커스텀 핸들러를 등록한다.
AsyncUncaughtExceptionHandler는 예외를 감지하고 로그를 남길 수는 있지만, 호출자에게 결과를 되돌려줄 수는 없다. 예외에 따라 다른 응답을 내려줘야 한다면 CompletableFuture 반환 방식을 사용해야 한다.
CompletableFuture 반환 @Async의 예외 처리
CompletableFuture를 반환하면 호출자가 예외를 직접 제어할 수 있다.
@Async("paymentExecutor")
public CompletableFuture<PaymentResult> processPaymentWithResult(String orderId, String customerName, int amount) {
log.info("[{}] 결제 처리 시작: 주문번호={}", Thread.currentThread().getName(), orderId);
sleep(2000);
if (amount > 100000) {
throw new PaymentException("결제 금액이 한도를 초과했습니다." + amount + "원");
}
return CompletableFuture.completedFuture(new PaymentResult(true, orderId, "결제 성공"));
}
컨트롤러에서 CompletableFuture의 체이닝 메서드로 성공/실패를 분기한다.
@PostMapping("/safe")
public CompletableFuture<ResponseEntity<Map<String, String>>> createOrderSafely(@RequestBody Map<String, Object> request) {
String orderId = "ORD-" + System.currentTimeMillis();
String customerName = (String) request.get("customerName");
Integer amount = (Integer) request.get("amount");
return paymentService.processPaymentWithResult(orderId, customerName, amount)
.orTimeout(10, TimeUnit.SECONDS)
.thenApply(result -> {
if (result.isSuccess()) {
return ResponseEntity.ok(Map.of(
"orderId", orderId,
"status", "SUCCESS",
"message", result.getMessage()
));
} else {
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
.body(Map.of("orderId", orderId, "status", "FAILED", "message", result.getMessage()));
}
})
.exceptionally(ex -> {
Throwable cause = ex.getCause();
if (ex instanceof TimeoutException || cause instanceof TimeoutException) {
return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT)
.body(Map.of("status", "TIMEOUT", "message", "결제 시간이 초과되었습니다."));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("status", "BAD_REQUEST", "message", cause.getMessage()));
});
}
orTimeout(10, TimeUnit.SECONDS)— 10초 안에 결과가 오지 않으면TimeoutException을 발생시킨다.thenApply()— 비동기 작업이 성공했을 때 결과를 변환한다. 여기서는PaymentResult를ResponseEntity로 변환한다.exceptionally()— 비동기 작업 중 예외가 발생했을 때 대체 값을 반환한다. 예외 타입에 따라 다른 HTTP 상태 코드를 내려준다.
직접 구현하는 재시도 로직
비동기 작업이 네트워크 문제 등으로 실패했을 때, 한 번 더 시도하면 성공할 수도 있다. 재시도 로직을 직접 만들어보자.
@Component
@Slf4j
public class AsyncUtil {
public <T> CompletableFuture<T> retry(Supplier<CompletableFuture<T>> action, int maxRetries) {
return action.get()
.handle((res, ex) -> {
if (ex == null) {
return CompletableFuture.completedFuture(res);
}
if (maxRetries > 0) {
log.warn("작업 실패! 재시도 남은 횟수: {}", maxRetries);
return retry(action, maxRetries - 1);
}
log.error("모든 재시도 실패! (원인: {})", ex.getMessage());
return CompletableFuture.<T>failedFuture(ex);
})
.thenCompose(f -> f);
}
}
Supplier<CompletableFuture<T>>— 재시도할 비동기 작업을 람다로 전달받는다.handle()— 성공과 실패를 모두 처리한다. 실패 시 재귀적으로retry()를 호출한다.thenCompose()—handle()이 반환하는CompletableFuture<CompletableFuture<T>>를 평탄화(flatten)한다.
사용할 때는 이렇게 호출한다.
asyncUtil.retry(
() -> paymentService.processPaymentWithResult(orderId, customerName, amount), 2
)
이 방식은 동작하지만, 재시도 간 대기 시간이 없고 설정이 코드에 하드코딩되어 있다. 이런 문제를 해결하기 위해 전문 라이브러리를 사용한다.
Spring Retry
Spring Retry는 어노테이션 기반의 재시도 라이브러리다.
의존성 추가
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
메인 클래스에 @EnableRetry를 추가한다.
@SpringBootApplication
@EnableRetry
public class SpringAsyncPracticeApplication { ... }
@Retryable 적용
@Async("paymentExecutor")
@Retryable(
retryFor = { PaymentException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public CompletableFuture<PaymentResult> processPaymentWithResult(String orderId, String customerName, int amount) {
log.info("[{}] 결제 처리 시작: 주문번호={}", Thread.currentThread().getName(), orderId);
sleep(2000);
if (amount > 100000) {
throw new PaymentException("결제 금액이 한도를 초과했습니다." + amount + "원");
}
return CompletableFuture.completedFuture(new PaymentResult(true, orderId, "결제 성공"));
}
retryFor— 이 예외가 발생했을 때만 재시도한다. 다른 예외는 즉시 실패 처리한다.maxAttempts = 3— 최대 3번 시도한다 (최초 1회 + 재시도 2회).@Backoff(delay = 1000, multiplier = 2)— 재시도 간 대기 시간을 설정한다. 첫 재시도는 1초, 두 번째 재시도는 2초를 대기한다. 지수 백오프라고 하며, 재시도가 반복될수록 대기 시간이 길어져 서버 부하를 줄인다.
@Recover 폴백 메서드
모든 재시도가 실패하면 @Recover 메서드가 호출된다.
@Recover
public CompletableFuture<PaymentResult> recover(PaymentException e, String orderId, String customerName, int amount) {
log.error("모든 재시도 실패... 주문번호: {}, 최종 원인: {}", orderId, e.getMessage());
return CompletableFuture.completedFuture(new PaymentResult(false, orderId, e.getMessage()));
}
@Recover메서드의 반환 타입은 원본 메서드와 동일해야 한다.- 첫 번째 파라미터는 예외 타입, 나머지는 원본 메서드의 파라미터와 동일해야 한다.
- 여기서 실패 응답을 반환하거나,
CompletableFuture.failedFuture(e)로 예외를 다시 던질 수도 있다.
Resilience4j Retry
Resilience4j는 Spring Retry보다 더 세밀한 제어가 가능한 라이브러리다. 설정을 코드가 아닌 application.yml에서 관리할 수 있다는 점이 큰 장점이다.
의존성 추가
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.1.0'
implementation 'org.springframework.boot:spring-boot-starter-aop'
Resilience4j를 사용하면 Spring Retry 의존성은 제거해도 된다. @EnableRetry도 필요 없다.
yml 설정
resilience4j:
retry:
instances:
paymentRetry:
maxAttempts: 3
waitDuration: 1000ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.lang.RuntimeException
- com.codeit.async.exception.PaymentException
ignoreExceptions:
- java.lang.IllegalArgumentException
paymentRetry— 재시도 규칙의 이름이다. 서비스 코드에서 이 이름을 참조한다.retryExceptions— 이 예외가 발생하면 재시도한다.ignoreExceptions— 이 예외는 재시도하지 않고 즉시 실패 처리한다. 예를 들어IllegalArgumentException은 클라이언트 입력 오류이므로 재시도해도 결과가 같다.
@Retry 적용
@Retry(name = "paymentRetry", fallbackMethod = "fallbackPayment")
@Async("paymentExecutor")
public CompletableFuture<PaymentResult> processPaymentWithResult(String orderId, String customerName, int amount) {
log.info("[{}] 결제 처리 시작: 주문번호={}", Thread.currentThread().getName(), orderId);
sleep(2000);
if (amount > 100000) {
throw new PaymentException("결제 금액이 한도를 초과했습니다." + amount + "원");
}
return CompletableFuture.completedFuture(new PaymentResult(true, orderId, "결제 성공"));
}
name = "paymentRetry"— yml에서 정의한 재시도 규칙 이름과 일치해야 한다.fallbackMethod = "fallbackPayment"— 모든 재시도 실패 시 호출할 폴백 메서드 이름이다.
폴백 메서드
public CompletableFuture<PaymentResult> fallbackPayment(String orderId, String customerName, int amount, Throwable e) {
log.error("모든 재시도 실패... 주문번호: {}, 최종 원인: {}", orderId, e.getMessage());
return CompletableFuture.completedFuture(new PaymentResult(false, orderId, e.getMessage()));
}
Spring Retry의 @Recover와 차이점이 있다.
- 메서드 이름이
fallbackMethod값과 일치해야 한다. - 파라미터 순서가 다르다. 원본 파라미터가 먼저,
Throwable이 마지막이다. Spring Retry는 예외가 첫 번째였다.
Spring Retry vs Resilience4j 비교
| 항목 | Spring Retry | Resilience4j |
|---|---|---|
| 설정 위치 | 어노테이션(코드) | yml(외부 설정) |
| 활성화 | @EnableRetry 필요 | 자동 설정 |
| 폴백 파라미터 순서 | 예외 먼저 | 원본 파라미터 먼저, 예외 마지막 |
| 추가 기능 | 재시도 전용 | Circuit Breaker, Rate Limiter 등 통합 |
| 설정 변경 | 코드 수정 + 재배포 | yml 수정으로 가능 |
단순히 재시도만 필요하면 Spring Retry가 간단하다. 하지만 Circuit Breaker, Rate Limiter 등 추가적인 장애 대응 패턴이 필요하거나, 설정을 외부에서 관리하고 싶다면 Resilience4j가 더 유연하다. 최근에는 Resilience4j를 선호하는 추세다.
자주 하는 실수
재시도해서는 안 되는 예외를 재시도
입력값 검증 실패(IllegalArgumentException)나 인증 실패(AuthenticationException) 같은 예외는 재시도해도 결과가 같다. retryExceptions와 ignoreExceptions를 구분해서 설정해야 한다.
지수 백오프 없이 재시도
재시도 간 대기 시간 없이 즉시 재시도하면, 이미 과부하 상태인 외부 서비스에 더 큰 부하를 가한다. 지수 백오프로 재시도 간격을 점점 늘리는 것이 기본이다.
@Retry와 @Async의 순서
Resilience4j의 @Retry와 @Async를 함께 사용할 때, 어노테이션의 선언 순서에 따라 동작이 달라질 수 있다. @Retry가 먼저 적용되어야 재시도가 같은 스레드에서 일어난다. 일반적으로 @Retry를 @Async 위에 선언한다.
정리
- void 반환 @Async의 예외는 AsyncUncaughtExceptionHandler로 잡는다.
- CompletableFuture 반환 @Async의 예외는 exceptionally(), handle()로 처리한다.
- 재시도 로직은 직접 구현하기보다 Spring Retry 또는 Resilience4j를 사용한다.
- Resilience4j는 yml 기반 설정, Circuit Breaker 등 추가 기능을 제공한다.
- 재시도할 예외와 하지 않을 예외를 명확히 구분해야 한다.
다음 편에서는 비동기 스레드에서 사용자 정보나 로그 추적 ID 같은 컨텍스트가 사라지는 문제와 그 해결책인 TaskDecorator를 다룬다.