Spring WebClient 외부 API 호출하기
Perform HTTP Requests with Spring WebClient
Spring Framework 6가 공개되면서 현재 총 네 개의 Rest Clients가 제공되고 있습니다.
Rest Clients | Description |
---|---|
RestClient | 모던한 API를 제공하며 동기 방식의 HTTP 서비스를 제공합니다. |
WebClient ⭐ | 모던한 API를 제공하면서 논블록킹 방식의 리액티브한 HTTP 서비스를 제공합니다. |
RestTemplate | 템플릿 메서드 API를 통한 동기 방식의 HTTP 서비스를 제공합니다. |
HTTP Interface | 어노테이션이 추가된 Java의 interface와 동적 프록시 구현체를 통해 HTTP 서비스를 제공합니다. |
이번 포스팅에서는 상황에 따라 블록킹 또는 논블록킹 방식을 선택할 수 있고 비교적 편리하게 사용할 수 있는 WebClient
를 통해 외부 API를 호출하고 응답 데이터 및 예외를 처리하는 방법에 대해 살펴봅니다.
Dependencies
WebClient
는 Spring의 Reactive-Stack Web Framework인 WebFlux 기반 Rest Client입니다. 그래서 WebClient
를 사용하기 위해서는 WebFlux 의존성을 추가해야 합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
- Java 17
- Spring Boot 3.1.5
Creating WebClient
Spring에서 제공하는 WebClient
API를 사용하기 위해서는 먼저 WebClient
객체를 생성해야 합니다. 편리하게 객체 생성을 할 수 있도록 create()
와 builder()
팩토리 메서드를 제공하고 있습니다.
WebClient.create()
팩토리 메서드를 활용하는 경우 기본 옵션이 적용된 객체가 생성됩니다.
private WebClient createWebClient() {
return WebClient.create();
}
WebClient.create()
WebClient.create(String baseUrl)
개인적으로는 추가적인 설정이 가능한 WebClient.builder()
방식을 선호합니다.
private WebClient createWebClient() {
return WebClient.builder()
.build();
}
- uriBuilderFactory: 요청하는 서버에 대한 기본 URL을 지정합니다. 이를 통해 여러 요청에서 공통적으로 사용할 기본 URL을 설정할 수 있습니다.
- defaultUriVariables: URI 템플릿을 확장할 때 사용할 기본값을 지정합니다. URI 템플릿 내의 변수들을 미리 설정하여 다양한 요청에서 일관되게 사용할 수 있습니다.
- defaultHeader: 모든 요청에 포함할 HTTP Header를 지정합니다. 예를 들어, 공통적으로 포함되어야 하는 인증 토큰이나 콘텐츠 타입을 설정할 수 있습니다.
- defaultCookie: 모든 요청에 포함할 HTTP Cookie를 지정합니다. 이를 통해 매 요청마다 동일한 쿠키를 포함시킬 수 있습니다.
- defaultRequest: 각 요청을 커스터마이즈할 수 있는
Consumer
를 지정합니다. 이를 통해 모든 요청에 공통적으로 적용할 설정이나 변환 로직을 정의할 수 있습니다. - filter: 모든 요청에 포함할 필터를 추가합니다. 이를 통해 요청을 가로채고 필요한 처리를 수행할 수 있습니다.
- exchangeStrategies: HTTP 메시지 Reader/Writer를 커스터마이즈할 수 있습니다. 이를 통해 데이터 직렬화 및 역직렬화 방식을 설정할 수 있습니다.
- clientConnector: HTTP Client 라이브러리를 설정합니다. 이를 통해 네트워크 설정이나 타임아웃 등을 커스터마이즈할 수 있습니다.
- observationRegistry: 관찰 가능성 지원을 활성화하기 위한 레지스트리를 설정합니다. 이를 통해 요청과 응답의 메트릭을 수집하고 모니터링할 수 있습니다.
- observationConvention: 기록된 관찰에 대한 메타데이터를 추출하기 위한 커스텀 규칙을 설정할 수 있습니다. 이를 통해 모니터링 데이터의 커스터마이징이 가능합니다.
Sending Requests with WebClient
다음은 WebClient
를 통해 기본적인 CRUD REST API를 호출하는 방법입니다. 먼저, 위에서 살펴본 WebClient.builder()
팩토리 메서드를 활용하여 객체를 생성합니다.
private WebClient createWebClient() {
return WebClient.builder()
.baseUrl("https://app.catsriding.dev")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
baseUrl()
: 요청 서버의 기본 URL을https://app.catsriding.dev
로 지정합니다.defaultHeader()
: HTTP 기본 헤더로Content-Type
을application/json
으로 지정합니다.
GET Request
GET
방식의 API를 호출하여 서버로 부터 데이터 조회를 요청합니다.
public WebClientResponse get() {
return createWebClient()
.get()
.uri("/items")
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
webClient.get()
:HTTP GET
메서드를 지정합니다.uri("/items")
- API의 패스를 지정합니다.
baseUrl
뒤에uri()
로 지정한 값이 추가됩니다.https://app.catsriding.dev/items
GET Request with Query Parameters
GET
요청시 쿼리 파라미터를 추가해야 하는 경우 uriBuilder
의 queryParam()
을 통해 추가할 수 있습니다.
public WebClientResponse get(ItemParam param) {
return createWebClient()
.get()
.uri(uriBuilder -> uriBuilder
.path("/items")
.queryParam("id", param.getId())
.queryParam("author", param.getAuthor())
.build())
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
queryParam(name, value)
: 첫 번째 파라미터는 Query Paramter의 이름을 지정하고 두 번째는 Query Parameter의 값을 입력합니다.
GET /items?id=1&author=jynn HTTP/1.1
Content-Type: application/json
Host: app.catsriding.dev
GET Request with Path Variable
GET
요청시 동적으로 Path Variable 추가해야 한다면 uriBuilder
의 pathSegment()
를 활용합니다.
public WebClientResponse get(ItemParam param) {
return createWebClient()
.get()
.uri(uriBuilder -> uriBuilder
.path("/items")
.pathSegment("{id}")
.build(param.getId()))
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
pathSegment("{id}")
: 동적으로 URL 경로를 추가합니다.
GET /items/123 HTTP/1.1
Content-Type: application/json
Host: app.catsriding.dev
POST Request
POST
방식의 API를 호출하여 새로운 데이터 생성을 요청합니다. 전반적인 프로세스는 GET
요청과 유사하며 가장 큰 차이점은 HTTP 요청 바디가 추가된 것입니다.
public WebClientResponse post(ItemCreateRequest request) {
return createWebClient()
.post()
.uri("/items")
.bodyValue(request)
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
webClient.post()
:HTTP POST
메서드를 지정합니다.bodyValue(Object)
: 요청 바디를 추가합니다.
POST /items HTTP/1.1
Content-Type: application/json
Host: app.catsriding.dev
Content-Length: 67
{
"name": "apple",
"price": 1000,
"category": 6,
"tag": 5
}
일반적으로, CRUD의 UPDATE와 DELETE는 READ & WRITE의 응용입니다.
PATCH Request
PATCH
또는 PUT
방식의 API를 호출하여 기존 데이터 변경을 요청합니다.
public WebClientResponse patch(ItemParam param, ItemUpdateRequest request) {
return createWebClient()
.patch()
.uri(uriBuilder -> uriBuilder
.pathSegment("{id}")
.build(param.getId()))
.bodyValue(request)
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
UPDATE API는 주로 Path Variable로 변경할 데이터의 Key를, 그리고 요청 바디로 변경 값을 전달하는 형태입니다. pathSegment()
를 통해 ID를 URL 패스에 바인딩 하고 bodyValue()
에 요청 데이터를 담았습니다.
PATCH items/123 HTTP/1.1
Content-Type: application/json
Host: app.catsriding.dev
Content-Length: 67
{
"price": 3000,
}
DELETE Request
DELETE
방식의 API를 호출하여 기존 데이터 삭제를 요청합니다.
public WebClientResponse delete(Long id) {
return createWebClient()
.delete()
.uri(uriBuilder -> uriBuilder
.pathSegment("{id}")
.build(id))
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
Path Variable로 ID 값을 바인딩하여 DELETE API를 호출합니다.
DELETE /items/123 HTTP/1.1
Content-Type: application/json
Host: app.catsriding.dev
Receiving Responses with WebClient
외부 서버로 API 요청을 보내면 해당 서버에서 요청을 처리한 다음 그 결과를 반환합니다. WebClient
요청에 대한 응답 결과를 핸들링하는 방법에 대해 살펴보겠습니다.
Response with Payload
GET
방식의 API를 요청한 경우 응답 페이로드에 요청한 데이터가 담겨 있을 것입니다. 위에서 HTTP 요청을 보낼 때 마지막에 항상 반복되는 패턴이 있었습니다.
public WebClientResponse get() {
return createWebClient()
...
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
}
retrieve()
: 응답 결과 처리 프로세스 시작을 선언하는 Operator 입니다.bodyToMono()
: 응답 바디를 파라미터로 전달한 객체로 바인딩 합니다.block()
: 응답 결과가 전달될 때까지 기다리는 Blocking 방식입니다.
응답 바디 객체는 API 스펙에 맞춰 정의하면 됩니다. 다음과 같은 응답 데이터가 전달된다고 가정했을 때,
{
"code": 200,
"phrase": "OK",
"item": {
"id": 1,
"name": "apple",
"price": 1000
}
}
위 API 응답 스펙에 맞춘 WebClientResponse
객체는 다음과 같이 정의할 수 있습니다.
public class WebClientResponse {
private String code;
private String phrase;
private Item item;
public static class Item {
private Long id;
private String name;
private int price;
}
}
Java의 Generic을 활용하여 WebClient
공통 응답 객체를 정의할 수도 있습니다.
public class WebClientResponse<T> {
private String code;
private String phrase;
private T data;
}
Response without Body
API 요청에 대하여 응답 바디가 없거나 극단적으로는 그 요청 처리 결과를 확인할 필요가 없는 경우도 있습니다. 이런 상황에 대응할 수 있는 다양한 Operator를 제공하고 있으며, 여기서는 toBodilessEntity()
를 활용하였습니다.
public void requestOnly() {
createWebClient()
...
.retrieve()
.toBodilessEntity()
.subscribe(voidResponseEntity -> log.info("requestOnly: Requested Api"));
}
toBodilessEntity()
: 응답 바디가 없는ResponseEntity
를 반환합니다.subscribe()
: Non-Blocking 방식으로 응답을 처리합니다.
Exceptions
API 요청 스펙이 올바르지 않거나 외부 서버에 문제가 생긴 경우, 해당 서버에서는 HTTP Status Code 4xx 또는 5xx 에러를 반환할 것입니다. 이와 같은 경우를 대비하여 반드시 예외 처리를 해야 합니다.
Exception Filter
외부 API 호출 시 공통 예외 처리 로직을 구현해야 한다면 WebClient
객체를 생성하는 과정에서 예외 처리 필터를 추가하는 방법이 있습니다.
private WebClient createWebClient() {
return WebClient.builder()
...
.filter(exceptionFilter())
.build();
}
private static ExchangeFilterFunction exceptionFilter() {
return ExchangeFilterFunction.ofResponseProcessor(
clientResponse -> {
if (clientResponse.statusCode().is4xxClientError()) {
return clientResponse.bodyToMono(WebClientErrorResponse.class)
.flatMap(errorBody -> Mono.error(new IllegalArgumentException(errorBody.getPhrase())));
} else if (clientResponse.statusCode().is5xxServerError()) {
return clientResponse.bodyToMono(WebClientErrorResponse.class)
.flatMap(errorBody -> Mono.error(new RuntimeException(errorBody.getPhrase())));
} else {
return Mono.just(clientResponse);
}
});
}
Mono
: 0 또는 1개의 데이터를 다루는 데 특화된 Reactor의 Publisher입니다.just()
: 데이터를 생성해서 제공하는 역할을 하는 Operator입니다.flatMap()
: 입력된 데이터를 Publisher로 전환하는 Operator입니다.
Exception Handler
API 단위로 예외 처리를 해야 하는 경우에는 onStatus()
Operator를 사용하여 응답 상태에 따른 예외 처리 로직을 별도로 구성할 수 있습니다.
public void requestWithExceptionHandler() {
try {
createWebClient()
.post()
.uri("/items")
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, clientResponse ->
Mono.just(new IllegalArgumentException("Bad Request, please check your requests.")))
.onStatus(HttpStatusCode::is5xxServerError, clientResponse ->
Mono.just(new ExternalServerException("Internal Server Error.")))
.toBodilessEntity()
.subscribe(voidResponseEntity -> log.info("exceptionHandling: Failed requests"));
} catch (RuntimeException e) {
throw new ExternalServerException("We are investigating reports of issues with services.");
}
}
HTTP Connection Timeout
네트워크 상태가 불안정하거나 외부 서버에 문제가 발생하여 요청 처리가 지연되는 경우 무작정 대기하다간 다른 서비스까지 마비될 수 있습니다. 그래서 요청에 대한 Timeout 제한 시간을 설정하는 것이 필요합니다.
private WebClient createWebClientWithTimeout() {
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(createHttpClient()))
.build();
}
private static HttpClient createHttpClient() {
return HttpClient.create()
.responseTimeout(Duration.ofSeconds(15))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 20000)
.doOnConnected(connection -> connection
.addHandlerLast(new ReadTimeoutHandler(500, TimeUnit.MILLISECONDS))
.addHandlerLast(new WriteTimeoutHandler(1000, TimeUnit.MILLISECONDS))
);
}
responseTimeout()
: 응답 반환 Timeout 시간을 설정합니다.option()
: HTTP Connection 연결 Timeout 시간을 설정합니다.doOnConnected()
- HTTP Connection이 연결된 이후의 동작에 대해 설정합니다.
ReadTimeoutHandler
: 조회 프로세스에 대한 Timeout을 설정합니다.WriteTimeoutHandler
: 쓰기 프로세스에 대한 Timeout을 설정합니다.
- Spring
- Reactive