리액티브 스트림에서 에러가 발생하면, 그 에러는 어디로 갈까요? try-catch로 잡을 수 있을까요?

MVC에서는 try-catch 한 줄이면 끝나던 예외 처리가, 리액티브에서는 생각보다 까다롭습니다. 에러가 발생하는 시점이 "메서드 호출 시점"이 아니라 "구독 후 데이터가 흐르는 시점"이기 때문입니다. 공부하다 보니 이 차이를 제대로 이해하지 못하면, 에러가 조용히 삼켜지거나 예상치 못한 곳에서 터지는 상황을 자주 겪게 되더라고요.

이 글에서는 Reactor의 에러 오퍼레이터부터 WebFlux의 전역 에러 핸들링까지, 리액티브 환경에서 에러를 다루는 방법을 정리합니다.


MVC와 리액티브의 에러 처리, 뭐가 다를까

MVC에서는 컨트롤러 메서드 안에서 동기적으로 코드가 실행되기 때문에, try-catch로 예외를 직접 잡거나 @ExceptionHandler에게 위임하면 됩니다. 호출 스택이 명확하고, 예외가 발생한 위치를 바로 추적할 수 있습니다.

리액티브에서는 상황이 다릅니다.

  • 에러는 시그널이다 — Reactor에서 에러는 onError 시그널로 전파됩니다. Java의 예외처럼 콜 스택을 타고 올라가는 게 아니라, 스트림의 파이프라인을 따라 아래로 흘러갑니다.
  • ** 터미널 시그널이다** — onError가 발생하면 스트림은 즉시 종료됩니다. 이후의 onNext는 더 이상 호출되지 않습니다.
  • ** 처리하지 않으면 예외가 된다** — onError를 아무도 처리하지 않으면, 최종적으로 UnsupportedOperationException이나 ErrorCallbackNotImplemented가 터집니다.

핵심은 "에러도 데이터처럼 스트림 안에서 흐른다"는 점입니다. 그래서 에러를 처리하는 도구도 스트림 오퍼레이터 형태로 제공됩니다.


Reactor 에러 오퍼레이터

onErrorReturn — 기본값으로 대체

가장 단순한 전략입니다. 에러가 발생하면 미리 정해둔 기본값을 반환하고 스트림을 정상 종료합니다.

JAVA
Mono<String> result = service.findUser(id)
    .onErrorReturn("기본 사용자");  // 에러 시 기본값 반환

특정 예외 타입에만 반응하도록 제한할 수도 있습니다.

JAVA
Mono<String> result = service.findUser(id)
    .onErrorReturn(TimeoutException.class, "타임아웃 기본값");

단순하지만, 에러의 종류에 따라 다른 처리를 해야 한다면 한계가 있습니다.

onErrorResume — 대체 스트림으로 전환

에러가 발생하면 다른 MonoFlux로 전환합니다. 에러 타입에 따라 분기하거나, 캐시에서 대체 데이터를 가져오는 등 유연한 처리가 가능합니다.

JAVA
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 — 에러를 다른 에러로 변환

에러 자체를 다른 타입의 예외로 변환합니다. 저수준 예외를 비즈니스 예외로 감싸고 싶을 때 유용합니다.

JAVA
Mono<User> result = userRepository.findById(id)
    .onErrorMap(SQLException.class, e ->
        new ServiceException("사용자 조회 실패", e)  // 비즈니스 예외로 변환
    );

doOnError — 사이드 이펙트 (로깅 등)

에러를 처리하지 않고, 로깅이나 메트릭 기록 같은 사이드 이펙트만 수행합니다. 에러 시그널은 그대로 다음으로 전파됩니다.

JAVA
Mono<User> result = userRepository.findById(id)
    .doOnError(e -> log.error("사용자 조회 중 에러 발생: {}", e.getMessage()))
    .onErrorResume(e -> Mono.just(User.defaultUser()));

doOnError는 에러를 잡지 않습니다. 반드시 뒤에 onErrorResume 같은 실제 처리 오퍼레이터가 있어야 합니다.


retry와 retryWhen — 재시도 전략

일시적인 장애(네트워크 불안정, 일시적 서버 과부하 등)에 대응하려면 재시도가 필요합니다.

retry() — 단순 재시도

JAVA
Mono<String> result = externalApi.call()
    .retry(3);  // 최대 3번 재시도

에러 종류를 가리지 않고 무조건 재시도합니다. 간단하지만 실무에서는 거의 쓰지 않습니다. 재시도 간격 없이 바로 재시도하면 장애가 있는 서버에 부하만 가중시키기 때문입니다.

retryWhen() — 조건부 재시도 + 백오프

reactor-extraRetry 유틸리티와 함께 쓰면, 재시도 횟수, 간격, 대상 예외를 세밀하게 제어할 수 있습니다.

JAVA
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를 사용할 수 있습니다.

JAVA
@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로 분리합니다.

JAVA
@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를 구현합니다.

JAVA
@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을 직접 던질 수 있습니다.

JAVA
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에서도 에러 처리가 중요합니다.

JAVA
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. 에러가 조용히 삼켜지는 경우

JAVA
// 잘못된 예 — subscribe()에서 에러 콜백 없이 구독
service.process()
    .subscribe(result -> log.info("성공: {}", result));
// 에러 발생 시 ErrorCallbackNotImplemented 예외 터짐!

// 올바른 예 — 에러 콜백 추가
service.process()
    .subscribe(
        result -> log.info("성공: {}", result),
        error -> log.error("실패: {}", error.getMessage())
    );

2. 에러 핸들러 안에서 블로킹 호출

JAVA
// 잘못된 예 — onErrorResume 안에서 블로킹 I/O
.onErrorResume(e -> {
    // 블로킹 DB 호출 — 이벤트 루프 스레드를 막아버림!
    User fallback = jdbcTemplate.queryForObject("SELECT ...", User.class);
    return Mono.just(fallback);
})

// 올바른 예 — 리액티브 방식 또는 스케줄러 전환
.onErrorResume(e ->
    r2dbcRepository.findFallbackUser()  // 리액티브 DB 접근
)

리액티브 파이프라인 안에서 블로킹 호출을 하면 이벤트 루프 스레드가 멈춥니다. 에러 핸들러 안이라고 예외는 아닙니다.

3. onErrorResume 위치에 따른 차이

JAVA
// 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);                       // 여기서 에러 발생하면 전파됨

오퍼레이터의 위치가 곧 에러 처리 범위를 결정합니다. 가능하면 파이프라인 끝 쪽에 배치하는 것이 안전합니다.


실전 패턴 정리

실무에서 자주 쓰는 에러 핸들링 조합을 정리하면 이렇습니다.

JAVA
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으로 일시적 장애에 대응 (반드시 백오프 포함)
  • ** 전역 처리** — @ControllerAdviceWebExceptionHandler로 최종 방어선 구축

에러 처리도 결국 "스트림 안에서" 이루어진다는 점을 기억하면, MVC와의 차이가 자연스럽게 이해됩니다. try-catch 대신 오퍼레이터를, throws 대신 onErrorMap을, @ExceptionHandler는 그대로 — 이 세 가지 대응만 기억해도 대부분의 상황을 커버할 수 있습니다.

댓글 로딩 중...