Published on

RestClient 맛보기

Authors
  • avatar
    Name
    Hyyena
    Twitter

Introduction

Spring Boot 애플리케이션에서 외부 서비스에 HTTP 요청을 구현하기 위해 Spring이 제공하는 RestTemplate, WebClient와 같은 HTTP 클라이언트를 사용합니다.

RestTemplate은 Spring 3.0 버전부터 도입되었고, 템플릿 메소드 API를 사용하는 동기식 클라이언트로 전통적으로 많이 사용되어 왔습니다.

WebClient는 Sporing 5.0 버전에 추가되었으며, 모던 API 형식과 함수형 프로그래밍 지원으로 RestTemplate보다 사용이 편리하고 직관적입니다. 그러나, WebClient를 사용하기 위해서는 spring-boot-starter-webflux 의존성이 필요합니다. WebFlux는 비동기와 스트리밍 데이터 처리를 위한 리액티브 프로그래밍 프레임워크로, 동기식 HTTP 구현을 위한 애플리케이션에서 불필요한 의존성을 추가하게 되는 단점이 있습니다.

이러한 상황에서 Spring Boot 3.2 (Spring 6.1) 부터 추가된 RestClient를 사용하면 기존 MVC (spring-boot-starter-web) 애플리케이션에서 별도의 의존성 추가 없이 WebClient와 유사한 방식으로 모던한 동기식 HTTP 클라이언트를 구현할 수 있습니다.


1. RestClient 사용법

1.1. RestClient 객체 생성

RestClient 객체는 create() 메소드를 사용해 생성하거나 빌더 패턴을 사용해 URI, path variables, request header와 같은 옵션을 추가할 수 있습니다.

RestClient restClient = RestClient.create();
RestClient restClient = RestClient.builder()
  .baseUrl("https://example.com")
  .defaultHeader("My-Header", "Foo")
  .requestInterceptor(myCustomInterceptor)
  .build();

1.2. HTTP 요청

GET 요청은 기본적으로 아래 코드와 같이 사용할 수 있습니다.

String result = restClient.get()
  .uri("https://example.com")
  .retrieve()
  .body(String.class);

response body 이외에 response status code나 header 값이 필요한 경우 ResponseEntity로 반환 받을 수도 있습니다.

ResponseEntity<String> result = restClient.get()
    .uri("https://example.com")
    .retrieve()
    .toEntity(String.class);

System.out.println("Response status: " + result.getStatusCode());
System.out.println("Response headers: " + result.getHeaders());
System.out.println("Contents: " + result.getBody());

RestClient는 Jackson 라이브러리를 사용하기 때문에 콘텐츠 타입을 application/json으로 지정해주면 JSON을 객체로 역직렬화하는 것도 가능합니다.

Article article = restClient.get()
    .uri("https://example.com/articles/{id}", id)
    .contentType(APPLICATION_JSON)
    .retrieve()
    .body(Article.class);

POST 요청도 마찬가지로 request body에 객체를 담고, 콘텐츠 타입을 applicaation/json으로 지정해주면 Jackson을 통해 JSON으로 직렬화됩니다. 아래 코드에서는 toBodilessEntity() 메소드를 사용해 POST 응답 페이로드를 무시하도록 했습니다.

Article article = new Article(1, "How to use RestClient");
ResponseEntity<Void> response = restClient.post()
    .uri("https://example.com/articles")
    .contentType(APPLICATION_JSON)
    .body(article)
    .retrieve()
    .toBodilessEntity();

retrieve() 대신 exchange() 메소드를 통해 고급 기능을 구현할 수도 있습니다. API 서버에서 해당 데이터가 없을 때, 204 상태 코드를 반환해주는 경우, 아래 코드와 같이 커스텀 예외를 발생시킬 수 있습니다. exchange() 메소드는 HTTP request, response에 대해 액세스 할 수 있으며, 이 응답값은 raw 데이터이기 때문에 ObjectMapper를 통해 응답 데이터를 역직렬화했습니다.

List<Aritlce> articles = restClient.get()
    .uri("https://example.com/articles")
    .exchange((request, response) -> {
        if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(204))) {
            throw new ArticleNotFoundException();
        } else if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(200))) {
            return objectMapper.readValue(response.getBody(), new TypeReference<>() {});
        } else {
            throw new InvalidArticleResponseException();
        }
    });

2. Error Handling

2.1. RestTemplate

RestTemplate을 사용할 때, 커스텀 에러 처리를 하기 위해서는 가장 단순하게 try/catch로 wrapping하거나 아래 코드와 같이 ResponseErrorHandler를 구현해 hasErrorhandleError를 오버라이드해서 사용하면 됩니다.

@Component
public class RestTemplateResponseErrorHandler implements ResponseErrorHandler {

    @Override
    public boolean hasError(ClientHttpResponse httpResponse) throws IOException {
        return httpResponse.getStatusCode().is5xxServerError() ||
            httpResponse.getStatusCode().is4xxClientError();
    }

    @Override
    public void handleError(ClientHttpResponse httpResponse) throws IOException {
        if (httpResponse.getStatusCode().is5xxServerError()) {
            // Handle SERVER_ERROR
            throw new HttpClientErrorException(httpResponse.getStatusCode());
        } else if (httpResponse.getStatusCode().is4xxClientError()) {
            // Handle CLIENT_ERROR
            if (httpResponse.getStatusCode() == HttpStatus.NOT_FOUND) {
                throw new NotFoundException();
            }
        }
    }
}
RestTemplate restTemplate = restTemplateBuilder
    .errorHandler(new RestTemplateResponseErrorHandler())
    .build();

2.2. RestClient

RestClient도 기본적으로 HTTP 응답 코드가 4xx, 5xx 인 경우 RestClientException의 하위 클래스 예외를 발생시키는데, onStatus를 사용해서 직관적으로 커스텀 에러 처리가 가능합니다.

String result = restClient.get()
    .uri("https://example.com/this-url-does-not-exist")
    .retrieve()
    .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
        throw new MyCustomRuntimeException(response.getStatusCode(), response.getHeaders())
    })
    .onStatus(HttpStatusCode::is5xxServerError, response -> {
        throw new MyCustomRuntimeException(response.statusCode(), response.headers());
    })
    .body(String.class);

3. RestTemplate에서 RestClient로 마이그레이션 해보기

Naver OpenAPI를 이용해 네이버 쇼핑에서 상품을 검색하는 예시 코드를 통해 RestTemplate으로 API를 호출하는 코드를 RestClient로 리팩토링 해보겠습니다.

3.1. DTO Class

ItemResponse.java
@Getter
public class ItemResponse {

    private String title;
    private String thumbnailUrl;
    private String purchaseUrl;
    private int lowestPrice;
}
ItemsResponse.java
@Getter
public class ItemsResponse {

    private List<ItemResponse> items;
}

3.2. RestTemplate

아래 코드가 RestTemplate으로 구현된 Naver OpenAPI 호출 코드입니다. 서비스 클래스에서 RestTemplateBuilder를 생성자를 통해 주입 받고, serchItems 메소드를 통해 상품 검색 결과를 반환합니다. 해당 메서드에서 크게 세 가지 부분을 살펴보면 UriComponentsBuilder를 통해 URI 생성, HTTP 헤더 추가, restTemplate으로 GET 요청을 수행하여 ResponseEntity<ItemsResponse>로 응답 결과를 받습니다. 이 때, exchange() 내부에서 ItemsResponse.class를 통해 JSON을 ItemsResponse 객체로 역직렬화하게 됩니다.

RestTemplate(Legacy)
package com.example.naver.service;

import com.example.naver.controller.dto.response.ItemResponse;
import com.example.naver.controller.dto.response.ItemsResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

@Service
public class NaverOpenApiService {

    private final RestTemplate restTemplate;
    private final String clientId;
    private final String clientSecret;

    public NaverOpenApiService(RestTemplateBuilder builder,
                           @Value("${naver.client.id}") String clientId,
                           @Value("${naver.client.secret}") String clientSecret) {
        this.restTemplate = builder.build();
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    public ItemsResponse searchItems(String query) {
        URI uri = UriComponentsBuilder
                .fromUriString("https://openapi.naver.com")
                .path("/v1/search/shop.json")
                .queryParam("display", 15)
                .queryParam("query", query)
                .encode()
                .build()
                .toUri();

        HttpHeaders headers = new HttpHeaders();
        headers.set("X-Naver-Client-Id", clientId);
        headers.set("X-Naver-Client-Secret", clientSecret);

        ResponseEntity<ItemsResponse> responseEntity = restTemplate.exchange(
                uri,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                ItemsResponse.class
        );

        return responseEntity.getBody();
    }
}

3.3. RestClient

먼저, 서비스 클래스에서 RestTemplate 대신 RestClient를 생성자 주입 받습니다. 이 때, builder를 통해 API 호출에 필요한 base url과 헤더를 미리 설정해줍니다. serchItems 메소드에서 주입 받은 RestClient를 사용해 API를 호출하게 됩니다. URI는 람다 표현식으로 생성한 후 GET 요청 시 동일하게 JSON을 ItemsResponse 객체로 역직렬화 한 값을 반환합니다.

RestClient(Refactoring)
package com.example.shop.naver.service;

import com.example.shop.naver.controller.dto.response.ItemResponse;
import com.example.shop.naver.controller.dto.response.ItemsResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class NaverOpenApiService {

    private final RestClient restClient;

    public NaverOpenApiService(@Value("${naver.client.id}") String clientId,
                           @Value("${naver.client.secret}") String clientSecret) {
        this.restClient = RestClient.builder()
                .baseUrl("https://openapi.naver.com")
                .defaultHeader("X-Naver-Client-Id", clientId)
                .defaultHeader("X-Naver-Client-Secret", clientSecret)
                .build();
    }

    public ItemsResponse searchItems(String query) {
        return restClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/v1/search/shop.json")
                        .queryParam("query", query)
                        .queryParam("display", 15)
                        .build())
                .retrieve()
                .body(ItemsResponse.class);
        }
    }
}

Conclusion

RestTemplate에서 RestClient로 마이그레이션 해 본 결과 구현에 있어서 큰 차이는 존재하지 않습니다. 그러나, 메소드 체이닝과 같은 모던한 구현 방식으로 코드 가독성 측면에서 더 직관적입니다. 에러 핸들링 측면에서도 핸들러 클래스를 상속 받아 오버라이딩하는 RestTemplate보다는 RestClient가 더 편리해 보입니다.

RestClientRestTemplate의 인프라를 공유하기 때문에 message converters, request factories, interceptors와 같은 구성 요소들을 사용할 수 있으며, 기존 RestTemplate을 통해 RestClient를 생성할 수도 있습니다.

RestTemplate restTemplate;
RestClient restClient = RestClient.create(restTemplate);

개인적으로 두 방법 모두 동일한 기능을 제공하므로 기존 프로젝트의 RestTemplate 코드를 RestClient로 마이그레이션할 필요는 없어보입니다.

그러나, 신규 프로젝트에서 동기 방식의 HTTP 구현이 필요한 경우 RestClient를 사용하면 코드 가독성이 좋아지고, 더 모던한 방식으로 구현할 수 있기 때문에 RestClient를 적극 사용하게 될 것 같습니다.

Reference