다음 내용은 스프링 리액티브 웹 스택 공식 레퍼런스를 직접 번역하신
토리맘의 한글라이즈 프로젝트를 기반으로 제가 이해한 바를 요약하고, 여러 자료를 통해 내용을 조금 보충한 것입니다.
Spring WebFlux는 리액티브, Non-Blocking HTTP 요청을 위한 WebClient
를 제공한다. WebClient
내부에서는 HTTP 클라이언트 라이브러리에 처리를 위임한다. 디폴트로 Reactor Netty를 사용하고, Jetty reactive HttpClient를 기본으로 제공하며, 다른 라이브러리는 ClientHttpConnector
에 등록할 수 있다.
WebClient
를 생성하는 가장 간단한 방법은 스태틱 팩토리 메소드를 사용하는 것이다.
WebClient.create()
WebClient.create(String baseUrl)
위 메소드는 디폴트로 Reactor Netty HttpClient를 사용하므로, 클래스패스에 io.projectreactor.netty:reactor-netty
가 있어야 한다.
다른 옵션을 사용하려면, WebClient.builder()
를 사용한다.
uriBuilderFactory
: base URL을 커스텀defaultHeader
: 모든 요청에 사용할 헤더defaultCookie
: 모든 요청에 사용할 쿠키defaultRequest
: 모든 요청을 커스텀할 Consumerfilter
: 모든 요청에 사용할 클라이언트 필터exchangeStrategies
: HTTP 메세지 reader/writer 커스텀clientConnector
: HTTP 클라이언트 라이브러리 세팅아래는 HTTP 코덱을 설정하는 예제이다.
WebClient client = WebClient.builder()
.exchangeStrategies(builder -> {
return builder.codecs(codeConfigurer -> {
//...
});
})
.build();
WebClient
는 한 번 빌드하고 나면, 상태를 변경할 수 없다. 대신, 원본 인스턴스를 복사해서 설정을 추가하는 것은 가능하다.
// client1은 filterA, filterB를 가짐
WebClient client1 = WebClient.builder()
.filter(filterA).filter(filterB).build();
// client2는 filterA, filterB에 더해 filterC, filterD도 가짐
WebClient client2 = client1.mutate()
.filter(filterC).filter(filterD).build();
Spring WebFlux는 어플리케이션의 메모리 이슈를 방지하기 위해, 코덱의 메모리 버퍼 사이즈를 제한할 수 있다. 디폴트는 256KB로 설정돼 있는데, 버퍼의 메모리 용량이 부족하면 다음 에러가 발생한다.
org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer
다음 코드를 사용하여, 코덱의 최대 버퍼 사이즈를 조절할 수 있다.
WebClient webClient = WebClient.builder()
.exchageStrategies(builder ->
builder.codecs(codecs ->
codecs.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)))
.builder();
HttpClient
를 사용하여, WebClient의 디폴트 HTTP 클라이언트 라이브러리인 Reactor Netty를 커스텀할 수 있다.
HttpClient httpClient =
HttpClient.create().secure(sslSpec -> ...);
WebClient webClient = WebClient.builder()
.clientConnector(new
ReactorClientHttpConnector(httpClient))
.build();
기본적으로 HttpClient
는 reactor.netty.http.HttpResources
에 묶여 있는 Reactor Netty의 글로벌 리소스를 사용한다. 이는 이벤트 루프 쓰레드와 커넥션 풀도 포함하는데, 이벤트 루프로 동시성을 제어하려면 공유 리소스를 고정해 놓고 사용하는 것이 좋기 떄문이다. 이 모드에서는 프로세스가 종료될 때까지 공유 자원을 active 상태로 유지한다.
서버가 프로세스와 함께 중단된다면, 명시적으로 리소스를 종료할 필요는 없다. 하지만, 프로세스 내에서 서버를 시작하거나 중단할 수 있다면 (WAR로 배포한 스프링 MVC 어플리케이션과 같이), 스프링이 관리하는 ReactorResourceFactory
빈을 globalResource=true
(디폴트) 옵션을 설정해야 스프링 컨테이너가 종료될 때 Reactor Netty
글로벌 리소스도 종료한다. 아래 코드처럼 설정하면 된다.
@Bean
public ReactorResourceFactory reactorResourceFactory(){
return new ReactorResourceFactory(); // 기본값이 globalResource=true
}
원한다면 글로벌 Reactory Netty 리소스를 사용하지 않아도 되긴 하지만, 직접 Reactory Netty의 클라이언트와 서버 인스턴스가 공유 자원을 사용하도록 코드를 직접 작성해야 한다.
HTTP 커넥션 타임아웃은 다음과 같이 설정할 수 있다.
HttpClient httpClient = HttpClient.create()
.tcpConfiguration(client -> client.option(ChannerOption.CONNECT_TIME_MILLIS, 10000);
read/write 타임아웃은 다음과 같이 설정할 수 있다.
HttpClient httpClient = HttpClient.create()
.tcpConfiguration(client -> client.doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(10))
.addHandlerLast(new WriteTimeoutHandler(10))));
retrieve()
는 repsonse body를 받아 디코딩하는 가장 간단한 메소드다.
WebClient client =
WebClient.create("https://example.org");
Mono<Person> result = client.get()
.uri("/persons/{id}",id)
.accept(MediaType.APPLICATION_JSON) // MediaType 설정
.retrieve() // 받은 응답 디코딩
.bodyToMono(Person.class); // response body -> Mono 인스턴스
응답을 다음과 같이 객체 스트림으로도 디코딩할 수 있다.
Flux<Quote> result = client.get()
.uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(Quote.class); // response body -> Flux
만약 응답 코드로 4xx
, 5xx
가 넘어오면 예외를 던진다. 디폴트는 WebClientResponseException
(최상위 예외) 이고, 또는 각 HTTP 상태에 해당하는 WebClientResponseException.BadRequest
, WebClientResponseException.NotFound
등의 하위 예외를 던진다. 다음과 같이 onStatus
메소드로 상태별 예외를 커스텀할 수도 있다.
Mono<Person> result = client.get()
.uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> ...) // 4xx 예외 커스텀
.onStatus(HttpStatus::is5xxServerError, response -> ...) // 5xx 예외 커스텀
.bodyToMono(Person.class);
만약 onStatus
를 사용할 때는, response에 body가 존재하면 onStatus 콜백에서 소비해야 한다. 그렇지 않으면 리소스 반환을 위해 body를 자동으로 비운다.
exchange()
메소드는 retrieve
보다 더 많은 기능을 제공한다. 다음 예제는 위의 retrieve()
코드와 동일하지만, ClientResponse 에 접근한다.
WebClient client =
WebClient.create("https://example.org");
Mono<Person> result = client.get()
.accept(MediaType.APPLICATION_JSON)
.exchange()
.flatMap(response -> response.bodyToMono(Person.class));
같은 레벨에서 ResponseEntity
를 만들 수도 있다.
Mono<ResponseEntity<Person>> result = client.get()
.uri("/persons/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.flatMap(response -> response.toEntity(Person.class));
exchange() 사용시 주의할 점
exchange()
는retrieve()
와 달리 4xx, 5xx 응답을 자동으로 예외를 던져주지 않는다. 직접 상태 코드를 확인하고, 어떻게 처리할지 결정해야 한다.- 또한,
exchange()
는retrieve()
와 다르게 발생할 수 있는 모든 시나리오(성공, 오류, 예기치 못한 데이터 등)에서 어플리케이션이 직접 response body를 소비해야 한다. 그렇지 않으면 Memory 누수가 발생할 수 있다.ClientResponse
javadoc에 response body를 소비할 수 있는 모든 옵션이 나와있긴 하다.
exchage()
를 사용해서 응답 코드나 헤더를 봐야 로직을 결정할 수 있다거나, 아니면 직접 응답을 소비해야 한다거나 하는 특별한 이유가 없다면,retrieve()
를 쓰는 것이 좋다.
Request body는 Mono
, 코틀린 코루틴 Deferred
등, ReactiveAdapterRegistry
에 등록한 모든 비동기 타입으로 인코딩할 수 있다.
WebClient client =
WebClient.create("https://example.org");
Mono<Person> personMono = ...;
Mono<Void> result = client.post() // 보낼 Http 메서드
.uri("/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.body(personMono, Person.class) // Body에 들어갈 내용
.retrieve()
.bodyToMono(Void.class);
다음 코드는 객체 스트림으로 인코딩하는 예제이다.
Flux<Person> personFlux = ...;
Mono<Void> result = client.post()
.uri("/persons/{id}", id)
.contentType(MediaType.APPLICATION_STREAM_JSON)
.body(personFlux, Person.class) // Flux 인코딩
.retrieve()
.bodyToMono(Void.class);
만약 비동기 타입이 아닌 실제 값을 가지고 있다면, bodyValue
를 사용하면 된다.
Person person = ...; // 실제 객체
Mono<Void> result = client.post()
.uri("/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(person) // bodyValue 사용
.retrieve()
.bodyToMono(Void.class);
Form으로 데이터를 보내려면, MultiValueMap<String, String>
을 body로 사용해야 한다. 이 경우, Content-type은 FormHttpMessageWriter
가 자동으로 applcation/x-www-form-urlencoded
로 설정해준다.
MultiValueMap<String, String> formData = ...; // 데이터 타입 확인
Mono<Void> result = client.post()
.uri("/path", id)
.bodyValue(formData)
.retrieve()
.bodyToMono(Void.class); // ContentType을 따로 지정하지 않았다.
인라인으로 코드를 form 데이터를 만들어 전송하려면 BodyInserters
를 사용한다
// 스태틱 import
import static org.springframework.web.reactive.function.BodyInserters.*;
Mono<Void> result = client.post()
.uri("/path", id)
.body(fromFormData("k1", "v1").with("k2", "v2"))
.retrieve()
.bodyToMono(Void.class);
Multipart 데이터를 보낼 때는, MultiValueMap<String, ?>
을 사용해서 각 value에 part 컨텐츠를 나타내는 Object
인스턴스 혹은, part의 컨텐츠와 헤더를 나타내는 HttpEntity
를 담아야 한다. 이 때, MultipartBodyBuilder
를 사용하면 좀 더 편리하다.
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fieldPart1", "fieldValue");
builder.part("fieldPart2", new
FileSystemResource("...logo.png")); // 이미지 파일 담기
builder.part("jsonPart", new Person("Jason")); // json 담기
builder.part("myPart", part); // Server request 파트
MultiValueMap<String, HttpEntity<?>> parts = builder.build();
각 파트의 Content type은 HttpMessageWriter
또는 파일 확장자에 의해 자동으로 결정되므로 명시하지 않아도 된다. 필요할 경우, 빌더의 part
메소드 중 MediaType
을 받는 메소드를 사용하면 된다.
다음은 생성한 MultiValueMap
을 WebClient
에 넘기는 코드이다.
MultipartBodyBuilder builder = ...;
Mono<Void> result = client.post()
.uri("/path", id)
.body(builder.build())
.retrieve()
bodyToMono(Void.class);
Multipart
컨텐츠를 인라인으로 작성하려면 Form Data와 같이 BodyInserters
를 사용하면 된다.
// 스태틱 import
import static org.springframework.web.reactive.function.BodyInserters.*;
Mono<Void> result = client.post()
.uri("/path", id)
.body(fromMultipartData("fieldPart", "value").with("filePart", resource))
.retrieve()
.bodyToMono(Void.class);
WebClient 요청에 필터를 적용하려면 WebClient.Builder
에서 클라이언트 필터인 ExchangeFilterFunction
을 등록하면 된다.
WebClient client = WebClient.builder()
.filter((request, next) -> { // 나만의 필터 등록
ClientRequest filtered = ClientRequest.from(request)
.header("foo", "bar")
.build();
return next.exchange(filtered);
})
.build();
다음 코드는 스태틱 팩토리 메소드를 사용해서 기본 인증 필터를 추가하는 예제이다.
import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; // 스태틱 import
WebClient client = WebClient.builder()
.filter(basicAuthentication("user", "password")) // 필터 추가
.build();
필터는 기본적으로 모든 요청에 전역적으로 적용되지만, 필터에서 특정 요청만 처리하고 싶다면 ClientRequest
에 request attribute를 추가하고, 필터에서 해당 attribute에 접근하면 된다.
WebClient client = WebClient.builder()
.filter((request, next) -> { // 여기서 추가한 attribute에 접근
Optional<Object> usr = request.attribute("myAttribute");
// ...
})
.build();
client.get().uri("https://example.org/")
.attribute("myAttribute", "...") // request attribute 추가
.retrieve()
.bodyToMono(Void.class);
}
WebClient
를 복제해서 필터를 추가하거나 삭제하는 것도 가능하다. 다음 코드는 첫 번째 순서로 인증 필터를 추가하는 예제이다.
import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;
WebClient client = webClient.mutate() // WebClient 복제
.filters(filterList -> {
filterList.add(0, basicAuthentication("user", "password"));
}) // 필터 추가
.build();
WebClient
테스팅에 관해서는 또 다른 포스트로 찾아오겠습니다.
읽어 주셔서 감사합니다!!