프로그래밍 일기 — WebClient
REST라는 개념에 좀 더 가까이 다가가기
프로젝트가 끝난 3일째, 드디어 이제 숨을 조금 고르면서 여유를 가지게되었다. 이런 여유가 있을 때 가장 먼저 떠오른 것은 REST였다. 단어 그대로 “휴식"을 의미하는 이 영단어는 프로그래밍 세계에서는 REST한 아키텍쳐를 말한다. REST아키텍쳐는 유저로 하여금 쉽게 웹 서비스에 접근해 활용할 수 있게해주는 웹 프로그래밍 개념이다. 이 구조에 맞게끔 잘 프로그램을 설계한다면 RESTful하게 설계했다고 말할 수 있다. REST를 접하면서 또 알게된 것은 RestTemplate라는 것이었다. Spring에서 REST 클라이언트가 사용될 수 있는 양식 혹은 방식이라 볼 수 있다.
이 개념을 공부하면서 Spring 프레임워크 5 부터는 WebClient라는 것이 RestTemplate를 대체할 것이라는 사실을 깨달았다(2). 이 것은 새로운 HTTP 클라이언트로써 WebFlux라는 스택과함께 RestTemplate가 제공했던 비동기화(asynchronous) API를 제공할 뿐만 아니라 더 효율적인 비차단식(nonblocking) 및 비동기화 접근을 지원한다. 따라서 장기적으로 WebClient가 Deprecated될 것이기 때문에, 현실적으로 WebClient라는 것을 공부하는 것이 더 나을 것이라는 판단이 들었다.
WebClient
Spring 5 에서 제공하는 반응형(Reactive) 웹 클라이언트로써, 웹 요청을 수행하기 위한 메인 엔트리 포인트를 표현하는 인터페이스라 할 수 있다. 처음에는 Spring Web Reactive Module의 일부로 개발되었으나, 점차적으로 RestTemplate을 대체할 계획으로 발전하고 있다. 이 새로운 클라이언트는 반응형(reactive)이며, 비차단식(non-blocking) 솔루션으로써 HTTP/1.1 프로토콜에서 작동할 수 있다.
중요한 점은 이 비차단식 클라이언트가 spring-webflux
라이브러리에 속하지만, 솔루션이 동기화(synchronous) 및 비동기화(asynchronous) 동작을 지원한다는 것이다. 이는 Servlet 스택에서 동작하는 어플리케이션에도 알맞다. 작동을 차단(block)하는 방식으로 결과를 얻는데, 이는 반응형(Reactive) 스택에서 작업시 권장하지 않는다.
WebClient는 WebTestClient
라는 테스트 코드 작성에 활용 가능한 클래스와 함께 기본적으로 DefaultWebClient
를 제공한다.
1. 사용전 준비
먼저 아래와 같은 dependency가 필요하다. WebFlux
라는 라이브러리다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
2. WebClient 인스턴스 생성
WebClient
를 제대로 활용하기 위해서는 아래 3가지를 하는 방법을 익혀야한다.
- 인스턴스 생성
- 요청 만들기
- 응답 다루기
먼저 WebClient
의 인스턴스를 생성해본다. 기본(default) 설정으로 WebClient
인스턴스를 생성할 수 있다. 혹은 URI가 있다면 이를 활용해 직접 WebClient
객체에 넣어 생성할 수도 있다. 가장 심화된 방법은 DefaultWebClientBuilder
클래스를 활용하여 클라이언트를 생성하는 것으로써, 사용자에게 최대한의 커스터마이징을 할 수 있게한다.
// WebClient 객체 생성 (기본 설정)
WebClient client = WebClient.create();
// 주어진 URI를 활용해 WebClient 인스턴스 생성
WebClient client = WebClient.create("http://localhost:8080");
// DefaultWebClientBuilder클래스로 직접 심화 설정
WebClient client = WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultCookie("cookieKey", "cookieValue")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultUriVariables(Collections.singletonMap("url", "http://localhost:8080"))
.build();
또한 Timeout을 활용해 WebClient의 객체를 생성할 수 있다. 많은 상황에서 HTTP의 기본 Timeout이 30초로 설정되어있어, 사용 목적에 따라 너무 느리다고 느껴질 수도 있다. 이를 커스터마이징하기 위해서 HttpClient
인스턴스를 생성하여 생성된 WebClient
객체에 맞게 설정이 가능하다.
ChannelOption.CONNECT_TIMEOUT_MILLIS
: connectin timeout을 설정할 수 있게한다.ReadTimeoutHandler
/WriteTimeoutHandler
: read/write timeout을 설정한다.responseTimeout
: response에 대한 timeout을 설정한다.
주의할 것은 이 timeout이 신호에 대한 것이지 HTTP 연결이나 실제 read/write 동작, 혹은 응답에 대한 것은 아니라는 것이다. Mono/Flux
의 Publisher에 대한 timeout이라고 봐야 정확하다.
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)));
WebClient client = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
3. 요청 준비하기
HttpMethod
메서드를 명기해 HTTP 요청을 할 수 있다. 혹은 이하 get
, post
, delete
요청을 메서드로 할 수 있다.
// HttpMethod 직접 명기
UriSpec<RequestBodySpec> uriSpec = client.method(HttpMethod.POST);
// HttpMethod 사용
UriSpec<RequestBodySpec> uriSpec = client.post();
URL을 직접 정의하여 요청을 준비할 수 있다. 먼저 URL API를 String으로 정의하여 매서드내 명기할 수 있다.
// Method내 URL String으로 명기
RequestBodySpec bodySpec = uriSpec.uri("/resource");
// UriBuilder 기능 사용
RequestBodySpec bodySpec = uriSpec.uri(
uriBuilder -> uriBuilder.pathSegment("/resource").build());
// java.net.URL 인스턴스 사용
RequestBodySpec bodySpec = uriSpec.uri(URI.create("/resource"));
Body를 정의하기
다음으로 Body를 정의한다. Request Body및 컨텐츠 타입, 크기, 쿠키, 헤더를 정의한다. request body를 정의할 때에도 여러가지 방법이 존재한다. 가장 보편적인 방법은 bodyValue
메서드를 활용하는 것이다. 또는 Publisher
를 body
메서드에 명기하는 방법(추가적으로 publish할 요소들의 타입 명기)으로 가능하다. BodyInserters
utility 클래스를 사용하는 방식도 있다.Reactor 인스턴스를 활용하여 BodyInserters#fromPublisher
메서드를 이용하는 방식으로도 가능하다. 이 방식은 더 심화된 시나리오를 커버할 수 있는 직관적인 함수를 제공한다.
// bodyValue로 정의
RequestHeadersSpec<?> headersSpec = bodySpec.bodyValue("data");
// Publisher를 body에 명기하여 정의
RequestHeadersSpec<?> headersSpec = bodySpec.body(
Mono.just(new Foo("name")), Foo.class);
// BodyInserter utility 클래스 활용하여 정의
RequestHeadersSpec<?> headersSpec = bodySpec.body(
BodyInserters.fromValue("data"));
// BodyInserters#fromPublisher 활용하여 정의
RequestHeadersSpec headersSpec = bodySpec.body(
BodyInserters.fromPublisher(Mono.just("data")),
String.class);
BodyInserters#fromPublisher
를 활용할 시에 더 직관적으로 활용할 수 있다. 예를 들어 여러 개의 요청을 한꺼번에 보내야할 때 아래와 같이 정의가능하다. 아래의 모든 메서드는 BodyInserter
인스턴스를 하나 생성하며, 요청에서 body로 표현될 수 있다. BodyInserter
는 주어진 출력 메시지와 insertion동안 활용되는 컨텍스트와 함께 ReactiveHttpOutputMessage
의 body에 콘텐츠를 넣게 해주는 인스턴스다. Publisher
는 반응형 컴포넌트로써 잠재적으로 묶이지 않은(unbounded) 순서화된(sequenced) 요소들을 제공하는 역할을 한다. 또한 인터페이스로써의 역할을 하며, Mono
및 Flux
가 가장 널리 알려진 구현체(implementation)이다.
LinkedMultiValueMap map = new LinkedMultiValueMap();
map.add("key1", "value1");
map.add("key2", "value2");
RequestHeadersSpec<?> headersSpec = bodySpec.body(
BodyInserters.fromMultipartData(map));
헤더를 정의하기
Body를 설정한 후, 헤더, 쿠키 및 허용가능한 미디어 타입을 설정할 수 있다. 값(Values)은 클라이언트를 인스턴스화(instantiating)할 때 이미 설정된 것들에 추가될 것이다. 또한, If-None-Match
, If-Modified-Since
, Accept
, 및 Accept-Charset
와같이 공용으로 가장 널리 사용되는 헤더에 대한 추가적인 지원을한다.
예를 들어 아래와 같이 값들이 활용될 수 있다.
ResponseSpec responseSpec = headersSpec.header(
HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML)
.acceptCharset(StandardCharsets.UTF_8)
.ifNoneMatch("*")
.ifModifiedSince(ZonedDateTime.now())
.retrieve();
4. 응답 받기
마지막 단계는 요청을 보내고 응답을 받는 것이다. 이는 exchangeToMono/exchangeToFlux
혹은 retrieve
메서드를 활용해 구현할 수 있다.
exchangeToMono/exchangeToFlux
메서드는 ClientResponse
및 해당 응답의 status와 헤더에 접근을 가능케한다.
retrieve
메서드는 body를 fetch하는 가장 빠른 방법이지만, ResponseSpec.bodyToMono
메서드가 status code가 4xx(클라이언트 에러) 혹은 5xx(서버 에러)일시에 WebClientException
을 반환하는 것에 주목할 필요가있다.
// exchangeToMono/exchangeToFlux 방식
Mono<String> response = headersSpec.exchangeToMono(response -> {
if (response.statusCode().equals(HttpStatus.OK)) {
return response.bodyToMono(String.class);
} else if (response.statusCode().is4xxClientError()) {
return Mono.just("Error response");
} else {
return response.createException()
.flatMap(Mono::error);
}
});
// retrieve 방식
Mono<String> response = headersSpec.retrieve()
.bodyToMono(String.class);
테스트 코드 작성
1. WebTestClient를 사용해 작업하기
WebTestClient
는 WebFlux
서버 엔드포인트를 테스트 하기 위한 메인 엔트리포인트(시작점)이다. WebClient
와 아주 비슷한 API를 가지며, 대부분의 작업은 WebClient
인스턴스의 내부에 할당(delegate)된다. 다만 WebTestClient
는 테스트 컨텍스트를 제공하는 것에 집중되어있다. DefaultWebTestClient
클래스는 단수(single) 인터페이스 구현(implementation)이다. 테스트시 클라이언트는 특정 컨트롤러 및 함수와 함께 실제 서버 혹은 작업에 묶인다(bound).
2. 서버에 묶기
실제 작동중인 서버에 실제 요청을 활용한 end-to-end 통합테스트를 완전하게 진행하기 위해, bindToServer
메서드를 이용할 수 있다.
WebTestClient testClient = WebTestClient
.bindToServer()
.baseUrl("http://localhost:8080")
.build();
3. 라우터에 묶기
특정 RouterFunction
을 bindToRouterFunction
메서드에 전달하는 방식으로 테스트할 수 있다.
RouterFunction function = RouterFunctions.route(
RequestPredicates.GET("/resource"),
request -> ServerResponse.ok().build()
);
WebTestClient
.bindToRouterFunction(function)
.build().get().uri("/resource")
.exchange()
.expectStatus().isOk()
.expectBody().isEmpty();
4. 웹 핸들러에 묶기
bindToWebHandler
를 활용해 같은 행위를 수행할 수 있다. 이는 WebHandler
인스턴스를 필요로한다.
WebHandler handler = exchange -> Mono.empty();
WebTestClient.bindToWebHandler(handler).build();
5. 웹 어플리케이션 컨텍스트에 묶기
bindToApplicationContext
를 활용하면 더 흥미로운 상황이 연출된다. 이는 ApplicationContext
를 받아 컨트롤러와 Bean과 @EnableWebFlux
ㅅ설정에 대한 컨텍스트를 분석한다. 만약 ApplicationContext
의 인스턴스를 주입한다면, 단순한 형태의 코드는 아래와 같다.
@Autowired
private ApplicationContext context;
WebTestClient testClient = WebTestClient.bindToApplicationContext(context)
.build();
6. 컨트롤러에 묶기
더 단순한 접근도 가능하다. 테스트하고자하는 컨트롤러의 배열(array)을 bindToController
메서드를 활용해 지정하는 것이다. Controller
클래스를 import했다는 가정하에, 이를 필요로하는 클래스에 주입하면 아래와 같은 코드를 작성할 수도 있다.
@Autowired
private Controller controller;
WebTestClient testClient = WebTestClient.bindToController(controller).build();
7. 요청하기
WebTestClient
객체를 빌드한 이후에는 예를 들어 exchange
메서드(응답을 받는 방법 중 하나)를 통해 모든 동작들이 WebClient
와 같이 수행될 수 있다. exchange
메서드는 WebTestClient.ResponseSpec
인터페이스를 제공하는데, 이는 expectStatus
, expectBody
, and expectHeader
등의 테스트용 메서드들을 활용하는데 있어 매우 유용하다.
WebTestClient
.bindToServer()
.baseUrl("http://localhost:8080")
.build()
.post()
.uri("/resource")
.exchange()
.expectStatus().isCreated()
.expectHeader().valueEquals("Content-Type", "application/json")
.expectBody().jsonPath("field").isEqualTo("value");
요약
WebClient
는 Spring에서 클라이언트 요청을 용이하게하는 도구로써 장기적으로RestTemplate
를 대체할 것이다.- 사용자는 클라이언트를 설정하고, 요청을 준비하며, 응답을 처리하는 방식을 지정해 줌으로써 이 도구를 효과적으로 활용할 수 있다.
위 코드의 스니펫은 참조(3)에서 확인 가능하다.
참조:
(1) https://pixabay.com/photos/woman-brunette-lying-down-rest-2003647/