RESTAPI를 만들어보려고 기존 프로젝트를 리펙토링하면서 api 서버에 접근하기 위해 WebClient를 사용해보게 되었다.

선택지는 HttpURLConnection, WebClient, RestTemplate 이 세가지가 있었다.

 

검색했을 때 가장 많이 볼 수 있었던것은 RestTemplate이었지만 Spring 5.0부터는 WebClient를 사용하도록 권장하고 있기 때문에 WebClient로 결정하게 되었다.

'RestTemplate이 Deprecated되었다' 라고 많이 볼 수 있었는데 아예 Deprecated 된 것은 아니었다.

Spring 깃에서 20년 2월 11일자 이슈를 보면

이런 내용을 확인할 수 있다.

'RestTemplate 향후 사용 중단 가능성에 대해 언급하는것 보다는 유지관리 모드에 있음을 설명하는것이 더 유용하고 정확하다.' 라는 내용이다.

 

즉, Deprecated 된 것이 아니라 유지보수만 하겠다는 것이다.

직접 찾아보진 못했지만 스프링에서 직접적으로 Deprecated를 언급한 적이 있다고 한다.

그 날짜가 20년 2월 12일인데

'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.'

이런 내용이었다.

아마 이 내용으로 인해 Deprecated 된다는 소문이 퍼지면서 저 이슈를 다시 작성하지 않았을까 싶다.

 

중요한건 지금도 RestTemplate은 Deprecated되지 않았고 아직 언급이 없다는 점이다.

그럼에도 WebClient로 해본 이유는 '스프링에서 권장하고 있으니 먼저 써보자 '라는 생각이었다.

물론 RestTemplate도 Deprecated가 아직은 될 예정이 없으니 써볼 예정.

 

HttpURLConnection은 결과값을 받아올 때 Stream으로 직접 하나하나 처리해야 하고 이것저것 설정해야 하는 게 많아 보여 일단은 가장 마지막에.....

 

 

WebClient란

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

 

WebClient는 Non-blocking 방식이다.

Non-blocking이라고 해서 비동기(Asynchronous)를 의미하는 것은 아니다.

크게 보자면 Non-blocking은 요청하고 딴일 하다가 응답이 오면 결과를 처리하는 방식이고

비동기 역시 요청 후 딴일 하다가 응답이 오면 처리하는 방식이다.

 

하지만 조금만 깊게 들어가면 비동기방식은 conneciton이 끊어지고 서로간에 이벤트를 통해 통신하는 방식으로 요청자와 제공자 사이에서 Message Broker라는 서비스가 중계해주게 된다.

 

참고했던 블로그 포스팅에서는 동기(Synchronous)와 비동기(Asynchronous)를 이렇게 정리해주셨다.

  • 호출'된' 함수의 수행결과 및 종료를 호출'한' 함수가(호출된 함수뿐 아니라 호출한 함수도 함께) 신경을 쓰고 있다면 Synchronous
  • 호출'된' 함수의 수행결과 및 종료를 호출'된' 함수 혼자서만 직접 신경쓰고 처리한다면 Asynchronous이다.

그럼 blocking과 Non-blocking은?

  • 호출'된' 함수가 자신이 할 일을 모두 마칠때까지 제어권을 갖고 호출'한' 함수에게 돌려주지 않는다면 block
  • 호출'된' 함수가 자신이 할 일을 마치지 않았지만 바로 제어권을 건네주어(return) 호출'한' 함수가 다른 일을 진행하도록 해주면 Non-block이다.

처음 이 포스팅을 보면서는 뭔가 말장난인가... 싶은 느낌도 있었다.

좀 정리를 다시 해보면 blocking과 Non-blocking은 제어권을 어디에서 갖고 있느냐에 따른 구분이기 때문에 요청 후 다른 처리를 하느냐 마느냐의 차이이고,

synchronous과 Asynchronous는 요청자가 처리를 계속해서 신경을 쓰고 있느냐 안쓰고 있느냐 라는 개념으로 이해하면 좀 편하다.

 

좀 더 상세하게 정리를 해주셨으니 참고.

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

 

Spring WebClient 쉽게 이해하기

1. Spring WebClient 이해 이 글을 읽기 전에 먼저 일하는 방식 변화를 이끌고 있는 애자일, 마이크로서비스, 데브옵스, 클라우드에 대해 기본적인 이해를 하실것을 권장 합니다. https://happycloud-lee.tisto

happycloud-lee.tistory.com

 

 

WebClient 사용 코드

일단 WebClient를 적용해보면서 가장 많이 보게 된 사이트는 baeldung이었다.

 

https://www.baeldung.com 

 

Baeldung

In-depth, to-the-point tutorials on Java, Spring, Spring Boot, Security, and REST.

www.baeldung.com

 

WebClient뿐만 아니라 REST, Security, OAuth 등등 여러가지가 정리되어있어 참고하기 좋았다.

이번에는 사용예제가 정리된 블로그 포스팅보다는 아무래도 여기가 더 도움이 많이 되었다.

 

프로젝트는 기본 게시판 형태로 이미지파일을 업로드할 수 있는 게시판과 일반 게시판 두가지를 갖고 있는 작은 프로젝트다.

 

환경은 아래와 같다.

  • Spring boot
  • Gradle
  • JDK 1.8
  • JWT
  • Spring Data JPA

API서버와 Client 서버를 분리해서 프로젝트를 진행했고 한대의 데스크탑에서 처리했기 때문에

API서버의 경우 localhost:9095, Client 서버의 경우 localhost:8080으로 포트만 다르게 해서 진행했다.

 

WebClient 사용 예제는 아래처럼 정리한다.

  1. WebClient 사용법(인스턴스 생성)
  2. get(), post()요청 및 Query String, PathVariable, Multipart

 

일단 WebClient를 사용하기 위해서는 Dependency를 추가해야 한다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux 

    implementation 'org.springframework.boot::spring-boot-starter-webflux:2.7.6'

 

WebClient의 가장 기본적인 사용법

import org.springframework.web.reactive.function.client.WebClient;

....
	String response = WebClient.builder()
            .baseUrl("http://localhost:9095")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build()
                .get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", 1)
                        .build())
                .retrieve()
                .bodyToMono(String.class)
                .block();

이 코드는 API서버에 board-list를 요청한 경우인데 WebClient. ~~ .build()까지가 WebClient를 설정하는 부분이다.

그리고 build다음으로 .get()이 요청 형태 그 뒤로 uri와 응답받는 타입 등의 요청에 대한 설정을 하고 block()으로 마무리해준다.

분리해서 아래처럼 처리할 수도 있다.

 

import org.springframework.web.reactive.function.client.WebClient;

...
    
    WebClient client = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
                    
    String response = client.get()
                        .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                .queryParam("pageNum", 1)
                                .build())
                        .retrieve()
                        .bodyToMono(String.class)
                        .block();

 

이런식으로 WebClient 인스턴스를 생성하고 그걸 가져다 사용하는 형태로 사용할 수 있다.

또한 아무런 설정도 없이 인스턴스만 생성하는 것이 가능하다.

 

WebClient client1 = WebClient.create();
WebClient client2 = WebClient.create("http://localhost:9095");

 

이렇게 아주 간단하게만 생성해두고 나머지는 이 인스턴스를 사용할 때 속성을 추가해서 상황에 맞게 사용할 수도 있다.

이번 프로젝트는 굳이 이렇게 막 여러 상황에 대한 처리를 할만한게 없었어서 이렇게 간단하게 생성하는 방법으로는 하지 않았고 서비스별로 나눠서 작성했기 때문에 WebClientConfig라는 컴포넌트를 만들어 분리해서 사용했다.

 

//WebClientConfig
import org.springframework.stereotype.Component;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
        
        return webClient;
    }
}


//service

...

@Service
@RequiredArgsConstructor
public class HierarchicalBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri) 
                                            throws JsonProcessingException {
        
        WebClient client = webClientConfig.useWebClient();
        
        String response = client.get()
                            .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                    .queryParam("pageNum", cri.getPageNum())
                                    .queryParam("amount", cri.getBoardAmount())
                                    .build())
                            .retrieve()
                            .bodyToMono(String.class)
                            .block();
        
        ...
    }
    
    ...
}

 

하나의 API서버에만 요청하고 받고 처리하기 때문에 기본적인 baseUrl은 9095까지 적어주어 서비스단에서 사용할때는 필요한 위치의 요청만 작성하면 되도록 했다.

이렇게 하면 모든 서비스단에서 WebClient 인스턴스를 생성할때 매번 설정하지 않아도 되니 코드 중복도 해결되고 간단하게 처리할 수 있다.

 

 

get(), post() 요청 및 Query String, PathVariable, Multipart

각 요청을 어떻게 하는지에 대해 정리한다.

API서버에 요청을 하는 방식은 get, post, patch, delete 이렇게 구분할 수 있다.

get, post, put, delete 이렇게 처음에는 배우긴 했는데 put은 리소스 전체를 업데이트할때, patch는 일부를 업데이트할때 사용하는것으로 현재 프로젝트의 경우 update처리에 대해서 전체를 업데이트하는 경우는 전혀 없기 때문에 patch만 사용했다.

근데 put이나 patch나 요청하는 방법에 대해서는 별반 차이가 없어서 큰 문제는 안될것으로 보인다.

복잡하게 요청하는 경우라면 좀 다를수도 있겠지만...

 

get 요청에서 많이 사용하는 방법이 Query String과 PathVariable 이 두가지가 있다.

아무런 매개변수가 없이 get 요청만 보내서 받는 경우도 있지만 그렇지 않은 경우도 있다.

프로젝트에서 게시판은 페이징 기능을 처리하기 때문에 쿼리스트링이 필요한 경우가 있었고,

게시판 내용을 보는 상세페이지에서는 글번호를 PathVariable로 받아 처리했기 때문에 필요했다.

 

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri)
                                    throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        // QueryString & header
        String response1 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", cri.getPageNum())
                        .queryParam("amount", cri.getBoardAmount())
                        .build())
                .header("headerName", "headerValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        //아무것도 없이 그냥 get요청 하는 경우
        String response2 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list").build())
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        
        ObjectMapper om = new ObjectMapper();
        
        om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        
        HierarchicalBoardListDTO dto;
        
        dto = om.readValue(response1, HierarchicalBoardListDTO.class);
        
        dto.setPageDTO(new PageDTO(cri, dto.getTotalPages()));
        
        return dto;
    }
    
    public HierarchicalBoardDetailDTO getHierarchicalBoardDetail(long boardNo)
                                                     throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        //PathVariable & cookie
        String response = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-detail/{boardNo}")
                        .build(boardNo))
                .cookie("cookieName", "cookieValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        ObjectMapper om = new ObjectMapper();
        
        HierarchicalBoardDetailDTO dto = om.readValue(responseVal, HierarhicalBoardDetailDTO.class);
        
        return dto;
    }
}

위 메소드는 게시판 리스트 요청, 아래 메소드는 상세페이지 데이터 요청이다.

게시판 리스트 요청에서 사실 pageNum과 amout없이 넘기는 일은 없긴한데 쿼리스트링을 쓸때랑 안쓸때를 비교하기 위해 나눠두었다.

쿼리스트링을 사용하는 경우 path() 뒤로 queryParam()으로 추가를 해주면되고 하나하나 추가하기 귀찮다면

MultiValueMap 타입으로 정리해 queryParams()로 보내는 방법도 있다.

위 코드에 보이는대로 queryParam의 경우 (name, value)로 작성해 요청을 보내면 된다.

 

아래 메소드에서는 PathVariable로 받는 경우인데 요청 uri의 경우 그대로 작성을 해주고 build()에 값을 넣어 넘겨주는 형태로 처리한다.

 

더 다양하고 복잡한 속성들도 존재하지만 기본적인 요청에 대해서는 이렇게 처리하는것으로 응답을 받을 수 있다.

 

이 프로젝트에서는 JWT로 인증, 인가 처리를 하도록 했는데 그것때문에 header, cookie 옵션 역시 기록한다.

JWT의 경우 요청헤더에 담아 받거나 쿠키에 담아 받는데 클라이언트 서버에서 API 서버로 요청할때 위 처럼 보내줄 수 있다.

굳이 JWT가 아니더라도 cookie나 RequestHeader에 담아 보내야 하는 데이터가 있다면 위 처럼 보내주면 된다.

보이는 그대로 보내주면 되고 JWT라고 가정한다면

.header("Authorization", "tokenValue")  ||  .cookie("Authorization", "tokenValue")

이런 형태로 담아서 보내주면 된다.

header와 cookie 속성 역시 하나만 보낼 수 있는것이 아니기 때문에 아래에 더 추가해서 보낼 수 있고

역시 headers와 cookies로 MultiValueMap으로 처리해 보낼 수 있다.

map으로 보내게 된다면 queryParam과는 조금 다르게 처리해야 하는데 consumer를 통해 보내야 한다.

consumer는 이번에 처음 봤는데 함수적 인터페이스로 단지 매개값을 소비하는 역할만을 한다고 한다.

 

 

 

다른 요청을 정리하기 전에 남은 속성들과 코드를 정리.

 

retrieve()는 ResponseEntity를 받아 디코딩하는 경우에 사용한다.

이 위치에 retrieve() 대신 exchange()를 사용할 수 있는데 exchange는 retrieve보다 더 많은 기능을 제공하고 모든 시나리오에서 application이 직접 ResponseBody를 consume해야 한다.

요약하면 retrieve() 보다 exchange()를 사용하는게 더 복잡하다.

그렇기때문에 exchange()를 사용해서 응답코드나 응답헤더를 봐야한다거나, 아니면 직접 응답을 consume해야 한다거나 하는 특별한 이유가 없다면 retrieve()를 사용해서 처리하면 된다.

 

 

bodyToMono()에서는 응답을 받는 타입을 설정해준다.

bodyToMono()말고 bodyToFlux()도 있는데 Mono의 경우 0 ~ 1개의 결과를 처리하는 경우에 사용하고

Flux는 0 ~ N개의 결과를 처리하는 경우에 사용하면 된다.

 

프로젝트를 진행하면서는 JSON으로 다 정리된 상태로 받았기 때문에 Mono만 사용해 처리해봤고 대부분 String으로 받아와서 처리했다.

받는 타입은 크게 상관이 없는데 응답으로 넘어오는 데이터와 동일한 DTO가 존재한다면 해당 DTO.class로 받을 수도 있다.

또한 리턴 결과를 Long 타입으로 처리하는 경우도 있었어서 Long으로 받아서 처리한 경우도 있다.

 

마지막 block()은 blocking을 하겠다 라고 보면된다. 즉, 완전한 비동기 방식이 아니다.

처음에 Non-blocking과 Asynchronous의 차이를 설명한 이유이다.

block()을 설정하게 되면 결국 결과가 반환될때까지 기다렸다가 다음으로 넘어가게 된다.

근데 이렇게 사용한 이유는 프로젝트에서는 그냥 결과값을 매핑해 응답을 반환하는 형태라서 크게 문제가 되지 않기 때문이다.

하지만 만약 결과값을 다시 어떠한 알고리즘에 처리하는 과정이 추가가 된다면 비동기라고 부를수가 없다.

block()말고도 toStream()이라는 속성도 있는데 이 속성 역시 마찬가지로 대기하게 되므로 문제 해결이 되지 않는다.

이걸 완전한 비동기 방식으로 사용하려면 subscribe() 속성을 사용하면 되는데 이걸 사용하게 되면 요청 후 응답이 오지 않았더라도 비동기 처리로 다음 요청을 수행하게 되고 응답이 도착하면 그에 대한 처리를 수행하게 된다.

subscribe()의 경우 사용해보지 않아서 잘 정리해주신 블로그를 아래에..

 

https://tecoble.techcourse.co.kr/post/2021-10-20-synchronous-asynchronous/

 

동기와 비동기 with webClient

‘여기서 만나’ 프로젝트를 진행하면서 초반에는 RestTemplate을 통하여 외부 API와 데이터를 주고받았다. 이후 WebClient가 비동기 방식으로 쓰여 더 좋다고 하여 WebClient로 변경했다. 그리고 이전 글

tecoble.techcourse.co.kr

 

남은건 요청이 아닌 응답 데이터를 처리해주는 ObjectMapper이다.

응답받은 json을 dto로 파싱해주는 방법은 다양한데 가장 많이 보이기도 하고 편하게 파싱이 되는 ObjectMapper를 사용했다.

ObjectMapper의 경우 .readValue(응답데이터, 원하는DTO.class) 이렇게 작성하는것으로 간단하게 파싱이 가능하다.

그러나 만약 리턴되는 데이터가 상황에따라 굳이 DTO에는 안들어가도 되는 경우가 있을 수 있다.

이 프로젝트의 경우는 그게 페이징 데이터였다.

API서버에서 게시판 리스트를 가져올때 Page 타입으로 데이터를 꺼내 그걸 리턴해주도록 했는데 그러다보니 불필요한 페이징 데이터가 많았다. 중복되는 데이터도 많았고.

그래서 클라이언트 DTO에는 그런 데이터들을 아예 작성하지 않았다. 쓸일도 없었기 때문에.

물론 API 서버에서 미리 걸러내서 응답해준다면 더욱 좋겠지만 아쉽게도 JPA는 아직 한창 공부중이라서 방법을 찾을 수 없었고 해결하지 못해 클라이언트서버에서 해결해야 했다.

겸사겸사 상황에 따라 다른 DTO에 받는다면 이런 경우도 있겠지 하면서 사용하게 되었다.

 

그 설정은 .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 이다.

파싱하고자 하는 json 데이터에는 존재하지만 이 데이터를 정리할 dto에는 해당 필드가 존재하지 않는다면 그것을 무시하고 존재하는 필드에만 파싱을 하도록 하는 설정이다.

이 설정을 해주지 않고 그냥 처리한다면 Exception이 발생한다.

사용해보진 않았지만 비슷한 옵션으로 null 값을 무시하는 옵션도 있다.

.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); 이다.

이 옵션들 둘다 true로 설정하면 json데이터가 모두 dto에 들어갈 수 있어야 하며 null값도 있으면 안된다.

당연히 옵션 설정 따로 안하면 default가 true이다.

 

 

그럼 다음은 post요청이다.

post 요청의 경우 DTO에 데이터를 담아 요청하는 방법과 Multipart를 담아 요청하는 두가지 방법이 있다.

다른 방식으로 요청을 보내는 케이스는 따로 찾아볼 수 없어서 이 두가지 방법만 사용해봤다.

 

일단은 기본적인 post 요청을 먼저 정리.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public long boardInsert(HttpServletRequest request, HttpServletResponse response) {
        
        WebClient client = webClientConfig.useWebClient();
        
        HierarchicalBoardDTO dto = HierarchicalBoardDTO.builder()
                    .boardTitle(request.getParameter("boardTitle"))
                    .boardContent(reqeuest.getParameter("boardContent"))
                    .build();
                    
        /*
            Mono<HierarchicalBoardDTO> dto2 = HierarchicalBoardDTO.builder()
                        .boardTitle(request.getParameter("boardTitle"))
                        .boardContent(request.getParameter("boardContent"))
                        .build();
        */
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(Mono.just(dto), HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(dto2, HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
        
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .bodyValue(dto)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
    }
}

post 요청의 경우 이런 형태로 요청할 수 있다.

return에서 보면 주석포함 총 3가지 방식으로 리턴하고 있는데, 모두 사용할 수 있는 방법이다.

차이를 보자면 body에 어떻게 담아서 처리하는지, 아니면 bodyValue를 사용해 처리하는지의 차이다.

body를 활용하는 경우는 비동기 타입으로 인코딩해야 하는 경우이다.

dto만 먼저 보면 HierarchicalBoardDTO 타입으로 생성했다.

이걸 Mono<HIerarchialBoardDTO>로 만들어주는것이 Mono.just(dto) 부분이다.

그 뒤에는 elementClass를 넣어주어 body를 작성하게 된다.

그래서 애초에 Mono<HierarchicalBoardDTO> 로 만들어준 dto2는 Mono.just로 처리해줄 필요가 없어진다.

이걸 이해하기 위해서는 WebFlux를 이해해야 하고 Reactive Programming을 이해해야 한다.

근데 이게 좀 어려워서... 아직 정리가 안된상태...

 

bodyValue()의 경우는 비동기타입이 아닌 실제 값인 객체를 갖고 있는 경우 사용한다.

사실상 dto의 경우는 body로 인코딩을 해서 보낼 필요없이 bodyValue()로 보내주면 된다.

 

깊은 내용없이 간단하게 정리하자면 비동기타입이 아닌 실제값(객체)를 보낼것이라면 bodyValue(),

그렇지 않고 비동기타입(Mono, Flux 등)을 보낼것이라면 body()를 사용하면 된다.

그리고 이걸 이해하고 제대로 사용하기 위해서는 WebFlux를 이해해야 한다.

 

patch(), delete(), put() 요청은 post()와 거의 동일하기 때문에 따로 정리하지 않는다.

 

 

그럼 마지막으로 Multipart.

 

WebClient에서는 메모리문제를 피하기 위해 기본 in-memory buffer 값이 256KB로 설정이 되어있다.

256KB 이하의 파일만 처리를 할것이라면 굳이 손댈 필요 없겠지만 그렇지 않다면 따로 설정을 해줘야 한다.

그래서 WebClientConfig에 Bean을 추가했다.

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        ....
    }
    
    public WebClient useImageWebClient() {
        
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs()
                                .maxInmemorySize(20 * 1024 * 1024)).build();
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .exchangeStrategies(exchangeStrategies)
                    .build();
        
        return webClient;
    }
}

이렇게 ExchangeStrategies를 통해 maxInmemorySize를 늘려주고 WebClient를 생성할때 속성을 넣어 설정해주면 문제가 해결된다.

 

요청은 아래처럼 처리한다.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
....

@Service
@RequiredArgsConstructor
public class ImageBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public long imageBoardInsert(String imageTitle
                                 , String imageContent
                                 , List<MultipartFile> files) {
        
        WebClient client = webClientConfig.useImageWebClient();
        
        //요청 데이터를 담아줄 bodyBuilder
        MultipartBodyBuilder mbBuilder = new MultipartBodyBuilder();
        
        for(int i = 0; i < files.size(); i++)
            mbBuilder.part("files", files.get(i).getResource()); //이미지 파일
        
        mbBuilder.part("imageTitle", imageTitle);     //게시글 제목
        mbBuilder.part("imageContent", imageContent); //게시글 내용
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/image-board/image-insert").build())
                    .contentType(MediaType.MULTIPART_FORM_DATA)
                    .body(BodyInserters.fromMultipartData(mbBuilder.build()))
                    .retrieve()
                    .bodyToMono(Long.class)
                    .block();
    }
}

 

기존 post 요청에서 dto에 담아 보냈던것 처럼 Multipart처리시에는 MultipartBodyBuilder에 데이터를 담아 보내주면 된다.

MultipartBodyBuilder는 <String, HttpEntity> 형태로 담아줄 수 있다.

게시글에서 기본적인 파일, 제목, 내용을 담아준 뒤, 요청 body에서는 BodyInserters.fromMultipartData()를 사용해

MultipartBodyBuilder를 빌드해 body에 담아주면 된다.

MultipartBodyBuilder를 빌드하게 되면 MultiValueMap 타입이 된다.

BodyInserters.fromMultipartData()에는 이 MultiValueMap 타입만 받을 수 있고 받은 MultipartValueMap을 MultipartData로 쓰기위해 MultipartInserter를 return한다.

이때 받는 MultiValueMap의 값은 Obejct 또는 HttpEntity이다.

 

다시 좀 정리하면 MultiPartBodyBuilder에 데이터를 담아준다.

그걸 build() 하게 되면 MultiValueMap으로 리턴된다.

BodyInserters.fromMultipartData()는 MultiValueMap을 받으며 해당 map의 값으로는 Object 또는 HttpEntity여야 한다.

받은 MultiValueMap을 MultipartData로 쓰기위해 MultipartInserters를 반환해 body에 담아준다.

 

//api server insert
@PostMapping("/image-insert")
public long imageBoardInsert(@RequestParam List<MultipartFile> files
                             , @RequestParam String imageTitle
                             , @RequestParam String imageContent) {
    
    ....
}

api 서버에서 받을때는 이렇게 받을 수 있다.

 

 

Reference

 

+ Recent posts