WebFlux를 쓰다 보면 한 번쯤 이런 상황을 만납니다. 분명 onErrorResume을 붙여놨는데, 예외가 fallback으로 가지 않고 바로 터져버리는 케이스입니다. 처음에는 “연산자 체인 순서가 꼬였나?“라고 생각하기 쉬운데, 핵심은 순서보다 예외가 발생한 시점입니다.
이 글은 “왜 이런 일이 벌어지는지"를 Reactive Stream의 생명주기(assembly/subscription) 관점으로 풀고, 실무에서 재발을 줄이는 패턴까지 정리합니다.
이 글에서 얻는 것
throw와Mono.error(...)가 왜 다르게 동작하는지 실행 시점 기준으로 이해할 수 있습니다.Mono.defer,Mono.fromCallable을 언제 써야 안전한지 판단 기준을 잡을 수 있습니다.- 운영에서 흔한 안티패턴과 디버깅 체크리스트를 바로 적용할 수 있습니다.
문제 재현: 왜 onErrorResume이 스킵될까?
아래 메서드는 반환 타입이 Mono<List<String>>지만, 실제로는 Mono를 “리턴하기도 전에” 예외를 던집니다.
Mono<List<String>> getNameList() {
throw new RuntimeException("boom");
}
그리고 호출부에서 이런 체인을 구성했다고 가정해봅시다.
getNameList()
.flatMapMany(Flux::fromIterable)
.collectList()
.onErrorResume(e -> Mono.just(List.of("error")))
.block();
문제는 getNameList() 호출 시점에 이미 예외가 발생한다는 점입니다. 즉, Reactor가 구독(subscribe)을 시작하기 전에 자바 메서드 호출 자체가 실패합니다. 이 경우 예외는 Reactive Stream 내부 시그널(onError)로 변환되지 않으므로 onErrorResume이 개입할 기회가 없습니다.
정리하면,
throw가 메서드 실행 즉시 발생하면: 스트림 밖 예외(연산자 미개입)Mono.error(...)처럼 퍼블리셔 내부 시그널이면: 스트림 안 예외(onErrorResume가능)
핵심 원인: Assembly vs Subscription
Reactor 코드를 읽을 때 가장 중요한 축은 아래 두 단계입니다.
- Assembly(조립 단계): 연산자 체인을 선언하는 단계
- Subscription(구독 단계): 실제 데이터/에러 시그널이 흐르는 실행 단계
onErrorResume은 Subscription 단계에서 onError 시그널을 받아 동작합니다. 그런데 즉시 throw는 Assembly 이전(혹은 Assembly 진입 직전)에서 터질 수 있습니다. 그래서 동일한 “예외"처럼 보여도 처리 경로가 완전히 달라집니다.
이 차이를 이해하지 못하면 “왜 어떤 코드는 fallback 되고, 어떤 코드는 그냥 죽지?“라는 혼란이 반복됩니다.
해결 패턴 1: Mono.error로 명시적으로 스트림 안으로 넣기
예외를 반환 타입 안에서 표현할 수 있다면 가장 명확합니다.
Mono<List<String>> getNameList() {
return Mono.error(new IllegalStateException("name source unavailable"));
}
장점은 의도가 분명하다는 점입니다. “이 함수는 실패를 Publisher 시그널로 전달한다"는 계약이 코드에 드러납니다.
해결 패턴 2: Mono.defer로 실행 시점 지연
기존 메서드를 바꾸기 어렵다면 호출부에서 감쌀 수 있습니다.
Mono.defer(this::getNameList)
.flatMapMany(Flux::fromIterable)
.collectList()
.onErrorResume(e -> Mono.just(List.of("error")));
defer는 구독 시점에 supplier를 실행하므로, 내부 예외가 발생해도 Reactor가 이를 onError로 포장할 수 있습니다.
실무 팁 하나만 기억하면 됩니다.
- 이미 만들어진 Mono를 재사용할 때:
defer불필요 - 호출 시점마다 새로 평가되어야 하고 예외 가능성이 있는 동기 로직:
defer또는fromCallable권장
해결 패턴 3: 동기 블로킹/예외 가능 코드엔 fromCallable
동기 호출(예: 외부 SDK, 파일 I/O, 복잡한 파싱)을 래핑할 때는 fromCallable이 더 명확할 때가 많습니다.
Mono.fromCallable(() -> legacyClient.fetchNames())
.subscribeOn(Schedulers.boundedElastic())
.onErrorResume(e -> Mono.just(List.of("fallback")));
이 패턴의 장점:
- 예외를 자동으로
onError로 변환 - 블로킹 가능 코드를 별도 스케줄러로 분리
- 코드 리뷰 시 “동기 경계"가 눈에 잘 들어옴
운영에서 자주 나오는 안티패턴
1) 메서드 시그니처만 Reactive, 내부는 즉시 throw
반환 타입이 Mono<T>라는 사실만으로 reactive-safe가 보장되진 않습니다. “Publisher를 반환한다"와 “메서드 호출 시 안전하다"는 별개입니다.
2) onErrorResume 위치만 바꿔서 해결하려는 시도
연산자 위치 문제인 경우도 있지만, 즉시 throw는 체인 내부로 들어오지 않으므로 위치 조정만으로는 해결되지 않습니다.
3) block() 기반 테스트에서 원인 오판
block() 테스트는 간단하지만, stack trace가 호출 경계를 섞어서 보여줄 때가 있습니다. StepVerifier를 병행하면 시그널 기준 검증이 쉬워집니다.
실무 디버깅 체크리스트
아래 체크리스트를 팀 공통 리뷰 기준으로 쓰면 재발이 줄어듭니다.
- 예외가 “메서드 호출 즉시"인지, “구독 시점"인지 먼저 분리했다.
- 예외 가능 동기 로직을
defer/fromCallable로 감쌌다. -
onErrorResumefallback이 비즈니스적으로 안전한 값인지 검증했다(침묵 실패 금지). - fallback 발생 시 로깅/메트릭(에러 타입, 빈도, 요청 키)을 남긴다.
- 테스트에 StepVerifier 케이스(정상/에러/fallback)를 모두 추가했다.
결론
핵심은 단순합니다. Reactive 연산자는 스트림 내부 시그널만 다룬다는 원칙을 잊지 않으면 됩니다. 즉시 throw는 자바 메서드 호출 실패고, Mono.error/defer/fromCallable은 스트림 시그널로 변환된 실패입니다. 둘의 경계만 정확히 잡아도 WebFlux 예외 처리 이슈의 절반은 사라집니다.
운영 코드에서는 “실패를 숨기지 않되, 제어 가능한 실패로 바꾸는 것"이 중요합니다. fallback을 넣는 것보다, 어떤 실패를 어디서 어떤 정책으로 처리할지를 먼저 팀 규칙으로 고정하세요.
💬 댓글