[스프링] WebClient
WebClient
스프링 웹플럭스가 제공하는 HTTP 클라이언트인 WebClient
는 HTTP 요청 처리 작업을 논블로킹, 리액티브 방식으로 수행한다. RestTemplate
대안으로 사용할 수 있다. 동기와 비동기, 그리고 스트림 처리를 지원한다.
WebClient
는 다음을 지원한다.
- 논블로킹 I/O
- 리액티브 스트림 역압
- 적은 하드웨어 리소스로 높은 동시성을 제공
- 람다를 활용하는 함수형 스타일의 플루언트 API
- 동기 및 비동기
- 서버 스트리밍 업/다운
웹플럭스의 리액티브 코어는 클라이언트 사이드의 경우 기본적인 ClientHttpConnector
인터페이스를 통해 논블로킹 I/O 및 리액티브 스트림의 역압으로 HTTP 요청을 수행한다. WebClient
는 요청 처리를 위해 HTTP 클라이언트 라이브러리(구현체)를 필요로 하며 내부적으로 ClientHttpConnector
인터페이스에 대한 어댑터 라이브러리에 처리를 위임한다. 라이브러리의 예로 리액터 네티(reactor netty)가 제공하는 클라이언트인 HttpClient
, 아파치의 HttpComponents
링크가 제공하는 클라이언트인 HttpClient
등이 있으며 이들에 대한 내장 지원을 제공한다. 이외의 라이브러리에 대해서는 ClientHttpConnector
인터페이스를 기반으로 기능 구현을 한다. ClientHttpConnector
인터페이스 또는 그 구현체를 커넥터(connector)라고 한다.
스프링 웹플럭스는 기본적으로 리액터 네티의 HTTP 클라이언트인 HttpClient
를 사용한다. HttpClient
는 완전한 비동기 클라이언트이며 높은 성능과 낮은 지연을 제공한다. 리액터 네티 대신 사용할 수 있는 서블릿 컨테이너인 톰캣은 기본적으로 HTTP 클라이언트 구현체를 포함하지 않으므로 사용하고자 하는 HTTP 클라이언트 라이브러리를 프로젝트에 포함시킨 후 ClientHttpConnector
인터페이스를 구현하면 된다.
org.springframework.http.client.reactive
패키지에 ClientHttpConnector
인터페이스가 정의되어 있다. ClientHttpConnector
는 요청 대상 서버에 HTTP 연결을 하기 위한 HTTP 클라이언트를 추상화하여 ClientHttpRequest
를 요청으로 보내고 ClientHttpResponse
를 응답으로 받는 데 필요한 모든 인프라를 제공한다.
ClientHttpConnector
인터페이스에 정의되어 있는 connect()
메서드는 HTTP 메서드, URI, 요청 콜백 함수형 인터페이스를 인자로 받는다. 이 메서드는 주어진 HTTP 메서드와 URI를 사용하여 요청 대상 서버에 연결을 시도하고 연결이 맺어진 후 요청이 초기화 및 작성 가능한 상태가 되면 주어진 요청 콜백을 적용한다. 세 번째 인자인 요청 콜백은 요청을 준비하고 작성하는 함수이다. 콜백은 요청 작성이 완료되면, 시그널을 보내는 업스트림 데이터 스트림인 발행자(publisher)를 반환한다. 구현체는 Mono<Void>
타입을 반환할 수 있다.
각각의 HTTP 클라이언트들에 대해 ClientHttpConnector
인터페이스에 대한 구현체들이 정의되어 있다. 예컨대 리액터 네티가 제공하는 클라이언트인 HttpClient
의 ClientHttpConnector
구현체는 ReactorClientHttpConnector
이며, 아파치 HttpComponents
가 제공하는 클라이언트인 HttpClient
5.x 버전의 구현체는 HttpComponentsClientHttpConnector
이다.
WebClient
에는 다음과 같은 정적 인터페이스가 정의되어 있으며 구현체들은 이를 기반으로 요청 및 응답 처리에 필요한 기능 구현을 제공한다.
WebClient.Builder
:WebClient
인스턴스 생성을 위한 가변(mutable) 빌더WebClient.RequestBodySpec
: 요청 데이터 명세 관련 처리WebClient.RequestHeadersSpec<S extends WebClient.RequestHeadersSpec<S>>
: 요청 헤더 지정WebClient.ResponseSpec
: 응답 데이터 명세 관련 처리WebClient.UriSpec<S extends WebClient.RequestHeadersSpec<?>>
: 요청 URI 지정
WebClient 인스턴스 생성과 구성
WebClient
인스턴스를 생성하는 가장 간단한 방법은 팩토리 메서드인 create()
메서드를 사용하는 것이다. 인자 없이 호출하거나 URL을 인자로 넘길 수 있다.
WebClient.create()
WebClient.create(String baseUrl)
위 경우 WebClient
는 불변(immutable) 객체이므로 한 번 인스턴스화하고 나면 속성을 변경할 수 없다.
두 번째 방법은 builder()
메서드를 사용하여 WebClient.Builder
인터페이스 구현체를 인스턴스화한 후 빌더 메서드를 사용하여 다양한 속성을 설정한 후 인스턴스화 하는 것이다. WebClient
는 필요에 따라 적절히 인스턴스화 해야하며 그렇지 않을 경우 리소스 관련 이슈가 발생할 수 있다.
WebClient client = WebClient.builder()
.빌더메서드.build();
WebClient client = WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.baseUrl(url)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
WebClient
는 불변 객체이므로 인스턴스화된 후 속성을 변경할 수 없다. 대신 인스턴스를 복제하고 속성을 변경하여 변경된 복제 인스턴스를 사용할 수 있으며 이 경우 원본 인스턴스에 영향을 주지 않는다. 다음과 같이 WebClient
의 mutate()
메서드를 사용하여 인스턴스를 복제한 후 WebClient.Builder
인터페이스의 메서드를 사용하여 가변(mutable) 구현체의 속성을 변경하면 된다.
WebClient client1 = WebClient.builder()
.빌더메서드.build();
WebClient client2 = client1.mutate()
.빌더메서드.build();
빌더 메서드 중 clientConnector()
메서드는 ClientHttpConnector
를 인자로 받아 WebClient
가 해당 커넥터를 사용하도록 구성한다. 이 메서드를 사용하여 HTTP 클라이언트 라이브러리의 기본 설정을 사용자 정의할 수 있다. 기본적으로 ClientHttpConnector
는 ReactorClientHttpConnector
로 설정되어 있다.
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
WebClient client = WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.baseUrl(url)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
HTTP 요청 시 커넥션은 내부 라이브러리에 의해 관리된다. 기본적으로 내부 라이브러리에서 커넥터인 ClientHttpConnector
구현체는 재사용되므로 애플리케이션 상에서 커넥션 풀이 공유된다. 커넥션 풀을 공유하는 대신 새로운 커넥션을 생성하여 사용하는 것이 필요하다면 새로운 WebClient
인스턴스를 생성하면 된다.
요청 처리
URI 경로 및 쿼리 파라미터 컴포넌트 구성
URI 경로(path) 컴포넌트와 쿼리 파라미터 컴포넌트를 구성하기 위해 UriBuilder
인터페이스를 구현한 UriComponentsBuilder
를 사용할 수 있다. URI
를 생성하고 RequestHeadersUriSpec
인터페이스의 uri()
메서드의 인자로 전달하면 된다. 경로 구성 시 pathSegment()
, 쿼리 파라미터 구성 시 queryParam()
메서드를 사용한다. build()
메서드를 사용하여 경로 세그먼트(segment)를 변수로 전달할 수 있다. URI 템플릿 변수({}
)로 표기한 경로 세그먼트에 인자를 차례대로 전달한다. 인자를 전달받는 build()
메서드는 URI를 반환하지만, 인자를 받지 않는 build()
메서드는 UriComponents
를 반환하므로 URI
를 생성하기 위해 toUri()
메서드를 마지막으로 호출해야 한다.
// URI 생성
URI uri = UriComponentsBuilder.fromUri(URI.create(baseUrl))
.pathSegment("segment1", "{segment2}", "segment3")
.queryParam("key1", "value1")
.queryParam("key2", "value2")
.queryParam("key3", "{value3}")
.build("segment2", "value3")
uri = UriComponentsBuilder.fromUri(URI.create(baseUrl))
.pathSegment("segment1", "segment2", "segment3")
.queryParam("key1", "value1")
.queryParam("key2", "value2")
.queryParam("key3", "value3")
.build()
.toUri()
// 요청 및 응답
WebClient.ResponseSpec result = client
.get()
.uri(uri) // "baseUrl/segment1/segment2/segment3?key1=value1&key2=value2&key3=value3"
.retrieve()
요청 바디 구성
응답 처리
retrieve()
WebClient.RequestHeadersSpec
인터페이스는 retrieve()
메서드를 제공한다. 이 메서드가 반환하는 WebClient.ResponseSpec
인터페이스를 사용하여 응답으로부터 데이터를 추출하는 방법을 선언한다.
응답 데이터로부터 상태, 헤더 및 본문을 갖는 ResponseEntity
를 추출하려면 entity()
메서드를 사용하여 다음과 같이 코드를 작성한다.
Mono<ResponseEntity<Entity>> entityMono = client.get()
.uri("URI")
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toEntity(Entity.class);
응답 본문만 추출하려면 bodyToMono()
를 메서드를 사용한다.
Mono<Entity> entityMono = client.get()
.uri("URI")
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(Entity.class);
JSON 문자열 데이터의 필드와 객체의 필드명이 동일하다면 역직렬화하기 위해 잭슨 라이브러리가 제공하는 @JsonProperty
어노테이션을 사용할 필요는 없다.
기본적으로, 서버의 4xx 및 5xx 응답은 WebClientResponseException
을 발생시킨다. 예외 처리를 사용자 정의하려면 onStatus()
핸들러를 사용하면 된다.
스레드 안전성
WebClient
는 비동기 논블로킹 기반 리액티브 프로그래밍을 위해 스프링 웹플럭스가 제공하는 HTTP 클라이언트이다. 따라서 불변 객체이며 스레드 안전하다.
예외
요청 도중 오류가 발생할 경우 WebClient
가 발생시키는 예외에 대한 추상 기본 클래스는 org.springframework.web.reactive.function.client
패키지의 WebClientException
이다. 요청 예외인 WebClientRequestException
와 응답 예외인 WebClientResponseException
는 WebClientException
의 서브 클래스이다.
타임아웃 설정
WebClient
를 사용한 HTTP 요청 시 설정 가능한 타임아웃의 종류는 다음과 같다.
- HTTP 클라이언트 타임아웃 설정
- 연결(connection) 타임아웃
- 읽기(read) 타임아웃
- 쓰기(write) 타임아웃
- 응답(response) 타임아웃
- 요청 별 타임아웃 설정
- 응답 타임아웃
- 리액티브(또는 시그널) 타임아웃
HTTP 클라이언트의 타임아웃을 설정하는 것은 WebClient
인스턴스에 대해 전역적으로 설정하는 것이다.
연결 타임아웃은 클라이언트와 서버(원격 호스트) 간의 연결이 설정되어야 하는 시간인 TCP 연결(TCP 핸드셰이크) 시간에 대한 타임아웃을 말한다. HTTP 클라이언트가 동기적으로 동작하든 비동기적으로 동작하든 동일하게 TCP 프로토콜을 사용하기 때문에 TCP 연결 타임아웃은 동일한 의미이다. 리액터 네티 클라이언트의 연결 타임아웃에 대한 기본 설정은 30초이다. 주어진 시간에 TCP 연결이 설정되지 않거나 제거되면, io.netty.channel.ConnectTimeoutException
예외가 발생한다. 리액터 네티의 HTTP 클라이언트인 HttpClient
의 연결 속성 중 연결 타임아웃을 설정하는 코드는 다음과 같다.
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
HttpClient
의 연결 속성 설정 후 ReactorClientHttpConnector
객체에 주입하여 설정한다.
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
WebClient
는 ConnectTimeoutException
발생 시 해당 예외를 WebClientRequestException
로 래핑하여 발생시킨다. 이러한 예외 래핑 처리는 ExchangeFunction
인터페이스 구현체인 DefaultExchangeFunction
정적 클래스의 wrapException()
메서드에 의해 수행된다.
읽기 타임아웃은 TCP 연결이 성공적으로 설정되고 클라이언트가 서버에 요청을 보낸 이후 일정 시간 내에 아무런 데이터를 읽지 못하는 경우에 대해 설정하는 타임아웃이다. 읽기 타임아웃은 연결을 설정한 후 서버로부터 데이터를 기다리는 시간에 대한 것으로, 두 데이터 패킷 사이의 최대 비활성 시간이다. 쓰기 타임아웃은 일정 시간 내에 쓰기 작업이 완료되지 않는 경우에 대해 설정하는 타임아웃이다. 읽기와 쓰기 각각의 타임아웃에 대해 핸들러를 등록하여 타임아웃 설정이 가능하다. 타임아웃 발생 시 각각 ReadTimeoutException
와 WriteTimeoutException
예외가 발생한다.
HttpClient client = HttpClient.create()
.doOnConnected(conn -> conn
.addHandler(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
.addHandler(new WriteTimeoutHandler(10)));
ReadTimeoutException
과 WriteTimeoutException
은 TimeoutException
의 서브 클래스이며 이 예외 클래스들은 모두 io.netty.handler.timeout
패키지에 정의되어 있다. ReadTimeoutException
은 ReadTimeoutHandler
에 의해, WriteTimeoutException
은 WriteTimeoutHandler
에 의해 발생하는 TimeoutException
이다.
응답 타임아웃은 TCP 연결이 성공적으로 설정되고 클라이언트가 서버에 요청을 보낸 이후 전체 데이터 중 일부를 읽기 시작한 후 전체 데이터를 읽기까지 걸리는 시간에 대한 타임아웃이다. 즉, 지정된 응답을 읽는 작업에 허용되는 최대 시간을 설정하는 타임아웃이다. 주어진 시간에 응답이 완료되지 않은 경우, io.netty.handler.timeout.ReadTimeoutException
예외가 발생한다. 리액터 네티 클라이언트는 응답 타임아웃에 대한 기본값이 설정되어 있지 않다.
HttpClient client = HttpClient.create()
.responseTimeout(Duration.ofSeconds(1));
리액터 네티 클라이언트의 경우 서버에 요청을 전송하면 채널 파이프라인에 ReadTimeoutHandler
가 추가되고 서버로부터 응답이 완전히 수신되면 제거된다. 따라서 ReadTimeoutHandler
는 읽기 타임아웃과 응답 타임아웃을 모두 처리한다고 볼 수 있다.
WebClient
는 응답 타임아웃에 대한 ReadTimeoutException
발생 시 해당 예외를 WebClientResponseException
으로 래핑하여 발생시킨다. 이러한 예외 래핑 처리는 ClientResponse
인터페이스 구현체인 DefaultClientResponse
클래스
의 createException()
메서드에 의해 수행된다.
리액터 네티의 HttpClient
대신 아파치 HttpComponents
의 HttpClient
를 HTTP 클라이언트로 사용하는 경우 타임아웃 설정은 다음과 같이 할 수 있다.
전역 설정과 독립적으로 요청 별로 응답 타임아웃을 설정할 수도 있다. WebClient
의 httpRequest()
메서드를 사용하여 요청 시 응답 타임아웃을 설정하면 된다. httpRequest()
메서드는 리액터 네티 라이브러리의 네이티브 HttpClientRequest
에 접근한다. 이러한 응답 타임아웃 설정은 HttpClient
의 응답 타임아웃 설정을 재정의한다.
webClient.get()
.uri("URI")
.httpRequest(httpRequest -> {
HttpClientRequest reactorRequest = httpRequest.getNativeRequest();
reactorRequest.responseTimeout(Duration.ofSeconds(2));
});
웹플럭스는 비동기 데이터 스트림을 처리하고 한 데이터 스트림이 다른 데이터 스트림으로 시그널을 전파함으로써 비동기적으로 데이터를 전달하는 처리를 하므로 시그널 전파와 관련된 리액티브 타임아웃이 존재한다. 리액티브 타임아웃은 발행자가 주어진 시간 내에 onNext()
시그널을 방출하지 못한 경우 발생하는 타임아웃을 말한다. 이 타임아웃은 TCP 연결 타임아웃과 전혀 관계가 없다.
Publisher
인터페이스(리액터 타입)의 timeout()
연산자는 리액티브 타임아웃에 대한 설정을 수행한다. 즉, timeout()
메서드는 서버에 대한 TCP 연결을 설정하는 것부터 응답을 받기까지의 전체 작업에 대한 타임아웃 설정이다.
webClient.get()
.uri(uri)
.retrieve()
.bodyToMono(JsonNode.class)
.timeout(Duration.ofMillis(timeout));
timeout()
메서드는 주어진 시간 내에 항목이 다운스트림에 하나도 전달되지 않은 경우 java.util.concurrent.TimeoutException
을 전파한다.이 예외는 주어진 시간 동안 이벤트가 방출되지 않음을 나타낸다. 타임아웃 발생 시 나타나는 로그는 다음과 같다.
java.lang.AssertionError: expectation "assertNext" failed (expected: onNext(); actual: onError(java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 3000ms in 'flatMap' (and no fallback has been configured)))
onNext()
시그널이 예상되었지만 onError()
시그널이 발생하였고 TimeoutException
예외가 전달되었음을 알 수 있다. 타임아웃 발생 시 onError()
시그널이 전파될 때 특정 코드를 실행하기 위해서는 doOnError()
메서드를 사용한다.
webClient.get()
.uri(uri)
.retrieve()
.bodyToMono(JsonNode.class)
.timeout(Duration.ofMillis(timeout));
.doOnError(error -> 에러처리코드);
timeout()
메서드의 타임아웃 설정은 HttpClient
의 설정을 재정의하지 않는다.
로깅
리액터 네티 HTTP 클라이언트 사용 시 요청과 응답 데이터를 로깅하는 방법은 다음과 같다.
WebClient
의 필터 기능 사용- HTTP 클라이언트 구현체의 기능 사용
WebClient
의 필터 사용 방법은 다음과 같다. 필터 함수 생성 시 요청과 응답과 관련된 객체를 인자로 전달 받는 함수를 사용한다.
ExchangeFilterFunction requestLoggingFilter = ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
로깅 처리
return Mono.just(clientRequest);
};
ExchangeFilterFunction responseLoggingFilter = ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
로깅 처리
return Mono.just(clientResponse);
};
WebClient webClient = WebClient.builder()
.filter(loggingFilter)
.build();
HTTP 클라이언트 구현체의 기능을 사용하는 방법은 구현체 마다 다르다. 먼저 리액터 네티의 HTTP 클라이언트가 제공하는 wiretap()
메서드를 사용한 로깅 방법은 다음과 같다.
HttpClient httpClient = HttpClient
.create()
.wiretap(true)
WebClient
.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build()
이때, 로그 포맷을 읽을 수 있는 형태로 설정하기 위해 wiretap()
메서드에 다음과 같이 인자를 전달한다.
wiretap("reactor.netty.http.client.HttpClient",
LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL)
제티의 리액티브 HTTP 클라이언트 사용하여 다음과 같이 데이터를 로깅할 수도 있다.
코덱과 버퍼
스프링 웹플럭스는 HTTP를 통해 전송되는 데이터의 직렬화 및 역직렬화를 위한 다양한 코덱(codec)을 통해 요청 및 응답 데이터를 특정 형태로 처리한다. HTTP 코덱은 논블로킹 I/O 및 리액티브 스트림의 역압으로 바이트 배열 데이터를 객체로 역직렬화(deserialization)하고 객체를 바이트 배열 데이터로 직렬화(serialization)한다.
HTTP 프로토콜과 관계 없이 데이터의 인코딩 및 디코딩을 하기 위해서는 org.springframework.core.codec
의 Encoder
와 Decoder
인터페이스의 다양한 구현체를 사용한다. 클라이언트와 서버 간 요청 및 응답 데이터인 HTTP 메시지(HTTP messsage)를 처리하기 위해서는 org.springframework.http.codec
의 HttpMessageReader
와 HttpMessageWriter
인터페이스의 다양한 구현체를 사용한다. Encoder
와 Decoder
는 각각 HttpMessageWriter
와 HttpMessageRdaer
에 의해 래핑되어 웹 애플리케이션을 위해 사용될 수도 있다. Encoder
의 래핑 클래스는 EncoderHttpMessageWriter
, Decoder
의 래핑 클래스는 DecoderHttpMessageReader
이다. EncoderHttpMessageWriter
는 Encoder
를 반환하는 getEncoder()
메서드를, DecoderHttpMessageReader
는 Decoder
를 반환하는 getDecoder()
메서드를 제공한다.
바이트 배열 데이터를 버퍼로 처리하는 경우 바이트 버퍼의 추상화를 위한 org.springframework.core.io.buffer
의 DataBuffer
를 사용할 수 있다. 바이트 버퍼 객체의 예로는 자바의 java.nio.ByteBuffer
, 네티의 io.netty.buffer.ByteBuf
등이 있다. 배열 데이터를 담은 버퍼 객체를
스프링 코어 모듈은 byte[]
, ByteBuffer
, DataBuffer
, Resource
, String
타입에 대한 인코더 및 디코더 구현을 제공하며, 스프링 웹 모듈은 폼 데이터(form data), 멀티파트(multipart) 콘텐츠, 서버 전송 이벤트(server-side event, SSE) 등을 위한 웹 전용 HTTP 메시지 입력 및 출력기 구현체와 함께 잭슨 JSON, 잭슨 스마일(Smile), JAXB2, 프로토콜 버퍼 등의 인코더와 디코더를 제공한다.
애플리케이션에서 사용할 코덱을 구성하고 사용자 정의하려면 클라이언트 측에서는 ClientCodecConfigurer
를 사용하고, 서버 측에서는 ServerCodecConfigurer
을 사용한다. WebClient
를 통한 HTTP 요청 시 codecs()
의 인자로 Consumer<ClientCodecConfigurer>
를 전달하여 기본 ExchangeStrategies
에서 WebClient
에 대한 코덱을 구성한다. 대부분의 경우 exchangeStrategies()
메서드 대신 codec()
메서드를 사용하여 기본 ExchangeStrategies
에서 코덱을 사용자 정의할 수 있는 코덱을 사용하는 것이 권장된다.
클라이언트인 경우 HTTP 요청의 쓰기 및 응답의 쓰기와 관련된 코덱 구성을 하기 위해 ClientCodecConfigurer
과 codec()
을 사용한다.
WebClient client = builder
.defaultHeader("Accept", "image/*")
.defaultHeader("Accept-Encoding", "gzip", "deflate", "br")
.defaultHeader("Connection", "keep-alive")
.baseUrl(requestUrl)
// ClientCodecConfigurer
.codecs { configurer -> configurer.defaultCodecs().maxInMemorySize(500 * 1024) }
.clientConnector(ReactorClientHttpConnector(httpClientForTls13))
.build()
ObjectMapper 구성
전역 ObjectMapper
빈의 구성을 변경하는 경우 해당 빈을 사용하는 모든 곳에 대해 인코딩 및 디코딩 구성이 적용되므로 주의해야 한다. 특정 기능에서만 별도의 구성이 필요하다면 직접 인스턴스화를 하여 사용하거나 구성 별 여러 인스턴스를 빈 등록 후 탐색 또는 빈 주입하오 사용하는 것이 좋다.
버퍼
OSI 모델의 통신 규약에 따라 애플리케이션 간 적절한 네트워크 통신이 이루어질 수 있는 환경이 구성되었다면 서로 다른 애플리케이션들은 HTTP 뿐만 아니라 다양한 애플리케이션 계층의 프로토콜을 통해 서로 데이터(바이너리 데이터)를 주고 받을 수 있다. 데이터는 애플리케이션 레벨에서 바이트 배열로 처리된다. 바이트 배열은 플랫폼에 종속적이지 않으면서 데이터의 다양한 형식으로 변환할 수 있는 낮은 수준의 표현이다. 데이터를 어떤 형식으로 주고 받는 처리를 어떻게 할 것인지는 애플리케이션 레벨에서 결정하며, 바이트 배열은 해당 형식에 따라 다른 형식으로 변환된다.
네트워크를 통해 주고 받는 데이터의 크기가 매우 큰 경우 해당 데이터를 완전히 구성한 후 송수신하게 되면 데이터 전송 시간이 길어지며, 전송 중 오류가 발생하는 경우 처음부터 재전송이 필요할 수 있지만 입출력은 한 번만 발생하므로 리소스의 부하가 적다. 하지만 데이터를 여러 부분으로 나누어 전송하는 경우 데이터 전송 시간은 짧지만 입출력이 여러 번 발생하므로 리소스 부하가 상대적으로 크다. 또한 분할된 데이터의 송수신 속도가 빠르고 그 주기가 짧을수록 애플리케이션(프로세스)이 해당 데이터를 처리하기 위해 CPU(연산 처리)나 메모리(데이터와 기능 처리) 사용률이 증가하게 된다. 애플리케이션이 처리할 수 있는 능력 보다 많은 양의 데이터를 송수신하게 되면 그 차이 만큼 성능 저하가 발생하게 된다.
데이터를 리소스 면에서 효율적으로 처리하기 위해 데이터의 임시 저장소인 버퍼(buffer)라는 메모리 공간을 사용하는 것이 일반적이다. 버퍼는 메모리 상에 데이터를 일시적으로 저장하고 애플리케이션이 필요한 시점에 해당 데이터를 처리할 수 있도록 도와주므로 네트워크를 통한 데이터 송수신 처리에 있어 애플리케이션의 성능 부담을 줄여준다.
WebClient
를 통해 HTTP 요청에 대한 응답을 바이트 배열로 처리하는 방법은 다음과 같다.
바이트 배열을 원시 타입으로 처리하는 경우 자바의 byte[]
나 코틀린의 ByteArray
를 사용할 수 있다.
네티의 레퍼런스 카운트 객체
네티 4 버전 이후 객체의 생명주기는 레퍼런스 카운트(reference count)에 의해 관리된다. 애플리케이션에서 객체를 참조하지 않는 경우 더 이상 사용되지 않는 해당 객체는 이후 재사용을 위해 객체 풀(object pool)로 반환된다. 가비지 콜렉션의 객체의 도달 가능성(reachability) 상태를 관리하기 위한 레퍼런스 큐(reference queue)는 객체의 도달 가능성 상태를 실시간으로 효율적으로 보장하지 못하며 네티는 레퍼런스 카운트 관리를 통해 약간의 불편함을 감수하고 대체 메커니즘을 제공한다. 레퍼런스 카운트 속성을 갖는 객체를 레퍼런스 카운트 객체(reference counted object)라고 한다.
네티는 객체의 레퍼런스 카운트가 0이 되면 객체를 풀에 반환하고 이를 재사용하도록 함으로써 메모리 할당과 해제 성능을 개선한다. 레퍼런스 카운트 객체의 초기 레퍼런스 카운트는 1이며, 객체를 릴리즈하면 카운트가 1씩 감소한다. 카운트가 0이 되면 객체는 메모리 할당이 해제되거나 원래의 객체 풀로 반환된다. 만약 레퍼런스 카운트가 0인 객체에 접근을 시도하면 IllegalReferenceCountException
예외가 발생하게 된다. 가비지 컬렉터에 의해 컬렉션(소멸) 되지 않은 객체의 레퍼런스 카운트는 retain()
메서드를 통해 1씩 증가시킬 수 있다.
WebClient
를 통한 요청 및 응답 데이터를 바이트 배열 형식으로 처리할 때 버퍼를 이용하는 경우 바이트 버퍼 추상화를 위한 인터페이스인 org.springframework.core.io.buffer.DataBuffer
를 사용할 수 있다. 리액터 네티를 사용하는 경우 버퍼 객체의 구현체인 io.netty.buffer.ByteBuf
가 사용되어 네티에 의해 버퍼 객체에 대한 레퍼런스 카운트가 증감되는 처리가 발생한다. 이때,
io.netty.util.IllegalReferenceCountException: refCnt: 0, decrement: 1
참고
- https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client
- https://www.baeldung.com/java-socket-connection-read-timeout
- https://netmarble.engineering/java-nio-httpclient-test/
- https://www.baeldung.com/spring-5-webclient
- https://www.baeldung.com/httpclient-timeout
- https://netty.io/wiki/reference-counted-objects.html
- https://www.baeldung.com/webclient-stream-large-byte-array-to-file
- https://learn.microsoft.com/ko-kr/troubleshoot/windows-server/networking/overview-of-tcpip-performance
- https://www.baeldung.com/spring-log-webclient-calls
Comments