우당탕탕
Spring WebFlux 적용하면서 저도 헷갈렸던 개념들 직접 겪어봤어요 본문
처음에 Spring WebFlux를 도입했을 때 정말 삽질을 많이 했어요. 문서 읽을 때는 ‘리액티브로 하면 좋다더라’ 정도만 알았는데, 직접 코드를 짜면서 막히고 에러 뜨고, 개념이 헷갈려서 몇 번을 다시 고치고 다시 했거든요. 특히 Flux와 Mono, 그리고 스케줄러 개념이 제대로 머리에 잡히질 않아서 시간을 엄청 썼죠.
이 글에서는 제가 실제로 코딩하면서 마주쳤던 헷갈림, 실수 사례들을 중심으로 어떻게 해결했는지 상세히 공유할게요. 그래서 WebFlux 처음 도입하는 분들이 저처럼 삽질하지 않도록 도와드리고 싶었어요.
개발 환경 / 버전 정보
저는 Java 17에 Spring Boot 3.1.3 버전으로 작업했어요. 리액티브 스택을 완벽히 지원하는 버전이고, Reactor 3.x가 내장되어 있습니다.
Spring WebFlux 적용해보면서 헷갈렸던 개념들 관련 정보
리액티브 스트림 Flux와 Mono, 여기서 많이 틀립니다
사실 이 부분이 웹플럭스 입문자들이 가장 많이 헷갈리는 부분이에요. 저도 처음에는 Mono와 Flux를 엄청 헷갈렸죠. Mono는 단일 데이터, Flux는 0개 이상 여러 데이터를 비동기로 처리하는 거잖아요? 근데 실제로 쓰다 보니까 반환 타입만 바꿔서 된다고 생각했다가 큰일 났어요.
예를 들어 REST API에서 단일 객체를 반환할 때 Mono<User>를 쓰는데, 실수로 Flux<User>를 반환했다가 클라이언트 쪽에서 이상한 응답이 오더라고요. 반대로 여러 개 데이터를 반환하는데 Mono<List<User>> 이렇게 감싸면 스트림이 의미가 없어져요. 그래서 무조건 Flux를 써야 해요.
// 잘못된 예: 리스트 데이터인데 Mono로 감싸기
public Mono<List<User>> getUsers() {
return Mono.just(userRepository.findAll()); // findAll()이 리스트 반환한다고 가정
}
// 올바른 예: Flux로 직접 반환
public Flux<User> getUsers() {
return userRepository.findAll(); // findAll()이 Flux<User> 반환하도록 리액티브 라이브러리 사용
}
이렇게 리액티브 라이브러리를 제대로 써야만 스트림이 끊기지 않고, 후속 연산도 자연스럽게 이어져요. 저는 처음에 JpaRepository 같은 블로킹 리포지토리를 Mono나 Flux로 감싸서 썼다가 결국 반려당했거든요. WebFlux는 데이터 소스부터 리액티브면 리액티브인 거예요.
블로킹 코드와 논블로킹 코드를 섞으면 생기는 문제
그런데 여기서 더 큰 함정이 있었어요. WebFlux가 논블로킹 처리하는 걸로 알고 있는데, 하위에서 블로킹 호출이 있으면 그게 전체 리액티브 파이프라인을 막아버리더라고요. 그래서 저는 처음에 JpaRepository를 그대로 쓰면서 Mono나 Flux로 싸는 실수를 했어요.
실제로는 이렇게 되는 거죠. DB 호출은 블로킹인데, WebFlux가 CPU 스레드를 논블로킹으로 돌리니까 스레드가 막히면 동시 처리 장점이 다 사라집니다. 그래서 당장 Jpa를 버리고 R2DBC 같은 리액티브 DB 드라이버를 써야 했고, 이걸 몰라서 하루를 날렸죠.
// NG: 블로킹 코드 Mono 감싸기
public Mono<User> getUserById(Long id) {
User user = userRepository.findById(id).orElse(null); // 블로킹 호출
return Mono.justOrEmpty(user);
}
// OK: 리액티브 드라이버 쓰기
public Mono<User> getUserById(Long id) {
return userR2dbcRepository.findById(id); // 논블로킹 반환
}
Spring WebFlux 적용해보면서 헷갈렸던 개념들 관련 정보
스케줄러(Scheduler) 개념 때문에 멘붕
스케줄러도 처음에 진짜 헷갈렸어요. WebFlux는 기본적으로 Netty 이벤트 루프에서 논블로킹으로 처리하는데, 블로킹 작업이 있으면 별도 스케줄러에서 돌려야 한다고 하더라고요. 근데 저는 그냥 딱히 신경 안 쓰고 코딩했다가 데드락 나는 경우도 있었어요.
특히, 외부 API 호출(REST 템플릿)이나 파일 I/O 같은 블로킹 작업이 있으면 반드시 publishOn(Schedulers.boundedElastic()) 같은 걸 써줘야 하는데, 저는 이 부분을 늦게 알았어요.
Flux.just("file1.txt", "file2.txt")
.map(fileName -> {
// 블로킹 파일 읽기 작업
return readFileBlocking(fileName);
})
// 이걸 빼먹으면 메인 스레드가 막히면서 장애 발생
.publishOn(Schedulers.boundedElastic())
.subscribe(System.out::println);
// 이렇게 해야 에러 없이 비동기 처리 가능
스케줄러 없이 블로킹 작업하면 Netty 이벤트 루프 스레드가 멈춰서 전체 서버가 느려지는 증상도 봤고, 그래서 저는 개발 초기부터 무조건 블로킹 작업은 boundedElastic 스케줄러에서 처리하는 습관을 들였어요.
실제 겪은 에러와 해결 과정
저는 처음에 Mono, Flux 반환 타입만 바꿔주면 자동으로 논블로킹이 되는 줄 알았는데, 그런데 코드를 돌려보니 이런 에러가 자꾸 났어요.
java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-1
at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:99)
at reactor.core.publisher.Mono.block(Mono.java:1703)
at com.example.MyService.getUser(MyService.java:45)
이 에러는 제가 WebFlux 기반 서버 안에서 Mono.block()을 호출했기 때문에 발생했는데요. Mono.block()은 원래 블로킹 방식이라 Netty 이벤트 루프 스레드에서 호출하면 안 돼요. 그래서 저는 직관적으로 Mono 결과를 동기식으로 빨리 받고 싶어서 썼던 게 문제였죠.
해결은 간단했어요. Mono나 Flux 내부에서 계속 리액티브 체인을 유지하고, block()을 쓰지 말자는 거였죠. 필요하면 컨트롤러 레벨에서 Mono나 Flux를 반환하거나, 테스트 코드에서만 block()을 쓰라고 했어요.
Spring WebFlux 적용해보면서 헷갈렸던 개념들 관련 정보
작은 팁: WebClient 활용 시 주의점
WebClient로 외부 API 호출할 때도 개발하면서 헷갈렸어요. 특히 URI 경로나 헤더 설정이 틀리면 응답이 4xx나 5xx가 나는데, 로그만 보고 왜 실패하는지 못 찾았거든요.
제가 실수했던 건 WebClient가 기본적으로 비동기 호출이라 subscribe() 시점을 잘못 맞추면 요청이 안 가거나 중간에 사라질 수 있다는 점이에요. 저는 그냥 Mono 리턴받고 subscribe() 안 해서 실제 호출이 안 되는 경우가 많았죠.
// 잘못된 예: subscribe 안 하면 호출이 안 될 수 있음
Mono<String> response = webClient.get()
.uri("/api/data")
.retrieve()
.bodyToMono(String.class);
// subscribe() 호출 없음 - 실제 요청 안 감
// 올바른 예
webClient.get()
.uri("/api/data")
.retrieve()
.bodyToMono(String.class)
.subscribe(System.out::println);
물론 컨트롤러나 서비스 계층에서 Mono나 Flux를 반환하는 구조라면 subscribe를 직접 안 해도 괜찮지만, 단위 테스트나 별도 호출에서는 꼭 subscribe 또는 block()으로 흐름을 만져줘야 했어요.
자주 물어보시는 것들
Q. Mono와 Flux는 언제 써야 하나요?
A. 단순히 0~1개 결과면 Mono, 0개 이상이면 Flux를 쓰는 게 정석이에요. 단, Flux<List>처럼 중첩 컬렉션은 피하고 Flux<객체> 형태로 해야 리액티브 장점을 살릴 수 있어요.
Q. 블로킹 호출을 WebFlux와 함께 쓰고 싶으면 어떻게 해야 하죠?
A. 블로킹 코드는 Schedulers.boundedElastic() 같은 별도 스케줄러에서 실행하도록 지정해야 해요. 예를 들어 publishOn(Schedulers.boundedElastic()) 또는 subscribeOn(Schedulers.boundedElastic())을 적재적소에 넣어 막힘 없이 처리할 수 있습니다.
Q. WebFlux 도입 후 성능이 안 좋아졌는데 원인이 뭘까요?
A. 제 경험으로는 하위 데이터 소스가 블로킹인데 WebFlux로만 감싸서 비동기 처리한 척 했을 때 성능 저하가 심했어요. 결국 리액티브 드라이버로 바꾸거나 블로킹 구간을 별도 스케줄러에 분리해야 합니다.
Spring WebFlux를 제대로 쓰려면, 단순히 반환 타입만 바꾸는 게 아니라 데이터 흐름과 스레드 모델, 스케줄러 개념까지 이해해야 한다는 걸 몸으로 배웠어요. 저처럼 초반에 개념 없이 닥치는 대로 구현하고 에러보고 좌절하는 분들께 조금이라도 도움이 됐으면 하네요.
'Tech > Spring' 카테고리의 다른 글
| Spring Cloud Gateway 도입하며 겪은 라우팅 문제와 해결 경험 (0) | 2026.07.05 |
|---|---|
| JPA Auditing으로 생성·수정 시간 자동화하면서 겪은 비용 비교 경험 (0) | 2026.07.05 |
| JPA N+1 문제 실제 겪고 단계별로 해결한 경험담 (0) | 2026.06.20 |
| Spring Boot Validation 커스텀 어노테이션 숫자 비교하며 만들어본 후기 (0) | 2026.06.18 |
| Spring Batch 처음 써보면서 비용 비교와 숫자 처리에서 막혔던 점들 (0) | 2026.06.18 |
