본문 바로가기
  • 냥냥냥
Spring

WebClient가 빠른 진짜 이유: 논블로킹 I/O vs 블로킹 스레드

by 프로그래밍데 2025. 8. 3.

최근 저희 회사 시스템의 동기식 API 요청 병목 현상을 해결하기 위해
기존 Apache HttpClient 4.5 기반의 클라이언트를 Spring WebClient로 전환했습니다.

흥미로운 사실 :
WebClient는 비동기 라이브러리이지만, 저희는 처음에
block()을 사용한 동기식 방식으로 처리하고 있습니다. 
왜냐면 webflux를 도입하지 않았었거든요

그럼에도 불구하고, 
기존 ApacheHttpClient 사용 시 
최대 병목 시간이 약 18초였던 반면, 

WebClient 도입 후에는 11초 수준으로 줄어드는 개선이 있었습니다.

같은 동기식 요청 구조임에도 이런 차이가 발생한 게 신기해서 
WebClient가 빠른 진짜 이유
내부 구조, Netty, 이벤트 루프, 커넥션 풀에 대해 뜯어 보고 정리해 보았습니당.





 

우선, WebClient가 빠른 이유는 Netty 기반의 이벤트 루프 구조 + 리액티브 스트림 구독 모델때문입니다. 

원래는 단순 비동기 방식이기 때문이라고 공부했는데 생각해보니까 

비동기 방식은 응답을 기다리지 않고 다음 코드 실행이라서,

이건 사실 스레드를 하나 더 두면 RestTemplate도 비동기처럼 쓸 수 있더라구요  (비동기 블로킹 방식으로)

Webclient는 비동기 논블로킹 I/O 기반의 이벤트루프 방식이니까 그에 대해서 공부를 해보겠습니답. 

 

 

1. 전통적인 HTTP 클라이언트 구조: Apache HttpClient의 동작 방식

기존 Apache HTTPClient는 기본적으로 Blocking I/O 기반입니다. 

한 마디로, 요청 하나 마다 스레드를 하나씩 점유하는 구조입니다. 

 

 

1. Task 한 건이 오면, Thread 001이 Rest API를 호출하고 응답이 올 때 까지 block처리를 합니다. (기다린다는 뜻. )

2. 그리고, 응답을 받는 Task2 작업을 시행하고, 응답을 전달하는 Task3작업까지 끝내고 그 때 까지 다음 요청을 시행하지 않습니당.

3. 다 시행하면 현재 이 Thread 001은 반납하고, 다시 새로운 스레드를 발행하여 다음 작업을 실행하져

 

스레드는요 메모리 + 컨텍스트 스위칭 비용이 들어서 굉장히비쌉니다. 

왜냐면, 스레드는 생성할 때마다 독립된 스택 메모리 공간을 가지기 때문입니다.
자바에서는 기본적으로 스레드 1개당 약 1MB의 스택 메모리를 가짐 (-Xss 옵션으로 조절 가능).
1000개 스레드면 1GB가 넘는 메모리가 단지 대기 중인 스레드에만 사용될 수 있습니다.
=> 이건 힙 메모리와 별도로 운영체제가 관리하는 커널 리소스이기 때문에 더 무거워요.

🔸 컨텍스트 스위칭 : OS가 CPU를 여러 스레드에 나눠줄 때 발생하는 비용
스레드는 동시에 실행되지 않고, CPU 하나당 한 시점에 하나의 스레드만 실행됩니다.
실행 중인 스레드를 다른 스레드로 교체하려면: 레지스터, 프로그램 카운터, 스택 포인터 등
현재 상태를 저장 => 다음 스레드의 상태로 복원
이걸 컨텍스트 스위칭이라고 하는데, 이게 많아질수록 CPU는 일 자체보다 스레드 전환에 더 많은 자원을 소비합니다.
=> 수천 개의 스레드가 생기면 스케줄링 자체가 병목이 됩니다.

 

게다가, 응답 기다리는 동안 아무 일도 안 해요 !!
요청이 많아질 수록, 스레드가 늘고 그러면 OS 스케줄링에 병목이 생기게 돼요
그러다 보면 GC도 자주 발생합니다. 

왜 ?
=> 스레드 수 증가 → 객체 증가 → GC Pressure 증가
요청마다 스레드가 생기면, 그 스레드 내부에서 생성된 임시 객체들도 같이 늘어남.
예: HttpRequest, ResponseWrapper, Exception, StackFrame, Buffer, StringBuilder, 등등
이 객체들은 대부분 Young Generation에 할당되는데, 요청이 많으면 짧은 시간 안에 엄청나게 많이 쌓입니다.

결국. . Young GC가 자주 돌고, Full GC 확률도 올라갑니다.
많은 수의 임시 객체 → 자주 GC 수행 GC가 자주 일어나면 GC Pause 시간이 늘고,
전체 처리량에 악영향 특히, 병목 상태에서 많은 스레드가 동시에 살아 있으면,
그들의 레퍼런스를 가진 객체도 계속 살아있어 Old 영역으로 승격, Full GC까지 유발할 수 있는 것이죠 !! 

 

 

2. WebClient의 내부 구조는 뭐가 다를까요 ??

WebClient는 기본적으로 Reactor Netty를 기반으로 동작합니다. 핵심은 두 가지입니다.

1.  이벤트루프 기반의 Netty 아키텍처
2. 리액티브 스트림 (Mono/Flux) 모델

이 두 가지가 결합되면 무엇이 가능하냐면요:

 

=> 스레드를 고정된 수만 유지한 채로 다수의 요청을 처리

=> 스레드가 응답을 기다리지 않고, 콜백 등록만 해두고 다른 작업 수행

 
 

 

3. Netty의 이벤트루프가 뭐길래 그런 게 가능하다는 건지 ??

Netty의 스레드는 EventLoop라는 큐를 돌면서 I/O 이벤트들을 순회하면서 처리하죠.

EventLoopGroup group = new NioEventLoopGroup();

즉, 요청이 800개 들어와도 EventLoopGroup에 의해 생성된 스레드 수만큼만 동시에 처리됩니다.

EventLoop 스레드 수가 제한적이므로, 동시 I/O 처리 시 컨텍스트 스위칭 비용이 줄어드는 장점이 있습니다.

그치만, WebClient도 동기식으로 사용할 경우에는 사실 컨텍스트스위칭 비용은 똑같이 비쌉니다.

 

 

4. WebClient의 실제 동작 순서 

private final WebClient webClient = WebClient.create();

    public void sendRequest() {
        System.out.println("[Main Thread] Start: " + Thread.currentThread().getName());

        webClient.get()
                .uri("https://httpbin.org/get")
                .retrieve()
                .bodyToMono(String.class)
                .doOnSubscribe(sub -> System.out.println("[Main Thread] Subscribed: " + Thread.currentThread().getName()))
                .doOnNext(body -> System.out.println("[Callback] Received: " + Thread.currentThread().getName()))
                .subscribe(body -> {
                    System.out.println("[Callback] Response body length: " + body.length());
                });

        System.out.println("[Main Thread] End: " + Thread.currentThread().getName());
    }
  1. WebClient.create()로 요청 준비
  2. .get() 등으로 Mono 요청 객체 생성 (아직 실행 아님)
  3. .subscribe() 또는 .block()이 호출되어야 실행됨
  4. Netty의 EventLoop에 등록됨
  5. ConnectionProvider가 TCP 커넥션을 가져옴
  6. 요청 전송
  7. 응답 도착 시 콜백 실행
  8. .block()이면 이 타이밍에 메인 스레드가 결과 기다림

 

중요한 건
만약에 Subscribe 대신에
block()
을 쓰더라도 앞 단계는 비동기로 처리 되고
전체가 동기로 처리되는 것이 아니라, 마지막 단계에서만 잠깐 동기 처리된다
는 점.

 

 

5. 커넥션 풀과 TCP 소켓 재사용에 대해 간단히 짚고 넘어가기

사실 이 내용은 여기서는 간단히 필요한 개념만 넘어가고,
다음 글에서 커넥션 풀과 Active, idle Thread에 대해서 자세히 다뤄보려고 합니당. 

 

HTTP는 기본적으로 TCP 위에서 동작합니다.

매번 요청할 때마다 TCP 3-way-handshake를 다시 한다면, 성능에 큰 손실이 생기겠죠.

그래서 등장한 개념이:

  • HTTP Keep-Alive
  • 커넥션 풀 (Connection Pool)

 

5_1. WebClient의 커넥션 풀

WebClient는 내부적으로 Netty의 ConnectionProvider를 사용합니다.

기본적으로 글로벌 커넥션 풀을 공유하며, TCP 커넥션을 재사용합니다.

ConnectionProvider provider = ConnectionProvider.builder("custom")
    .maxConnections(100)
    .pendingAcquireTimeout(Duration.ofSeconds(10))
    .build();

이는 곧 다음 요청이 올 때, 새 TCP 연결을 만들지 않고 기존 연결을 재사용한다는 뜻입니다.

 

 

 

 

6. 실제로 그런지 테스트

테스트 목적 : webclient는 thread 를 block 하는가 + WebClient의 Block이 ApacheHttp의 Block보다 빠른가

그리고 WebClient Block도 혹시 스레드를 재사용하는지 !

package com.pec.mall.biz.search.service;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.StopWatch;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
public class SearchServiceTest {
   
    private static final Logger log = LoggerFactory.getLogger(SearchServiceTest.class);
    private static final int READ_TIMEOUT_SECONDS = 10;
    private static final int CONNECT_TIMEOUT_SECONDS = 10;
    private static final int TCP_SOCKET_TIMEOUT_SECONDS = 45;
    private HttpClient httpClient;
    private WebClient webClient;
    
    
    @Before
    public void setup() {
        httpClient = HttpClient.create()
                .responseTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS))
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS).toMillis())
                .doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT_SECONDS))
                        .addHandlerLast(new WriteTimeoutHandler(TCP_SOCKET_TIMEOUT_SECONDS)));
        webClient = WebClient.builder()
                .baseUrl("https://google.com")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .filter(logRequest())
                .filter(logResponse())
                .build();
    }
    
    
    @Test
    public void webclientThreadNameTest() {
        String request = webClient.get()
                .uri("/")
                .retrieve()
                .bodyToMono(String.class)
                .block();
    }
                
    private ExchangeFilterFunction logRequest() {
        return (clientRequest, next) -> {
    //        log.info("THREAD :::::::::::: name req {}", Thread.currentThread().getName());//            log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
            System.out.println(clientRequest.method().toString() + clientRequest.url());
            return next.exchange(clientRequest);
        };
    }
    
    private ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
    //        log.info("THREAD :::::::::::: name res{}", Thread.currentThread().getName());//            log.info("Response Status: {}", clientResponse.statusCode());
            return Mono.just(clientResponse);
        });
    }
    
    @Test
    public void restTemplateThreadNameTest(){
        RestTemplate restTemplate = new RestTemplate();
        log.info("resttemplate start THREAD name :::::: {}", Thread.currentThread().getName());
        ResponseEntity<String> responseEntity = restTemplate.getForEntity("https://google.com", String.class);
        log.info("resttemplate end THREAD name :::::: {}", Thread.currentThread().getName());
        log.info("Response status code: {}", responseEntity.getStatusCode());
        log.info("Response body: {}", responseEntity.getBody());
    }
    
    @Test
    public void webclient_nonblocking() throws InterruptedException {
        CountDownLatch cdl = new CountDownLatch(10);
        final StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        final StopWatch stopWatch2 = new StopWatch();
        stopWatch2.start();
        for (int i = 0; i < 10; i++) {
            webClient.get()
                    .retrieve()
                    .bodyToMono(String.class)
                    .subscribe(res -> {
                        cdl.countDown();
                        if (cdl.getCount() == 0) {
                            stopWatch2.stop();
                            log.info("total subscribe call time: {}", stopWatch2.getTotalTimeSeconds());
                        }
                    });
        }
        stopWatch.stop();
        log.info("total webclient call time : {}" ,stopWatch.getTotalTimeSeconds());
        cdl.await();
        
    //3:48:22.348 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - total webclient call time : 1.4437016
    }
    
    
    @Test
    public void webclient_blocking() throws InterruptedException {
        final StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        final StopWatch stopWatch2 = new StopWatch();
        stopWatch2.start();
        for (int i = 0; i < 10; i++) {
            webClient.get()
                    .retrieve()
                    .bodyToMono(String.class)
                    .block();
        }
        stopWatch.stop();
        log.info("total webclient call time : {}" ,stopWatch.getTotalTimeSeconds());
    //3:48:22.348 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - total webclient call time : 1.4437016
    }
    
    
    @Test
    public void resttemplate_blocking(){
        final StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        RestTemplate restTemplate = new RestTemplate();
        for(int i=0;i<10;i++){
            ResponseEntity<String> responseEntity = restTemplate.getForEntity("https://google.com", String.class);
        }
        stopWatch.stop();
        log.info("total resttemplate call time : {}" ,stopWatch.getTotalTimeSeconds());
    //13:48:59.571 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - total resttemplate call time : 4.8869339
    }
    
}

 

  • 스레드 확인 ( webclient )
    • http 호출시 스레드명은 : [main]
    • http 응답받는 스레드명 : [reactor-http-nio-2]
  • 스레드 확인 ( resttemplate ) 
    • http 호출 & 응답 스레드명 : [main]

쓰레드 로그

## webclient 방식
12:58:36.863 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - THREAD :::::::::::: name main
12:59:09.405 [reactor-http-nio-2] INFO com.pec.mall.biz.search.service.SearchServiceTest - THREAD :::::::::::: name resreactor-http-nio-2


## resttemplate 방식
13:02:42.379 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - resttemplate start THREAD name :::::: main
13:02:43.417 [main] INFO com.pec.mall.biz.search.service.SearchServiceTest - resttemplate end THREAD name :::::: main

 

  • 응답시간 비교 (테스트 환경)
    • 테스트 thread 1개로 google.com 100회 http 호출

 

[요약]

  • resttemplate은 http 호출시 thread 자원이 반납되지 않는다.
  • webclient는 http 호출시, thrad 자원 반납 & 비동기 처리를 하기(reactor threadpool 사용) 때문에 응답속도가 빠르다.
  • 이벤트기반
  • 응답시간 비교
    • resttemplate → 39초
    • webclient(block) → 7초
    • webclient(non block) → 1초

 

구분 WebClient(block) WebClient (non-block)  RestTemplate
호출 스레드 main 스레드에서 호출하지만,
block() 시 해당 스레드가 응답
대기(블록됨)
호출 즉시 반환, 응답 처리 reactor-netty
이벤트 루프 스레드
에서 비동기로 처리
main 스레드가 요청-응답 전 과정 블록(대기)
네트워크 IO 처리 Netty의 이벤트 루프 (비동기)에서 수행 Netty 이벤트 루프 동기식, IO 스레드 점유
스레드 자원 반납 여부 block() 시 해당 호출 스레드는
응답 받을 때까지 점유(반납 안 됨)
호출 스레드 곧바로 반환,
이벤트 루프가 비동기 응답 처리
→ 자원 효율적
호출 스레드가 응답 끝날 때까지 점유
응답 처리 스레드 응답은 Netty 이벤트 루프에서
처리, 하지만 block()시
호출 스레드가 대기 상태
응답과 다음 처리는 Netty 이벤트 루프 스레드 모두 호출 스레드에서 처리

 

 

 

정리해보면

WebClient block()과 Non-block 차이

  • block()은 Mono/Flux의 결과를 동기적으로 기다리는 호출이라서, 호출한 스레드가 결과가 올 때까지 대기(블록)합니다.
    → 즉, 호출 스레드 자원을 반납하지 않고 점유
  • 하지만 WebClient의 네트워크 I/O는 내부적으로 Netty의 이벤트루프(reactor-http-nio-*)가 비동기로 처리합니다.
    I/O 처리는 논블로킹, 고성능 이벤트 기반이지만, block() 호출하는 쪽 스레드는 대기 상태라는 점이 중요!
  • 따라서 block()은 "네트워크 I/O는 비동기/논블록" + "결과를 기다리기 위해 호출 스레드는 블록"인 혼합형입니다.
  • 반면 non-blocking 방식은 subscribe() 등으로 결과를 콜백에서 받기 때문에 호출 스레드가 즉시 반환되고, 전체적으로 완전 논블로킹입니다.

 

 

결론

WebClient block()도 스레드 자원 반납 + 이벤트 방식인가? 네트워크 I/O는 이벤트 기반, 논블로킹으로 처리하지만,
block() 호출 스레드는 응답 받을 때까지 블록되어 자원을 점유.
즉, 스레드 자원은 반납하지 않음
block()은 논블로킹 방식인가? 놉. block() 호출한 스레드는 응답까지 대기하는 동기 블록 방식
진정한 논블로킹은? WebClient의 subscribe(), flatMap(), Flux/Mono 조합 등
비동기 체인을 사용할 때.호
출 스레드는 바로 반환되고, 이벤트 루프가 I/O와 후속 처리 담당

 

 

 

 

 


참고 

'Spring' 카테고리의 다른 글

Bean vs Component 차이점이 뭘까  (0) 2025.03.31

최근댓글

최근글

skin by © 2024 ttuttak