WebClient retrieve()와 onErrorDropped 이슈

현상

Spring WebClient에서 retrieve().awaitBody()를 사용할 때, 에러 응답(4xx/5xx)을 호출자가 runCatching이나 try-catch로 잡으면 아래 경고가 발생한다.

Operator called default onErrorDropped

원인

retrieve()가 에러 응답을 받으면 response body를 비동기로 읽어 WebClientResponseException을 생성한다. 이 과정은 Reactor의 reactive stream 안에서 일어나기 때문에, body 읽기가 아직 진행 중인 상태에서 예외 시그널이 먼저 발생할 수 있다.

이때 Kotlin coroutine 쪽에서 예외를 잡아버리면:

  1. coroutine은 예외를 catch하고 정상 흐름으로 복귀
  2. Reactor 내부에서는 아직 response body를 읽고 있던 stream이 남아있음
  3. 이 stream이 에러를 emit하지만, 이미 subscriber(coroutine)가 없음
  4. subscriber 없는 에러 시그널 -> Reactor가 onErrorDropped 경고를 출력

coroutine cancellation과 Reactor의 에러 전파 타이밍이 어긋나면서 body가 drain되지 않은 채 subscriber가 사라지는 것이 근본 원인이다.

해결: exchangeToMono 사용

exchangeToMonoClientResponse 객체를 직접 받아 body 라이프사이클을 명시적으로 관리한다.

// AS-IS: onErrorDropped 발생 가능
webClient
    .patch()
    .uri(url)
    .bodyValue(body)
    .retrieve()
    .awaitBody<JsonNode>()

// TO-BE: body를 명시적으로 소비
webClient
    .patch()
    .uri(url)
    .bodyValue(body)
    .exchangeToMono { response ->
        if (response.statusCode().isError) {
            response.createError()  // body를 drain한 뒤 에러 Mono 반환
        } else {
            response.bodyToMono(JsonNode::class.java)
        }
    }.awaitSingle()

404를 null로 처리하는 경우

webClient
    .get()
    .uri(url)
    .exchangeToMono { response ->
        when {
            response.statusCode() == HttpStatus.NOT_FOUND ->
                response.releaseBody().then(Mono.empty())  // body 명시적 해제
            response.statusCode().isError -> response.createError()
            else -> response.bodyToMono(JsonNode::class.java)
        }
    }.awaitSingleOrNull()

핵심은:

  • 에러 시 response.createError() -> body를 완전히 drain한 뒤 에러 전파
  • 무시할 때 response.releaseBody() -> body를 명시적으로 해제
  • "읽다 만 body" 상황을 만들지 않는 것

Spring 공식 대응 현황

2019년에 #23365으로 최초 보고되었고, #27789은 duplicate로 닫혔다. 2026년 현재까지 근본적인 fix는 없다.

Reactor와 Kotlin coroutine 사이의 구조적 문제(cancellation 전파 타이밍)라서 Spring 한쪽에서 깔끔하게 고치기 어려운 영역이다. exchangeToMono로 body drain을 직접 관리하는 것이 사실상 표준 워크어라운드.

참고