안녕하세요. J4J입니다.
이번 포스팅은 web client 도입을 해보면서 경험했던 공통 설정 및 현실적인 타협에 대해 적어보는 시간을 가져보려고 합니다.
관련 글
[SpringBoot] WebClient를 이용하여 외부 API 호출하기
[SpringBoot] WebClient를 이용하여 외부 API 호출하기
안녕하세요. J4J입니다. 이번 포스팅은 webClient를 이용하여 외부 api 호출하는 방법에 대해 적어보는 시간을 가져보려고 합니다. WebClient란? webClient는 spring 5에서 부터 등장한 HTTP 클라이언트 라이
jforj.tistory.com
RestTemplate 말고 이거 사용하세요: RestClient 입문 가이드
RestTemplate 말고 이거 사용하세요: RestClient 입문 가이드
안녕하세요. J4J입니다. 이번 포스팅은 rest client가 무엇인지, 어떤 식으로 사용할 수 있는지에 대해 적어보는 시간을 가져보려고 합니다. RestClient rest client는 spring boot 3.2 이후부터 등장한 http clien
jforj.tistory.com
공통 설정
이전 글 중 하나인 rest client에서도 언급되었던 것처럼 web client도 사용하다 보면 다음과 같은 고민이 들게 됩니다.
- web client를 이용하여 호출하는 모든 api 들은 항상 공통 설정이 담겨 있어야 해
- 그리고 호출되는 api 도메인 별 서로 다른 설정도 할 수 있어야 해
그리고 공통 설정 들에는 다음과 같은 것들을 보통 필요로 합니다.
- 모든 요청/응답 정보에 대한 logging이 기록되어야 해
- 모든 요청에는 기본 header/cookie 값이 설정되어야 해
- 모든 요청에 header 값이 전파되어야 해
- 모든 요청에 동일한 timeout 설정이 되어야 해
- 등등...
이런 상황들을 해결하기 위해 적용해 볼 수 있는 가장 이상적인 방법은 customizer를 이용하는 것이라고 생각합니다.
물론, 설정들이 모두 담긴 web client를 bean으로 등록하는 방법도 또 다른 대안이 될 수 있습니다.
하지만 customizer를 이용하면 공통 설정도 항상 포함시킬 수 있을 뿐 아니라, 서로 다른 api 도메인들에도 유연하게 서로 다른 설정을 넣기 편리해집니다.
그러므로 가장 바람직한 방법은 각 프로젝트의 상황에 맞는 방법을 선택하는 것이 제일 좋으며, 호출되는 api 서비스가 다양하다면 customizer 설정을 권장합니다.
설정은 다음과 같이 해볼 수 있습니다.
[ 1. dependency 추가 ]
// build.gradle
dependencies {
implementation 'org.apache.httpcomponents.client5:httpclient5:5.5.1'
}
[ 2. config 작성 ]
package com.jforj.webclientasync.config;
import io.netty.channel.ChannelOption;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
import java.util.List;
import java.util.function.Consumer;
@Configuration
@Slf4j
public class WebClientConfig {
@Bean
public HttpClient httpClient() {
// connection pool 설정
ConnectionProvider connectionProvider =
ConnectionProvider
.builder("webclient-pool")
.maxConnections(200) // pool 전체 최대 커넥션 수
.pendingAcquireTimeout(Duration.ofSeconds(3)) // pool에서 connection을 가져오는데 걸리는 시간
.build();
// http client 생성
return HttpClient.create(connectionProvider)
// 서버에 TCP 연결을 맺는데 걸리는 시간 (3초)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
// 요청을 보내고 응답이 돌아올 때까지의 전체 시간
// 장기간 요청 처리가 필요한 것은 목적만을 위한 web client 생성하여 사용
.responseTimeout(Duration.ofSeconds(5));
}
@Bean
public ClientHttpConnector clientHttpConnector(HttpClient httpClient) {
// client http connector 생성
return new ReactorClientHttpConnector(httpClient);
}
@Bean
public ExchangeFilterFunction loggingFilterFunction() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
// 로그 설정
log.info(
"""
[method]: {}
[uri]: {}
[attributes]: {}
""",
request.method(),
request.url(),
request.attributes()
);
return Mono.just(request);
})
.andThen(
ExchangeFilterFunction.ofResponseProcessor(
response ->
response.bodyToMono(String.class)
.defaultIfEmpty("")
.flatMap(
body -> {
log.info(
"""
[statusCode]: {}
[body]: {}
""",
response.statusCode(),
body
);
return Mono.just(
ClientResponse
.create(response.statusCode())
.headers(headers -> headers.addAll(response.headers().asHttpHeaders()))
.cookies(cookies -> cookies.addAll(response.cookies()))
.body(body)
.build()
);
}
)
)
);
}
@Bean
public ExchangeFilterFunction headerPropagationFilterFunction() {
// header 전파 설정
List<String> headers = List.of(
"Authorization",
"X-Request-Id",
"X-Custom-Header"
);
return (request, next) -> {
ClientRequest.Builder builder = ClientRequest.from(request);
// (주의) web mvc 기반의 application 에서만 설정 가능한 방법
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) {
HttpServletRequest currentRequest = servletRequestAttributes.getRequest();
for (String header : headers) {
String value = currentRequest.getHeader(header);
if (StringUtils.hasText(value)) {
builder.header(header, value);
}
}
}
return next.exchange(builder.build());
};
}
@Bean
public Consumer<HttpHeaders> defaultHeaders() {
// default header 설정
return httpHeaders -> {
httpHeaders.add("X-Default-Header", "default-value");
};
}
// web client customizer를 이용하여 어디서든 WebClient.Builder를 사용하는 경우 공통 설정이 담기게하기
@Bean
public WebClientCustomizer webClientCustomizer(
ClientHttpConnector clientHttpConnector,
ExchangeFilterFunction loggingFilterFunction,
ExchangeFilterFunction headerPropagationFilterFunction,
Consumer<HttpHeaders> defaultHeaders
) {
// 전역 공통 설정이 이루어진 web client builder 반환
return builder ->
builder
.clientConnector(clientHttpConnector)
.filters(
filters -> {
filters.add(loggingFilterFunction);
filters.add(headerPropagationFilterFunction);
}
)
.defaultHeaders(defaultHeaders);
}
}
config 작성이 완료되었다면 web client를 사용해야 하는 class 파일 내부에 builder를 주입받아 사용해 주시면 됩니다.
builder를 주입받은 상태로 사용해야 공통 설정이 모두 들어가 있고, builder 주입 방식을 따르지 않고 web client를 임의로 생성하는 방법을 선택한다면 설정이 적용되지 않게 됩니다.
간단한 예시로 다음과 같이 활용될 수 있습니다.
public class Clazz {
private final WebClient webClient;
public Clazz(WebClient.Builder builder) {
// builder에 공통 설정이 모두 포함되어 있음
this.webClient = builder
.baseUrl("https://...")
.defaultHeader("key", "value")
.defaultCookie("key", "value")
...
.build();
}
}
WebClient에 대한 오해
web client를 지금까지 비동기 기반의 요청 처리를 해주는 것이라고 오해하고 있었습니다.
정확히 표현하자면 web client는 비동기로 동작하는 것이 아니고, non-blcoking 방식을 사용하여 비동기적인 통신을 가능하도록 도와주는 것입니다.
이 부분은 사실 아직도 굉장히 헷갈려하며 어려워하는 개념이고, 잘못된 생각을 가지기 쉬운 곳이라고 생각합니다.
이를 완벽하게 이해하기 위해서는 동기/비동기의 차이가 무엇이며 blocking/non-blocking의 차이를 확실히 이해해야 합니다.
[ 동기 방식 ]
동기 방식은 요청을 보낸 곳에서 응답을 기다리는 방식입니다.
우리가 가장 일반적으로 많이 사용하는 방식이며, 다음과 같은 흐름을 생각할 수 있습니다.
callApi(); -- 1
nextCall(); -- 2
이런 경우에 1번에 대한 요청을 보내고 나서 바로 2번에 대한 요청을 보내지 않고 1번의 응답이 올 때까지 기다립니다.
그리고 1번의 응답이 오면 그 뒤에 2번을 요청하는 구조가 됩니다.
[ 비동기 방식 ]
비동기 방식은 요청을 보낸 곳에서 응답을 기다리지 않습니다.
요청을 보낸 뒤 응답과 상관없이 그 뒤에 존재하는 로직을 처리하고, 비동기 요청 처리는 나중에 응답이 오게 되면 처리하게 됩니다.
kafka를 사용하면 한 번씩 볼 수 있는 completableFuture 등이 비동기 처리의 대표적인 예시가 될 수 있습니다.
CompletableFuture.supplyAsync(...).thenApply(...) -- 1
callApi() -- 2
이런 경우에 1번에 대한 요청을 보낸 뒤 응답을 기다리지 않고 바로 2번에 대한 요청을 보냅니다.
그리고 1번에 대한 응답이 오게 되면 그때 비즈니스 처리가 수행됩니다.
[ blocking 방식 ]
blocking 방식은 요청 thread가 응답이 올 때까지 멈춰 있는 것을 말합니다.
사실 blocking 방식은 동기 처리와 굉장히 유사하게 생각이 됩니다.
개인적으로 가장 크게 구분하는 방법은 blocking은 thread가 대기를 하는 것을 의미하고, 동기 처리는 순서를 보장하며 처리하는 것이라고 생각합니다.
그래서 위에서 얘기했던 이 소스 코드는 순서를 보장하며 처리하고, 응답이 올 때까지 thread가 대기하기 때문에 "동기 + blocking" 처리라고도 볼 수 있습니다.
callApi(); -- 1
nextCall(); -- 2
그러나 다음과 같은 소스 코드는 "비동기 + blocking" 처리라고 볼 수 있습니다.
왜냐하면 1번에서 비동기 요청을 보낸 뒤 응답을 기다리지 않고 2번이 처리가 되었지만, 3번에서 비동기 요청이 돌아올 때까지 thread가 대기하고 있기 때문입니다.
future = CompletableFuture.supplyAsync(...).thenApply(...) -- 1
callApi() -- 2
future.join() -- 3
[ non-blocking 방식 ]
non-blocking 방식은 요청 thread가 응답이 올 때까지 대기하고 있지 않는 것을 의미합니다.
이게 무슨 말이냐면, web client 기준으로 subscribe 동작이 이루어지기 전 까지는 처리해야 되는 로직들을 조립만 하지 실제로 요청을 보내지 않는 것을 의미합니다.
예를 들어, 다음과 같은 소스가 존재한다고 가정하겠습니다.
webClient.get()...bodyToMono() -- 1
webClient.get()...bodyToMono() -- 2
webClient.get()...bodyToMono() -- 3
subscribe 발생 -- 4
1번, 2번, 3번들을 각각 거치게 될 텐데 non-blocking인 경우에는 요청 정보를 확인해도 바로 요청을 보내지 않습니다.
그리고 subscribe 처리가 이루어지는 4번과 같은 상황이 발생되면 그때 1번, 2번, 3번들의 요청들을 보내게 됩니다.
그래서 요청을 보내야 하는 상황에 마주하더라도 thread는 응답이 올 때까지 대기를 하지 않게 되는 것입니다.
또한 1번, 2번, 3번의 응답 정보들은 요청 thread가 아닌 netty 이벤트 루프 thread에 의해 처리가 되는데, 이 상황에서 이벤트 루프 thread는 무한정 대기를 하지 않습니다.
요청에 대한 응답이 올 때 사용되고 그렇지 않은 경우에는 pool에 반환되어 다른 요청 처리에 대한 응답을 처리할 수도 있습니다.
동기/비동기와 blocking/non-blocking에 대해 어느 정도 이해가 되었다면 web client를 사용할 때 block 처리를 하는 경우에 대해서 얘기를 안 할 수 없습니다.
web client에 대한 깊은 이해를 하기 전에 제가 가장 많이 실수한 것은 다음과 같이 web client를 사용했던 것입니다.
webClient.get()...bodyToMono().block() -- 1
webClient.get()...bodyToMono().block() -- 2
webClient.get()...bodyToMono().block() -- 3
해당 방식의 문제점은 non-blocking을 이용할 수 있는 상황에서 모든 요청들에 대해 응답이 올 때까지 thread가 멈춰 있는 것이라고 생각합니다.
1번이 3초, 2번이 2초, 4번이 4초가 걸렸다면 해당 요청 처리를 위해 thread는 9초간 대기해야 합니다.
그러면 해당 thread는 반환이 되지 않은 상태라 응답이 올 때까지 무한정 대기를 하게 되고, 또 다른 요청 처리가 온 것에 대해 관여를 할 수 없게 됩니다.
하지만 non-blocking을 사용하면 얘기가 달라집니다.
우선 1번, 2번, 3번이 바로 요청이 가지 않을뿐더러 subscribe 발생이 되면 3개의 요청이 동시에 전달됩니다.
그렇기 때문에 모든 요청 중 가장 오래 걸리는 1개의 요청 처리 시간만 소요되기 때문에 약 3초 정도의 시간 안에 요청 처리가 완료될 수 있습니다.
api가 겨우 3개에 해당하는 상황에 대해 가정했는데도 소요되는 시간이 약 1/3 만큼 줄어든 것을 볼 수 있습니다.
그렇다면 더 많은 api를 호출하는 경우에는 이보다 더 많은 시간이 절약되는 것을 체감할 수 있게 됩니다.
또한 애플리케이션에는 thread의 총 갯수가 정해져 있는데, thread가 대기하는 시간이 줄어들기 때문에 요청 병목이 발생하는 효과도 감소되는 결과를 볼 수 있습니다.
Web MVC 서비스의 현실적인 타협
non-blocking의 효과를 가장 높인 상태로 web client를 사용하기 위해서는 web mvc 서비스가 아닌 webflux 서비스여야 합니다.
물론 web mvc 서비스에서도 non-blocking의 효과를 볼 수 있습니다.
다만, 이 부분은 정확히는 모르지만 대규모의 요청 처리와 servlet 기반의 스레드가 사용되는 web mvc 보다는 non-blocking을 제대로 사용하고 싶다면 webflux 서비스로 구성되어 있어야 한다고 합니다.
나의 서비스가 web mvc인지 webflux인지 구분하는 방법은 간단합니다.
build.gradle과 같은 의존성 관리 파일에 들어가서 starter-web에 대한 dependency가 존재한다면 web mvc 서비스입니다.
그리고 starter-web이 없는 상태에서 starter-webflux만 있다면 webflux 서비스입니다.
(1) web mvc
implementation 'org.springframework.boot:spring-boot-starter-web' 만 존재
(2) web mvc
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux' 모두 존재
(3) webflux
implementation 'org.springframework.boot:spring-boot-starter-webflux' 만 존재
이 구조만 본다면 webflux 서비스로 가야만 한다고 생각할 수 있습니다.
하지만 이 생각은 굉장히 위험한 판단이 됩니다.
먼저, web mvc 서비스인 경우 기본 thread의 개수가 보통 200개 이지만 webflux 서비스인 경우 기본 thread의 개수가 cpu core * 2 만큼 존재합니다.
web mvc는 요청이 올 때마다 thread를 1개씩 차지하지만, webflux 기반의 non-blocking 구조가 완벽하게 되어 있는 곳에서는 thread 1개가 다수의 요청을 처리한다고 볼 수 있습니다.
왜냐하면 대기 상태에서 thraed가 pool에 반납되어 다른 요청에 사용될 수 있기 때문입니다.
그러나 webflux를 사용할 때 가장 치명적인 문제는 thread가 대기하는 상황을 만드는 것입니다.
대표적으로 Thread.sleep() 같은 기능을 사용하면 thread가 pool에 반납되지 않고 대기 상태가 됩니다.
그러면 web mvc보다 thread의 개수가 적은 webflux 서비스는 금방 thread가 고갈되어 요청 처리가 오히려 더 늦어지는 결과를 만듭니다.
또한, 이런 식으로 thread를 붙잡아 두는 다른 상황들도 존재합니다.
일반적으로 애플리케이션 기능 개발을 하며 많이 사용되는 rdb, kafka, redis 등을 우리가 아는 방식으로 사용하면 모두 thread를 붙잡는 상황이 발생됩니다.
그래서 완벽한 non-blocking 구조를 만들고 싶다면 r2dbc, reactor kafka, reactor redis 등 reactor 기반의 처리가 이루어지는 것들로 모두 변경되어야 합니다.
물론, boundedElastic 등을 이용하여 blocking과 non-blocking의 혼합 방식을 차용하는 것도 존재하기는 합니다.
그래서 이미 web mvc 서비스의 구조에 맞게 비즈니스 개발이 이루어진 곳에서 webflux 기반으로 옮기는 것은 변경되어야 하는 곳이 굉장히 많아지기 때문에 힘든 작업이 될 수 있습니다.
처음부터 시작하는 프로젝트에서는 webflux를 고려하며 개발할 수 있지만, web mvc 기반의 서비스여도 크게 문제가 없는 곳들이 많습니다.
그리고 이미 대부분의 서비스들은 web mvc 구조가 유지되어 있기도 합니다.
그러므로 현실적인 타협을 다음과 같이 해보는 것을 권장합니다.
[ 1. 네트워크 I/O에서만 non-blocking 활용 ]
web mvc에서도 non-blocking을 활용할 수 있습니다.
그리고 사용 영역을 네트워크 I/O에 대해서만 제한해 보는 방법이 있습니다.
외부 요청을 보내는 것을 제외하고는 servlet thread 기반으로 동작이 이루어지게 한 뒤 Mono.zip, Flux.fromIterable 등을 활용하여 non-blocking 기반으로 네트워크 요청을 보내는 것입니다.
Mono.zip 같은 경우는 서로 다른 api 호출들을 동시에 보내기 위해 사용할 수 있습니다.
Flux.fromIterable은 이 자체로 non-blocking을 지원하지 않지만 flatMap과 함께 조합하여 동일 api에 대해 동시 요청을 보내는 구조를 만들 수 있습니다.
간단하게 코드를 작성해 보면 다음과 같습니다.
(1) Mono.zip
Mono.zip(Mono1, Mono2, ...)
.flatMap(tuple -> { return ... }) // non-blocking
.block() // blocking
(2) Flux.fromIterable
Flux.fromIterable(datas)
.flatMap(data -> Mono)
.collectList() // non-blocking
.block() // blocking
이러면 다음과 같은 결과를 얻을 수 있습니다.
- 네트워크 I/O 요청들을 동시에 요청
- 응답이 오는 순서대로 netty 이벤트 루프 thread에 의해 처리
- 모든 응답이 올 때까지 요청 thread는 block 되어서 대기
추가적으로 subscribeOn과 publishOn 그리고 boundedElastic scheduler에 대한 이해를 통해 더 이상적인 결과물들을 얻을 수도 있습니다.
이런 구조를 가지게 된다면 요청 thread가 대기하는 문제도 있고 client의 요청에 대해 모든 비즈니스 로직이 non-blocking의 형태로 구성되지는 않지만, 네트워크 I/O 영역에서 만큼은 non-blocking의 구조로 뛰어난 퍼포먼스를 체감할 수 있습니다.
[ 2. 단순 요청만 보낼 것이라면 rest client 활용 ]
네트워크 요청이 굉장히 적고, 동기 기반의 비즈니스를 구성하고 싶다면 web client 보다는 rest client를 사용하는 것이 좋은 선택지가 될 수 있습니다.
물론, web client를 이용하여 동기 기반으로 사용하는 것도 방법이 될 수 있습니다.
하지만 reactor, netty 이벤트 루프, webflux 등 rest client를 사용하면 몰라도 되는 개념들이 web client를 사용하기 위해서는 숙지하고 있어야 합니다.
또한 어떤 상황에 어떤 thread가 사용되는지를 이해하지 못하면 요청 퍼포먼스에 문제를 발생시킬 수 있습니다.
그래서 non-blocking을 활용할 것이 아니라면 마음 편히 rest client를 이용하는 것이 올바른 선택지가 될 수 있습니다.
이상으로 web client 도입을 해보면서 경험했던 공통 설정 및 현실적인 타협에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'Spring > SpringBoot' 카테고리의 다른 글
| JPA 대용량 데이터 페이징, Offset보다 Keyset을 선택해야 하는 이유 (1) | 2025.12.30 |
|---|---|
| JPA로 대용량 데이터 처리하기: 벌크 업데이트를 포함한 다양한 방식 (0) | 2025.12.24 |
| RestTemplate 말고 이거 사용하세요: RestClient 입문 가이드 (0) | 2025.12.12 |
| Micrometer Tracing으로 Spring 애플리케이션 분산 트레이싱하기 (3) | 2025.09.29 |
| Kubernetes에서 Spring 애플리케이션 Metric 수집하여 모니터링하기: Prometheus & Grafana 연동 (0) | 2025.09.15 |
댓글