RestTemplate 대신 WebClient
기존에 springboot에서 http 통신을 해야할 때 RestTemplate을 곧잘 쓰곤 했다.
하지만 spring 5.0 버전부터는 RestTemplate은 더 이상 새로운 버전을 내놓지 않을 것이며 WebClient를 쓰라고 권장하고 있다.
As of 5.0, the non-blocking, reactive org.springframework.web.reactive.client.WebClient offers a modern alternative to the RestTemplate with efficient support for both sync and async, as well as streaming scenarios. The RestTemplate will be deprecated in a future version and will not have major new features added going forward. See the WebClient section of the Spring Framework reference documentation for more details and example code.
https://docs.spring.io/spring-framework/docs/5.2.2.RELEASE/javadoc-api/index.html?org/springframework/web/client/RestTemplate.html
WebClient란?
그렇다면 WebClient란 무엇일까.
Spring WebFlux에서 사용하는 Http Client로 비동기로 동작한다.
하지만 스프링에서 설명도 그렇고 비동기 뿐만 아니라 동기로 동작할 수도 있어 기존 RestTemplate을 대체할 수 있는 Http Client이다.
WebClient 기본 설정
WebClient 기본 설정하는 방법은 매우 간단하다.
우선 webFlux에서 제공하는 http client인 만큼 webFlux dependency 설정을 해야한다.
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux/3.2.3
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux
implementation("org.springframework.boot:spring-boot-starter-webflux:3.2.3")
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>3.2.3</version>
</dependency>
그리고 WebClient를 생성해서 사용하면 된다.
WebClient는 reactor.netty.http.client.HttpClient를 통해 connection timeout, readTimeout 등을 설정할 수 있다.
이렇게 만든 WebClient는 Bean으로 등록해서 공통으로 사용할 수 있다.
val httpClient: HttpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofMillis(5000))
.doOnConnected { conn ->
conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
.addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
}
val client = WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
생성한 webClient는 아래와 같이 method별로 함수가 있어 원하는 method를 형식에 맞춰 설정해 사용하면 된다.
기본 webFlux 기반의 비동기 동작을 하고 아래와 같이 awaitBody()을 이용해서 동기로 동작하게 할 수 있다.
// get reqeust
val result = runBlocking {
client.get()
.uri(buildUrl(url, parameterMap))
.headers {
headerMap.forEach { header ->
it[header.key] = header.value
}
}
.retrieve()
.awaitBody()
}
// get request에 필요한 buildUrl (baseUrl 설정을 했다면 필요 없음)
fun buildUrl(url: String, queryParams: Map<String, String>): String {
val queryString = queryParams.map { (key, value) -> "$key=$value" }.joinToString("&")
return if (queryString.isNotEmpty()) {
"$url?$queryString"
} else {
url
}
}
// post request
val result = runBlocking {
client.post()
.uri(url)
.headers {
headerMap.forEach { header ->
it[header.key] = header.value
}
}
.bodyValue(body)
.retrieve()
.awaitBody<Test>()
}
retrieve(), exchangeToMono()/exchangeToFlux()
WebClient에서 응답 값을 핸들링 하기 위한 함수로는 retrieve(), exchangeToMono()/exchangeToFlux()가 있다.
retrieve()는 response body를 받아서 핸들링 하고 exchangeToXXX()의 경우는 ClientResponse를 받아서 핸들링 한다.
(기존에 있던 exchange()는 메모리 누수 이슈 때문에 spring 5.3부터는 deprecated되고 exchangeToMono(), exchangeToFlux()를 사용하도록 하고 있다.)
mutate()
WebClient에서는 mutate()라는 함수가 존재해 베이스가 되는 webClient의 설정을 복사해서 사용할 수 있다.
이런 설정 복사 기능을 사용해서 아래와 같이 각 도메인 별로 베이스가 되는 path 설정 및 기본 인증 header 등을 설정해 request를 보낼 때마다 별도 path 설정 및 header 설정을 할 필요가 없는 것이다.
예를 들어 아래와 같이 connection timeout 등 기본 설정을 한 client를 mutate()를 통해 설정 복사해 client2, client3를 만들어서 각각 요청하는 곳의 기본 path와 header를 설정해 사용할 수 있다. https://test3.com으로 요청해주실 때는 무조건 header에 x-test3 값을 넣어주세요~ 라는 요구를 여기저기 코드에 넣지 않아도 손쉽게 해결할 수 있다는 것이다.
val client2 = client.mutate()
.baseUrl("https://test2.com")
.build()
val client3 = client.mutate()
.baseUrl("https://test3.com")
.defaultHeaders {
it["Authorization"] = "Bearer test3Token"
it["x-test3"] = "default header value"
}
org.springframework.web.reactive.function.client.WebClientRequestException: Host is not specified
WebClient를 설절하고 이것저것 테스트 해보는데 위와 같은 에러가 종종 났다.
해당 에러는 baseUrl 설정 없는 webClient에 pull uri string을 넣지 않고 UriBuilder를 통해 설정을 했을 경우 발생했다.
예를 들어 아래와 같이 UriBuilder를 통해 설정을 하면 Host is not specified 에러가 발생했다.
val result = runBlocking {
client.post()
.uri {
it.path(url)
.build()
}
.headers {
headerMap.forEach { header ->
it[header.key] = header.value
}
}
.bodyValue(body)
.retrieve()
.awaitBody<Test>()
}
이는 get request를 할 때 query param 설정을 할 때도 마찬가지였다.
val result = runBlocking {
client.get()
.uri {
it.path("resource")
.apply {
queryParams.forEach { (key, value) ->
queryParam(key, value)
}
}
.build()
}
.retrieve()
.awaitBody<String>()
}
아래와 같이 uri()에 pull url을 넣어서 설정하면 에러가 발생하지 않는다.
query param의 경우 따로 query param을 url과 붙여서 만들어주는 함수를 통해 pull url을 하나의 string으로 만들어 넣어주면 에러가 발생하지 않았다.
val result = runBlocking {
client.get()
.uri(buildUrl(url, parameterMap))
.headers {
headerMap.forEach { header ->
it[header.key] = header.value
}
}
.retrieve()
.awaitBody<Test>()
}
// get request에 필요한 buildUrl (baseUrl 설정을 했다면 필요 없음)
fun buildUrl(url: String, queryParams: Map<String, String>): String {
val queryString = queryParams.map { (key, value) -> "$key=$value" }.joinToString("&")
return if (queryString.isNotEmpty()) {
"$url?$queryString"
} else {
url
}
}
WebClient에 baseUrl을 설정했을 때는 UriBuilder를 통해서 path 설정을 하고 query param을 넣어도 에러가 발생하지 않는데 baseUrl을 설정하지 않았을 때는 왜 이와 같은 에러가 발생할까?
baseUrl을 설정하지 않았을 경우 Http Client는 Host가 무엇인지 UriBuilder를 통해 넣은 값으로 추론하지 않는다고 한다. 즉, UriBuilder는 baseUrl로 host가 이미 지정된 상태에서 path, query param 등 설정을 하는데 써야지 baseUrl이 지정되지 않아 client가 host를 모르는 상태에서는 uri에 pull uri string(query param이 포함된)을 넣어야 client가 아 host가 이거구나~ 하고 알게된 다는 것이다.
pull uri string을 넣지 않았을 경우 아래와 같이 기존 baseUrl 값으로 설정한 uriBuilderFactory를 통해 expand(확장) 하는 것을 볼 수 있다.
@Override
public RequestBodySpec uri(String uriTemplate, Object... uriVariables) {
attribute(URI_TEMPLATE_ATTRIBUTE, uriTemplate);
return uri(uriBuilderFactory.expand(uriTemplate, uriVariables));
}
반면에 pull uri을 넣는 함수의 경우는 그냥 넣은 uri 자체를 쓰고 있다. 이런 차이 때문에 baseUrl 설정 없이 UriBuilder 등을 사용해 uri를 설정하면 Host is not specified 에러가 발생했던 것이다.
@Override
public RequestBodySpec uri(URI uri) {
this.uri = uri;
return this;
}
✋ Spring boot Webclient's retrieve vs exchange
https://stackoverflow.com/questions/58410352/spring-boot-webclients-retrieve-vs-exchange