본문 바로가기
Spring

Spring WebClient + Reactor 기반 비동기 흐름 정리

by PROMISE_YOO 2025. 5. 20.

 

 

subscribe(), doOnSuccess, onErrorResume의 동작 방식과 예외 처리 흐름, 그리고 실패 이력 저장까지. 실무 중심으로 정리한 비동기 처리를 정리했습니다.

 

왜 이 글을 쓰게 됐는가

Spring WebClient 또는 Reactor (Mono, Flux) 기반의 비동기 처리 코드를 다루다 보면, 이런 고민이 한두 번씩은 생깁니다.

  • subscribe() 내부에서 예외를 던지면 호출자가 인식할까?
  • doOnSuccess, onErrorResume은 실제 흐름을 어떻게 바꾸는 걸까?
  • 메시지 전송 실패를 DB에 기록하려면 로직을 어디에 넣는 게 좋을까?

이 글은 그런 궁금증을 실제 코드와 함께 짚어보고, 실무에 적용할 수 있도록 정리해봤습니다.


Mono는 언제 실행되는가?

다음처럼 생긴 코드는 많이 보셨을 겁니다.

webClient.sendMessage(...)
    .subscribe(
        { result -> println("성공: $result") },
        { error -> println("실패: ${error.message}") }
    )

중요한 건 Mono는 선언만으로 실행되지 않는다는 점입니다.
위 코드에서 sendMessage(...)는 실행 준비만 된 상태고,
subscribe()가 호출되는 순간 실제 네트워크 요청이 전송됩니다.

즉, Mono는 "지연 실행(deferred execution)" 구조를 갖고 있기 때문에
subscribe() 없이 아무 일도 일어나지 않습니다.


doOnSuccess와 onErrorResume은 무슨 역할인가?

doOnSuccess – 성공 시 부가 로직을 삽입

 
Mono.just("ok")
    .doOnSuccess { println("성공값: $it") }
    .subscribe()


대신 결과가 성공적으로 들어왔을 때 로그를 남기거나, 캐시를 갱신하는 식의 부가 작업을 넣는 데에 유용합니다.

이건 흐름을 "관찰"만 할 뿐, "제어"하진 않아요.


onErrorResume – 실패 시 흐름을 바꾼다

Mono.error(RuntimeException("Boom"))
    .onErrorResume { e ->
        println("예외 발생: ${e.message}")
        Mono.just("fallback")
    }
    .subscribe { println("결과: $it") }

onErrorResume은 onError 시그널이 발생했을 때
새로운 Mono로 흐름을 대체합니다.
실패했을 때 fallback 값을 넘기거나, 로그 저장을 하거나, 다른 API를 호출하는 식으로 확장 가능합니다.


subscribe 안에서 throw 하면 어떻게 될까?

.subscribe(
    { println("성공") },
    { e -> throw e } // 이 예외는 호출자에게 전달되지 않음
)

subscribe() 안에서 예외를 던져도 호출자에게 전달되지 않습니다.
왜냐하면 subscribe()는 이미 별도의 스레드 또는 이벤트 루프에서 동작하기 때문에
예외는 거기서 끝나고 바깥으로 퍼지지 않거든요.

예외 처리가 필요하다면 subscribe() 대신 Mono 자체를 반환해서
상위에서 .onErrorResume, .doOnError, .block() 등의 연산자로 처리하는 방식이 안전합니다.


메시지 전송 실패 이력을 DB에 남기고 싶다면

방법 1. subscribe 내부에서 직접 처리

.subscribe(
    { log.info("전송 성공") },
    { e ->
        log.error("전송 실패", e)
        try {
            failureLogRepository.save(FailureLog(...)) // 동기 호출이므로 주의
        } catch (ex: Exception) {
            log.warn("실패 이력 저장 중 에러", ex)
        }
    }
)

이 방식은 간단하지만, Spring Data JPA와 같은 블로킹 기반 저장소를 사용한다면
비동기 컨텍스트에서 블로킹 코드를 쓰게 되는 구조라 주의가 필요합니다.


방법 2. onErrorResume 내부에서 처리

 
webClient.sendMessage(...)
    .onErrorResume { e ->
        val logEntry = FailureLog(...)
        failureLogRepository.save(logEntry)
            .doOnSuccess { log.info("실패 이력 저장 완료") }
            .then(Mono.empty()) // 흐름 종료
    }
    .subscribe()


구조상 더 깔끔하고, 테스트나 재사용에도 유리합니다.


연산자 정리

연산자 / 메서드역할흐름 변경 여부
subscribe() 실제 실행을 시작함 실행 트리거
doOnSuccess 성공 시 부수효과 흐름 유지
onErrorResume 실패 시 새로운 흐름으로 대체 흐름 변경
throw in subscribe 예외 발생시키나 호출자에게 전달되지 않음 무시됨
 

마무리하며

Reactor 기반의 비동기 스트림은 단순한 비동기 코드가 아닙니다.
성공, 실패, 완료, 없음 같은 다양한 상태를 정확하게 제어하고 조립할 수 있는 흐름 제어 도구입니다.

그만큼 언제 실행되는지, 실패는 어디서 처리할지,
추가 로직은 어느 연산자에 걸어야 할지를 잘 판단해야 실제 운영에서 예외 없는 시스템을 만들 수 있습니다.

 

읽어주셔서 감사합니다. :)