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 쪽에서 예외를 잡아버리면:
- coroutine은 예외를 catch하고 정상 흐름으로 복귀
- Reactor 내부에서는 아직 response body를 읽고 있던 stream이 남아있음
- 이 stream이 에러를 emit하지만, 이미 subscriber(coroutine)가 없음
- subscriber 없는 에러 시그널 -> Reactor가
onErrorDropped경고를 출력
coroutine cancellation과 Reactor의 에러 전파 타이밍이 어긋나면서 body가 drain되지 않은 채 subscriber가 사라지는 것이 근본 원인이다.
해결: exchangeToMono 사용
exchangeToMono는 ClientResponse 객체를 직접 받아 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을 직접 관리하는 것이 사실상 표준 워크어라운드.
참고
- Spring Framework #27789 - Kotlin coroutine +
retrieve().awaitBody()조합에서 onErrorDropped 발생 - reactor-core #2931 - onErrorDropped 메시지의 적절성 논의
- reactor-netty #472 - stream cancel 후 에러 발생 케이스
- Baeldung: exchange() vs retrieve() - 두 방식의 차이와 body 소비 책임
- Spring Framework #23365 - WebClient subscribing twice (원본 이슈, 2019~현재 미해결)
- platform-service PR #2465 - apps-api에서 동일 이슈 수정 사례