본문 바로가기

Backend study/Spring Boot

Spring WebFlux와 리액티브 프로그래밍 정리

320x100

스프링에서 외부 API 통신을 공부하다가 WebClient를 만났다. RestTemplate과 뭐가 다른지 찾아보니 "RestTemplate은 동기, WebClient는 비동기도 가능"이라는 설명이 나오는데, 이 한 줄을 제대로 이해하려면 리액티브 프로그래밍과 WebFlux까지 내려가야 했다. 왜 스레드를 늘리는 대신 대기를 없애려 하는지, 왜 WebFlux에서는 JDBC/JPA를 못 쓰는지, Mono/Flux는 어떻게 다뤄야 하는지. 그 과정에서 생긴 질문들을 순서대로 따라가며 정리해보고자 한다.

 

RestTemplate vs WebClient

둘 다 외부 API를 호출하는 HTTP 클라이언트다. 차이는 호출한 스레드가 어떻게 되느냐에 있다.

  • RestTemplate: 동기 호출이다. 요청을 보내고 응답이 올 때까지 호출한 스레드가 그 자리에서 기다린다. 응답이 오기 전까지 스레드는 다른 일을 못 하고 점유된 상태다.
  • WebClient: 동기/비동기 둘 다 가능하다. 비동기로 쓰면 요청을 보낸 뒤 스레드를 기다리게 하지 않고, "응답이 오면 이걸 실행해라"만 등록해 두고 스레드는 즉시 반납된다.

그런데 "스레드가 기다린다 / 안 기다린다"가 왜 그렇게 중요한 걸까. 이걸 이해하려면 먼저 용어부터 정리해야 한다.

 

동기/비동기, 블로킹/논블로킹

네 단어가 늘 섞여 쓰이지만, 사실 두 개의 다른 축이다.

동기 / 비동기 결과를 누가 챙기는가? 동기: 호출한 쪽이 결과가 나올 때까지 지켜보고 직접 받는다 / 비동기: 결과 처리를 콜백 등으로 맡겨두고 호출한 쪽은 제 갈 길을 간다
블로킹 / 논블로킹 호출한 스레드가 멈추는가? 블로킹: 결과가 올 때까지 스레드가 정지한다 / 논블로킹: 호출이 즉시 리턴되고 스레드는 계속 다른 일을 한다
  • 동기 + 블로킹: RestTemplate, JDBC의 executeQuery(). 가장 익숙한 형태다. 부르고, 서서 기다리고, 결과를 받아서 다음 줄로 간다.
  • 비동기 + 논블로킹: WebClient의 비동기 사용. 콜백만 등록하고 스레드는 즉시 풀려난다. 결과는 나중에 콜백으로 도착한다.

이 글에서 계속 문제 삼는 것은 블로킹, 즉 "스레드가 멈추는가"이다. 스레드가 멈추는 게 왜 문제인지는 전통적인 MVC의 구조를 뜯어보면 보인다.

 

MVC 스레드 모델의 한계

전통적인 Spring MVC는 요청이 하나 들어오면 스레드 하나를 통째로 배정한다. 그 스레드는 요청을 받고, DB를 조회하고, 응답을 만들어 보낼 때까지 그 요청만 전담한다.

그런데 이 스레드가 하는 일을 시간으로 쪼개보면, 대부분이 일이 아니라 대기다. DB 조회가 200ms 걸리면 그 200ms 동안 스레드는 CPU를 쓰는 게 아니라 그냥 응답이 오기를 기다리며 서 있다.

 
[MVC 스레드의 시간 사용]
요청 파싱(1ms) → DB 대기(200ms) → 외부 API 대기(300ms) → 응답 생성(1ms)
                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                  전체 시간의 99%가 "서 있는" 시간

동시 요청을 더 받고 싶으면 이런 "서 있는 스레드"를 더 만들어야 하는데, 스레드는 개당 메모리를 먹고 많아질수록 컨텍스트 스위칭 비용이 커지니 한계가 명확하다.

 

리액티브 프로그래밍의 핵심 아이디어

여기서 나온 발상이 리액티브다. 핵심 아이디어는 하나다.

스레드를 늘리는 게 아니라, 기다리는 시간 자체를 없애자.

어떻게 없애느냐면, 기다려야 하는 지점에서 기다리는 대신 "응답이 도착하면 그때 이 다음 작업을 실행해줘"라고 등록만 해두고, 스레드는 풀에 반납되어 바로 다른 요청을 처리하러 간다. 스레드는 대기가 아니라 실제로 일이 생겼을 때만 투입된다. 이렇게 하면 스레드는 항상 실제 일만 하게 되고, 대기 시간이 0에 가까워진다.

 

WebFlux의 이벤트 루프 구조

WebFlux는 이 아이디어를 구현한 프레임워크다. 요청과 응답을 특정 스레드에 종속시키는 게 아니라, 이벤트 루프 스레드에 배정한다.

  • 스레드를 CPU 코어 수만큼(대략 4~8개)만 둔다.
  • 각 이벤트 루프 스레드(EventLoopThread)는 이벤트 루프 그룹(EventLoopGroup)에 속하고, 처리할 작업들은 태스크 큐(ScheduledTaskQueue)에 쌓인다.
  • 요청이 올 때마다 쉬고 있는 스레드가 큐에서 작업을 꺼내 처리한다. 스레드 하나가 요청 하나를 전담하는 게 아니라, 수백 개 요청의 작은 조각들을 번갈아가며 처리한다.

이 구조로 4~8개의 스레드가 수천 개의 동시 요청을 처리한다. 그런데 이 구조가 성립하려면 반드시 지켜져야 하는 조건이 하나 생긴다.

이벤트 루프 스레드는 절대 한 곳에서 오래 멈추면 안 된다.

스레드 하나가 수백 개 요청이 같이 쓰는 공용 자원이 됐기 때문이다. MVC에서는 스레드가 멈춰도 그 스레드 전용인 요청 하나만 느려지고 만다. 하지만 WebFlux에서 이벤트 루프 스레드가 멈추면, 그 스레드 차례를 기다리던 수백 개 요청이 통째로 정지한다. 스레드가 4개뿐이니 하나만 멈춰도 서버 처리 능력의 1/4이 날아간다.

 

WebFlux에서 JDBC/JPA를 못 쓰는 이유

바로 이 지점에서 JDBC와 JPA가 문제가 된다.

  • JDBC의 executeQuery()는 DB 응답이 올 때까지 호출한 스레드를 정지시키도록 표준 자체가 정의돼 있다.
  • JPA는 그 JDBC 위에서 도는 추상화라, findById()를 불러도 밑에서는 똑같은 일이 일어난다.

즉 JPA 호출 한 줄이 이벤트 루프 스레드를 세워버리고, 방금 본 "절대 멈추면 안 된다"는 조건을 정면으로 깬다.

무서운 점은 에러가 나지 않는다는 것이다. 코드는 돌아가고 트래픽이 적을 땐 멀쩡해 보인다. 하지만 트래픽이 몰리면 처리량이 무너져서, 적은 스레드로 많이 처리하겠다던 WebFlux의 존재 이유가 사라지고 오히려 MVC보다 못한 상태가 된다. "WebFlux에서 JDBC/JPA를 못 쓴다"는 말의 정확한 의미가 이것이다. 문법적으로 못 쓰는 게 아니라, 쓰는 순간 구조가 무너진다.

 

대체 라이브러리: R2DBC, Lettuce Reactive, Reactor Kafka

원칙은 스택 전체가 논블로킹이어야 한다는 것이다. 체인 어딘가에 블로킹이 하나라도 끼면 거기서 이벤트 루프가 잡히기 때문이다.

블로킹 (MVC 스택) 논블로킹 짝 (WebFlux 스택)
JDBC / JPA R2DBC
RestTemplate WebClient
Jedis (동기 Redis) Lettuce Reactive
KafkaTemplate Reactor Kafka

각각을 간단히 보면 이렇다.

  • R2DBC: MySQL, PostgreSQL 같은 관계형 DB에 논블로킹으로 접속하는 표준과 드라이버다. JDBC가 하는 일(쿼리 던지고 결과 받기)을 똑같이 하되, 쿼리를 던진 뒤 스레드를 세우지 않고 결과가 도착하면 Mono/Flux로 흘려보낸다. Spring Data R2DBC를 쓰면 JPA 리포지토리와 거의 비슷하게 ReactiveCrudRepository 인터페이스로 사용하는데, 반환 타입만 User가 아니라 Mono<User>가 된다. 단, JPA가 아니라서 연관관계 매핑이나 영속성 컨텍스트(더티 체킹, 지연로딩) 같은 건 없다. SQL에 가까운 얇은 계층이다.
  • Lettuce Reactive: 자바 Redis 클라이언트는 Jedis와 Lettuce가 유명한데, Jedis는 블로킹 전용이고 Lettuce는 동기/비동기/리액티브 세 모드를 다 지원한다. 리액티브 모드를 스프링에서는 ReactiveRedisTemplate으로 감싸서 쓴다. get("key")가 String 대신 Mono<String>을 돌려주는 식이다.
  • Reactor Kafka: 전송 결과를 Mono로, 수신 메시지 스트림을 Flux로 다룬다. 특히 컨슈머 쪽에서 끝없이 흘러들어오는 메시지를 Flux로 받으므로, 소비 속도가 밀릴 때 조절하는 백프레셔와 궁합이 좋다.

셋의 공통점은 하나다. 기존 블로킹 클라이언트와 같은 일을 하되, 스레드를 세우지 않고 결과를 Mono/Flux로 준다. 그래서 이벤트 루프 위에서 안전하다.

 

차선책: boundedElastic 격리

차선책은 있다. 블로킹 호출만 별도 전용 스레드풀로 보내는 것이다.

Mono.fromCallable(() -> jpaRepository.findById(id).orElseThrow())
    .subscribeOn(Schedulers.boundedElastic());

블로킹이 일어나긴 하지만 이벤트 루프가 아닌 격리된 스레드에서 일어나므로, 최소한 서버 전체가 멈추는 건 막는다. 물론 그 구간만큼은 사실상 MVC 방식으로 도는 것이라 임시방편이다.

 

Mono와 Flux

R2DBC나 WebClient를 쓰면 결과가 Mono<User> 같은 타입으로 돌아온다.

  • Mono: 결과가 0~1개 흐르는 타입 (단건 조회, 저장 결과)
  • Flux: 결과가 0~N개 흐르는 타입 (목록 조회, 스트리밍)

핵심 성질: Mono/Flux는 "값이 든 상자"가 아니라 *나중에 값이 흐를 실행 계획서"다.
repository.findById(id)를 호출한 시점에는 DB 조회가 일어나지 않는다. 조회 계획만 만들어지고, 실제 실행은 누군가 이 계획을 구독(subscribe)하는 순간 시작된다. (Nothing happens until you subscribe)

전제: 내 코드는 어떤 스레드가 실행하고 있는가

모든 코드는 어떤 스레드가 실행한다. 그 스레드가 어떤 종류인지는 프레임워크가 결정한다.

  • MVC: 컨트롤러/서비스 코드를 톰캣 워커 스레드가 실행한다. 이 스레드는 지금 요청 하나만 전담한다.
  • WebFlux: 같은 모양의 코드를 이벤트 루프 스레드가 실행한다. 이 스레드는 수백 개 요청을 번갈아 처리하는 공용 스레드다.

이 차이가 아래 세 가지 방법의 가능 여부를 가른다.

 

Mono의 값을 다루는 세 가지 방법

방법 1) block()

block()은 "자신을 실행 중인 스레드"를 값이 나올 때까지 세워두고, 값이 오면 리턴하는 메서드다. block()은 자기를 실행하는 스레드가 어떤 종류인지 구분하지 않고 그냥 세운다. 그래서 완전히 같은 코드라도 어디서 실행되느냐에 따라 결과가 달라진다.

User user = webClient.get().uri("/users/1")
        .retrieve().bodyToMono(User.class)
        .block();   // "나를 실행 중인 스레드"를 세운다
  • MVC 프로젝트 안에서: 세워지는 건 톰캣 워커 스레드. 어차피 이 요청 하나를 전담하며 기다리는 게 원래 역할이므로, 이 요청만 응답을 기다릴 뿐 문제가 없다. (RestTemplate과 같은 동작)
  • WebFlux 프로젝트 안에서: 요청 처리 코드는 이벤트 루프 스레드가 실행하므로, 세워지는 건 공용 스레드다. 그 스레드가 번갈아 처리하던 다른 수백 개 요청이 전부 멈춘다. JPA 블로킹과 동일한 사고이며, Reactor가 감지하면 예외를 던진다.
  • 결론: WebFlux 요청 처리 코드에서는 block()을 쓸 수 없다. (테스트 코드, main, 배치, MVC처럼 이벤트 루프가 없는 곳에서는 정상적인 사용법이다)

 

방법 2) subscribe()

subscribe()는 콜백을 등록해서 파이프라인 실행을 시작시키는 메서드다. 논블로킹이므로 등록만 하고 즉시 리턴된다. 여기서 실무에서 가장 흔한 오해가 나온다.

User[] user = new User[1];
repository.findById(id).subscribe(u -> user[0] = u); // 콜백 등록 후 즉시 다음 줄로
return user[0]; // 이 시점에 DB 응답은 아직 안 옴 → 항상 null
  • return이 실행되는 시점에 DB 응답은 도착하지 않았다. 콜백은 수십 ms 뒤, 이 메서드가 이미 끝난 다음에 실행된다.
  • 결과는 콜백 으로만 오고, 호출한 쪽의 실행 흐름으로는 되돌아오지 않는다. → "subscribe 방식을 쓰면 결과를 받지 못한다"
  • 구독은 원래 파이프라인의 최종 소비자가 하는 것이다. 프레임워크가 대신 구독해줄 수 없는 끝단(배치 진입점, 결과를 기다릴 필요 없는 알림 전송)에서만 직접 호출한다.

 

방법 3) Mono/Flux를 그대로 반환 — WebFlux에서의 정답

값을 꺼내려 하지 말고, 체인을 유지한 채 Mono/Flux를 그대로 반환한다.

구독은 원래 파이프라인의 최종 소비자가 하는 것이다. HTTP 요청 처리에서 최종 소비자는 내 코드가 아니라 프레임워크다. 그래서 컨트롤러가 Mono<UserResponse>를 반환하면, HTTP 응답을 내보내야 하는 시점에 WebFlux가 대신 구독하고, 값이 도착하면 응답으로 변환해서 전송한다.

우리가 subscribe()를 직접 부르는 건 프레임워크가 받아줄 수 없는 끝단뿐이다. 배치 작업의 진입점이나, 결과를 기다릴 필요 없는 알림 전송(fire-and-forget) 정도가 여기에 해당한다.

// 컨트롤러: Mono를 그대로 반환 → WebFlux가 구독하고 응답으로 변환
@GetMapping("/users/{id}")
public Mono<UserResponse> getUser(@PathVariable Long id) {
    return userService.getUser(id);
}

// 서비스: 가공은 체인 안에서 (block X, subscribe X)
public Mono<UserResponse> getUser(Long id) {
    return userRepository.findById(id)      // Mono<User>
        .map(UserResponse::from)            // 동기 변환은 map
        .switchIfEmpty(Mono.error(new UserNotFoundException(id)));
}

 

체인 안에서의 가공: map, flatMap

값을 그대로 반환하라고 하면 "그럼 가공은 어떻게 하냐"는 질문이 나오는데, 가공도 체인을 끊지 않고 체인 안에서 연산자로 한다.

하려는 일 연산자
값을 다른 값으로 바꾸는 동기 변환 map (.map(UserResponse::from))
결과를 가지고 또 다른 DB 조회/API 호출을 이어붙임 flatMap (변환 함수가 Mono/Flux를 리턴하면 flatMap)
서로 독립적인 두 호출을 병렬 실행해 합침 Mono.zip(a, b)
결과가 비었을 때 처리 switchIfEmpty
에러가 났을 때 처리 onErrorResume, onErrorMap

map 안에서 block()을 부르고 싶어지는 순간이 있는데, 그 자리가 바로 flatMap을 써야 하는 자리다.

 

ThreadLocal 주의사항

리액티브에서는 체인의 단계마다 실행 스레드가 바뀔 수 있다. MVC에서는 요청 하나가 같은 스레드에서 처음부터 끝까지 돌았기 때문에, 스레드에 값을 붙여두는 ThreadLocal 기반 기술들(JPA의 @Transactional, SecurityContext, 로깅 MDC)이 잘 동작했다. 하지만 스레드가 계속 바뀌는 리액티브 체인에서는 이 값들이 중간에 유실된다. 그래서 트랜잭션도 R2DBC용 리액티브 트랜잭션 매니저를 써야 하고, 값 전파가 필요하면 ThreadLocal 대신 Reactor Context를 쓴다.

 

Django ORM과의 비교

Mono/Flux가 "구독 전엔 실행되지 않는 계획서"라는 얘기를 들으면, Django를 써본 사람은 QuerySet이 떠오른다. 정확히는 QuerySet의 지연 평가(lazy evaluation) 와 같은 성질이다.

qs = User.objects.filter(age__gte=20)   # 이 시점엔 쿼리 안 나감
qs = qs.exclude(name="kim")             # 여전히 안 나감, 조건만 쌓임
list(qs)                                # 여기서 비로소 SQL 실행

QuerySet에 filter를 체이닝하는 동안은 "쿼리 계획"만 쌓이고, list(), for 반복, len()처럼 실제로 값이 필요해지는 순간에 SQL이 나간다. Mono/Flux도 똑같다. findById(id).map(...)을 체이닝하는 동안은 계획만 조립되고, subscribe되는 순간 실행된다. "계획 조립"과 "실행"이 분리돼 있다는 점에서 구조가 같다.

다만 결정적인 차이가 하나 있다. 실행이 시작된 뒤의 동작이다. Django에서 list(qs)가 실행되면 그 스레드가 DB 응답이 올 때까지 서서 기다린다. 즉 "지연 평가지만, 평가 자체는 블로킹"이다. QuerySet 평가는 block()에 가깝고, WebFlux에서는 그걸 못 쓰니까 결과 도착 이후의 처리까지 전부 체인에 미리 걸어두는 것이다.

 

이벤트 루프 모델의 공통 규칙

파이썬에도 asyncio라는 이벤트 루프 기반 비동기 모델이 있고, 규칙도 동일하다. 이벤트 루프를 블로킹하면 안 된다. 그런데 Django ORM은 태생이 동기+블로킹이다. User.objects.get(id=1)을 부르면 그 스레드가 DB 응답까지 서서 기다린다. JDBC와 똑같은 처지인 것이다.

 

그래서 언제 WebFlux를 쓰고, 언제 MVC를 쓰나

판단 기준은 하나다. 이 서버의 병목이 "I/O 대기와 동시 요청"인가.

숫자로 보면 명확하다. 요청 하나를 처리하는 데 500ms가 걸리는 서버가 있고, 그중 495ms가 DB와 외부 API의 응답을 기다리는 시간이라고 하자. MVC에서는 이 495ms 동안 스레드가 통째로 잡혀 있다. 그래서 톰캣 스레드가 200개면 동시 처리도 200개에서 막히고, 더 받으려면 스레드를 늘려서 메모리와 컨텍스트 스위칭 비용을 지불해야 한다. WebFlux는 이 495ms 동안 스레드를 잡아두지 않으므로 같은 장비로 수천 개의 동시 요청을 받을 수 있다. 정리하면, 요청 처리 시간에서 대기가 차지하는 비중이 크고 그 대기가 동시에 많이 겹치는 서버일수록 WebFlux의 효과가 커진다.

이 조건에 들어맞는 대표적인 경우가 세 가지다. 첫째로 여러 백엔드 서비스를 대신 호출해서 결과를 합쳐주는 중계 서버다(API 게이트웨이, BFF). 이런 서버는 자기 연산이 거의 없고 하는 일의 대부분이 다른 서버의 응답 대기라서, 대기를 없애는 효과가 극대화된다. 둘째로 외부 API 여러 개를 병렬로 호출해 집계하는 서버다. 셋째로 SSE나 웹소켓처럼 연결을 오래 열어두는 실시간 서비스다. MVC는 열린 연결 수만큼 스레드가 잡히지만, WebFlux는 수만 개의 연결을 스레드 몇 개로 유지할 수 있다.

단, 전제 조건이 있다. DB 드라이버부터 Redis, HTTP 클라이언트까지 스택 전체를 논블로킹으로 채울 수 있어야 한다. 핵심 경로에 블로킹 라이브러리가 하나라도 남으면 그 지점에서 위의 이점이 사라진다.

반대로 이 조건에 해당하지 않는다면 WebFlux를 고를 이유는 급격히 약해진다. 여기서 두 가지 착각을 걷어낼 필요가 있다.

첫째, "WebFlux를 쓰면 빨라진다"는 착각. 아니다. DB가 200ms 걸리면 WebFlux에서도 200ms 걸린다. 좋아지는 건 개별 요청의 응답 속도가 아니라 같은 자원으로 버티는 동시 요청 수(처리량)다. 그러니 동시 요청이 기존 스레드 풀로 충분히 감당되는 서비스라면 WebFlux로 바꿔도 체감되는 이득이 없다. CPU 연산이 병목인 서버는 아예 대상이 아니다. 리액티브가 없애주는 건 "대기"인데, 없앨 대기가 없기 때문이다.

둘째, "요즘은 리액티브가 표준"이라는 착각. 리액티브는 공짜가 아니다. R2DBC로 가면 JPA의 영속성 컨텍스트와 연관관계 매핑을 포기해야 하고, 스택트레이스가 체인 조각으로 흩어져 디버깅이 어려워지며, 무엇보다 팀 전원이 "블로킹 금지" 규칙을 이해하고 지켜야만 유지된다. 누군가 무심코 넣은 block() 한 줄, JPA 호출 한 줄이 아키텍처 전체를 무너뜨릴 수 있는, 규율에 의존하는 구조다.

결론은 이렇다. WebFlux냐 MVC냐는 우열의 문제가 아니라 병목의 종류 문제다. 병목이 "I/O 대기 × 동시성"이고 스택을 전부 논블로킹으로 구성할 수 있다면 WebFlux, 그게 아니라면 검증된 MVC에 필요한 곳만 WebClient를 쓰는 구성이 정답이다.

 

정리

처음의 "RestTemplate vs WebClient" 질문에서 시작한 이야기가 결국 하나로 이어진다.

  1. MVC는 요청마다 스레드를 배정하고 그 스레드가 대기까지 떠안는 구조라 동시성에 한계가 있다.
  2. WebFlux는 대기를 없애고 소수의 이벤트 루프 스레드가 수백 요청을 번갈아 처리해서 이를 해결했다.
  3. 그 대가로 "스레드가 절대 멈추면 안 된다"는 조건이 생겼다.
  4. JDBC/JPA는 스펙상 스레드를 멈추는 블로킹이라 이 조건을 깨므로 못 쓰고, R2DBC/WebClient 같은 논블로킹 짝으로 대체한다. 불가피하면 boundedElastic으로 격리한다.
  5. 그 결과물인 Mono/Flux는 구독돼야 실행되는 실행 계획서인데, block은 스레드를 멈춰서 안 되고 subscribe는 즉시 리턴돼서 결과를 돌려받을 수 없으니, Mono/Flux를 그대로 반환해 구독을 프레임워크에 맡기고 가공은 map/flatMap으로 체인 안에서 한다.

 

참고

728x90