본문 바로가기
Java

RestClient로 동기 HTTP 클라이언트 갈아타기

후아빠·2026년 9월 19일·조회 0

서버 사이드에서 외부 API를 호출하는 코드는 대부분 RestTemplate으로 짜여 있다. 그런데 요즘 새 프로젝트를 열어보면 자동완성에서 RestClient가 먼저 뜬다. WebFlux를 쓰지도 않는데 WebClient를 억지로 끌어다 쓰던 코드도 종종 보인다. 정리할 겸, 동기 HTTP 호출을 RestClient로 옮기는 방법을 한 번에 적어둔다.

요약: Spring Framework 6.1(Spring Boot 3.2)부터 들어온 RestClientRestTemplate과 같은 동기 방식이면서 WebClient처럼 메서드를 이어 붙이는 API를 쓴다. 동기 호출이면 RestClient가 기본 선택이다. RestTemplate은 유지보수 모드라 신규 코드에 쓸 이유가 없고, WebClient는 리액티브 스택에서만 값을 한다.

1. RestClient가 무엇이고 왜 나왔나

RestClient는 블로킹 방식으로 HTTP를 호출하는 클라이언트다. 스레드를 붙잡고 응답이 올 때까지 기다리는 전통적인 동기 호출이라는 점은 RestTemplate과 같다. 대신 API 모양이 get().uri().retrieve().body()처럼 이어 붙이는 형태라 읽기 편하고, 인터셉터와 에러 핸들링을 거는 방식이 WebClient와 비슷하다.

세 클라이언트를 언제 쓰는지부터 정리한다.

  • RestTemplate: 유지보수 모드. 제거 예정은 아니지만 새 기능은 들어오지 않는다. 신규 코드에는 권장하지 않는다.
  • WebClient: 리액티브(WebFlux) 스택이거나 논블로킹, 스트리밍이 필요할 때. MVC 앱에 이것만 쓰려고 spring-boot-starter-webflux를 끌어오는 건 과하다.
  • RestClient: 동기 호출이면 여기부터 본다. MVC 앱에서 외부 API를 부르는 대부분의 경우가 여기에 해당한다.

2. 의존성과 기본 사용법

RestClientspring-boot-starter-web에 이미 들어 있다. 뒤에서 다룰 커넥션 풀 설정을 위해 Apache HttpClient5만 추가로 넣는다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
</dependency>

가장 단순한 GET, POST는 이렇게 쓴다.

RestClient restClient = RestClient.create();

// GET, 경로 변수 바인딩
Pet pet = restClient.get()
        .uri("https://petclinic.example.com/pets/{id}", 42)
        .accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .body(Pet.class);

// POST, JSON 본문 전송 후 상태 코드만 확인
ResponseEntity<Void> res = restClient.post()
        .uri("https://petclinic.example.com/pets/new")
        .contentType(MediaType.APPLICATION_JSON)
        .body(pet)
        .retrieve()
        .toBodilessEntity();

retrieve()는 종단 연산이 아니다. 뒤에 body(...), toEntity(...), toBodilessEntity() 중 하나를 붙여야 실제 호출이 일어난다.

3. RestTemplate 코드 대응표

기존 RestTemplate 호출을 옮길 때 참고할 대응이다.

  • getForObject(url, Pet.class)get().uri(url).retrieve().body(Pet.class)
  • getForEntity(url, Pet.class)get().uri(url).retrieve().toEntity(Pet.class)
  • postForObject(url, req, Res.class)post().uri(url).body(req).retrieve().body(Res.class)
  • exchange(...)method(...).uri(...).retrieve().toEntity(...) 또는 아래 exchange(...) 콜백
  • getForObjectMap으로 넘기던 URI 변수 → uri("/pets/{id}", id) 가변 인자

기존 코드에 ResponseErrorHandler나 인터셉터를 물려 두었다면 그것도 함께 옮겨야 한다. 아래에서 차례로 다룬다.

4. 공용 빈으로 등록하기

매번 RestClient.create()를 부르는 대신 RestClient.Builder를 주입받아 공용 설정을 한 곳에 모은다. Spring Boot는 이 빌더를 자동 구성으로 제공하므로 그대로 받아 쓰면 된다.

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient petClient(RestClient.Builder builder) {
        return builder
                .baseUrl("https://petclinic.example.com")
                .defaultHeader("X-App", "sarc")
                .requestInterceptor(new LoggingInterceptor())
                .build();
    }
}

주입받은 Builder를 쓰면 메시지 컨버터 같은 앱 전역 설정이 함께 적용된다. RestClient.builder()를 새로 만들면 그 설정이 빠지니 주의한다.

5. 인터셉터로 요청 로깅하기

인터셉터는 ClientHttpRequestInterceptor 하나만 구현하면 된다. RestTemplate에서 쓰던 인터셉터를 그대로 재사용할 수 있다.

public class LoggingInterceptor implements ClientHttpRequestInterceptor {

    private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);

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

호출하면 이런 로그가 남는다.

2026-09-19 14:03:11.482  INFO 12043 --- [nio-8080-exec-1] c.s.LoggingInterceptor : GET https://petclinic.example.com/pets/42 -> 200 OK (37ms)

인터셉터를 걸면 요청 본문을 버퍼링한다. 대용량 스트리밍 업로드에서는 메모리를 그만큼 잡으니, 그럴 때는 인터셉터 대신 requestInitializer를 검토한다.

6. 에러 핸들링, 4xx와 5xx

retrieve()는 응답이 4xx면 HttpClientErrorException, 5xx면 HttpServerErrorException을 던진다. 두 예외 모두 RestClientResponseException을 상속한다. 기본 동작을 바꾸려면 onStatus로 상태별 처리를 등록한다.

Pet pet = restClient.get()
        .uri("/pets/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
            throw new PetNotFoundException(res.getStatusCode());
        })
        .body(Pet.class);

응답 본문까지 직접 다뤄야 하면 exchange를 쓴다. 이 경우 onStatus 핸들러는 적용되지 않고, 상태 판단과 예외 처리를 콜백 안에서 전부 해야 한다.

Pet pet = restClient.get()
        .uri("/pets/{id}", id)
        .exchange((req, res) -> {
            if (res.getStatusCode().is4xxClientError()) {
                throw new PetNotFoundException(res.getStatusCode());
            }
            return objectMapper.readValue(res.getBody(), Pet.class);
        });

7. 타임아웃과 커넥션 풀 설정

여기서 한 번 걸리기 쉽다. 예전 RestTemplate 예제를 그대로 옮겨 HttpComponentsClientHttpRequestFactorysetConnectTimeout(...), setReadTimeout(...)을 부르면 최근 버전에서 컴파일이 안 된다. 팩토리의 setConnectTimeout(int)은 Spring Framework 6.2.13에서 사용 중단, 7.0에서 제거됐다. setReadTimeout도 6.1에서 빠졌다가 6.2에서 되살아나는 등 오락가락했다. 그러니 팩토리에 타임아웃을 직접 걸지 말고 HttpClient5 쪽에 설정한다.

Apache HttpClient5는 타임아웃 종류를 나눠서 다룬다. 각각 무엇인지부터 짚는다.

  • connectTimeout: TCP 연결을 맺을 때까지 기다리는 시간. ConnectionConfig에 건다.
  • socketTimeout: 연결 후 데이터가 오기까지의 대기 시간, 즉 읽기 타임아웃(SO_TIMEOUT). 역시 ConnectionConfig에 건다.
  • connectionRequestTimeout: 풀에서 커넥션을 빌려올 때까지 기다리는 시간. RequestConfig에 건다.

커넥션 풀과 타임아웃을 함께 잡은 팩토리 빈이다.

@Bean
public ClientHttpRequestFactory clientHttpRequestFactory() {
    ConnectionConfig connectionConfig = ConnectionConfig.custom()
            .setConnectTimeout(Timeout.ofSeconds(3))   // TCP 연결
            .setSocketTimeout(Timeout.ofSeconds(5))    // 읽기
            .build();

    PoolingHttpClientConnectionManager cm =
            PoolingHttpClientConnectionManagerBuilder.create()
                    .setDefaultConnectionConfig(connectionConfig)
                    .setMaxConnTotal(100)      // 전체 최대 커넥션
                    .setMaxConnPerRoute(20)    // 대상 호스트별 최대
                    .build();

    RequestConfig requestConfig = RequestConfig.custom()
            .setConnectionRequestTimeout(Timeout.ofSeconds(2)) // 풀 대여 대기
            .build();

    CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .setDefaultRequestConfig(requestConfig)
            .build();

    return new HttpComponentsClientHttpRequestFactory(httpClient);
}

이 팩토리를 빌더에 물린다.

@Bean
public RestClient petClient(RestClient.Builder builder,
                            ClientHttpRequestFactory factory) {
    return builder
            .baseUrl("https://petclinic.example.com")
            .requestFactory(factory)
            .build();
}

풀을 작게 잡아두면 트래픽이 몰릴 때 connectionRequestTimeout에 걸려 요청이 줄줄이 실패한다. 대상 호스트별 동시 호출량을 보고 maxConnPerRoute를 정한다. 기본값은 라우트당 5로 작은 편이라 그대로 두면 병목이 되기 쉽다.

8. 프로퍼티로 타임아웃만 간단히 걸기

커넥션 풀까지는 필요 없고 타임아웃만 전역으로 걸고 싶으면 프로퍼티로도 된다. 여기서 버전별로 키 이름이 다르니 주의한다.

Spring Boot 3.4와 3.5는 단수형 키를 쓴다.

# Spring Boot 3.4 / 3.5
spring.http.client.connect-timeout=3s
spring.http.client.read-timeout=5s
spring.http.client.factory=http-components

Spring Boot 4.x에서는 복수형으로 바뀌었고, 동기(imperative)와 리액티브를 나눠 지정한다.

# Spring Boot 4.x
spring.http.clients.connect-timeout=3s
spring.http.clients.read-timeout=5s
spring.http.clients.imperative.factory=http-components

Spring Boot 3.2와 3.3에는 이 통합 프로퍼티가 아직 없다. 그 버전에서는 7절처럼 RestClient.Builder에 팩토리를 직접 물려 설정한다. 프로퍼티 방식은 앱 전역에 일괄로 걸리므로, 대상별로 값을 달리 줘야 하면 빈으로 나눠 만드는 편이 관리하기 쉽다.

9. 정리

동기 HTTP 호출은 RestClient로 통일한다. 인터셉터와 에러 핸들러는 RestTemplate 자산을 그대로 재사용할 수 있어 옮기는 부담이 크지 않다. 걸리는 지점은 하나, 타임아웃이다. 팩토리에 직접 setConnectTimeout을 부르던 옛 코드는 최신 프레임워크에서 컴파일되지 않으니, HttpClient5의 ConnectionConfig와 커넥션 매니저에 설정하는 방식으로 바꾼다. 프로퍼티 키는 3.4/3.5의 단수형과 4.x의 복수형이 다르다는 점만 기억하면 된다.

자주 묻는 질문

RestTemplate은 이제 사용 중단인가?

제거 예정이나 deprecated는 아니다. 다만 유지보수 모드라 새 기능은 들어오지 않는다. 기존 코드를 당장 뜯어고칠 필요는 없지만, 신규 동기 호출 코드에는 RestClient를 쓰는 편을 권한다.

동기 호출인데 WebClient를 써도 되나?

block()으로 결과를 받으면 동기처럼 쓸 수는 있다. 그러나 MVC 앱에 WebClient만 쓰려고 spring-boot-starter-webflux와 리액터 스택을 끌어오는 건 과하다. 동기면 RestClient가 의존성도 가볍고 API도 목적에 맞다.

HttpComponentsClientHttpRequestFactory.setConnectTimeout이 왜 안 되나?

setConnectTimeout(int)은 Spring Framework 6.2.13에서 사용 중단되고 7.0에서 제거됐다. HttpClient5에서는 연결 타임아웃을 팩토리가 아니라 ConnectionConfig 또는 커넥션 매니저에 설정한다. 이 글 7절의 예제를 따르면 된다.

connect timeout, read timeout, connectionRequestTimeout은 무엇이 다른가?

connect timeout은 TCP 연결 수립까지의 대기, read timeout(socketTimeout)은 연결 후 데이터가 오기까지의 대기, connectionRequestTimeout은 커넥션 풀에서 커넥션을 빌려올 때까지의 대기다. 앞 둘은 ConnectionConfig, 마지막은 RequestConfig에 건다.

RestClient에서 4xx, 5xx 응답은 어떻게 처리하나?

retrieve()는 기본적으로 4xx에 HttpClientErrorException, 5xx에 HttpServerErrorException을 던진다. 동작을 바꾸려면 onStatus로 상태별 핸들러를 등록하거나, 응답을 직접 다뤄야 하면 exchange 콜백을 쓴다. exchange에서는 onStatus가 적용되지 않는다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.