WebFlux 에러 핸들링 — 리액티브에서 예외를 다루는 방법
리액티브 스트림에서 에러가 발생하면, 그 에러는 어디로 갈까요? try-catch로 잡을 수 있을까요?
MVC에서는 try-catch 한 줄이면 끝나던 예외 처리가, 리액티브에서는 생각보다 까다롭습니다. 에러가 발생하는 시점이 "메서드 호출 시점"이 아니라 "구독 후 데이터가 흐르는 시점"이기 때문입니다. 공부하다 보니 이 차이를 제대로 이해하지 못하면, 에러가 조용히 삼켜지거나 예상치 못한 곳에서 터지는 상황을 자주 겪게 되더라고요.
이 글에서는 Reactor의 에러 오퍼레이터부터 WebFlux의 전역 에러 핸들링까지, 리액티브 환경에서 에러를 다루는 방법을 정리합니다.
MVC와 리액티브의 에러 처리, 뭐가 다를까
MVC에서는 컨트롤러 메서드 안에서 동기적으로 코드가 실행되기 때문에, try-catch로 예외를 직접 잡거나 @ExceptionHandler에게 위임하면 됩니다. 호출 스택이 명확하고, 예외가 발생한 위치를 바로 추적할 수 있습니다.
리액티브에서는 상황이 다릅니다.
- 에러는 시그널이다 — Reactor에서 에러는
onError시그널로 전파됩니다. Java의 예외처럼 콜 스택을 타고 올라가는 게 아니라, 스트림의 파이프라인을 따라 아래로 흘러갑니다. - ** 터미널 시그널이다** —
onError가 발생하면 스트림은 즉시 종료됩니다. 이후의onNext는 더 이상 호출되지 않습니다. - ** 처리하지 않으면 예외가 된다** —
onError를 아무도 처리하지 않으면, 최종적으로UnsupportedOperationException이나ErrorCallbackNotImplemented가 터집니다.
핵심은 "에러도 데이터처럼 스트림 안에서 흐른다"는 점입니다. 그래서 에러를 처리하는 도구도 스트림 오퍼레이터 형태로 제공됩니다.
Reactor 에러 오퍼레이터
onErrorReturn — 기본값으로 대체
가장 단순한 전략입니다. 에러가 발생하면 미리 정해둔 기본값을 반환하고 스트림을 정상 종료합니다.
Mono<String> result = service.findUser(id)
.onErrorReturn("기본 사용자"); // 에러 시 기본값 반환
특정 예외 타입에만 반응하도록 제한할 수도 있습니다.
Mono<String> result = service.findUser(id)
.onErrorReturn(TimeoutException.class, "타임아웃 기본값");
단순하지만, 에러의 종류에 따라 다른 처리를 해야 한다면 한계가 있습니다.
onErrorResume — 대체 스트림으로 전환
에러가 발생하면 다른 Mono나 Flux로 전환합니다. 에러 타입에 따라 분기하거나, 캐시에서 대체 데이터를 가져오는 등 유연한 처리가 가능합니다.
Mono<User> result = userRepository.findById(id)
.onErrorResume(DatabaseException.class, e -> {
// DB 에러 시 캐시에서 조회
log.warn("DB 조회 실패, 캐시 조회로 전환: {}", e.getMessage());
return cacheService.findUser(id);
})
.onErrorResume(e -> {
// 그 외 에러는 빈 기본 객체 반환
log.error("알 수 없는 에러: {}", e.getMessage());
return Mono.just(User.defaultUser());
});
onErrorReturn은 고정된 값 하나를 반환하고,onErrorResume은 에러에 따라 다른 Publisher를 반환할 수 있습니다. 실무에서는onErrorResume을 훨씬 많이 씁니다.
onErrorMap — 에러를 다른 에러로 변환
에러 자체를 다른 타입의 예외로 변환합니다. 저수준 예외를 비즈니스 예외로 감싸고 싶을 때 유용합니다.
Mono<User> result = userRepository.findById(id)
.onErrorMap(SQLException.class, e ->
new ServiceException("사용자 조회 실패", e) // 비즈니스 예외로 변환
);
doOnError — 사이드 이펙트 (로깅 등)
에러를 처리하지 않고, 로깅이나 메트릭 기록 같은 사이드 이펙트만 수행합니다. 에러 시그널은 그대로 다음으로 전파됩니다.
Mono<User> result = userRepository.findById(id)
.doOnError(e -> log.error("사용자 조회 중 에러 발생: {}", e.getMessage()))
.onErrorResume(e -> Mono.just(User.defaultUser()));
doOnError는 에러를 잡지 않습니다. 반드시 뒤에 onErrorResume 같은 실제 처리 오퍼레이터가 있어야 합니다.
retry와 retryWhen — 재시도 전략
일시적인 장애(네트워크 불안정, 일시적 서버 과부하 등)에 대응하려면 재시도가 필요합니다.
retry() — 단순 재시도
Mono<String> result = externalApi.call()
.retry(3); // 최대 3번 재시도
에러 종류를 가리지 않고 무조건 재시도합니다. 간단하지만 실무에서는 거의 쓰지 않습니다. 재시도 간격 없이 바로 재시도하면 장애가 있는 서버에 부하만 가중시키기 때문입니다.
retryWhen() — 조건부 재시도 + 백오프
reactor-extra의 Retry 유틸리티와 함께 쓰면, 재시도 횟수, 간격, 대상 예외를 세밀하게 제어할 수 있습니다.
Mono<String> result = externalApi.call()
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) // 최대 3회, 1초 간격부터 지수 백오프
.maxBackoff(Duration.ofSeconds(10)) // 최대 대기 시간 10초
.filter(e -> e instanceof TimeoutException) // 타임아웃만 재시도
.onRetryExhaustedThrow((spec, signal) -> // 재시도 소진 시 커스텀 예외
new ServiceException("외부 API 호출 실패 — 재시도 소진", signal.failure()))
);
재시도는 "원본 시퀀스를 다시 구독"하는 것입니다. 따라서 원본이 Cold Publisher여야 의미가 있습니다. Hot Publisher에 retry를 걸면 이전 데이터를 다시 받을 수 없으니 주의해야 합니다.
@ExceptionHandler — WebFlux 컨트롤러 에러 처리
MVC와 동일하게, WebFlux의 어노테이션 기반 컨트롤러에서도 @ExceptionHandler를 사용할 수 있습니다.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userService.findById(id);
}
// 컨트롤러 내부 에러 핸들러
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Mono<ErrorResponse> handleNotFound(UserNotFoundException e) {
return Mono.just(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
}
}
MVC와의 차이점은 반환 타입이 Mono라는 것뿐입니다. 스프링이 내부적으로 리액티브 파이프라인에서 발생한 에러를 잡아 이 핸들러로 라우팅해 줍니다.
@ControllerAdvice — 전역 에러 핸들링
여러 컨트롤러에 공통으로 적용할 에러 처리 로직은 @ControllerAdvice로 분리합니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 비즈니스 예외
@ExceptionHandler(BusinessException.class)
public Mono<ResponseEntity<ErrorResponse>> handleBusiness(BusinessException e) {
ErrorResponse body = new ErrorResponse(e.getCode(), e.getMessage());
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body));
}
// 유효성 검증 예외
@ExceptionHandler(WebExchangeBindException.class)
public Mono<ResponseEntity<ErrorResponse>> handleValidation(WebExchangeBindException e) {
// 필드 에러를 문자열로 조합
String message = e.getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
ErrorResponse body = new ErrorResponse("VALIDATION_ERROR", message);
return Mono.just(ResponseEntity.badRequest().body(body));
}
// 기타 예외 — 최후의 방어선
@ExceptionHandler(Exception.class)
public Mono<ResponseEntity<ErrorResponse>> handleGeneral(Exception e) {
log.error("처리되지 않은 예외 발생", e);
ErrorResponse body = new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다.");
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body));
}
}
WebFlux에서
@ControllerAdvice는 ** 어노테이션 기반 컨트롤러에서만** 동작합니다. 함수형 엔드포인트(RouterFunction)를 사용한다면WebExceptionHandler를 써야 합니다.
WebExceptionHandler — 함수형 엔드포인트의 전역 에러 처리
함수형 라우팅(RouterFunction + HandlerFunction)에서는 @ControllerAdvice가 동작하지 않습니다. 대신 WebExceptionHandler를 구현합니다.
@Component
@Order(-2) // 기본 핸들러보다 높은 우선순위
public class GlobalWebExceptionHandler implements WebExceptionHandler {
private final ObjectMapper objectMapper;
public GlobalWebExceptionHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
if (ex instanceof BusinessException biz) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return writeErrorResponse(response, biz.getCode(), biz.getMessage());
}
if (ex instanceof ResponseStatusException rse) {
response.setStatusCode(rse.getStatusCode());
return writeErrorResponse(response, "HTTP_ERROR", rse.getReason());
}
// 기본: 500
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
return writeErrorResponse(response, "INTERNAL_ERROR", "서버 내부 오류");
}
private Mono<Void> writeErrorResponse(ServerHttpResponse response, String code, String message) {
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
ErrorResponse error = new ErrorResponse(code, message);
try {
byte[] bytes = objectMapper.writeValueAsBytes(error);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Mono.just(buffer));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
}
}
@Order(-2)를 지정하는 이유는, 스프링 부트의 DefaultErrorWebExceptionHandler가 -1 순서로 등록되어 있기 때문입니다. 우리 핸들러가 먼저 처리하려면 더 낮은 숫자(더 높은 우선순위)를 지정해야 합니다.
ResponseStatusException — 간단한 상태 코드 매핑
별도의 커스텀 예외 클래스를 만들기 부담스러울 때, ResponseStatusException을 직접 던질 수 있습니다.
public Mono<User> findById(Long id) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(
new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다: " + id)
));
}
스프링이 이 예외를 받으면 자동으로 해당 HTTP 상태 코드와 메시지를 응답에 매핑합니다. 프로토타이핑이나 간단한 API에서는 충분하지만, 에러 응답 포맷을 통일해야 하는 실무 프로젝트에서는 @ControllerAdvice 조합이 더 적합합니다.
WebClient 에러 핸들링
외부 API를 호출하는 WebClient에서도 에러 처리가 중요합니다.
public Mono<ExternalData> fetchData(String endpoint) {
return webClient.get()
.uri(endpoint)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
// 4xx 에러를 비즈니스 예외로 변환
response.bodyToMono(String.class)
.flatMap(body -> Mono.error(new ClientException("클라이언트 에러: " + body)))
)
.onStatus(HttpStatusCode::is5xxServerError, response ->
// 5xx 에러는 재시도 가능하도록 별도 예외로 변환
Mono.error(new RetryableException("외부 서버 에러"))
)
.bodyToMono(ExternalData.class)
.timeout(Duration.ofSeconds(5)) // 타임아웃 설정
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
.filter(e -> e instanceof RetryableException)) // 재시도 가능한 예외만 재시도
.onErrorResume(TimeoutException.class, e -> {
log.warn("외부 API 타임아웃: {}", endpoint);
return Mono.just(ExternalData.empty()); // 타임아웃 시 기본값
});
}
retrieve()의 기본 동작은 4xx/5xx 응답을WebClientResponseException으로 변환하는 것입니다.onStatus로 이 동작을 커스터마이즈하면 에러 종류에 따라 재시도 여부를 제어할 수 있습니다.
자주 하는 실수들
1. 에러가 조용히 삼켜지는 경우
// 잘못된 예 — subscribe()에서 에러 콜백 없이 구독
service.process()
.subscribe(result -> log.info("성공: {}", result));
// 에러 발생 시 ErrorCallbackNotImplemented 예외 터짐!
// 올바른 예 — 에러 콜백 추가
service.process()
.subscribe(
result -> log.info("성공: {}", result),
error -> log.error("실패: {}", error.getMessage())
);
2. 에러 핸들러 안에서 블로킹 호출
// 잘못된 예 — onErrorResume 안에서 블로킹 I/O
.onErrorResume(e -> {
// 블로킹 DB 호출 — 이벤트 루프 스레드를 막아버림!
User fallback = jdbcTemplate.queryForObject("SELECT ...", User.class);
return Mono.just(fallback);
})
// 올바른 예 — 리액티브 방식 또는 스케줄러 전환
.onErrorResume(e ->
r2dbcRepository.findFallbackUser() // 리액티브 DB 접근
)
리액티브 파이프라인 안에서 블로킹 호출을 하면 이벤트 루프 스레드가 멈춥니다. 에러 핸들러 안이라고 예외는 아닙니다.
3. onErrorResume 위치에 따른 차이
// A 위치 — map 이후 에러만 잡힘
service.getData()
.map(this::transform)
.onErrorResume(e -> Mono.just(defaultValue)); // map에서 발생한 에러 잡힘
// B 위치 — getData 에러는 잡히지만 map 에러는 안 잡힘
service.getData()
.onErrorResume(e -> Mono.just(rawDefault)) // getData 에러만 잡힘
.map(this::transform); // 여기서 에러 발생하면 전파됨
오퍼레이터의 위치가 곧 에러 처리 범위를 결정합니다. 가능하면 파이프라인 끝 쪽에 배치하는 것이 안전합니다.
실전 패턴 정리
실무에서 자주 쓰는 에러 핸들링 조합을 정리하면 이렇습니다.
public Mono<OrderResponse> createOrder(OrderRequest request) {
return validateRequest(request) // 1. 유효성 검증
.flatMap(orderRepository::save) // 2. 저장
.flatMap(order -> paymentService.charge(order) // 3. 결제
.retryWhen(Retry.backoff(2, Duration.ofMillis(500))
.filter(e -> e instanceof RetryableException))
.onErrorMap(e -> new OrderException("결제 처리 실패", e))
)
.doOnError(e -> log.error("주문 생성 실패: {}", e.getMessage()))
.doOnSuccess(r -> log.info("주문 생성 완료: {}", r.getOrderId()));
}
패턴을 정리하면 다음과 같습니다.
- ** 로깅** —
doOnError로 에러 발생 사실을 기록 - ** 변환** —
onErrorMap으로 저수준 예외를 비즈니스 예외로 감싸기 - ** 복구** —
onErrorResume으로 대체 값이나 대체 로직 제공 - ** 재시도** —
retryWhen으로 일시적 장애에 대응 (반드시 백오프 포함) - ** 전역 처리** —
@ControllerAdvice나WebExceptionHandler로 최종 방어선 구축
에러 처리도 결국 "스트림 안에서" 이루어진다는 점을 기억하면, MVC와의 차이가 자연스럽게 이해됩니다. try-catch 대신 오퍼레이터를, throws 대신 onErrorMap을, @ExceptionHandler는 그대로 — 이 세 가지 대응만 기억해도 대부분의 상황을 커버할 수 있습니다.