본문 바로가기
Spring/SpringBoot

RestTemplate 말고 이거 사용하세요: RestClient 입문 가이드

by J4J 2025. 12. 12.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 rest client가 무엇인지, 어떤 식으로 사용할 수 있는지에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

RestClient

 

rest client는 spring boot 3.2 이후부터 등장한 http client입니다.

 

http client이기 때문에 rest client의 주요 사용처는 spring boot 애플리케이션 내부에서 다른 서비스의 api 등을 호출하는 상황에 많이 사용됩니다.

 

 

 

Spring 공식 문서를 확인해 보면 http 통신을 위해 사용되는 것은 다음과 같이 존재합니다.

 

REST Clients :: Spring Framework

You can define an HTTP Service as a Java interface with @HttpExchange methods, and use HttpServiceProxyFactory to create a client proxy from it for remote access over HTTP via RestClient, WebClient, or RestTemplate. On the server side, an @Controller class

docs.spring.io

 

  • RestClient
  • WebClient
  • RestTemplate

 

 

 

이들 중 오래전부터 spring 진영에서 사용되던 통신 방식은 rest template을 이용하는 것이었습니다.

 

그러던 중 비동기 + non-blocking 기반의 통신을 위해 등장한 것이 web client입니다.

 

rest template 자체로는 동기 기반의 요청만 가능했기 때문에 web client를 이용하면 rest template에서 제공되지 않는 비즈니스 구성이 가능했습니다.

 

또한 사용 방식에 있어서도 web client를 이용하는 것이 더 간편하고 구조가 단순하게 느껴지게 됩니다.

 

 

 

web client는 비동기 + non-blocking 기반으로 통신이 되면서도 원한다면 동기 처리 방식으로도 구성해 볼 수 있습니다.

 

다만 web client에 대해 자세하게 알고 계시는 분들은 더 잘 아실 것 같지만, netty 기반의 이벤트 루프 처리를 위한 thread부터 시작하여 동기 처리 방식으로 사용하기에는 불필요한 리소스들이 사용됩니다.

 

또한 동기 처리 방식으로만 web client를 사용한다고 하더라도 비동기와 non-blocking에 대해서도 추가 학습이 필요하게 될 수 있고요.

 

 

 

그래서 동기 기반으로 http client 역할을 수행할 수 있고, web client처럼 간편하고 단순화 된 구조를 이용하여 통신을 하기 위해 rest client가 등장하게 되었습니다.

 

rest client의 경우 rest template과 같이 동기 처리 방식으로만 사용이 가능하지만, 사용 방식의 경우 web client와 매우 유사하다고 느낄 수 있습니다.

 

또한 web client를 동기 방식으로 사용하는 것과 비교했을 때 불필요한 리소스의 사용이 없어지며, 학습 곡선 또한 낮아지게 됩니다.

 

추가적으로 rest template의 경우에는 이제 `deprecated`가 되어 더 이상 관리가 되지 않습니다.

 

공식 문서를 확인하면 rest template을 rest client로 migration 하는 것에 대해 안내가 되어 있으니 필요하신 분들은 확인하시면 좋을 것 같습니다.

 

 

 

결론적으로 만약 spring boot 애플리케이션 내부에서 현재 필요한 상황이

  • 단순 동기 처리 방식으로 api 통신이 필요하다 > rest client를 사용
  • 비동기 + non-blocking 기반으로 api 통신이 필요하다 > web client를 사용

한다고 이해하시면 됩니다.

 

 

반응형

 

 

RestClient 단순 사용법

 

rest client 사용을 위해서는 RestClient 객체가 필요합니다.

 

객체를 획득하는 방법으로는 다음과 같은 방법들이 존재합니다.

 

(1) 생성하기
RestClient restClient = RestClient.create();


(2) builder 기반 구성하기
RestClient restClient = RestClient.builder()
	.baseUrl("https://...")
	.defaultHeader("key", "value")
	.defaultCookie("key", "value")
        ...
	.build();


(3) 주입 받기
public class Clazz {
  private final RestClient restClient;
  
  public Clazz(RestClient.Builder builder) {
    this.restClient = builder
                         .baseUrl("https://...")
	                 .defaultHeader("key", "value")
	                 .defaultCookie("key", "value")
                         ...
	                 .build();
  }
}

 

 

 

그리고 GET / POST 기반의 호출은 다음과 같이 해볼 수 있습니다.

 

PUT이나 DELETE 같은 경우는 api 명세서에 따라 GET / POST 방식과 유사하게 사용하면 됩니다.

 

또한 단순 응답 결과 값이 아니라 responseEntity 객체 형태로 반환을 원한다면 retrieve().body() 대신 retrieve().toEntity() 등의 방식을 사용할 수도 있습니다.

 

(1) GET
Map<String, String> responseMap =
        restClient
                .get()
                .uri(
                        uriBuilder -> 
                                uriBuilder.path("/get/{id}")
                                        .queryParamIfPresent("name", Optional.ofNullable("string")) // parameter 설정
                                        .build(1234) // path variable 설정
                )
                .retrieve()
                .body(Map.class); // map 대신 반환 객체를 정의하여 사용하는 것을 권장
                
                
(2) POST
// map 대신 요청 객체를 정의하여 사용하는 것을 권장
Map<String, String> requestMap = new HashMap<>();
requestMap.put("id", "1234");
requestMap.put("name", "string");

Map<String, String> responseMap =
        restClient
                .post()
                .uri(uriBuilder -> uriBuilder.path("/post").build())
                .body(requestMap) // request body 전달
                .retrieve()
                .body(Map.class); // map 대신 반환 객체를 정의하여 사용하는 것을 권장

 

 

 

 

에러 핸들링

 

요청에 대한 에러 핸들링을 수행할 수도 있습니다.

 

에러 핸들링을 하는 방식은 HTTP method 구분 없이 모두 동일하며 retrieve() 이후에 핸들링을 위한 소스 코드를 넣어 볼 수 있습니다.

 

예를 들어, 500 에러가 발생하는 경우에 대해 핸들링을 해본다면 다음과 같이 할 수 있습니다.

 

Map<String, String> responseMap =
        restClient
                .get()
                .uri(
                        uriBuilder -> 
                                uriBuilder.path("/get/{id}")
                                        .queryParamIfPresent("name", Optional.ofNullable("string")) // parameter 설정
                                        .build(1234) // path variable 설정
                )
                .retrieve()
                // 에러 핸들링
                .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                    throw new IllegalArgumentException("요청에 대해 서버 에러가 발생했습니다.");
                })
                .body(Map.class); // map 대신 반환 객체를 정의하여 사용하는 것을 권장

 

 

 

 

RestClient 공통 설정

 

다양한 서비스에 api 호출을 수행해야 하는 상황에서 rest client를 사용하다 보면 다음과 같은 고민이 들 수 있습니다.

 

  • 호출하는 api의 도메인 별로 서로 다른 설정들을 넣고 싶어
  • 하지만 기본적으로 모든 rest client에 공통 설정들은 담겨 있어야 해

 

 

그리고 공통 설정들에 대해서는 다음과 같은 것들을 얘기해볼 수 있습니다.

 

  • 모든 요청/응답 정보에 대한 logging이 기록되어야 해
  • 모든 요청에는 기본 header/cookie 값이 설정되어야 해
  • 모든 요청에 header 값이 전파되어야 해
  • 모든 요청에 동일한 timeout 설정이 되어야 해
  • 등등...

 

 

 

이런 상황이 발생할 때 가장 쉽게 접근할 수 있는 방법은 configuration class 파일 하위에 설정들이 담긴 rest client의 bean을 등록하는 것입니다.

 

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient myRestClient() {
        return RestClient.builder()
	              .baseUrl("https://...")
	              .defaultHeader("key", "value")
	              .defaultCookie("key", "value")
                      ...
	              .build();
    }
}

 

 

이와 같이 적용한다면 api를 호출하는 모든 도메인에 bean으로 등록된 myRestClient를 사용하여 공통 설정들이 담길 수 있습니다.

 

하지만 호출해야 되는 서로 다른 서비스 도메인의 rest client를 구성해야 하는 경우 유연하게 관리가 되지 않을 수 있습니다.

 

왜냐하면 필요한 서비스만큼 bean을 여러 개 등록하거나 또 다른 대안을 제시해야 하기 때문이죠.

 

그래서 공통 구성을 하는 방법은 프로젝트 상황 별 여러 가지 선택지가 제공될 수 있고, 이곳에서는 주입받는 방식의 builder에 공통 구성이 담겨져 있도록 다음과 같이 코드들을 작성해 봤습니다.

 

 

 

 

[ 1. dependency 추가 ]

 

// build.gradle
dependencies {
	implementation 'org.apache.httpcomponents.client5:httpclient5:5.5.1'
}

 

 

 

[ 2. config 작성 ]

 

package com.jforj.restclient.config;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.List;
import java.util.function.Consumer;

@Configuration
@Slf4j
public class RestClientConfig {

    @Bean
    public CloseableHttpClient closeableHttpClient() {
        // connection 설정
        ConnectionConfig connectionConfig =
                ConnectionConfig.custom()
                        .setConnectTimeout(Timeout.ofSeconds(3)) // 서버에 TCP 연결을 맺는데 걸리는 시간
                        .build();

        PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager();
        poolingHttpClientConnectionManager.setMaxTotal(200); // pool 전체 최대 커넥션 수
        poolingHttpClientConnectionManager.setDefaultMaxPerRoute(50); // route 별 최대 커넥션 수 (host:port)
        poolingHttpClientConnectionManager.setDefaultConnectionConfig(connectionConfig);

        // request 설정
        RequestConfig requestConfig =
                RequestConfig.custom()
                        .setConnectionRequestTimeout(Timeout.ofSeconds(3)) // pool에서 connection을 가져오는데 걸리는 시간
                        // 요청을 보내고 응답이 돌아올 때까지의 전체 시간
                        // 장기간 요청 처리가 필요한 것은 목적만을 위한 rest client 생성하여 사용
                        .setResponseTimeout(Timeout.ofSeconds(5))
                        .build();

        // http client 생성
        return HttpClientBuilder.create()
                .evictIdleConnections(TimeValue.ofSeconds(30)) // 일정 시간동안 한 번도 사용되지 않은 idle 상태 connection 제거
                .evictExpiredConnections() // TTL을 초과한 connection 제거
                .setConnectionManager(poolingHttpClientConnectionManager)
                .setDefaultRequestConfig(requestConfig)
                .setRetryStrategy(
                        // 재 시도 설정
                        // GET method, 네트워크 계층 에러 등 다시 재 전송을 해도 안전하다고 판단되는 요청만 재 시도
                        new DefaultHttpRequestRetryStrategy(
                                3, // 최대 재시도 횟수
                                TimeValue.ofSeconds(1) // 재시도 간격
                        )
                )
                .build();
    }

    @Bean
    public HttpComponentsClientHttpRequestFactory requestFactory(CloseableHttpClient closeableHttpClient) {
        // request factory 생성
        return new HttpComponentsClientHttpRequestFactory(closeableHttpClient);
    }

    @Bean
    public ClientHttpRequestInterceptor loggingRequestInterceptor() {
        return (request, body, execution) -> {
            // 로그 설정
            log.info(
                    """
                                                        
                            [method]: {}
                            [uri]: {}
                            [attributes]: {}
                            """,
                    request.getMethod(),
                    request.getURI(),
                    request.getAttributes()
            );

            ClientHttpResponse response = execution.execute(request, body);
            response.getStatusCode();
            response.getBody();

            log.info(
                    """
                                                        
                            [statusCode]: {}
                            [body]: {}
                            """,
                    response.getStatusCode(),
                    response.getBody()
            );

            return response;
        };
    }

    @Bean
    public ClientHttpRequestInterceptor headerPropagationRequestInterceptor() {
        return (request, body, execution) -> {
            // header 전파 설정
            List<String> headers = List.of(
                    "Authorization",
                    "X-Request-Id",
                    "X-Custom-Header"
            );

            RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
            if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) {
                HttpServletRequest currentRequest = servletRequestAttributes.getRequest();
                HttpHeaders targetHeaders = request.getHeaders();

                for (String header : headers) {
                    String value = currentRequest.getHeader(header);
                    if (StringUtils.hasText(value)) {
                        targetHeaders.set(header, value);
                    }
                }
            }

            return execution.execute(request, body);
        };
    }

    @Bean
    public Consumer<HttpHeaders> defaultHeaders() {
        // default header 설정
        return httpHeaders -> {
            httpHeaders.add("X-Default-Header", "default-value");
        };
    }

    @Bean
    public Consumer<List<HttpMessageConverter<?>>> defaultMessageConverters() {
        // message converter 설정
        // 기본적으로 제공하는 converter 외의 설정이 필요한 경우 추가적인 커스터 마이징 설정
        return converters -> {
            //
        };
    }

    // rest client customizer를 이용하여 어디서든 RestClient.Builder를 사용하는 경우 공통 설정이 담기게하기
    @Bean
    public RestClientCustomizer restClientCustomizer(
            HttpComponentsClientHttpRequestFactory requestFactory,
            ClientHttpRequestInterceptor loggingRequestInterceptor,
            ClientHttpRequestInterceptor headerPropagationRequestInterceptor,
            Consumer<HttpHeaders> defaultHeaders,
            Consumer<List<HttpMessageConverter<?>>> defaultMessageConverters
    ) {
        // 전역 공통 설정이 이루어진 rest client builder 반환
        return builder ->
                builder
                        .requestFactory(requestFactory)
                        .requestInterceptors(
                                clientHttpRequestInterceptors -> {
                                    clientHttpRequestInterceptors.add(loggingRequestInterceptor);
                                    clientHttpRequestInterceptors.add(headerPropagationRequestInterceptor);
                                }
                        )
                        .defaultHeaders(defaultHeaders)
                        .messageConverters(defaultMessageConverters);
    }
}

 

 

 

이와 같이 설정이 되어 있다면 위에서 소개해 드렸던 다음과 같은 주입 받는 방식을 사용할 경우 공통 설정이 함께 포함되어 있는 것을 확인할 수 있습니다.

 

(3) 주입 받기
public class Clazz {
  private final RestClient restClient;
  
  public Clazz(RestClient.Builder builder) {
    // builder에 공통 설정이 모두 포함되어 있음
    this.restClient = builder
                         .baseUrl("https://...")
	                 .defaultHeader("key", "value")
	                 .defaultCookie("key", "value")
                         ...
	                 .build();
  }
}

 

 

 

 

 

 

 

이상으로 rest client가 무엇인지, 어떤 식으로 사용할 수 있는지에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글