끄적끄적

Spring WebFlux — Mono/Flux 원리부터 블로킹 함정, 트러블슈팅까지 (도입 판단 기준 포함) 본문

Back-end/Java

Spring WebFlux — Mono/Flux 원리부터 블로킹 함정, 트러블슈팅까지 (도입 판단 기준 포함)

mashko 2026. 2. 27. 20:34
반응형
⚡ Spring WebFlux 실무 완전 정복

WebFlux, 왜 쓰고 언제 쓰면 안 되는지

논블로킹 원리 · Mono/Flux 실전 코드 · 블로킹 함정 · R2DBC · 실무 트러블슈팅 완전 정리

☕ Spring Boot 3.x / WebFlux ⚡ Reactor 3.x 기준 ⏱ 읽기 약 20분 🔥 실무 트러블슈팅 포함

📋 목차

  1. 들어가며 — 동시 접속 5만에서 MVC가 먼저 죽던 이유
  2. Spring MVC vs WebFlux — 스레드 모델 비교
  3. Mono와 Flux — 핵심 타입 완전 이해
  4. WebFlux 실전 컨트롤러 코드
  5. WebClient — RestTemplate 대체
  6. R2DBC — 리액티브 DB 연동
  7. WebFlux의 함정 — 블로킹 코드가 섞이면 일어나는 일
  8. 실무 트러블슈팅 BEST 4
  9. WebFlux를 쓰면 안 되는 경우
📖 Section 01

들어가며 — 동시 접속 5만에서 MVC가 먼저 죽던 이유

실시간 알림 서비스를 Spring MVC로 운영하다가 특정 이벤트(할인 쿠폰 발행 등) 때마다 동시 접속이 폭증해서 서버가 죽는 일이 반복됐습니다. Tomcat 스레드 풀(기본 200개)이 가득 차면서 요청이 큐에 쌓이고, 결국 타임아웃이 연쇄적으로 발생하는 패턴이었습니다.

스케일 아웃이 근본 해결책이지만, 그 전에 서버 1대가 더 많은 동시 연결을 감당하도록 WebFlux로 전환했습니다. 결과적으로 같은 메모리에서 처리할 수 있는 동시 연결 수가 10배 이상 늘었습니다.

💡
WebFlux가 필요한 상황:
① 수만 건의 동시 연결을 적은 스레드로 처리해야 할 때
② 여러 외부 API를 병렬로 동시에 호출해야 할 때
③ SSE(Server-Sent Events), WebSocket 같은 스트리밍이 필요할 때
④ MSA에서 API Gateway / BFF 역할을 할 때
· · ·
⚖️ Section 02

Spring MVC vs WebFlux — 스레드 모델 비교

동시 요청 1000개가 들어왔을 때
❌ Spring MVC (동기/블로킹)
요청 1 → Thread-1 할당
DB 쿼리 대기 중... (Thread-1 점유, 블로킹)
요청 2 → Thread-2 할당
외부 API 대기 중... (Thread-2 점유, 블로킹)
Thread Pool 고갈 → 요청 큐잉 → 타임아웃 💀
1요청 = 1스레드 점유 (I/O 대기 중에도)
✅ Spring WebFlux (비동기/논블로킹)
요청 1 → EventLoop Thread
DB 쿼리 비동기 요청 → Thread 즉시 반환
같은 Thread로 요청 2, 3, 4... 처리
DB 응답 도착 → 콜백으로 처리 재개
소수 스레드로 수만 요청 처리 가능 ✅
I/O 대기 중 Thread 반환 → 다른 요청 처리
WebFlux는 CPU 코어 수 × 2개 스레드(기본)로 수만 요청 처리
항목 Spring MVC Spring WebFlux
처리 모델 동기/블로킹 비동기/논블로킹
서버 Tomcat (기본) Netty (기본)
스레드 수 요청 당 1스레드 CPU 코어 × 2 (고정)
반환 타입 Object, ResponseEntity Mono<T>, Flux<T>
DB 연동 JPA, JDBC R2DBC, ReactiveMongo
학습 난이도 쉬움 높음
디버깅 직관적 스택트레이스 복잡
적합한 상황 일반 CRUD, 낮은 동시성 고동시성, 스트리밍, API 조합
· · ·
⚛️ Section 03

Mono와 Flux — 핵심 타입 완전 이해

🔑
한 줄 정의:
Mono<T> = 0개 또는 1개의 결과를 비동기로 담는 컨테이너 (Optional의 리액티브 버전)
Flux<T> = 0개 ~ N개의 결과 스트림을 비동기로 담는 컨테이너 (Stream의 리액티브 버전)

중요: Mono/Flux는 실행 계획을 담은 레시피입니다. subscribe()가 호출돼야 실제로 실행됩니다.
 
 
 
Mono / Flux 핵심 연산자 실전 예시
Java
// ===== Mono 기본 =====
Mono<User> userMono = userRepository.findById(1L);

// map: 동기 변환 (값이 있을 때만 실행)
Mono<UserDto> dto = userMono
    .map(user -> new UserDto(user))
    .switchIfEmpty(Mono.error(new NotFoundException("User not found")));

// flatMap: 비동기 변환 (안에서 또 다른 Mono/Flux 반환)
Mono<Order> orderMono = userMono
    .flatMap(user -> orderRepository.findByUserId(user.getId()));

// ===== Flux 기본 =====
Flux<Product> products = productRepository.findAll();

// filter + map + take
products
    .filter(p -> p.getPrice() > 10000)
    .map(p -> new ProductDto(p))
    .take(10)               // 최대 10개만
    .collectList();        // Flux → Mono<List> 변환

// ===== 병렬 API 호출 =====
Mono.zip(
    userService.getUser(userId),       // 동시에 호출
    orderService.getOrders(userId),    // 동시에 호출
    pointService.getPoints(userId)     // 동시에 호출
)
.map(tuple -> new UserDashboardDto(
    tuple.getT1(),  // user
    tuple.getT2(),  // orders
    tuple.getT3()   // points
));
// 3개 API가 병렬 실행 → 가장 느린 API 시간만큼만 대기
· · ·
🎮 Section 04

WebFlux 실전 컨트롤러 코드

 
 
 
UserController.java — WebFlux 컨트롤러
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // 단건 조회 — Mono 반환
    @GetMapping("/{id}")
    public Mono<ResponseEntity<UserDto>> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(user -> ResponseEntity.ok(user))
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    // 목록 조회 — Flux 반환
    @GetMapping
    public Flux<UserDto> getUsers() {
        return userService.findAll();
    }

    // 생성 — Mono<ResponseEntity> 반환
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<UserDto> createUser(@RequestBody @Valid Mono<CreateUserRequest> request) {
        return request.flatMap(userService::create);
    }

    // SSE 스트리밍 — 실시간 이벤트 전송
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<UserEvent> streamUserEvents() {
        return userService.getUserEventStream()
            .delayElements(Duration.ofMillis(100));
    }
}
· · ·
🌐 Section 05

WebClient — RestTemplate 완전 대체

WebFlux 환경에서 RestTemplate을 쓰면 블로킹이 발생해 이벤트 루프 스레드를 점유합니다. 반드시 WebClient를 사용해야 합니다.

 
 
 
WebClientConfig.java + ExternalApiService.java
Java
// ===== WebClient Bean 등록 =====
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .baseUrl("https://api.external.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .codecs(c -> c.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)) // 16MB
            .build();
    }
}

// ===== 실전 사용 =====
@Service
@RequiredArgsConstructor
public class ExternalApiService {

    private final WebClient webClient;

    public Mono<PaymentResult> processPayment(PaymentRequest req) {
        return webClient.post()
            .uri("/v1/payments")
            .bodyValue(req)
            .retrieve()
            // HTTP 4xx → BusinessException
            .onStatus(HttpStatusCode::is4xxClientError,
                res -> res.bodyToMono(ErrorResponse.class)
                    .flatMap(err -> Mono.error(new PaymentException(err.getMessage())))
            )
            .bodyToMono(PaymentResult.class)
            // 타임아웃 설정
            .timeout(Duration.ofSeconds(5))
            // 실패 시 fallback
            .onErrorReturn(PaymentResult.failed());
    }
}
· · ·
☠️ Section 06

WebFlux의 함정 — 블로킹 코드가 섞이면 일어나는 일

🚨
WebFlux에서 절대 하면 안 되는 것:
이벤트 루프 스레드에서 블로킹 코드를 실행하면 해당 스레드가 점유되어 전체 서버가 멈춥니다.
WebFlux의 기본 스레드가 CPU 코어 × 2개(8코어면 16개)뿐이라, 그 중 1~2개만 블로킹돼도 처리량이 급감합니다.
 
 
 
블로킹 함정과 올바른 처리법
Java
// ❌ 절대 금지 — 이벤트 루프에서 블로킹!
public Mono<User> getUser(Long id) {
    User user = jpaUserRepository.findById(id).orElseThrow(); // ❌ JPA 블로킹!
    String result = restTemplate.getForObject(url, String.class);       // ❌ 블로킹!
    Thread.sleep(1000);                                               // ❌ 블로킹!
    return Mono.just(user);
}

// ✅ 불가피하게 블로킹 코드가 있다면 — boundedElastic 스케줄러로 격리
public Mono<User> getUserSafely(Long id) {
    return Mono.fromCallable(() ->
        jpaUserRepository.findById(id).orElseThrow()
    )
    // ✅ boundedElastic: 블로킹 작업 전용 스레드 풀 (I/O 스레드와 분리)
    .subscribeOn(Schedulers.boundedElastic());
}
· · ·
🚨 Section 07

실무 트러블슈팅 BEST 4

🔴

Case 1 — 아무것도 실행이 안 됨 (Nothing happens)

🚨 현상: Mono/Flux를 조합했는데 실제로 실행이 안 됨. 로그도 없고 DB도 안 찌름.
✅ 원인: subscribe()가 없으면 실행 자체가 안 됩니다. Mono/Flux는 Cold Publisher — 구독자가 없으면 흐름이 시작되지 않아요.
Controller에서 반환하면 Spring이 자동 subscribe. 서비스 내부에서 직접 실행하려면 반드시 .subscribe() 또는 .block() 호출.
🔴

Case 2 — 스택트레이스에 내 코드가 없음

🚨 현상: 에러 발생 시 스택트레이스가 reactor 내부 코드만 가득하고 내 코드가 어디서 터졌는지 알 수 없음.
✅ 개발 환경에서 Hooks.onOperatorDebug() 활성화 (성능 저하 주의, 운영 사용 금지)
운영 환경에서는 ReactorDebugAgent.install() (reactor-tools 라이브러리) 사용
또는 .checkpoint("여기서 에러")를 파이프라인 중간에 삽입해서 위치 파악
🔴

Case 3 — DataBuffer memory leak 경고

🚨 현상: 로그에 "DataBuffer has not been released" 경고 반복. 메모리 사용량 지속 증가.
✅ WebClient 응답을 bodyToMono()로 소비하지 않고 중간에 흐름을 끊으면 발생.
모든 응답은 반드시 끝까지 소비하거나, exchange() 대신 retrieve() 사용.
직접 DataBuffer를 다룰 때는 DataBufferUtils.release() 명시적 호출 필수.
🔴

Case 4 — block() 호출 시 IllegalStateException

🚨 현상: "block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-X" 에러
✅ 이벤트 루프 스레드에서 block()을 호출하면 데드락이 발생합니다.
해결: .subscribeOn(Schedulers.boundedElastic())으로 별도 스레드에서 실행하거나,
근본적으로 전체 파이프라인을 논블로킹으로 재설계하는 것이 올바른 방법입니다.
· · ·
🚫 Section 08

WebFlux를 쓰면 안 되는 경우

⚠️
WebFlux가 오히려 독이 되는 상황:
JPA를 써야 하는 프로젝트 — JPA는 블로킹. R2DBC로 전환은 러닝커브와 기능 제한이 큼
팀원 대부분이 리액티브 프로그래밍을 모름 — 코드 이해/유지보수 비용이 급증
단순 CRUD 서비스 — 동시성 이슈가 없으면 MVC가 더 단순하고 빠름
복잡한 트랜잭션이 많은 서비스 — 리액티브 트랜잭션 관리가 매우 복잡
상황 추천 이유
일반 CRUD API Spring MVC 단순하고 JPA 호환, 팀 러닝커브 없음
고동시성 API Gateway WebFlux 논블로킹으로 동시 연결 수십만 처리
여러 외부 API 병렬 호출 WebFlux Mono.zip으로 병렬 처리 성능 극대화
SSE / WebSocket 스트리밍 WebFlux Flux 스트림이 SSE와 완벽 매핑
복잡한 비즈니스 로직 + JPA Spring MVC 블로킹 DB 연동, 트랜잭션 관리 단순

⚡ "WebFlux는 만능이 아닙니다 — 고동시성이 필요할 때만 꺼내는 도구입니다"

Mono, Flux의 원리만 이해하면 나머지는 연산자 조합입니다.
가장 중요한 것은 블로킹 코드가 이벤트 루프에 섞이지 않도록 경계를 지키는 것,
그리고 팀의 역량과 서비스 특성에 맞게 WebFlux 도입 여부를 결정하는 것입니다 💪

비슷한 경험 있으시면 댓글로 공유해 주세요!

#SpringWebFlux #리액티브프로그래밍 #Mono #Flux #논블로킹 #WebClient #R2DBC #SpringBoot3 #백엔드개발 #자바개발 #개발블로그 #고동시성
반응형
Comments