Micro Service/mSVC개발

Spring WebClient 쉽게 이해하기

Happy@Cloud 2021. 2. 20. 18:59

1. Spring WebClient 이해

이 글을 읽기 전에 먼저 일하는 방식 변화를 이끌고 있는 애자일, 마이크로서비스, 데브옵스, 클라우드에 대해

기본적인 이해를 하실것을 권장 합니다.

https://happycloud-lee.tistory.com/261?category=8322466


1) WHY ?

우리가 개발하는 어플리케이션들을 크게 2개로 나눠보면 요청자와 제공자라고 할 수 있습니다.
요청자를 consumer 또는 subscriber라고 하고, 제공자를 producer 또는 provider라고 합니다.
요청자가 제공자에게 무언가를 요청할 때 제공자가 공개한 API를 이용하게 되는거구요.
요청 시 프로그램에서 우리가 가장 흔하게 사용하는 것이 Http Client입니다.
Spring WebClient는 웹으로 API를 호출하기 위해 사용되는 Http Client 모듈 중 하나입니다.
Java에서 가장 많이 사용하는 Http Client는 RestTemplate입니다.
RestTemplate예시는 Spring Cloud Hystrix편을 열어 'RestTemplateConfig.java'를 찾아 읽어 보십시오.
그럼 RestTemplate과 WebClient는 어떤 공통점과 차이가 있을까요?
공통점은 둘다 HttpClient모듈이라는 겁니다.
차이점은 통신방법이 RestTemplate은 Blocking방식이고, WebClient는 Non-Blocking방식이라는겁니다.
Blocking과 Non-Blocking에 대해선 마이크로서비스패턴: 핵심패턴만 빠르게 이해하기편의 '동기와 비동기 처리 이해'부분을 보십시오.
(많은 사람들이 동기를 Blocking과 비동기를 Non-Blocking으로 잘못 이해하고 있습니다. 위 글을 꼭 보시기 바랍니다.)
Non-blocking방식이 필요한 이유는 네트워킹의 병목현상을 줄이고 성능을 향상시키기 위해서입니다.

결론적으로, Spring WebClient가 필요한 이유
요청자와 제공자 사이의 통신을 좀 더 효율적인 Non-Blocking방식으로 하기 위해서입니다.

2) HOW ?

Spring webclient의 동작 원리를 이해하기 위해서는 RestTemplate의 동작원리를 같이 이해해야 합니다.
RestTemplate
RestTemplate은 Multi-Thread와 Blocking방식을 사용합니다.

Thread pool은 요청자 어플리케이션 구동시에 미리 만들어 놓습니다.
Request는 먼저 Queue에 쌓이고 가용한 스레드가 있으면 그 스레드에 할당되어 처리됩니다.
즉, 1 요청 당 1 스레드가 할당됩니다.
각 스레드에서는 Blocking방식으로 처리되어 응답이 올때까지 그 스레드는 다른 요청에 할당될 수 없습니다.

아래는 RestTemplate을 Connection Pool에 Spring Bean으로 등록하기 위한 예제입니다.
이 예제를 보면 처리방식을 보다 명확하게 이해하실겁니다.
요청 당 20개의 RestTemplate client를 만들고, 최대 50개까지 증가할 수 있도록 했습니다.

@Configuration
public class RestTemplateConfig {
	public RestTemplate getRestTemplate(int defaultMaxPerRoute, int maxTotal) {
		PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager();
		connManager.setDefaultMaxPerRoute(defaultMaxPerRoute);
		connManager.setMaxTotal(maxTotal);

		HttpClient client = HttpClientBuilder.create().setConnectionManager(connManager).build();

		HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client);
		factory.setConnectTimeout(3000);
		factory.setReadTimeout(3000);

		return new RestTemplate(factory);

	}

	@Bean
	public RestTemplate coffeeRestTemplate() {
		return getRestTemplate(20, 50);
	}
}


요청을 처리할 스레드가 있으면 아무런 문제가 없지만, 스레드가 다 차는 경우 이후의 요청은 Queue에 대기하게 됩니다.
대부분의 문제는 네트워킹이나 DB와의 통신에서 생기는데 이런 문제가 여러 스레드에서 발생하면
가용한 스레드수가 현저하게 줄어들게 되고, 결국 전체 서비스는 매우 느려지게 됩니다.

Spring WebClient
Spring WebClient는 Single Thread와 Non-Blocking방식을 사용합니다.
Core 당 1개의 Thread를 이용합니다.

그림출처: https://luminousmen.com/post/asynchronous-programming-blocking-and-non-blocking

각 요청은 Event Loop내에 Job으로 등록이 됩니다.
Event Loop는 각 Job을 제공자에게 요청한 후, 결과를 기다리지 않고 다른 Job을 처리합니다.
Event Loop는 제공자로부터 callback으로 응답이 오면, 그 결과를 요청자에게 제공합니다.
WebClient는 이렇게 이벤트에 반응형으로 동작하도록 설계되었습니다.
그래서 반응성, 탄력성, 가용성, 비동기성을 보장하는 Spring React 프레임워크를 사용합니다.
또한, React Web 프레임워크인 Spring WebFlux 에서 Http Client로 사용됩니다.

성능비교
아래는 RestTemplate을 사용하는 Spring Boot1과 WebClient를 사용하는 Spring Boot2의 성능비교 결과입니다.
1000명까지는 비슷하지만 동시사용자가 늘수록 RestTemplate은 급격하게 느려지는것을 볼 수 있습니다.

그림출처: https://alwayspr.tistory.com/44

위 글(alwayspr.tistory.com/44)에서는 동시사용자의 규모에 따라 RestTemplate을 쓰는것도 좋다고 하였지만,
Spring커뮤니티에서는 RestTemplate을 이미 Depreciated시키고 WebClient를 사용할것을 강력히 권고하고 있습니다.

NOTE: As of 5.0 this class is in maintenance mode, 
with only minor requests for changes and bugs to be accepted going forward. 
Please, consider using the org.springframework.web.reactive.client.WebClient 
which has a more modern API and supports sync, async, and streaming scenarios.


2. Spring WebClient 실습

개발 IDE는 MS vscode를 사용하겠습니다. vscode설치 및 사용법을 참조하세요.
매뉴얼: docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client
참고글: medium.com/@odysseymoon/spring-webclient-%EC%82%AC%EC%9A%A9%EB%B2%95-5f92d295edc0

예제 소스에서는 람다식 표현법이 많이 등장합니다.
람다식이 익숙하지 않은 분은 구글에서 '람다식 쉽게'로 검색하셔서 미리 학습하시고 보시기 바랍니다.

실습을 위해서는 2개 어플리케이션이 필요합니다. 요청자와 제공자죠.
제공자앱의 이름은 webserver로하고, 요청자앱의 이름은 webclient라고 하겠습니다.

2.1 WebServer 개발

1) 프로젝트 생성
vscode에서 java project를 처음 만드시는분은 Spring Cloud Gateway편의 '2. SCG서버 개발 > 1) 프로젝트 생성'부분을 참고하십시오,.
아래와 같이 프로젝트를 생성합니다.
- SpringBoot 버전: 2.3.9
- Group ID: com.springcloud
- Artifact ID: webserver
- Packaging Type: Jar
- Java Version: 1.8이상
- Dependency: Spring Web
맨 마지막 폴더 선택은 프로젝트를 생성할 상위 디렉토리로 지정하십시오.

2) application.yaml 작성

server.port: 5011
spring:
  application:
    name: webserver

※ application.properties를 그대로 사용해도 되나, 그때는 아래와 같이 지정하는 방법이 약간 다릅니다.

server.port=${service_port:5011}
spring.application.name=webserver

※ yaml형식으로 지정할 때의 파일 확장자는 보통은 yml로 합니다.
저는 kubernetes용 yaml파일의 확장자와 맞추기 위해 'yaml'로 했습니다. 어떤걸로 하던 잘 적용됩니다.

3) API class 작성
WebserverController라는 이름으로 작성합니다.
'/webclient/{param}'이라는 API를 작성합니다.
- Http Header의 'httpclient-type'이라는 이름의 쿠키값을 로깅
- 모든 Http Request Header의 key와 value를 로깅
- 파라미터를 합쳐서 리턴할 문자열을 생성하여 응답함

package com.springcloud.webserver;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class WebserverController {
    private final Logger log = LoggerFactory.getLogger(getClass());

    @GetMapping("/webclient/{param}")
	public String testWebClient(
		@PathVariable String param, 
		@RequestHeader HttpHeaders headers,
		@CookieValue(name = "httpclient-type", required=false, defaultValue="undefined") String httpClientType) {
		
		log.info(">>>> Cookie 'httpclient-type={}'", httpClientType);

		headers.forEach((key, value) -> {
			log.info(String.format(">>>>> Header '%s' => %s", key, value));
		});
		
        log.info("### Received: /webclient/" + param);
		
		String msg = param + " => Working successfully !!!";
		log.info("### Sent: " + msg);
		return msg;
	}    
}

※ '{}'는 순서대로 뒤쪽에 오는 변수로 치환됩니다.
log.info(">>>> Cookie 'httpclient-type' is {}", httpClientType);
또는, 아래와 같이 String.format을 이용해도 동일합니다.
log.info(String.format(">>>>> Header '%s' => %s", key, value));

4) 실행 및 테스트
Terminal을 열고 webserver프로젝트 디렉토리로 이동합니다. 그리고, 아래 명령어로 실행jar를 생성한 후 실행합니다.

./mvnw clean package -DskipTests && java -jar target/webserver.jar

아래와 같이 curl명령으로 webserver를 호출하여, 결과가 정상적으로 나오는지 확인합니다.

❯ curl -i http://localhost:5011/webclient/hi
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 30
Date: Mon, 22 Feb 2021 06:37:07 GMT

hi => Working successfully !!!

2.2 WebClient 개발

1) 프로젝트 생성

vscode에서 java project를 처음 만드시는분은 Spring Cloud Gateway편의 '2. SCG서버 개발 > 1) 프로젝트 생성'부분을 참고하십시오,.
아래와 같이 프로젝트를 생성합니다.
- SpringBoot 버전: 2.3.9
- Group ID: com.springcloud
- Artifact ID: webclient
- Packaging Type: Jar
- Java Version: 1.8이상
- Dependency: Spring Reactive Web
맨 마지막 폴더 선택은 프로젝트를 생성할 상위 디렉토리로 지정하십시오.

2) pom.xml 수정

- finalName추가
생성할 jar파일명을 지정합니다.

...

	<build>
		<finalName>webclient</finalName>
		...
	</build>
...

3) application.yaml 설정

src/main/resources/application.properties의 이름을 application.yaml로 고치고 아래와 같이 포트와 앱명을 지정합니다.

server:
  port: ${service_port:5001}
spring:
  application:
    name: webclient
    

4) API 작성
WebclientController라는 이름으로 API를 작성합니다.
- @RestController 주석은 요청을 처리하는 Controller이고, HttpResponse객체를 직접 리턴하는 REST Controller임을 의미합니다.
- @GetMapping주석을 통해 처리할 URI패턴을 정의합니다.

@RestController
public class WebClientController {

    @GetMapping("/test")
    public Mono<String> doTest() {
        WebClient client = WebClient.create();
        return client.get()
            .uri("http://localhost:5011/webclient/test-create")
            .retrieve()
            .bodyToMono(String.class);
    }

}

가장 기본적인 webclient 사용 예제입니다.
WebClient.create()는 빠르게 테스트할 때 사용하고, 실제로는 WebClient.builder()를 이용하여 여러가지 옵션을 부여하여 사용합니다.
자세한 것은 3. WebClient 활용 매뉴얼에서 다룹니다.
1개의 값을 리턴할 때는 bodyToMono, 복수의 값을 리턴할 때는 bodyToFlux를 사용합니다.

5) 실행 및 테스트
Terminal을 열고 webserver프로젝트 디렉토리로 이동합니다. 그리고, 아래 명령어로 실행jar를 생성한 후 실행합니다.

./mvnw clean package -DskipTests && java -jar target/webclient.jar

아래와 같이 curl명령으로 webserver를 호출하여, 결과가 정상적으로 나오는지 확인합니다.

❯ curl -i http://localhost:5001/test
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 39

test-create => Working successfully !!!

3. WebClient 활용 매뉴얼

공식매뉴얼은 아래 링크에 있습니다.
docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client

Web on Reactive Stack

The original web framework included in the Spring Framework, Spring Web MVC, was purpose-built for the Servlet API and Servlet containers. The reactive-stack web framework, Spring WebFlux, was added later in version 5.0. It is fully non-blocking, supports

docs.spring.io

1) timeout 지정
아래예와 같이 HttpClient를 생성하면서 timeout을 지정하고, WebClient.clientConnector를 이용하여 적용합니다.

...
	@GetMapping("/test2")
    public Mono<String> doTest2() {
        HttpClient httpClient = HttpClient.create()
            .tcpConfiguration(
                client -> client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) //miliseconds
                    .doOnConnected(
                        conn -> conn.addHandlerLast(new ReadTimeoutHandler(5))  //sec
                            .addHandlerLast(new WriteTimeoutHandler(60)) //sec
                    )
            );    

        WebClient client = WebClient.builder()
            .baseUrl("http://localhost:5011")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
        
        return client.get() 
            .uri("/webclient/test-builder")
            .retrieve()
            .bodyToMono(String.class);          
    }
...

2) baseUrl
위 예에서처럼 baseUrl에 Host를 지정하고, uri에는 Path만 지정하여 사용할 수 있습니다.

3) filter
filter를 이용하여 Request, Response를 컨트롤할 수 있습니다.
doTest2 메소드에 아래와 같이 filter를 추가합니다.
첫번째 필터는 Request Header를 추가하고, 두번째는 Request Header를 로깅하며, 세번째는 Response Header를 로깅합니다.

@RestController
public class WebClientController {
    private final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(getClass());
    
	...
    @GetMapping("/test2")
    public Mono<String> doTest2() {
		...
        
        WebClient client = WebClient.builder()
            .baseUrl("http://localhost:5011")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .filter(
                (req, next) -> next.exchange(
                    ClientRequest.from(req).header("from", "webclient").build()
                )
            )
            .filter(
                ExchangeFilterFunction.ofRequestProcessor(
                    clientRequest -> {
                        log.info(">>>>>>>>>> REQUEST <<<<<<<<<<");
                        log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
                        clientRequest.headers().forEach(
                            (name, values) -> values.forEach(value -> log.info("{} : {}", name, value))
                        );
                        return Mono.just(clientRequest);
                    }
                )
            )
            .filter(
                ExchangeFilterFunction.ofResponseProcessor(
                    clientResponse -> {
                        log.info(">>>>>>>>>> RESPONSE <<<<<<<<<<");
                        clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.info("{} : {}", name, value)));
                        return Mono.just(clientResponse);
                    }
                )
            )
            .build();
		
        ...
    }    

}

Webclient를 다시 빌드 & 실행하십시오.

./mvnw clean package -DskipTests && java -jar target/webclient.jar

curl -i http://localhost:5001/test2
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 40

test-builder => Working successfully !!!

Webclient의 콘솔 로그를 보십시오.


WebServer의 콘솔 로그를 보십시오. Request header에 'from'이 있는걸 확인하십시오.


4) Buff memory 늘리기
Request Data를 버퍼링하기 위한 메모리의 기본값은 256KB입니다.
만약 아래와 같은 에러가 발생하면 버퍼 메모리를 늘려주어야 합니다.

org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer


ExchangeStrategies로 메모리를 지정하고, WebClient.exchangeStragegies로 적용합니다.

...

    @GetMapping("/test2")
    public Mono<String> doTest2() {
		...
        
        //Memory 조정: 2M (default 256KB)
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
            .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2*1024*1024)) 
            .build();

        WebClient client = WebClient.builder()
			...
			.exchangeStrategies(exchangeStrategies)
			.build();

		...
    }
 ...


5) Spring Bean class 이용(defaultHeader, defaultCookie 포함)
지금까지는 WebClient를 필요할 때 만들어서 사용했습니다.
그러나 Webclient를 만드는 시간이 걸리므로, 이 방법보다는 미리 만들어 놓고 사용하는것이 더 좋습니다.

@Configuration과 @Bean을 이용하여 어플리케이션 구동시에 생성되는 Spring Bean을 생성합니다.
지금까지 실습한것이 적용된 WebClient를 아래와 같이 만듭니다.
추가로, 이 WebClient를 사용하는 모든 수행에서 공통 HttpRequest Header와 공통 Cookie가 있다면,
defaultHeader와 defaultCookie를 이용하여 추가하십시오.

@Configuration
public class WebClientConfig {
    private final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(getClass());
    
    @Bean
    public WebClient webClient() {
        HttpClient httpClient = HttpClient.create()
            .tcpConfiguration(
                client -> client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) //miliseconds
                    .doOnConnected(
                        conn -> conn.addHandlerLast(new ReadTimeoutHandler(5))  //sec
                            .addHandlerLast(new WriteTimeoutHandler(60)) //sec
                    )
            );

        //Memory 조정: 2M (default 256KB)
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
            .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2*1024*1024)) 
            .build();

        return WebClient.builder()
            .baseUrl("http://localhost:5011")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .filter(
                (req, next) -> next.exchange(
                    ClientRequest.from(req).header("from", "webclient").build()
                )
            )
            .filter(
                ExchangeFilterFunction.ofRequestProcessor(
                    clientRequest -> {
                        log.info(">>>>>>>>>> REQUEST <<<<<<<<<<");
                        log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
                        clientRequest.headers().forEach(
                            (name, values) -> values.forEach(value -> log.info("{} : {}", name, value))
                        );
                        return Mono.just(clientRequest);
                    }
                )
            )
            .filter(
                ExchangeFilterFunction.ofResponseProcessor(
                    clientResponse -> {
                        log.info(">>>>>>>>>> RESPONSE <<<<<<<<<<");
                        clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.info("{} : {}", name, value)));
                        return Mono.just(clientResponse);
                    }
                )
            )
            .exchangeStrategies(exchangeStrategies)            
            .defaultHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.3")
            .defaultCookie("httpclient-type", "webclient")
            .build();
    }
}


이제 WebclientController.java를 아래와 같이 단순하게 만들 수 있습니다.
@Autowired 주석을 지정하고 WebClient를 정의하면, 자동으로 미리 만들어진 WebClient를 사용할 수 있게 됩니다.

@RestController
public class WebClientController {
    private final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(getClass());
    
    @Autowired
    private WebClient webClient;
    
    @GetMapping("/test")
    public Mono<String> doTest() {
        WebClient client = WebClient.create();
        return client.get()
            .uri("http://localhost:5011/webclient/test-create")
            .retrieve()
            .bodyToMono(String.class);
    }

    @GetMapping("/test2")
    public Mono<String> doTest2() {
        return webClient.get()
                .uri("/webclient/test-builder")
                .retrieve()
                .bodyToMono(String.class);
    }
}


이제, 다시 Webclient를 빌드하고 실행하십시오.
테스트 하면서 Webclient와 Webserver의 콘솔을 확인하여, 개발한대로 동작하는지 테스트 하십시오.

6) mutate
WebClientConfig를 다시 보면, 아래와 같이 baseUrl이 지정되어 있습니다.
그래서, WebClientController에서는 webClient.get().uri("/webclient/test-builder")라고 지정할 수 있었습니다.

...
		return WebClient.builder()
            .baseUrl("http://localhost:5011")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
...

그런데, 이렇게 WebClient를 만들어 버리면 baseUrl(즉, Target host)이 다른 경우 사용할 수가 없습니다.
WebClientConfig에서 baseUrl설정을 없애고, 다시 테스트 해 보십시오.
이번엔 에러가 납니다. 왜냐하면 Host가 뭔지 모르기 때문입니다.
물론 webClient.get().uri("http://localhost:5011/webclient/test-builder")과 같이 host를 지정하면 해결됩니다.
Bean으로 생성된 Web Client를 그대로 이용하면서 옵션(예를 들어 baseUrl)을 재지정할 수 없을까요 ?
그때 사용하는것이 mutate입니다.
WebConfigController에 아래 메소드를 추가하십시오.

...

    @GetMapping("/test3")
    public Mono<String> doTest3() {
        return webClient
                .mutate() 
                .baseUrl("http://localhost:5011")
                .build()
                .get()
                .uri("/webclient/test-mutate")
                .retrieve()
                .bodyToMono(String.class);
    }
...


Webclient를 다시 빌드 & 실행한 후 테스트하십시오.

curl -i http://localhost:5001/test3

Webclient와 Webserver의 콘솔을 보시면, Bean으로 생성한 Webclient에 적용한 모든 옵션이 동작하는것을 확인할 수 있습니다.