RestClient로 외부 API 호출하기 (Spring Boot 3.2+)

1. 개요

Spring Framework 6.1(Spring Boot 3.2와 함께 제공)부터 Spring은 RestClient를 추가했습니다. 이것은 동기식(blocking) HTTP 클라이언트로, WebClient와 같은 fluent API를 사용하지만 Reactor가 필요 없습니다. 이 글은 RestClient를 만들고, GET/POST를 호출하고, 에러를 처리하고, 타임아웃을 설정하고, 인터셉터를 추가하고, 테스트를 작성하는 방법을 안내합니다. 읽은 후, 새로운 프로젝트에서 RestTemplate을 RestClient로 바꿀 수 있습니다.

2. 설치

RestClient는 spring-web 모듈에 포함되어 있습니다. Starter web에는 이미 이 모듈과 JSON 변환을 위한 Jackson이 포함되어 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

이 글 전체에서 https://api.shop.example의 가상 주문 API를 호출합니다. record를 사용하여 데이터를 선언합니다:

public record Order(Long id, String customerName, BigDecimal total, String status) {}

public record CreateOrderRequest(String customerName, BigDecimal total) {}

Base URL은 application.properties에 설정됩니다:

shop.api.base-url=https://api.shop.example

3. RestClient 생성

3.1. 가장 빠른 방법

RestClient restClient = RestClient.create("https://api.shop.example");

이 방법은 테스트를 작성하거나 스크립트를 실행할 때 적합합니다. Spring Boot 애플리케이션에서는 Boot가 제공하는 builder를 사용해야 합니다.

3.2. Spring Boot의 RestClient.Builder 사용

Spring Boot는 자동으로 RestClient.Builder 빈을 구성합니다. 이 빌더는 이미 애플리케이션의 HttpMessageConverter가 첨부되어 있으며, 예를 들어 커스터마이징한 ObjectMapper가 있습니다. 또한 Micrometer를 통한 관찰성을 지원합니다.

@Component
public class OrderClient {

    private final RestClient restClient;

    public OrderClient(RestClient.Builder builder,
                       @Value("${shop.api.base-url}") String baseUrl) {
        this.restClient = builder
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

설명:

  • baseUrl(...): 이후 호출에는 /orders/42와 같은 상대 경로만 전달하면 됩니다.
  • defaultHeader(...): 이 헤더는 모든 요청에 첨부됩니다.
  • build(): RestClient 인스턴스를 생성합니다. 이 인스턴스는 스레드 안전하므로 한 번 생성하고 재사용할 수 있습니다.

참고: RestClient.Builder 빈의 범위는 prototype입니다. 주입할 때마다 새로운 빌더를 받습니다. 따라서 OrderClient의 구성은 다른 클라이언트에 영향을 주지 않습니다. 모든 빌더에 공통 구성을 적용하려면 RestClientCustomizer 빈을 선언하세요.

4. GET 요청 전송

4.1. body를 객체로 가져오기

public Order getOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .retrieve()
            .body(Order.class);
}
  • get(): HTTP 메서드를 선택합니다.
  • uri("/orders/{id}", id): 변수 {id}가 자동으로 대체되고 인코딩됩니다. 결과는 GET https://api.shop.example/orders/42입니다.
  • retrieve(): 요청을 보내고 응답을 읽을 준비를 합니다.
  • body(Order.class): Jackson이 JSON을 Order 레코드로 변환합니다.

4.2. 상태와 헤더도 가져오기

상태 코드나 헤더가 필요할 때 toEntity를 사용합니다:

ResponseEntity<Order> response = restClient.get()
        .uri("/orders/{id}", 42)
        .retrieve()
        .toEntity(Order.class);

System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
200 OK
application/json
Order[id=42, customerName=Lan, total=250000, status=PAID]

4.3. 목록과 쿼리 파라미터 가져오기

List<Order>과 같은 제네릭 타입은 런타임에 지워집니다. 따라서 ParameterizedTypeReference가 필요합니다:

public List<Order> findOrders(String status, int page) {
    return restClient.get()
            .uri(uriBuilder -> uriBuilder
                    .path("/orders")
                    .queryParam("status", status)
                    .queryParam("page", page)
                    .build())
            .retrieve()
            .body(new ParameterizedTypeReference<List<Order>>() {});
}

findOrders("PAID", 1) 호출은 GET /orders?status=PAID&page=1 요청을 생성합니다. uriBuilder를 사용하면 Base URL은 유지됩니다.

5. POST, PUT, DELETE 요청 전송

public Order createOrder(CreateOrderRequest request) {
    return restClient.post()
            .uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(Order.class);
}

public void cancelOrder(Long id) {
    restClient.delete()
            .uri("/orders/{id}", id)
            .retrieve()
            .toBodilessEntity();
}
  • body(request): 선언된 contentType 덕분에 객체가 JSON으로 직렬화됩니다.
  • toBodilessEntity(): 응답 본문을 읽을 필요가 없을 때 사용합니다. 이 메서드는 여전히 요청을 트리거하고 에러를 처리합니다.

put()과 patch()는 post()와 똑같이 사용됩니다.

참고: retrieve()를 호출한 후 body(...), toEntity(...), 또는 toBodilessEntity()를 호출하지 않으면 요청이 전송되지 않습니다. 이것은 DELETE 호출에서 흔히 발생하는 오류입니다.

6. 에러 처리

6.1. 기본 동작

기본적으로 retrieve()는 응답에 오류 상태가 있을 때 예외를 던집니다:

  • 4xx: HttpClientErrorException을 던집니다. 예를 들어 HttpClientErrorException.NotFound.
  • 5xx: HttpServerErrorException을 던집니다.

연결 오류 또는 타임아웃은 ResourceAccessException을 던집니다.

6.2. onStatus로 사용자 정의하기

보통 HTTP 오류를 비즈니스 예외로 변경하고 싶습니다:

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("주문을 찾을 수 없습니다: " + id);
    }
}
public Order getOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .retrieve()
            .onStatus(status -> status.value() == 404, (request, response) -> {
                throw new OrderNotFoundException(id);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new IllegalStateException(
                        "Shop API 오류: " + response.getStatusCode());
            })
            .body(Order.class);
}

핸들러들은 선언 순서대로 검사됩니다. 상태와 일치하는 첫 번째 핸들러가 실행됩니다. 일치하는 핸들러가 없으면 섹션 6.1의 기본 동작이 적용됩니다.

6.3. exchange로 완전한 제어

오류 본문을 읽거나 상태에 따라 분기해야 할 때 exchange를 사용합니다:

public Optional<Order> findOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .exchange((request, response) -> {
                if (response.getStatusCode().value() == 404) {
                    return Optional.empty();
                }
                if (response.getStatusCode().isError()) {
                    throw new IllegalStateException("오류: " + response.getStatusCode());
                }
                return Optional.ofNullable(
                        response.bodyTo(Order.class));
            });
}

참고: exchange 내에서 기본 상태 핸들러는 적용되지 않습니다. 상태를 직접 확인해야 합니다. 만약 잊으면 오류 본문이 필드가 null인 Order로 파싱될 수 있습니다.

7. 타임아웃 및 기본 HTTP 클라이언트

기본적으로 RestClient는 classpath에 있는 HTTP 라이브러리를 이 순서대로 선택합니다: Apache HttpClient 5, Jetty, Reactor Netty, JDK의 java.net.http.HttpClient, 그리고 SimpleClientHttpRequestFactory. 항상 타임아웃을 설정해야 합니다. 그렇지 않으면 느린 API가 스레드를 오랫동안 보유할 수 있습니다.

@Configuration
public class ShopClientConfig {

    @Bean
    public RestClientCustomizer timeoutCustomizer() {
        return builder -> {
            var factory = new SimpleClientHttpRequestFactory();
            factory.setConnectTimeout(Duration.ofSeconds(2));
            factory.setReadTimeout(Duration.ofSeconds(5));
            builder.requestFactory(factory);
        };
    }
}

RestClientCustomizer는 Spring Boot가 생성하는 모든 RestClient.Builder에 적용됩니다. 한 클라이언트에만 타임아웃을 설정하려면 해당 클라이언트의 생성자 내에서 builder.requestFactory(factory)를 호출합니다.

느린 API의 경우 요청은 5초 후 실패합니다:

org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out

Spring Boot 3.4부터는 spring.http.client.connect-timeout 및 spring.http.client.read-timeout 속성을 사용하여 타임아웃을 선언할 수도 있습니다.

8. 인터셉터: 토큰 첨부, 로깅

ClientHttpRequestInterceptor는 모든 요청을 가로챌 수 있게 해줍니다. 예를 들어 다음 코드는 API 키를 첨부하고 응답 시간을 로깅합니다:

@Slf4j
public class ApiKeyInterceptor implements ClientHttpRequestInterceptor {

    private final String apiKey;

    public ApiKeyInterceptor(String apiKey) {
        this.apiKey = apiKey;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        request.getHeaders().set("X-Api-Key", apiKey);
        long start = System.currentTimeMillis();
        ClientHttpResponse response = execution.execute(request, body);
        log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getURI(),
                response.getStatusCode(), System.currentTimeMillis() - start);
        return response;
    }
}

클라이언트를 빌드할 때 인터셉터를 등록합니다:

this.restClient = builder
        .baseUrl(baseUrl)
        .requestInterceptor(new ApiKeyInterceptor(apiKey))
        .build();
INFO  ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)

참고: 로깅하기 위해 인터셉터에서 response.getBody()를 읽지 마세요. 스트림은 한 번만 읽을 수 있습니다. 반드시 읽어야 하는 경우 BufferingClientHttpRequestFactory로 요청 팩토리를 감싸세요.

9. HTTP 인터페이스: 인터페이스로 클라이언트 선언

각 호출을 직접 작성하는 대신 인터페이스로 API를 설명할 수 있습니다. Spring이 RestClient를 기반으로 구현을 생성합니다:

public interface OrderApi {

    @GetExchange("/orders/{id}")
    Order getOrder(@PathVariable Long id);

    @PostExchange("/orders")
    Order createOrder(@RequestBody CreateOrderRequest request);
}
@Configuration
public class OrderApiConfig {

    @Bean
    public OrderApi orderApi(RestClient.Builder builder,
                             @Value("${shop.api.base-url}") String baseUrl) {
        RestClient restClient = builder.baseUrl(baseUrl).build();
        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build()
                .createClient(OrderApi.class);
    }
}

이제 OrderApi만 주입하고 orderApi.getOrder(42L)을 호출하면 됩니다. API에 많은 엔드포인트가 있을 때 이 방법이 깔끔합니다. 타임아웃, 인터셉터, 상태 핸들러는 여전히 기본 RestClient에서 구성됩니다.

10. @RestClientTest로 테스트 작성

Spring Boot 3.2부터 @RestClientTest는 RestClient를 지원합니다. 이 주석은 HTTP 클라이언트와 관련된 빈만 초기화합니다. 또한 MockRestServiceServer를 RestClient.Builder에 연결하므로 실제 요청이 전송되지 않습니다.

섹션 6.2의 onStatus가 있는 getOrder 버전에서 OrderClient를 테스트합니다:

@RestClientTest(components = OrderClient.class,
        properties = "shop.api.base-url=https://api.shop.example")
class OrderClientTest {

    @Autowired
    private OrderClient orderClient;

    @Autowired
    private MockRestServiceServer server;

    @Test
    void givenExistingOrder_whenGetOrder_thenReturnsOrder() {
        server.expect(requestTo("https://api.shop.example/orders/42"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess("""
                        {"id":42,"customerName":"Lan","total":250000,"status":"PAID"}
                        """, MediaType.APPLICATION_JSON));

        Order order = orderClient.getOrder(42L);

        assertThat(order.id()).isEqualTo(42L);
        assertThat(order.customerName()).isEqualTo("Lan");
        assertThat(order.total()).isEqualByComparingTo("250000");
        server.verify();
    }

    @Test
    void givenMissingOrder_whenGetOrder_thenThrowsOrderNotFound() {
        server.expect(requestTo("https://api.shop.example/orders/99"))
                .andRespond(withStatus(HttpStatus.NOT_FOUND));

        assertThatThrownBy(() -> orderClient.getOrder(99L))
                .isInstanceOf(OrderNotFoundException.class)
                .hasMessage("주문을 찾을 수 없습니다: 99");
    }
}

필요한 정적 임포트는 org.springframework.test.web.client.match와 org.springframework.test.web.client.response 패키지의 MockRestRequestMatchers 및 MockRestResponseCreators에 있습니다.

  • server.expect(...): 테스트가 예상하는 요청을 선언합니다.
  • andRespond(...): 해당 요청에 대한 모의 응답을 반환합니다.
  • server.verify(): 선언된 모든 요청이 실제로 전송되었는지 확인합니다.

참고: MockRestServiceServer는 Spring이 주입하는 RestClient.Builder에서 생성된 클라이언트일 때만 작동합니다. 클래스 내에서 RestClient.create(...)를 호출하면 테스트가 실제 네트워크로 요청을 보냅니다.

11. RestClient를 사용하면 안 되는 경우

  • WebFlux 애플리케이션(반응형): RestClient는 스레드를 차단합니다. 반응형 파이프라인에서 호출하면 이벤트 루프가 막힙니다. WebClient를 사용하세요.
  • 대용량 데이터 스트리밍 또는 비차단 Server-Sent Events가 필요한 경우: WebClient가 더 적합합니다.
  • 레거시 코드가 RestTemplate에서 잘 작동하는 경우: 다시 쓸 필요가 없습니다. RestTemplate은 여전히 지원되지만 새로운 기능을 받지는 않습니다. 현재 구성을 유지하려면 RestClient.create(restTemplate)을 사용하여 점진적으로 마이그레이션할 수 있습니다.

애플리케이션이 Java 21에서 가상 스레드(spring.threads.virtual.enabled=true)로 실행되는 경우, RestClient의 차단 모델은 확장성 측면에서 거의 단점이 없습니다. 코드는 여전히 간단하고 디버깅하기 쉽습니다.

12. 결론

이 글에서 fluent하고 읽기 쉬운 API로 RestClient를 사용하여 외부 API를 호출했습니다. 우리는 Spring Boot의 빌더에서 클라이언트를 만들고, GET/POST를 보내고, onStatus 또는 exchange로 HTTP 오류를 비즈니스 예외로 변경하고, 타임아웃을 설정하고, 인터셉터를 추가하고, HTTP 인터페이스로 클라이언트를 선언하는 단계를 거쳤습니다. 마지막으로, 실제 네트워크 호출 없이 @RestClientTest와 MockRestServiceServer로 테스트를 작성했습니다.

servlet 모델의 Spring Boot 3.2+ 프로젝트에서는 RestClient가 RestTemplate 대신 기본값으로 선택되어야 합니다. 두 가지를 기억하세요: 항상 타임아웃을 설정하고, 항상 retrieve() 체인을 응답을 읽는 메서드로 종료하세요.

spring-bootrestclienthttprest-apitesting