JaeWon's Devlog
article thumbnail
반응형

회사에서 코드를 분석하다가 외부 API 통신 시 사용하고 있는 WebClient 를 발견할 수 있었다. Spring 5 부터 제공하는 기능이였고, 이전에는 RestTemplate 를 사용하고 있었어서 WebClient 에 대해 알아보고 정리해보고자 한다.


1. WebClient 란?

- Spring 5 부터 제공하는 RestTemplate 를 대체하는 웹 클라이언트(HTTP Client).

- 웹으로 API를 호출하기 위해 사용되는 Http Client 모듈 중 하나.

- 기존의 동기(Sync) API 를 제공할 뿐만 아니라, 논블로킹(Non-Blocking) 및 비동기(Async) 방식을 지원해서 효율적인 통신이 가능.

- 요청을 나타내고 전송하게 해주는 빌더(Builder) 방식의 인터페이스를 사용.

- API 요청시에는 리액티브(Reactive) 타입의 요청과 응답을 합니다.(Mono, Flux)

1-1. 특징

- WebClient 의 특징은 다음과 같다.

  • Reactor 기반의 Functional, Fulent API.
  • 싱글 스레드 방식을 사용.(쓰레드, 동시성 문제들을 다룰 필요 없이 비동기 로직의 선언적 형태로 사용 가능)
  • Non-Blocking 방식을 사용.
  • 서버 사이드의 Request / Response 를 인코딩, 디코딩하는 코덱들을 지원.
  • HTTP/1.1 프로토콜을 통해 작동하는 반으형 비차단 솔루션.

1-2. WebClient VS RestTemplate

- RestTemplate 은 Deprecated 되어 스프링에서도 WebClient 사용을 권장하고 있다.

- 공통점

  • 웹 통신을 위한 HttpClient 모듈.

- 차이점

  • RestTemplate 은 Blocking 기반의 Synchronous API
  • WebClient 는 Non-Blocking 기반의 Asynchronous API

- 아래 그림은 성능을 비교한 그림입니다.(빨강 : WebClient, 초록 : RestTemplate)

https://alwayspr.tistory.com/44

  • 사용자가 늘어날수록 WebClient 와 RestTemplate 성능차이가 심해진다.(Thread Pool Size를 초과할 경우 Queue에서 대기하는 작업이 늘어나기 때문)
  • 동시사용자가 1000명까지는 비슷하고, 서버의 성능이 좋아지면 비슷한 구간이 더 늘어난다.
  • 어플리케이션 사용자가 비슷한 구간에 해당한다면 오히려 RestTemplate 사용하는것이 생산성 측면에서 유리할 수 있다.(동기 방식이 코드작성, 이해, 디버깅하기가 쉬움)

1-3. 동작 방식

- WebClient 의 동작 방식을 이해하기 전에 RestTemplate 의 방식도 이해해야 한다.

  • RestTemplate

https://happycloud-lee.tistory.com/220

- Thread Pool 은 어플리케이션 구동 시에 미리 만들어둔다.

- Request(요청) 는 먼저 Queue 에 쌓아두고, 가용 가능한 쓰레드(Thread)가 있다면 해당 쓰레드에 할당하여 처리한다.

   (즉, 1 Request 당 1 Thread 가 할당)

- 각 쓰레드는 Blocking 방식으로 처리되어 응답이 올 때까지 해당 스레드는 다른 요청에 할당 될 수 없다.

- 요청을 처리할 쓰레드가 있으면 문제가 없지만, 모든 쓰레드를 사용하고 있는 경우 이후의 요청은 Queue 에서 대기하게 된다.

- 대부분의 속도 문제는 네트워크나 DB와의 통신에서 생기는데, 이런 문제가 쓰레드에서 발생하면 가용가능한 쓰레드 수가 줄어들면서, 전체 서비스가 느려지는 현상이 발생한다.

  • WebClient

https://luminousmen.com/post/asynchronous-programming-blocking-and-non-blocking

- WebClient 는 Single Thread 와 Non-Blocking 방식을 사용한다.(1 Core 당 1 개의 Thread 를 이용)

- 각 요청은 Event Loop 내에 Job 으로 등록된다.

- Event Loop 는 각 Job 을 제공자(Wokres)에게 요청한 후, 응답을 기다리지 않고 다른 Job 을 처리한다.

2. WebClient 사용해보기

- 간단하게 Spring Boot 프로젝트를 생성합니다.

2-1. Dependency 설정

- WebClient 를 사용하기 위해서는 spring-boot-starter-webflux Dependency만 있으면 사용이 가능합니다.

  • Maven
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
  • Gradle
dependencies {
    compile 'org.springframework.boot:spring-boot-starter-webflux'
}

2-2. API Class 작성

- 간단하게 테스트를 진행하기 위해 Controller 를 생성하고 그 안에 구현해보겠습니다.

- 기능별로 나누어서 작성이 필요하지만, 너무 길어질 수 있어 최대한 간단하게 구현하였습니다.

참고!!!
테스트와 블로그 상에서 불필요 코드가 많아질수 있어 cotroller에 구현하였지만, 보통 공통 모듈로 만들고, service 단계에서 구현하는게 일반적일 거라 생각합니다.
  • Response Class
    - 만약 공통 객체로 사용할 때는 rsp_msg 도 String 형태로 받고, 이후 알맞게 파싱하여 사용할 수 있습니다.
@Data
public class ApiResponse {

    @JsonProperty("rsp_code")
    private String rsp_code;
    @JsonProperty("rsp_msg")
    private List<Todo> rsp_msg;
}
  • Todo Class
@Getter
@Setter
@ToString(callSuper = true)
public class Todo {

    private Long id;
    private String item;
    private String date;
    private boolean completed;
    private String time;
    private LocalDateTime writeDate;
}
  • Controller
@RestController
@Slf4j
@RequiredArgsConstructor
public class WebclientTestContorller {

    private HttpClient httpClient;

    private ExchangeStrategies exchangeStrategies;

    // (1)
    @PostConstruct
    public void initSetting(){
    
    	// (2)
        //  HttpClient 타임아웃 설정
        httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
                .responseTimeout(Duration.ofMillis(5000))
                .doOnConnected(conn ->
                        conn.addHandlerLast(new ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
                                .addHandlerLast(new WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS)));

        // (3)
        //  메모리 설정
        exchangeStrategies = ExchangeStrategies.builder()
                .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2*1024*1024))
                .build();
    }

    @RequestMapping("/webclient")
    public void webclientTest(){
        try{
            // (4)
            //  WebClient 통신
            ApiResponse resp = WebClient.builder()
                    .clientConnector(new ReactorClientHttpConnector(httpClient))
                    .baseUrl("https://test-jpa-api.herokuapp.com/")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .exchangeStrategies(exchangeStrategies)
                    .build()
                    .get()
                    .uri("api/v1/todo")
                    // (5)
                    .exchangeToMono(response -> {
                        if (response.statusCode().equals(HttpStatus.OK)){
                            return response.bodyToMono(ApiResponse.class)
                                    .doOnError(e -> {
                                        //  에러 구현
                                        log.error("통신 후 에러 : " + e.getMessage());
                                        throw new RuntimeException();
                                    })
                                    .map(t -> {
                                        log.info(t.toString());

                                        if(!t.getRsp_code().equals("200")){
                                            throw new RuntimeException();
                                        }

                                        return t;
                                    });
                        } else {
                            log.error("통신 실패 에러");
                            return response.createException().flatMap(Mono::error);
                        }
                    })
                    .onErrorResume(throwable -> {
                        log.error("err : {}", throwable.getMessage());
                        return Mono.empty();
                    })
                    .block();

            //  Null 체크
            if(!ObjectUtils.isEmpty(resp)){
                log.info("Result : " + resp.getRsp_msg());

                //  이후 필요 로직 수행
            }
        } catch (Exception e){
            log.error("Error : {} ", e.getMessage());
        }
    }
}

3. 소스 설명

- 간략하게나마 소스에 대해서 설명드리겠습니다.

3-1. @PostConstruct

- 종속성 주입이 완료된 후 실행되어야 하는 메서드(간단하게 의존성 주입 이후 아래 메소드를 실행해준다고 생각하시면 됩니다)
- 좀 더 자세한 설명은 추후 블로그에 포스팅 하도록 하겠습니다.

3-2. HttpClient

- WebClient 는 netty 기반의 httpClient 를 사용합니다.

- HttpClient 를 커스텀하여 사용할 수 있고, 해당 글에서는 TimeOut 설정을 위해 사용하였습니다.

3-3. ExchangeStrategies

- Http 메시지의 Reader & Writer 커스텀을 제공합니다.

- 해당 글에서는 메모리 설정을 위해 사용하였습니다.

3-4. WebClient

- WebClient 를 생성하는 방법은 2가지가 있습니다.

  • create()
    - 가장 기본적인 WebClient 세팅으로 생성하고, 요청할 uri 와 함께 생성합니다.
WebClient.create();
// or
WebClient.create("http://localhost:8080");
  • build()
    - WebClient 의 설정을 모두 커스텀마이징 하여 사용할 수 있도록 제공합니다.
    - Builder 를 통해 생성 시 다음과 같은 이점이 있습니다.
    1. 모든 호출에 대한 기본 Header / Cookie 설정 가능
    2. filter 를 통한 Request / Response 처리
    3. Http 메시지 Reader / Writer 조작(커스텀마이징)
    4. Http Client Library 설정

- 해당 글에서는 build() 를 통해서 생성하였고, 기본적으로 적용 가능한 옵션들은 아래와 같습니다.

  • clientConnector
    - HTTP Client 세팅
    - 위(3-2)에서 생성한 HttpClient 를 세팅합니다.
  • baseUrl
    - 기본적인 호스트의 URL을 작성합니다.
  • defualtHeader
    - 요청시 사용하고자 하는 헤더.
    - 헤더를 생성하여 필요한 정보를 담아 세팅할 수 있습니다.
  • exchangeStrategies
    - HTTP 메시지의 Reader & Writer 를 세팅.
    - 위(3-3)에서 생성한 ExchangeStrategies 를 세팅합니다.
  • defulatCookie
    - 요청시 사용하고자 하는 쿠키
  • defaultRequest
    - 요청 시 커스텀할 Consumer
  • filter
    - 모든 요청에 사용할 Client filter

- get() : get 방식으로 통신하겠다를 의미합니다.(post 라면 post(), delete 라면 delete() ...)

- uri() : baseUrl 뒤에 붙을 uri 를 설정합니다.

3-5. exchangeToMono

- 요청한 값에 대해 응답을 받아 처리를 합니다.

- 응답을 받을 때에도 2 가지의 메소드가 있습니다.

  • retrive()
    - body 를 받아 디코딩하는 간단한 메소드.
    - 응답값을 어떻게 추출할 것인지 명시하는데 사용.
WebClient.builder()
	.exchangesStrategies(...)
    	...
    .retrieve()
    .toEntity(response.class)
    .block();
  • exchangeToXXXX()
    - 응답값의 Response Status 에 따라 결과를 커스텀하여 사용.
    - ClientResponse 를 상태값과 헤더와 함께 가져오는 메소드
    - 기존에는 exchange() 였지만, 메모리 누수 가능성으로 인해 Deprecated가 되었고, exchangeToMono/exchangeToFlux 로 대체되었습니다.
    - 해당 글에서는 exchnageToMono 를 사용합니다.
.exchangeToMono(response -> {
            if (response.statusCode().equals(HttpStatus.OK)){
                ...
            } else {
                log.error("통신 실패 에러");
                ...
                return response.createException().flatMap(Mono::error);
            }
        })
  • bodyToMono & bodyToFlux
    - 응답값 중 Body 에 대해 Mono, Flux 형식으로 받아 처리.
    - 1개의 값을 리턴할 때는 bodyToMono 를 사용하고, 복수의 값을 리턴할 때는 bodyToFlux 를 사용.
  • doOnError
    - 응답 과정에서 오류가 발생하면 해당 영역 실행.
    - 통신은 정상적으로 된 것이고, 응답 이후 로직에서 오류가 발생하는 것.
    - 해당 영역에서 exception 을 Throw 하면 onErrorResume 이 실행되어 최종적으로 Exception 을 처리.
  • map
    - 응답값 중 필요한 부분에 대해서만 전달하기 위해 응답값을 새롭게 생성.
    - 아래 그림은 Mono(좌), Flux(우) 에서 Map 동작 순서이다.

  • onErrorResume
    - 통신과정에서 발생한 Exception 에 대한 처리 및 doOnError 에서 Throw 된 Exception 최종 처리.
    - 통신 이후 400,500 에러에 대해서는 무조건 적으로 해당 영역이 실행되는 것이 아니라, 응답에 대해 실패한 경우에 실행.
  • block
    - WebClient 는 기본적으로 비동기로 동작.
    - 동기 방식으로 동작하게 하기 위해서는 block() 메소드를 추가하여 사용.

4. 응답 확인

- 간단하게 호출하여 확인해보면 정상적으로 통신되는 것을 확인할 수 있습니다.

5. Reactive Programming(Mono 와 Flux)

  • Reactive Programming(리액티브 프로그래밍)
    - 일련의 작업 단계를 기술하는 것이 아니라 데이터가 전달될 파이프라인을 구성.
    - 어플리케이션을 논블로킹 프로세스로 동작하기 위해 지원하는 프로그래밍.
  • Reactor
    - 리엑티브 스트림을 구현하는 라이브러리.
    - Mono, Flux 두 가지 타입으로 스트림 정의.
  • Mono
    - 0 또는 1 개의 데이터 항목과 에러를 갖는다.
  • Flux
    - 0 또는 1 개 이상의 데이터 항목과 에러를 갖는다.

- 보통 여러 스트림을 하나의 결과로 모아줄 때 Mono 를 사용하고, 각각의 Mono 를 합쳐 여러개의 값을 처리할 때 Flux 를 사용합니다.


참고

- https://gngsn.tistory.com/154

- https://bravenamme.github.io/2021/01/07/web_client/

- https://www.baeldung.com/spring-5-webclient
- https://binux.tistory.com/56

반응형
profile

JaeWon's Devlog

@Wonol

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!