Spring WebFlux 입문 — 리액티브 vs 전통 비교

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 핵심 정리 시리즈 첫 글. 전통적 Spring MVC 톰캣 모델과 WebFlux 네티 이벤트 루프 모델이 동일한 외부 API 10초 응답을 어떻게 다르게 처리하는지 콜센터 비유로 풀어가며 — 스레드당 요청 vs 이벤트 루프, 200 스레드 한계, 요청 취소 전파, WebClient·Mono·Flux, 블로킹 금지 안티패턴까지 실험으로 잡아 가는 13편 시리즈의 출발점.

📚 Spring WebFlux 핵심 정리 · 1편 / 14편 — 리액티브 vs 전통 비교

이 글은 Spring WebFlux 핵심 정리 시리즈의 첫 번째 편입니다. 마이크로서비스·실시간 스트리밍·외부 API 다수 호출 같은 단어가 자주 등장하는 백엔드에서 사실상 표준이 되어가는 도구가 Spring WebFlux예요. 그 기반은 Project Reactor — 직전 시리즈에서 다뤘던 Mono·Flux·Schedulers·Backpressure가 그대로 위에 올라갑니다.

이 시리즈는 13편을 통해 R2DBC·CRUD·검증·WebFilter·함수형 엔드포인트·WebClient·SSE·성능 최적화·리액티브 마이크로서비스까지 차근차근 쌓아 갑니다. 한 번에 다 외우려 하지 마시고, 이번 1편에서는 "왜 Spring WebFlux가 만들어졌는가, 톰캣 모델과 어떻게 다른가, 같은 10초짜리 외부 API를 두 모델이 어떻게 다르게 처리하는가" — 이 세 가지 질문의 답만 머리에 들어와도 충분합니다.

본문 흐름은 콜센터 비유를 따라 풀어 가요. 톰캣 = "상담원 200명, 한 사람이 한 통화에 매달림" / 네티 이벤트 루프 = "CPU 코어 수만큼의 베테랑 상담원이 통화 대기 중에도 다른 전화 받음" — 이 한 그림만 잡고 가면 스레드 모델이 한 번에 정리됩니다.

📚 학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 비동기 백엔드 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

읽으면서 IDE에 spring-boot-starter-webflux 의존성을 추가하고 직접 컨트롤러를 띄워 보면 머리에 훨씬 잘 박혀요. 30분이면 첫 비동기 컨트롤러를 띄울 수 있습니다.

왜 Spring WebFlux가 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 이미 잘 굴러가는 MVC가 있는데 굳이? 라는 의문이 듭니다. 톰캣 + Spring MVC + JPA — 자바 백엔드의 표준 조합이 한국 시장에서 절대다수예요. 동작하는 시스템을 두고 왜 새 프레임워크를 배워야 하는지가 처음엔 안 보입니다.

둘째, 반환 타입이 갑자기 Mono·Flux로 바뀝니다. 메서드 시그니처가 User getUser() 가 아니라 Mono getUser() 가 돼요. "이 객체가 결과인가, 아니면 결과를 약속하는 객체인가"가 머릿속에서 충돌합니다.

셋째, 블로킹 코드 한 줄이 전체 서비스를 마비시킵니다. Spring MVC에서는 Thread.sleep(5000) 한 줄을 박아도 그 요청만 5초 늦어져요. WebFlux에서는 같은 줄이 이벤트 루프 스레드를 점유해 다른 모든 요청까지 멈춥니다. 디버깅도 어렵고요.

넷째, JPA에서 R2DBC로 전환이라는 큰 진입 장벽이 있습니다. 영속성 계층이 통째로 바뀌고, 트랜잭션 관리·Lazy Loading·연관 매핑 모두 다른 모델로 다시 짜야 해요. 작업량이 만만치 않습니다.

해결법은 한 가지예요. WebFlux를 "콜센터 두 모델 비교" 로 잡고 풀면 갑자기 명확해집니다. 톰캣 = "전통 콜센터 — 직원 200명, 한 직원이 한 통화 끝날 때까지 다른 전화 못 받음" / 네티 = "현대 콜센터 — 직원 4~8명, 통화 대기 중에 다른 전화도 받고 응답 오면 다시 그 통화로" — 이 그림 하나가 모든 후속 개념을 묶어 줘요. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.

같은 10초짜리 외부 API, 두 모델은 어떻게 다른가

먼저 실험 환경을 잡고 시작해요. 외부 Product 서비스 하나가 /demo01/products 엔드포인트로 10개 제품을 1초씩 끊어서 반환합니다. 총 10초가 걸리는 거예요. 현실의 느린 DB 조회나 느린 외부 API 응답을 시뮬레이션한 환경입니다.

이 한 엔드포인트를 두 컨트롤러가 다르게 호출합니다.

항목전통 (Spring MVC + RestClient)리액티브 (WebFlux + WebClient)
반환 타입ListFlux
첫 응답까지10초 (전부 받고 한 번에)즉시 (Flux 객체만 반환)
데이터 도착10초 후 한꺼번에 List1초·2초·3초 … 각 도착마다 흘러 옴
호출 스레드10초간 블로킹즉시 반환 (이벤트 루프는 다른 일)
새로고침 5번5×10초 = 50 스레드초 낭비이전 4번 즉시 취소, 마지막만 처리

여기서 시험 함정이 하나 있어요. curl로 같은 결과를 보면 둘 다 10초 후 한 번에 출력됩니다. WebFlux가 똑같이 느려 보여요. 이건 WebFlux 문제가 아니라 curl과 브라우저의 기본 버퍼링 때문이에요. curl --no-buffer 또는 curl -N 옵션을 붙이거나 produces = MediaType.TEXT_EVENT_STREAM_VALUE (SSE) 로 응답 타입을 바꾸면 그제서야 1초마다 한 줄씩 흘러나오는 진짜 차이가 보입니다.

톰캣 모델 — Thread-per-Request

전통적인 Spring MVC가 톰캣 위에서 도는 모델이에요. 요청당 스레드(Thread-per-Request) 라고 부릅니다.

회사 비유로 풀면 — 톰캣 스레드 풀은 상담원 200명이 있는 콜센터예요. 손님이 전화하면 상담원 한 명이 배정되고, 그 상담원은 통화가 끝날 때까지 다른 전화를 받지 않습니다.

동작 흐름:

  • 클라이언트 요청 → 톰캣이 스레드 풀에서 스레드 1개 할당
  • 그 스레드가 외부 API·DB I/O 수행
  • I/O 응답을 기다리는 10초 동안 스레드는 아무것도 못하고 대기(Blocking)
  • 응답 받으면 결과 반환, 스레드 반납

여기서 시험 함정이 하나 있어요. 스레드 풀 크기 = 동시 처리 가능 요청 수입니다. 톰캣 기본값 200개. 201번째 요청부터는 큐에서 대기해요. 외부 서비스가 10초씩 걸린다면 200명 동시 접속 = 200개 스레드 × 10초 = 모든 스레드가 10초간 대기로 점유. 그 시간 동안 새 요청은 줄을 섭니다.

더 큰 문제 — 스레드 한 개당 약 1MB 스택을 잡아먹어요. 직전 시리즈 1편에서 짚었던 바로 그 한계입니다. 4코어 8GB 서버에서 스레드 1만 개를 띄우면 스택만으로 10GB. 메모리 고갈입니다.

> 한 줄 정리 — 톰캣 모델은 "직원 1명 = 통화 1건 전담". 직원 수가 한계, 직원이 통화 대기 시간에 놀고 있어도 다른 일 못 함.

네티 이벤트 루프 모델 — Event Loop

Spring WebFlux는 네티(Netty) 위에서 도는 게 기본이에요. 이벤트 루프(Event Loop) 모델을 사용합니다.

콜센터 비유 — 네티는 베테랑 상담원 4~8명이 있는 콜센터예요. 손님이 전화하면 상담원이 받아서 외부 부서에 문의를 던지고, 응답 기다리는 동안 다른 손님 전화를 받습니다. 외부 부서에서 답이 오면 OS가 "이 통화 답 왔어요" 하고 신호를 주고, 베테랑 상담원이 다시 그 통화로 돌아와 마무리하는 흐름이에요.

동작 흐름:

  • 클라이언트 요청 → 네티 이벤트 루프 스레드가 처리 (CPU 코어 수와 동일)
  • I/O 작업은 운영체제의 비동기 I/O에 위임하고 스레드는 즉시 반환
  • I/O 완료 이벤트 도착 → 이벤트 루프 스레드가 콜백을 처리
  • 그 사이 다른 요청을 계속 처리

네티 내부 구조도 한 번 짚어 두면 좋아요.

부품역할
Boss Group새 연결을 수락(Accept)
Worker Group이벤트 루프 실행 (기본 = CPU 코어 수 × 2)
Channel한 연결당 한 이벤트 루프에 바인딩 → 스레드 간 경합 없음

여기서 정말 중요한 시험 함정 — WebFlux는 마법이 아닙니다. 이벤트 루프 스레드를 4~8개로 줄였기 때문에, 블로킹 코드 한 줄이 전체 서비스를 마비시킵니다. 톰캣 200스레드 중 한 개가 블로킹돼도 199개가 돕지만, 네티 4스레드 중 한 개가 블로킹되면 남은 3개로 모든 요청을 감당해야 해요. 이게 WebFlux 학습의 가장 큰 함정입니다 (잠시 뒤 안티패턴에서 자세히).

> 한 줄 정리 — 네티 모델은 "베테랑 4명이 통화 대기 시간에 다른 통화 동시 진행". 적은 인원으로 수천 건 처리, 단 블로킹 코드 한 줄로 전체 마비 위험.

요청 취소 전파 — 리액티브의 진짜 강력함

이 부분이 이번 편의 하이라이트예요. 단순히 "빠르다"가 아니라 자원을 진짜 절약하는 메커니즘입니다.

시나리오 — 사용자가 브라우저에서 5번 새로고침했어요.

모델5번 새로고침 결과
Spring MVC5개 요청이 서버에 쌓여 5개 스레드가 각자 10초 작업. 사용자는 마지막 새로고침 결과만 보지만 서버는 5×10초 = 50 스레드초 낭비
Spring WebFlux새로고침 = 이전 구독(Subscription) 취소 신호. 이전 작업 즉시 중단, 외부 서비스까지 취소 신호 전파 → 외부 부서도 일 멈춤

회사 비유로 한 번 더 — 톰캣은 "고객이 전화를 끊었는데 상담원은 끝까지 통화 종료 대사를 합니다." 네티는 "끊긴 즉시 알아채고 외부 부서에도 '취소됐습니다' 통지하고 다음 손님으로." 이게 리액티브 시스템의 회복탄력성(Resilience) 핵심이에요.

코드로 보면:

// 클라이언트가 연결을 끊으면 (새로고침·뒤로가기) 
// Flux 구독이 자동으로 취소됨
// WebClient는 이 취소 신호를 받아 HTTP 연결도 종료
// 외부 서비스 입장에서는 TCP 연결이 끊어짐 → 작업 중단

return this.webClient.get()
        .uri("/demo01/products")
        .retrieve()
        .bodyToFlux(Product.class)
        .doOnCancel(() -> log.info("Request cancelled by client, cleaning up..."));

여기서 시험 함정이 하나 있어요. 취소 전파는 WebClient → 외부 서비스까지 자동으로 흘러가지만, 그 외부 서비스도 WebFlux로 짜여 있어야 진짜 일을 멈춥니다. 외부 서비스가 Spring MVC라면 TCP만 끊기고 내부 처리는 끝까지 돌아요. 마이크로서비스 환경에서 양쪽 모두 리액티브일 때 자원 절약이 진짜 빛납니다.

핵심 코드 패턴 — RestClient vs WebClient

같은 외부 호출을 두 모델로 짜 비교해 보면 차이가 명확해져요.

// 전통 — Spring MVC + RestClient (Spring 6.1부터 RestTemplate의 현대 대체재)
@RestController
@RequestMapping("/traditional")
public class TraditionalWebController {

    private final RestClient restClient = RestClient.builder()
            .baseUrl("http://localhost:7070")
            .build();

    @GetMapping("/products")
    public List<Product> getProducts() {
        // 이 지점에서 스레드가 10초간 완전히 멈춤(blocking)
        return this.restClient.get()
                .uri("/demo01/products")
                .retrieve()
                .body(new ParameterizedTypeReference<List<Product>>() {});
    }
}
// 리액티브 — Spring WebFlux + WebClient
@RestController
@RequestMapping("/reactive")
public class ReactiveWebController {

    private final WebClient webClient = WebClient.builder()
            .baseUrl("http://localhost:7070")
            .build();

    @GetMapping("/products")
    public Flux<Product> getProducts() {
        // 즉시 Flux를 반환 (실제 데이터 아님, 데이터가 흘러올 파이프라인)
        return this.webClient.get()
                .uri("/demo01/products")
                .retrieve()
                .bodyToFlux(Product.class)
                .doOnNext(p -> log.info("Received: {}", p))
                .doOnComplete(() -> log.info("All products received"))
                .doOnError(e -> log.error("Error: {}", e.getMessage()));
    }
}

여기서 시험 함정이 하나 있어요. getProducts() 메서드는 즉시 리턴됩니다. Flux 객체만 만들어 던지고 끝. 실제 HTTP 요청은 WebFlux가 컨트롤러 반환값을 자동으로 subscribe()하는 순간 시작돼요. Lazy 실행 — 직전 시리즈에서 짚었던 그 함정이 그대로 적용됩니다.

핵심 연산자 한 번에 — map vs flatMap

리액티브 코드에서 가장 자주 헷갈리는 부분. 자세한 건 직전 시리즈 4편에서 풀었지만 WebFlux 컨트롤러 맥락에서 한 번 더 짚어요.

// map: 동기 변환 (값 → 값)
// 람다가 즉시 결과를 반환
Mono<UserDto> dto = userRepository.findById(id)
        .map(user -> new UserDto(user.getName(), user.getEmail()));

// flatMap: 비동기 변환 (값 → Publisher<값>)
// 람다가 Mono 또는 Flux를 반환
Mono<User> user = Mono.just(1)
        .flatMap(id -> userRepository.findById(id));  // DB 조회 = Mono<User>

Flux<Order> orders = Flux.just(1, 2, 3)
        .flatMap(userId -> orderService.findByUserId(userId));

여기서 시험 함정이 하나 있어요. map에 비동기 작업(DB 조회·HTTP 호출)을 넣으면 반환 타입이 Mono> 가 돼버립니다. IDE 빨간 줄로 알아채면 다행이고, 컴파일은 통과되더라도 의도와 다르게 동작해요. 비동기 = flatMap, 동기 = map 한 줄 외워두면 평생 갑니다.

리액티브 안티패턴 5가지 — 가장 많이 틀리는 함정

WebFlux에서 처음 8할 이상이 여기서 막힙니다.

(1) 이벤트 루프에서 블로킹 코드

// BAD — 이벤트 루프 스레드 점유 → 전체 애플리케이션 마비
@GetMapping("/bad")
public Mono<String> badExample() {
    String result = restTemplate.getForObject("http://...", String.class); // BLOCKING!
    return Mono.just(result);
}

// GOOD — 블로킹 코드는 boundedElastic 스케줄러로
@GetMapping("/good")
public Mono<String> goodExample() {
    return Mono.fromCallable(() -> restTemplate.getForObject("http://...", String.class))
               .subscribeOn(Schedulers.boundedElastic());
}

(2) .block() 남용

// BAD — 이벤트 루프에서 block() 호출 → 다른 모든 요청 지연
@GetMapping("/products/{id}")
public Mono<ProductDto> getProduct(@PathVariable Long id) {
    Product product = productRepository.findById(id).block();  // BAD
    return Mono.just(toDto(product));
}

// GOOD — 파이프라인 유지
@GetMapping("/products/{id}")
public Mono<ProductDto> getProduct(@PathVariable Long id) {
    return productRepository.findById(id)
            .map(this::toDto)
            .switchIfEmpty(Mono.error(new ProductNotFoundException(id)));
}

(3) 파이프라인 안에서 subscribe() 중첩

// BAD — "구독 안의 구독" → 리소스 누수, 오류 전파 끊김
return userRepository.findById(id)
        .doOnNext(user -> {
            orderRepository.findByUserId(id)
                    .subscribe(o -> log.info(o.toString()));  // 별도 구독 시작
        })
        .map(user -> "done");

// GOOD — flatMap으로 연결
return userRepository.findById(id)
        .flatMapMany(user -> orderRepository.findByUserId(user.getId()))
        .map(this::toDto);

(4) WebClient 매번 생성

// BAD — 요청마다 새 인스턴스 → 연결 풀 공유 안 됨
WebClient client = WebClient.create("http://...");

// GOOD — Bean으로 등록해 재사용
@Configuration
public class WebClientConfig {
    @Bean
    public WebClient webClient() {
        return WebClient.builder().baseUrl("http://localhost:7070").build();
    }
}

(5) Mono/Flux를 만들고 구독 안 함

// BAD — 절대 실행되지 않음
public void badExample() {
    Mono.fromCallable(() -> {
        System.out.println("This never prints!");
        return "result";
    });  // 구독 X
}

// GOOD — Spring WebFlux 컨트롤러가 자동 구독해 주거나 명시적 .subscribe()

여기서 시험 함정이 하나 있어요. 컨트롤러 메서드 안에서 직접 .subscribe()를 호출하지 마세요. WebFlux가 반환값을 자동으로 구독하는데 추가로 한 번 더 구독하면 데이터가 중복 처리되거나 외부 호출이 두 번 일어나요. 컨트롤러는 Mono/Flux를 만들기만, 구독은 프레임워크가.

두 모델 비교표

이번 편 정리용 통합표예요.

항목전통 (Spring MVC)리액티브 (Spring WebFlux)
웹 서버Tomcat (기본)Netty (기본)
스레드 모델Thread-per-RequestEvent Loop
스레드 수기본 200개CPU 코어 수 (4~8개)
HTTP 클라이언트RestClient / RestTemplateWebClient
반환 타입ListFlux / Mono
I/O 동작응답까지 블로킹응답 대기 중 다른 요청 처리
요청 취소클라이언트 취소 인지 X즉시 작업 중단·외부까지 전파
메모리 사용스레드 수에 비례 (많음)이벤트 루프 기반 (적음)
동시 처리 수스레드 풀 크기 한계수천 건 가능
응답 방식완전 데이터 한 번에스트리밍 가능
학습 곡선낮음높음 (리액티브 개념 필요)

언제 WebFlux를 선택하는가

기술 선택은 항상 trade-off예요. 기준을 정리합니다.

WebFlux가 어울리는 경우:

  • 동시 요청 수가 많고 각 요청이 I/O 대기 시간을 포함
  • 실시간 스트리밍이 필요 (SSE·WebSocket·gRPC 스트림)
  • 마이크로서비스 간 비동기 통신이 많은 시스템
  • 제한된 하드웨어로 최대 처리량을 짜내야 하는 환경

전통 Spring MVC가 어울리는 경우:

  • 팀의 리액티브 경험이 부족한 경우 (러닝 커브 부담)
  • CPU 집약적 작업이 주를 이루는 시스템 (이벤트 루프 모델 효율 X)
  • 대규모 JPA 코드베이스를 변경하기 어려운 경우
  • 간단한 CRUD 위주의 소규모 서비스

여기서 시험 함정이 하나 있어요. "무조건 WebFlux가 빠르다"는 거짓입니다. 단일 요청 처리 시간은 거의 같거나 오히려 약간 느려요. 차이가 나는 건 동시 요청이 많을 때 + I/O 대기 비중이 클 때입니다. CPU만 쓰는 작업은 톰캣이 더 단순하고 빠를 수 있어요.

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 Spring WebFlux 1편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Spring MVC = Thread-per-Request (톰캣 200스레드) / WebFlux = Event Loop (네티 4~8스레드)
  • 톰캣 스레드 1개 = 약 1MB 스택 → 1만 스레드 = 10GB
  • 네티 Boss Group(연결 수락) + Worker Group(이벤트 루프, 기본 CPU 코어 × 2)
  • 같은 10초 외부 API — MVC는 스레드 10초 점유, WebFlux는 즉시 Flux 반환
  • curl은 기본 버퍼링 → curl -N 또는 SSE로 진짜 스트리밍 보임
  • 요청 취소 전파 — WebFlux는 클라이언트 끊김 → 외부 서비스까지 전파 (양쪽 다 리액티브일 때 진짜 빛남)
  • 회복탄력성(Resilience) — onErrorComplete·onErrorResume으로 부분 성공 가능
  • Mono·Flux 반환 타입이 컨트롤러 시그니처
  • Lazysubscribe() 안 호출되면 아무것도 일어나지 않음 (WebFlux가 자동 구독)
  • 컨트롤러 안에서 직접 .subscribe() 호출 금지 (중복 처리 위험)
  • 블로킹 한 줄 = 전체 서비스 마비Thread.sleep·JDBC·RestTemplate 모두 위험
  • 블로킹 불가피하면 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())
  • .block() 남용 금지 — 이벤트 루프 스레드 차단
  • map = 동기 변환 / flatMap = 비동기 변환map에 DB 조회 넣으면 Mono>
  • switchIfEmpty(Mono.error(...)) — Mono.empty 명시적 오류 처리
  • WebClient는 싱글톤 Bean — 매번 WebClient.create() 금지
  • RestTemplate in WebFlux 금지 — 블로킹 HTTP 클라이언트, WebClient 사용
  • 전통 vs 리액티브 — 단일 요청은 비슷, 동시 + I/O 대기 多 일 때 차이 폭발
  • WebFlux는 마법 X — 잘못 쓰면 톰캣보다 나쁠 수 있음
  • 4코어 8GB에서 WebFlux = 적은 스레드 + 큰 처리량 구조

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!