본문 바로가기
백엔드

리액티브 프로그래밍이 대체 뭔가 — 정통 방식의 한계에서 WebFlux까지

코딩패트릭 2026. 8. 6.

리액티브(Reactive)라는 말은 스프링을 조금만 다뤄 봐도 자주 마주친다. WebFlux, Reactor, Mono, Flux 같은 단어가 따라붙는다. "논블로킹이다", "비동기다", "스트림이다" 같은 말이 뒤섞여 나오는데, 이 단어들이 서로 어떤 관계인지는 흐릿하다. 이번 기회에 이 개념을 처음부터 차근차근 공부해 보도록 하자.

이 글은 그 흐릿한 부분을 순서대로 잡아 간다. 순서가 중요하다. 리액티브부터 정의하면 왜 이런 게 필요한지 감이 안 잡힌다. 그래서 거꾸로, 지금 대부분의 서버가 쓰고 있는 정통 방식이 뭐가 문제인지부터 본다. 그다음 블로킹과 논블로킹이라는, 자주 섞여 쓰이지만 다른 개념을 정리한다. 이 둘이 잡히면 리액티브 프로그래밍이 무엇을 하려는 것인지 자연스럽게 보인다. 마지막으로 Spring WebFlux가 이 리액티브라는 개념과 정확히 어떤 관계인지 짚는다.

1부. 정통 방식은 뭐가 문제인가

1. 흔히 쓰는 코드부터

스프링으로 API 하나를 만들면 보통 이런 모양이 된다.

@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
    Order order = orderRepository.findById(id);      // DB 조회
    User user = userClient.getUser(order.getUserId()); // 다른 서비스 호출
    return new OrderResponse(order, user);
}

세 줄인데 안에 들어 있는 일은 간단하지 않다. DB에 쿼리를 날리고 응답이 올 때까지 기다린다. 그다음 다른 서버에 HTTP 요청을 보내고 또 응답을 기다린다. 이 두 번의 "기다림"이 이 글 전체의 출발점이다.

2. 요청 하나에 스레드 하나

이 코드가 실행되는 동안 스레드가 어떻게 움직이는지 보자. 톰캣 같은 서블릿 컨테이너는 요청이 들어오면 스레드 풀에서 스레드를 하나 꺼내 그 요청 전체를 맡긴다. getOrder 메서드가 시작해서 끝날 때까지, 그 요청은 처음부터 끝까지 같은 스레드가 담당한다.

요청 도착
   │
   ▼
스레드 풀에서 스레드 하나 배정
   │
   ▼
그 스레드가 getOrder() 실행
   │  (DB 조회 대기, 외부 API 대기 전부 포함)
   ▼
응답 완료 → 스레드 반납

이 모델을 흔히 스레드 퍼 리퀘스트(thread-per-request)라고 부른다. 요청과 스레드가 일대일로 묶인다는 뜻이다. 이 글에서 "정통 방식"이라고 부르는 게 바로 이것이다.

3. 기다리는 동안 스레드는 아무것도 안 한다

1번 코드의 findById를 부르면 스레드는 DB가 응답할 때까지 그 자리에서 멈춘다. 데이터베이스 서버까지 요청이 가고, 디스크에서 데이터를 찾고, 네트워크로 돌아오는 이 시간 동안 스레드는 손 놓고 기다리기만 한다. userClient.getUser도 마찬가지다. 다른 서버가 응답할 때까지 역시 손 놓고 기다린다.

이게 눈에 잘 안 띄는 이유는, 코드만 보면 그냥 함수 호출 두 번이기 때문이다. 하지만 실제 걸리는 시간을 보면 이야기가 다르다. CPU가 이 요청을 처리하려고 실제로 계산하는 시간은 밀리초 이하인데, DB와 외부 API를 기다리는 시간은 그보다 수십 배, 수백 배 길 수 있다. 이 코드가 도는 시간의 대부분은 사실 스레드가 놀고 있는 시간이다.

4. 놀고 있는 스레드가 왜 문제가 되나

한두 요청이면 문제없다. 스레드 풀에 여유가 있으니 몇 개가 놀아도 상관없다. 문제는 요청이 몰릴 때다.

스레드 풀 크기는 정해져 있다. 예를 들어 200개라고 하자. 평소에는 요청이 빨리 끝나서 스레드가 금방금방 반납되니 200개로 충분하다. 그런데 DB 응답이 느려지거나 외부 API가 지연되기 시작하면, 스레드들이 반납되지 않고 계속 기다리는 상태로 쌓인다. 200개가 전부 대기 상태로 묶이면, 201번째 요청은 스레드를 배정받지 못하고 큐에서 기다려야 한다. 이 상태가 계속되면 응답 지연이 사슬처럼 번지고, 결국 타임아웃과 장애로 이어진다.

이 현상을 스레드 고갈(thread pool exhaustion)이라고 부른다. 서버가 CPU도 메모리도 여유가 있는데 응답을 못 주는 대표적인 원인이다. 스레드 자체가 일을 못 해서가 아니라, 일하지 않고 그냥 기다리는 스레드가 자리를 다 차지해 버려서 생기는 문제다.

5. 스레드를 늘리면 되지 않나

자연스러운 반응은 스레드 풀을 키우는 것이다. 200개가 부족하면 2000개로 늘리면 되지 않을까.

어느 정도는 통한다. 하지만 스레드 하나에는 공짜가 아닌 비용이 붙는다. 스레드마다 스택 메모리가 따로 필요하고, CPU 코어 수는 정해져 있는데 스레드 수가 늘면 운영체제가 이 스레드 저 스레드로 실행을 옮겨 다니는 비용(컨텍스트 스위칭)도 같이 늘어난다. 스레드를 무한정 늘리는 방향으로는 결국 한계에 부딪힌다. 대기하는 스레드를 늘리는 것으로 대기 문제를 풀려는 것 자체가, 밑 빠진 독에 물 붓기에 가깝다.

여기서 질문이 하나 남는다. 스레드가 기다리는 것 자체를 없앨 수는 없을까. 즉 DB 응답이 오기 전까지 스레드가 아예 그 자리에 묶이지 않게 할 수는 없을까. 이 질문에 답하려면 먼저 "기다린다"는 게 정확히 뭔지 짚어야 한다.

2부. 블로킹과 논블로킹, 그리고 자주 헷갈리는 것들

6. 블로킹 — 부른 자리에서 멈춘다

카페에서 커피를 주문하는 상황을 생각해 보자. 주문을 하고 카운터 앞에 서서 커피가 나올 때까지 기다린다. 그동안 다른 건 아무것도 못 한다. 커피가 나와야 자리로 돌아간다.

1번 코드의 findById가 정확히 이 모양이다. 이 함수를 부른 스레드는 결과가 돌아올 때까지 그 줄에서 멈춘다. 이렇게 부른 자리에서 결과를 받을 때까지 멈춰 서 있는 방식을 블로킹(blocking)이라고 부른다. 자바에서 흔히 쓰는 JDBC 호출, RestTemplate, 일반적인 파일 읽기가 전부 이 방식이다.

7. 논블로킹 — 맡겨 두고 다른 일을 한다

같은 카페인데 이번엔 진동벨을 받는다. 주문하고 나면 카운터를 떠나 자리에 앉아 책을 읽는다. 커피가 준비되면 벨이 울리고, 그때 가서 받아 온다. 기다리는 동안 다른 일을 할 수 있다는 게 앞선 상황과 다른 점이다.

논블로킹(non-blocking)은 이 방식이다. 함수를 부르면 결과가 준비됐는지와 상관없이 즉시 돌아온다. 결과는 나중에 콜백이나 별도의 신호로 받는다. 부른 쪽은 그사이 다른 일을 할 수 있다.

8. 블로킹/논블로킹과 동기/비동기는 다른 축이다

여기서 자주 섞이는 두 쌍이 있다. 질문을 다르게 던지면 구분이 쉬워진다.

블로킹/논블로킹은 "호출하는 순간, 그 스레드가 멈추는가"를 묻는다. 함수를 부른 자리에서 결과가 나올 때까지 멈춰 서 있으면 블로킹이고, 결과와 상관없이 곧바로 다음 줄로 넘어가면 논블로킹이다.

동기/비동기는 "그 작업이 끝난 뒤 할 일을, 누가 이어서 하는가"를 묻는다. 호출한 코드 자신이 계속 그 자리를 지키면서(멈춰서든, 반복해서 확인하든) 결과를 받아 이어가면 동기다. 작업이 끝나는 시점과 그 뒷일이 호출한 코드의 흐름에서 아예 떨어져 나가, 나중에 다른 지점(콜백, 이벤트 루프)에서 처리되면 비동기다.

이렇게 보면 왜 두 축이 다른지 보인다. 하나는 "멈추냐 마느냐"이고, 다른 하나는 "누가 이어받냐"이다. 그래서 이론상 조합이 네 가지 다 나온다. 각 조합을 시간 흐름으로 그려 보면 이렇다.

동기 + 블로킹                              동기 + 논블로킹
(1번 코드의 findById)                       (직접 반복해서 확인하기)

호출 스레드                                 호출 스레드
  │                                          │
  ├─ 호출 ──┐                                ├─ 호출 ──► 즉시 "아직 없음"
  │         │ 멈춤(대기)                      ├─ 다시 호출 ──► 즉시 "아직 없음"
  │         │                                ├─ 다시 호출 ──► "결과 도착"
  ├◄─ 결과 ─┘                                 │
  ▼                                          ▼
다음 코드                                    다음 코드
  스레드가 처음부터 끝까지                       스레드는 안 멈추지만, 결과가 올 때까지
  이 흐름을 붙잡고 있다                          자기가 직접 계속 물어봐야 한다
  → 이어받는 주체가 "자기 자신"                   → 이것도 이어받는 주체가 "자기 자신"


비동기 + 블로킹                              비동기 + 논블로킹
(Future.get())                              (콜백 / 리액티브 체인)

호출 스레드        작업 스레드                  호출 스레드        이벤트 루프
  │                   │                        │                   │
  ├─ 작업 제출 ───────►│ 작업 진행               ├─ 콜백 등록 ───────►│ 작업 진행
  │                   │  (독립적으로)             ├─ 곧장 다음 코드     │  (독립적으로)
  ├─ get() 호출 ┐      │                        │   (멈추지 않는다)   │
  │      멈춤   │      │                        │                   │
  │             │      │ 완료                    │                   │ 완료
  ├◄─ 결과 ─────┴──────┤                        │        콜백 실행 ◄──┤
  ▼                                             ▼
다음 코드                                       (원래 흐름과는 별개로,
  작업 자체는 남에게 맡겼지만(비동기),                나중에 다른 지점에서
  get()을 부르는 순간 그 자리에서 멈춘다(블로킹)        이어받는다 = 비동기)

Future를 쓰는 코드가 딱 왼쪽 아래(비동기+블로킹) 모양이다.

Future<Order> future = executor.submit(() -> orderRepository.findById(id));  // 작업은 다른 스레드가 맡는다(비동기)
Order order = future.get();  // 하지만 결과를 받으려면 여기서 멈춘다(블로킹)

작업 자체는 호출 스레드와 무관하게 별도로 진행되니 비동기이지만, 그 결과를 get()으로 받으려는 순간 호출 스레드는 멈춘다. 그래서 비동기라고 해서 반드시 논블로킹인 것은 아니다.

표로 정리하면 이렇다.

  블로킹 논블로킹
동기 1번 코드의 findById. 부르고 그 자리에서 기다린다 함수는 바로 돌아오지만, 부른 쪽이 결과가 준비됐는지 직접 계속 확인해야 한다(polling)
비동기 Future.get()처럼 작업은 별도로 진행되지만, 결과를 받는 순간에는 그 자리에서 멈춘다 콜백이나 리액티브 체인처럼, 멈추지도 않고 직접 확인하지도 않는다. 완료되면 다른 지점에서 이어받는다

실무에서 거의 항상 마주치는 두 조합은 왼쪽 위(동기+블로킹)와 오른쪽 아래(비동기+논블로킹)다. 1번 코드는 왼쪽 위다. 다만 위 대각선의 두 조합이 보여 주듯, 비동기라고 반드시 논블로킹인 것은 아니고 논블로킹이라고 반드시 리액티브인 것도 아니다. 이 글이 관심을 두는 지점은 여러 논블로킹 작업을 비동기적인 데이터 흐름으로 엮어 내는 오른쪽 아래 조합이다.

9. 왜 이 구분이 필요한가

4번에서 짚은 문제, 즉 스레드가 대기 상태로 자리만 차지하는 문제로 돌아가 보자. 이 문제의 핵심은 "블로킹"에 있다. 스레드가 결과를 기다리는 동안 그 자리에 묶여 있다는 것 자체가 자원 낭비다.

이 낭비를 없애려면 논블로킹으로 가야 한다. 결과를 기다리지 않고 스레드를 곧바로 다른 일로 돌려보내면, 스레드 하나가 여러 요청을 번갈아 처리할 수 있다. 그런데 논블로킹 하나만으로 문제가 다 풀리진 않는다. 여러 비동기 작업을 어떻게 이어 붙일지, 값이 여러 개 연달아 올 때는 어떻게 다룰지, 만드는 쪽이 처리하는 쪽보다 빠르면 그 속도를 어떻게 조절할지가 남는다. 리액티브 프로그래밍은 단순히 논블로킹을 가리키는 말이 아니라, 이런 비동기 데이터 흐름을 선언적으로 조합하고 그 흐름의 속도까지 다루는 프로그래밍 모델이다.

3부. 리액티브 프로그래밍이란 무엇인가

10. 값이 아니라 흐름을 다룬다

1번 코드를 논블로킹으로 바꾼다고 생각해 보자. findById를 부르는 순간 결과가 아직 없다. 결과는 나중에 도착한다. 그렇다면 이 코드는 이제 "결과값"을 다루는 게 아니라 "언젠가 도착할 결과"를 다뤄야 한다.

리액티브 프로그래밍은 이걸 스트림(stream)으로 다룬다. 값 하나를 바로 돌려받는 대신, 값이 나중에(혹은 여러 번에 걸쳐) 도착할 수 있는 흐름을 돌려받는다. 그리고 그 흐름에 "값이 도착하면 이렇게 처리해라"는 규칙을 미리 걸어 둔다. 값이 실제로 도착하면 그 규칙이 그때 실행된다. 이름 그대로 반응(react)하는 프로그래밍이다.

11. 발행자와 구독자

이 흐름을 다루는 기본 구조는 발행자(Publisher)와 구독자(Subscriber)로 나뉜다. 발행자는 데이터를 만들어 흘려보내는 쪽이고, 구독자는 그 데이터가 도착할 때마다 처리할 로직을 등록해 둔 쪽이다.

Publisher                          Subscriber
   │                                   │
   │  onSubscribe(구독 시작)  ───────►│
   │                                   │
   │  onNext(값1)             ───────►│  값이 도착할 때마다 처리
   │  onNext(값2)             ───────►│
   │  onNext(값3)             ───────►│
   │                                   │
   │  onComplete(끝)          ───────►│  또는 onError(실패)

이 네 가지 신호(onSubscribe, onNext, onComplete, onError)로 데이터 흐름 전체를 표현한다. 값이 하나만 오고 끝날 수도 있고(단건 조회), 여러 개가 연달아 올 수도 있다(목록 조회, 실시간 스트림). 어느 쪽이든 같은 구조로 다룬다.

이 구조를 표준으로 정리해 둔 것이 Reactive Streams라는 명세다. Publisher, Subscriber, Subscription, Processor 네 인터페이스로 이루어져 있고, 자바 진영에서 리액티브를 표방하는 라이브러리들은 대체로 이 명세를 구현한다. 스프링의 Project Reactor(Mono, Flux)도, RxJava도 이 명세 위에 서 있다.

12. 백프레셔 — 받는 쪽이 속도를 조절한다

이 명세에서 눈여겨볼 부분이 하나 있다. Subscriber가 그냥 가만히 받기만 하는 게 아니라, Subscription을 통해 "지금 몇 개까지 더 보내도 된다"고 발행자에게 요청한다는 점이다. 발행자가 데이터를 만드는 속도가 구독자가 처리하는 속도보다 훨씬 빠르면, 처리 못 한 데이터가 쌓여서 메모리를 다 잡아먹는 문제가 생길 수 있다. 이걸 막으려고 구독자 쪽에서 감당할 수 있는 만큼만 요청하고, 발행자는 그 요청된 개수만큼만 보낸다.

이 조절 장치를 백프레셔(backpressure)라고 부른다. 카페 비유로 치면, 진동벨을 여러 개 동시에 눌러 놓고 커피가 한꺼번에 열 잔 나오는 게 아니라, 내가 한 잔을 다 마시고 다음 잔을 받을 준비가 됐을 때만 다음 잔을 만들어 달라고 요청하는 것과 비슷하다.

13. 코드로 보면

1번 코드를 리액티브 방식으로 바꾸면 이런 모양이 된다.

@GetMapping("/orders/{id}")
public Mono<OrderResponse> getOrder(@PathVariable Long id) {
    return orderRepository.findById(id)                 // Mono<Order>
        .flatMap(order -> userClient.getUser(order.getUserId())
            .map(user -> new OrderResponse(order, user)));
}

여기서 짚어야 할 게 하나 있다. Mono<Order>는 "언젠가 Order가 도착할 흐름"이라기보다, Order를 0개 또는 1개 전달할 수 있는 Publisher다. 값이 여러 개일 수 있으면 Mono 대신 Flux를 쓴다. 그리고 이 orderRepository는 1번 코드의 JPA 리포지토리와 같은 게 아니다. 일반적인 JPA의 findByIdOptional<Order>를 돌려주지 Mono를 돌려주지 않는다. 여기서는 R2DBC처럼 처음부터 논블로킹으로 설계된 리포지토리를 쓴다고 가정한다.

flatMap은 그 값이 도착했을 때 실행할 다음 단계를 미리 걸어 두는 것이다. 이 메서드가 실행되는 순간에는 아직 DB 조회도, 외부 API 호출도 일어나지 않는다. Reactor의 Publisher는 기본적으로 지연(lazy) 실행되기 때문이다. 실제로 누군가 이 흐름을 구독할 때(WebFlux에서는 프레임워크가 반환된 Publisher를 대신 구독한다) 그제야 값이 흐르기 시작한다.

1번의 동기 코드와 비교하면 결정적인 차이가 하나 보인다. 1번 코드에서는 findById를 부른 스레드가 그 결과가 나올 때까지 멈춰 있었다. 여기서는 findById를 부르는 순간 그 스레드는 곧바로 다음 요청을 처리하러 넘어갈 수 있다. 중요한 건 "다른 스레드가 대신 처리한다"는 게 아니다. 이어서 flatMap 안의 로직을 어떤 스레드가 실행하는지는 Reactor의 스케줄러 설정에 따라 달라진다. 핵심은 DB 응답을 기다리는 동안 그 스레드를 블로킹으로 붙잡아 두지 않는다는 것이다.

14. 이렇게 해서 얻는 것

4번과 5번에서 짚은 문제로 돌아가 보자. 스레드가 대기 상태로 자리를 차지하는 게 문제였다. 리액티브 방식에서는 스레드가 결과를 기다리며 멈춰 있는 시간이 없다. 논블로킹 호출을 걸어 두고 곧장 다른 일로 넘어가기 때문에, 적은 수의 스레드로도 훨씬 많은 요청을 동시에 처리할 수 있다.

이때 실제로 실행을 담당하는 스레드 모델은 스레드 퍼 리퀘스트가 아니라 이벤트 루프(event loop)에 가깝다. 소수의 스레드가 "지금 처리할 준비가 된 작업"만 골라 순서대로 처리하고, 아직 결과가 안 온 작업은 스레드를 붙잡지 않는다. 이 이벤트 루프가 정확히 어떻게 동작하는지, 어떻게 스레드 하나로 수천 개의 요청을 동시에 상대할 수 있는지는 이 글의 범위를 벗어나는 별도의 주제라 여기서는 결과만 짚고 넘어간다. 요지는 이렇다. 리액티브 프로그래밍이 그 자체로 연산을 빠르게 만들어 주는 건 아니다. 논블로킹 호출들을 흐름으로 엮어서 스레드를 블로킹으로 낭비하지 않게 하고, 그 결과로 같은 스레드 수로 더 많은 동시 요청을 감당할 수 있게 되는 것이다.

4부. Spring WebFlux는 리액티브 프로그래밍과 어떤 관계인가

15. WebFlux 이전 — Spring MVC

지금까지 1번 코드처럼 써 온 스프링 애플리케이션은 대부분 Spring MVC 위에서 돈다. Spring MVC는 서블릿(Servlet) API를 기반으로 하고, 서블릿 API 자체가 2번에서 본 스레드 퍼 리퀘스트 모델로 설계되어 있다. 톰캣이 요청마다 스레드를 배정하고, 컨트롤러 코드는 그 스레드 위에서 동기·블로킹 방식으로 실행된다.

16. WebFlux — 리액티브 스트림을 스프링에 들여온 것

15번에서 본 Spring MVC는 톰캣이 요청마다 스레드를 하나씩 붙여 주고, 그 스레드가 컨트롤러 코드를 처음부터 끝까지 실행했다. WebFlux는 이거랑 다르게 요청을 처리하려고 만든 스프링 모듈이다.

가장 눈에 띄는 차이는 컨트롤러가 돌려주는 값이다. 13번 코드처럼 Order 같은 값을 그대로 돌려주는 대신 Mono<Order>Flux<Order>를 돌려준다. "결과를 지금 바로 줄게"가 아니라 "결과가 준비되면 이 흐름을 통해 전달할게"로 바뀌는 것이다.

WebFlux도 톰캣 같은 서블릿 컨테이너 위에서 돌아갈 수 있다. 바뀐 건 서버 자체가 아니라, 요청과 응답을 주고받는 방식이 11번에서 본 Publisher·Subscriber 규칙을 따른다는 점이다. USB-C타입 충전기를 생각해 보자.

  • 먼저 "기기와 충전기가 이런 방식으로 연결되고 전기가 오가야 한다"는 약속이 있다. "데이터가 준비되면 그때 반응해서 처리하자"는 개념이다.
  • "커넥터는 이렇게 생기고, 전류는 이런 순서로 흘려보낸다"고 정해 둔 규격이 있다. Reactive Streams가 이 규격이다. 자바에서 이 아이디어를 코드로 쓰려면 Publisher, Subscriber, Subscription이라는 이름의 규칙을 이렇게 지키라고 정해 둔 표준 문서에 가깝다. 그 자체로는 아직 실제로 동작하는 물건이 아니다.
  • 이 규격을 지켜서 실제로 만들어진 충전기가 있다. Project Reactor가 이 충전기다. Reactive Streams라는 규격을 지키면서, MonoFlux라는 실제로 쓸 수 있는 도구를 만들어 뒀다. 11번과 12번에서 본 onNext, onComplete, 백프레셔가 전부 이 안에 코드로 구현되어 있다.
  • 이 충전기를 가져다 쓰는 노트북이 있다. Spring WebFlux가 이 노트북이다. Reactor가 만들어 둔 Mono, Flux를 가져다가, 웹 요청과 응답을 그 방식으로 주고받도록 스프링에 붙여 놓은 모듈이다.

그림으로 한 번 더 정리하면 이렇다.

리액티브 프로그래밍   ← 아이디어: "데이터가 준비되면 반응해서 처리하자"
      │
Reactive Streams      ← 그 아이디어를 코드로 쓰기 위한 규격 (Publisher, Subscriber, ...)
      │
Project Reactor       ← 그 규격을 지켜서 실제로 만든 도구 (Mono, Flux)
      │
Spring WebFlux        ← 그 도구를 가져다가 웹 요청을 처리하는 스프링 모듈

Spring Boot로 WebFlux 프로젝트를 만들면 서버로 보통 Netty가 자동으로 딸려 온다. 하필 Netty인 이유는, 14번에서 말한 "적은 스레드로 많은 요청을 처리하는" 방식으로 원래 만들어진 서버라 WebFlux와 궁합이 잘 맞기 때문이다. 그렇다고 WebFlux가 Netty에서만 돌아가는 건 아니다. 톰캣이나 언더토우처럼 MVC에서 쓰던 익숙한 서버 위에서도 WebFlux를 그대로 쓸 수 있다.

17. WebFlux를 쓴다고 저절로 논블로킹이 되는 건 아니다

여기서 실무에서 자주 걸리는 함정이 있다. WebFlux로 컨트롤러를 짜 놓고, 그 안에서 예전 방식의 블로킹 JDBC 호출을 그대로 쓰는 경우다.

@GetMapping("/orders/{id}")
public Mono<OrderResponse> getOrder(@PathVariable Long id) {
    return Mono.fromCallable(() -> jdbcTemplate.queryForObject(...))  // 여전히 블로킹 호출
        .map(order -> new OrderResponse(order, ...));
}

Mono로 감싸긴 했지만 안에서 부르는 jdbcTemplate 호출 자체는 여전히 블로킹이다. WebFlux의 이벤트 루프 스레드 수는 보통 CPU 코어 수 정도로 적게 유지되는데, 그 몇 안 되는 스레드 중 하나가 이 블로킹 호출에 붙잡히면, 그동안 그 스레드가 처리해야 할 다른 요청들이 전부 밀린다. 스레드 풀이 넉넉한 MVC보다 오히려 더 나쁜 상황이 될 수 있다.

그래서 WebFlux를 제대로 쓰려면 DB 드라이버부터 외부 호출까지 체인 전체가 논블로킹이어야 한다. R2DBC(Reactive Relational Database Connectivity)가 나온 이유가 이것이다. JDBC는 태생부터 블로킹이라 리액티브 체인에 낄 수 없고, R2DBC는 처음부터 논블로킹으로 설계된 DB 접근 규격이다.

18. 그래서 항상 WebFlux가 정답인가

여기까지 보면 WebFlux가 항상 더 나은 선택처럼 보이는데, 그렇지 않다. 리액티브 체인으로 코드를 짜면 스택 트레이스가 읽기 어려워지고, 디버깅이 동기 코드보다 까다로워진다. 팀 전체가 이 방식에 익숙해지는 데도 시간이 든다. 반대급부로 얻는 이득, 즉 적은 스레드로 높은 동시성을 얻는 이점은 요청 대부분이 I/O 대기로 시간을 보내는 서비스(외부 API를 많이 호출하는 게이트웨이, 대량의 동시 연결을 받는 서비스)에서 크게 나타난다. CPU 연산이 중심이거나 트래픽이 그리 크지 않은 서비스라면 정통 방식으로도 충분한 경우가 많다.

마무리. 네 질문을 한 문장씩으로

정통 방식은 요청마다 스레드를 하나씩 묶어 두고, 그 스레드가 DB나 외부 API 응답을 기다리는 동안에도 자리를 비우지 않아서 트래픽이 몰리면 스레드가 고갈된다. 블로킹은 그 기다림 동안 실행 흐름이 멈추는 것이고, 논블로킹은 멈추지 않고 곧장 돌아와 나중에 결과를 받는 것이다. 리액티브 프로그래밍은 논블로킹 그 자체를 가리키는 말이 아니라, 여러 비동기 작업을 발행자·구독자 구조로 엮고 백프레셔로 그 속도까지 조절하는 프로그래밍 모델이다. 그 결과로 스레드를 블로킹으로 낭비하지 않고도 많은 동시 요청을 감당할 수 있게 된다. Spring WebFlux는 이 Reactive Streams 명세를 구현한 Reactor 위에 스프링의 웹 계층을 얹은 모듈이며, 체인 전체가 논블로킹이어야 제 효과를 내고 Netty 전용도 아니다.

'백엔드' 카테고리의 다른 글

JPA 연관관계, 쓸까 말까 — @ManyToMany도 써도 될까?  (1) 2026.07.26