최근 저희 회사 시스템의 동기식 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());
}
- WebClient.create()로 요청 준비
- .get() 등으로 Mono 요청 객체 생성 (아직 실행 아님)
- .subscribe() 또는 .block()이 호출되어야 실행됨
- Netty의 EventLoop에 등록됨
- ConnectionProvider가 TCP 커넥션을 가져옴
- 요청 전송
- 응답 도착 시 콜백 실행
- .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 |
|---|