RestTemplate으로 외부 API를 호출하는 코드를 새로 짤 때마다 찜찜한 기분이 든 적이 있다면, 그 이유는 명확하다. RestTemplate은 스프링 5.0부터 유지보수 모드로 들어갔고, 공식 문서도 신규 프로젝트에는 다른 선택지를 권한다. 그렇다고 리액티브가 필요 없는 서비스에 WebClient를 끌어오는 것도 과하게 느껴진다. Spring RestClient는 정확히 이 틈을 메우는 HTTP 클라이언트다. Spring Framework 6.1과 Spring Boot 3.2에서 함께 등장했고, WebClient의 유려한 체이닝 API를 그대로 쓰면서도 결과는 동기적으로 즉시 받는다. 이번 글에서는 기본 사용법부터 쿼리 파라미터, 요청 바디 전송, 컬렉션 응답 처리, 인증 헤더, 타임아웃, 로깅, 에러 핸들링, 테스트 작성법까지 — 실제로 RestClient를 도입할 때 부딪히는 지점을 순서대로 정리한다.
Spring RestClient가 정확히 뭘 해결하나
RestClient는 RestTemplate의 동기 방식 호출과 WebClient의 fluent API를 결합한 HTTP 클라이언트다. org.springframework.web.client.RestClient 패키지로 Spring Framework 6.1에 새로 추가됐고, 블로킹 방식이라 리액티브 런타임 없이도 쓸 수 있다.
RestClient restClient = RestClient.create();
String result = restClient.get()
.uri("https://api.example.com/users/{id}", 1)
.retrieve()
.body(String.class);JavaRestClient.create()로 기본 인스턴스를 만들고, get()으로 메서드를 지정한 뒤 uri(), retrieve(), body()를 체이닝하는 구조다. RestTemplate의 getForObject(), postForEntity()처럼 메서드마다 이름이 다른 API 대신, HTTP 메서드 하나로 시작해 같은 패턴을 반복 적용한다. WebClient를 써본 적이 있다면 이 체이닝 구조가 낯설지 않을 것이다.
RestClient.Builder는 왜 직접 만들지 않고 주입받나
Spring Boot 3.2부터는 RestClient.Builder가 자동 구성되므로, 직접 RestClient.create()를 호출하는 대신 Builder를 주입받아 커스터마이징하는 편이 낫다. 그래야 공통 설정(베이스 URL, 헤더, 인터셉터)을 애플리케이션 전역에서 일관되게 유지할 수 있다.
@Service
public class UserApiClient {
private final RestClient restClient;
public UserApiClient(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://api.example.com")
.defaultHeader("Accept", "application/json")
.build();
}
public User getUser(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User.class);
}
}JavaRestClientAutoConfiguration이 RestClient.Builder 빈을 자동으로 만들어주기 때문에, 생성자에서 그대로 받아 쓰면 된다. baseUrl()로 공통 도메인을 지정해두면 이후 모든 요청에서 상대 경로만 쓰면 되고, defaultHeader()로 등록한 헤더는 이 Builder에서 만든 모든 요청에 자동으로 붙는다. Builder는 매번 새로 만들지 말고 서비스 하나당 한 번만 구성해 재사용하는 것이 원칙이다.
Spring Boot 4.0 이상을 쓴다면 확인할 것: 이 자동구성이 Boot 4.0부터
spring-boot-autoconfigure에서 분리됐다. 3.x에서는spring-boot-starter-web만으로RestClient.Builder가 자동으로 뜨지만, 4.0부터는spring-boot-starter-restclient의존성을 pom.xml(또는 build.gradle)에 직접 추가해야 한다. 이 글의 예제도 실제로 Boot 4.0.7 환경에서 이 의존성 없이 실행했더니 “No qualifying bean of type RestClient$Builder” 에러가 났다. 업그레이드 후 갑자기 이 빈이 안 뜬다면 가장 먼저 의심할 부분이다.
쿼리 파라미터를 붙이거나 다른 도메인을 호출하려면
baseUrl()을 설정해두고도 쿼리 파라미터를 붙이거나 가끔 다른 도메인을 호출해야 할 때가 있다. uri()는 문자열 템플릿뿐 아니라 UriBuilder를 받는 람다 형태도 지원하므로, 이 람다 안에서 경로와 쿼리 파라미터를 함께 조립하면 된다.
public List<User> searchUsers(String keyword, int page, int size) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("q", keyword)
.queryParam("page", page)
.queryParam("size", size)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
}JavauriBuilder.path()로 상대 경로를, queryParam()으로 쿼리 파라미터를 하나씩 붙인 뒤 build()로 최종 URI를 완성한다. 파라미터 값에 특수문자가 섞여 있어도 UriBuilder가 URL 인코딩을 자동으로 처리하므로, 문자열을 직접 이어붙이며 인코딩을 신경 쓸 필요가 없다.
여기서 실무자가 자주 놓치는 함정 하나가 있다. uri()에 /users처럼 상대 경로를 넘기면 baseUrl()에 이어 붙지만, https://other-api.com/users처럼 완전한 URL을 그대로 넘기면 baseUrl()이 통째로 무시되고 그 URL이 그대로 쓰인다. 여러 외부 API를 한 Builder로 처리하려다 의도치 않게 baseUrl()이 씹히는 사고가 종종 이 지점에서 난다. 도메인이 다른 API를 호출해야 한다면 아예 별도의 RestClient 인스턴스로 분리하는 편이 안전하다.
POST·PUT·DELETE로 데이터를 만들고 바꾸고 지우려면
지금까지는 조회(GET)만 다뤘지만, 실무에서는 리소스를 생성·수정·삭제하는 코드가 더 자주 필요하다. HTTP 메서드만 바꾸고 body()로 요청 바디를 실어 보내면 되는 대칭적인 구조다.
// 생성 (POST) — 응답으로 생성된 리소스를 받는다
public User createUser(NewUserRequest request) {
return restClient.post()
.uri("/users")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(User.class);
}
// 수정 (PUT) — 보통 바디가 없으므로 상태 코드만 확인한다
public void updateUser(Long id, UpdateUserRequest request) {
restClient.put()
.uri("/users/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.toBodilessEntity();
}
// 삭제 (DELETE)
public void deleteUser(Long id) {
restClient.delete()
.uri("/users/{id}", id)
.retrieve()
.toBodilessEntity();
}Java요청을 보내는 쪽(RequestBodySpec)에서 contentType()으로 Content-Type을 명시하고 body()에 객체를 넘기면, Jackson이 자동으로 JSON 직렬화를 처리한다. contentType()을 생략해도 대부분 application/json으로 잘 동작하지만, 명시적으로 지정해두면 나중에 XML이나 다른 포맷으로 바꿀 때 실수를 줄인다. PUT·DELETE처럼 응답 바디가 없는 요청에는 body(Class) 대신 toBodilessEntity()를 쓴다. 이 메서드는 ResponseEntity<Void>를 반환해서, 응답 바디를 억지로 읽으려다 예외가 나는 일 없이 상태 코드와 헤더만 깔끔하게 확인할 수 있다.
응답이 리스트나 Map이면 어떻게 받나
body(User.class)처럼 단일 객체는 문제없이 받아지지만, body(List<User>.class)는 애초에 컴파일되지 않는다. 자바 제네릭은 런타임에 타입 정보가 지워지기 때문에, List<User>와 List<Order>를 컴파일된 바이트코드 수준에서 구분할 방법이 없어서다. RestTemplate 시절부터 있던 이 문제는 RestClient에서도 똑같이 ParameterizedTypeReference로 해결한다.
// 리스트로 받기
List<User> users = restClient.get()
.uri("/users")
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
// Map으로 받기
Map<String, Object> raw = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(new ParameterizedTypeReference<Map<String, Object>>() {});Javanew ParameterizedTypeReference<List<User>>() {}처럼 뒤에 빈 중괄호({})를 붙여 익명 서브클래스를 만드는 게 핵심이다. 이렇게 하면 제네릭 타입 정보가 서브클래스의 상위 타입 정보(getGenericSuperclass())에 그대로 남기 때문에, 런타임에도 List<User>인지 List<Order>인지 구분할 수 있다. RestTemplate을 써봤다면 이미 익숙한 패턴이라 배울 게 새로 없고, 그대로 옮겨 쓰면 된다.
모든 요청에 인증 토큰을 자동으로 붙이려면
외부 API 대부분은 인증을 요구한다. 토큰이 고정값이면 defaultHeader()로 끝나지만, 토큰이 주기적으로 갱신되는 상황이라면 요청 시점마다 최신 토큰을 읽어와야 하므로 인터셉터가 필요하다.
// 방식 1: 고정 토큰이면 Builder 단계에서 한 번만 설정
RestClient restClient = builder
.baseUrl("https://api.example.com")
.defaultHeader("Authorization", "Bearer " + fixedToken)
.build();
// 방식 2: 토큰이 매번 갱신된다면 인터셉터로 요청 시점에 주입
RestClient restClient = builder
.baseUrl("https://api.example.com")
.requestInterceptor((request, body, execution) -> {
request.getHeaders().setBearerAuth(tokenProvider.getAccessToken());
return execution.execute(request, body);
})
.build();JavarequestInterceptor()는 ClientHttpRequestInterceptor를 받는데, 이 인터페이스는 RestTemplate 시절부터 쓰이던 것과 동일하다. intercept(request, body, execution) 형태로, 요청을 가로채 헤더를 조작한 뒤 execution.execute()를 호출해 실제 요청을 이어가는 구조다. request.getHeaders().setBearerAuth(token)은 Authorization: Bearer {token} 헤더를 직접 문자열로 조립할 필요 없이 만들어준다. 인터셉터는 등록된 순서대로 체이닝되므로, 인증 다음에 로깅을 붙이는 식으로 여러 개를 조합할 수도 있다.
응답이 하염없이 안 올 때 — 타임아웃 설정
RestClient 자체는 타임아웃을 직접 설정하는 옵션이 없다. 타임아웃은 그 아래에서 실제로 소켓을 여는 ClientHttpRequestFactory 책임이라서, 이 팩토리를 커스터마이징해 Builder에 꽂아주는 방식으로 설정한다.
import org.springframework.http.client.SimpleClientHttpRequestFactory;
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 연결 자체가 3초 안에 안 되면 실패
factory.setReadTimeout(5000); // 연결 후 응답이 5초 안에 안 오면 실패
RestClient restClient = builder
.baseUrl("https://api.example.com")
.requestFactory(factory)
.build();JavaconnectTimeout은 상대 서버와 TCP 연결 자체를 맺는 데 걸리는 최대 시간이고, readTimeout은 연결된 뒤 응답 데이터를 받는 데 걸리는 최대 시간이다. 이 둘을 구분해서 이해해야 하는 이유는 실패 원인이 다르기 때문이다. connectTimeout이 자주 걸린다면 네트워크 경로나 방화벽 문제일 가능성이 크고, readTimeout이 걸린다면 상대 서버가 응답을 늦게 준다는 뜻이다. 타임아웃 값을 아예 설정하지 않으면 기본적으로 무제한 대기가 되어, 외부 API 하나가 응답을 안 주는 순간 그 스레드가 통째로 멈춰버리는 장애로 이어질 수 있다. 운영 환경에 배포하기 전에 반드시 짚어야 하는 설정이다. 참고로 Spring Boot 3.4 이상에서는 ClientHttpRequestFactorySettings와 ClientHttpRequestFactoryBuilder라는 별도 설정 헬퍼도 제공되니, 최신 버전을 쓴다면 공식 문서에서 함께 확인해볼 만하다.
실제로 어떤 요청이 나갔는지 확인하려면
호출이 실패했는데 정확히 어떤 URL로, 어떤 헤더를 달고 나갔는지 궁금할 때가 있다. 인증 인터셉터를 만드는 데 썼던 것과 같은 ClientHttpRequestInterceptor로 요청 로깅을 붙이면 별도 라이브러리 없이 해결된다.
RestClient restClient = builder
.requestInterceptor((request, body, execution) -> {
log.info("요청: {} {}", request.getMethod(), request.getURI());
log.info("요청 헤더: {}", request.getHeaders());
return execution.execute(request, body);
})
.build();Java인터셉터 안에서 execution.execute(request, body)가 반환하는 ClientHttpResponse를 받으면 상태 코드와 응답 헤더도 같은 자리에서 로깅할 수 있다. 다만 응답 바디까지 로깅하려면 주의가 필요하다. 응답 바디는 한 번만 읽을 수 있는 스트림이라, 로깅을 위해 먼저 읽어버리면 정작 retrieve().body()가 읽을 데이터가 남지 않는다. 바디까지 남기고 싶다면 BufferingClientHttpRequestFactory로 요청 팩토리를 감싸서 응답을 버퍼링해야 여러 번 읽을 수 있다. 급하게 원인만 확인할 때는 메서드·URI·헤더 로깅만으로도 대부분의 문제(엉뚱한 경로, 빠진 헤더)는 바로 드러난다.
RestClient를 쓰는 코드는 어떻게 테스트하나
UserApiClient처럼 RestClient를 감싼 서비스는 실제 네트워크 호출 없이 @RestClientTest와 MockRestServiceServer로 테스트한다. @RestClientTest는 테스트에 필요한 RestClient 관련 빈만 로드하는 슬라이스 테스트 어노테이션이라, @SpringBootTest로 전체 컨텍스트를 띄우는 것보다 훨씬 빠르다.
@RestClientTest(UserApiClient.class)
class UserApiClientTest {
@Autowired
private UserApiClient userApiClient;
@Autowired
private MockRestServiceServer mockServer;
@Test
void 존재하는_사용자를_조회하면_정상_응답을_받는다() {
mockServer.expect(requestTo("https://api.example.com/users/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id": 1, "name": "홍길동"}
""", MediaType.APPLICATION_JSON));
User user = userApiClient.getUser(1L);
assertThat(user.name()).isEqualTo("홍길동");
}
}Java@RestClientTest(UserApiClient.class)는 테스트 대상 빈과 RestClient.Builder, JSON 컨버터 정도만 로드하고 DB나 다른 서비스 빈은 올리지 않는다. MockRestServiceServer는 이 테스트 컨텍스트 안에서 자동으로 구성되어 주입받을 수 있고, expect()로 “이런 요청이 오면”을, andRespond()로 “이렇게 응답한다”를 미리 등록해둔다. 실제 요청이 등록해둔 조건과 다르면 테스트가 바로 실패하므로, URL이나 메서드가 틀리는 실수도 테스트 단계에서 잡힌다. 외부 API가 실제로 살아있는지와 무관하게 CI에서 항상 같은 결과로 돌아간다는 점이 이 방식의 핵심 이점이다.
4xx, 5xx 응답은 어떻게 처리하나

기본적으로 RestClient는 4xx 응답에 HttpClientErrorException을, 5xx 응답에 HttpServerErrorException을 던진다. 상태 코드별로 다른 처리가 필요하면 onStatus()로 커스텀 핸들러를 등록한다.
User user = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + id);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new ExternalApiException("외부 API 오류: " + response.getStatusCode());
})
.body(User.class);JavaonStatus()는 첫 번째 인자로 상태 코드를 판별하는 조건을, 두 번째 인자로 예외 처리 로직을 받는다. HttpStatusCode::is4xxClientError처럼 메서드 레퍼런스를 그대로 넘길 수 있어 조건이 간결해진다. 아무 onStatus()도 등록하지 않으면 기본 예외가 그대로 던져지므로, 상위 계층에서 RestClientResponseException을 잡아 처리하는 것도 방법이다.
위 UserApiClient 코드를 실제 Spring Boot 4.0.7 프로젝트로 만들어 공개 테스트 API(JSONPlaceholder)에 대고 직접 실행해봤다. 존재하는 사용자(id=1)는 정상 조회되고, 존재하지 않는 사용자(id=999)는 실제로 onStatus(4xx) 핸들러가 동작해 UserNotFoundException을 던지는 것까지 확인했다.

RestTemplate, WebClient, RestClient 중 뭘 써야 하나
셋 다 Spring이 공식 지원하는 HTTP 클라이언트지만, 상태와 방향성이 다르다. RestTemplate은 유지보수 모드, WebClient는 리액티브 전용은 아니지만 리액티브 스택에 최적화, RestClient는 동기 방식의 신규 표준이다.
| 항목 | RestTemplate | WebClient | RestClient |
|---|---|---|---|
| 실행 방식 | 동기(블로킹) | 비동기(논블로킹), 블로킹 호출도 가능 | 동기(블로킹) |
| 반환 타입 | 즉시 값 | Mono/Flux | 즉시 값 |
| API 스타일 | 메서드별 개별 이름 | fluent 체이닝 | fluent 체이닝 |
| 상태 | 유지보수 모드(신규 기능 추가 없음) | 활발히 유지보수 | 활발히 유지보수, 신규 표준 |
| 의존성 | spring-web | spring-webflux 필요 | spring-web (추가 의존성 불필요) |
| 적합한 상황 | 레거시 코드 유지 | 리액티브 스택, 높은 동시성 | 블로킹 서비스의 신규 코드 |
표에서 가장 중요한 줄은 의존성이다. WebClient를 쓰려면 spring-webflux 의존성이 필요하지만, RestClient는 spring-web에 포함돼 있어 리액티브 스택을 전혀 안 쓰는 전통적인 MVC 프로젝트에도 추가 의존성 없이 바로 쓸 수 있다. 리액티브가 필요 없는 대다수의 서비스라면, 신규 코드는 RestTemplate이 아니라 RestClient로 시작하는 것이 Spring 팀이 권장하는 방향이다.
기존 RestTemplate 코드를 RestClient로 옮기려면

RestTemplate 코드가 이미 많다면 한 번에 전부 바꾸기보다, RestClient가 내부적으로 RestTemplate과 같은 ClientHttpRequestFactory 인프라를 공유한다는 점을 활용해 점진적으로 옮기는 편이 안전하다.
// 기존 RestTemplate 방식
RestTemplate restTemplate = new RestTemplate();
User user = restTemplate.getForObject("/users/{id}", User.class, id);
// RestClient로 전환
RestClient restClient = RestClient.create();
User user = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User.class);Java두 코드는 결과적으로 같은 HTTP 요청을 만든다. 심지어 RestClient.builder().requestFactory(clientHttpRequestFactory)로 기존에 쓰던 ClientHttpRequestFactory(타임아웃, 커넥션 풀 설정이 담긴)를 그대로 재사용할 수도 있어서, 커넥션 관련 설정을 다시 튜닝할 필요가 없다. 팀에서 자주 호출하는 API 클라이언트부터 하나씩 RestClient로 옮기고, 나머지는 RestTemplate이 유지보수 모드로 남아있는 동안 천천히 정리하면 된다.
FAQ
RestTemplate은 이제 완전히 사라지나요?
아니다. RestTemplate은 deprecated 상태가 아니라 유지보수 모드다. 기존 코드는 계속 동작하고 보안 패치도 이어지지만, 새로운 기능은 추가되지 않는다. 신규 코드 작성 시에는 RestClient를 권장한다.
RestClient는 언제부터 쓸 수 있나요?
Spring Framework 6.1, Spring Boot 3.2부터 사용할 수 있다. 그 이전 버전을 쓰고 있다면 프레임워크 업그레이드가 먼저 필요하다.
WebClient를 이미 쓰고 있다면 RestClient로 바꿔야 하나요?
꼭 그럴 필요는 없다. 리액티브 스트림(Mono/Flux)을 실제로 활용하고 있거나 높은 동시성이 필요한 상황이면 WebClient가 여전히 맞다. 단순히 블로킹 호출 결과만 필요해서 WebClient에 .block()을 붙여 쓰고 있었다면, RestClient로 옮기는 편이 코드가 더 명확해진다.
타임아웃을 안 걸어두면 실제로 무슨 일이 생기나요?
외부 API가 응답을 주지 않는 순간, 그 요청을 처리하던 스레드가 무기한 대기 상태로 묶인다. 트래픽이 몰리는 서비스라면 스레드 풀이 이런 요청들로 하나씩 잠식되다가 결국 다른 요청까지 처리 못 하는 전면 장애로 번질 수 있다. connectTimeout·readTimeout은 선택이 아니라 운영 환경 배포 전 필수 점검 항목으로 보는 게 안전하다.
RestClient로 파일 업로드나 멀티파트 요청도 가능한가요?
가능하다. body()에 MultiValueMap이나 MultipartBodyBuilder로 만든 멀티파트 데이터를 전달하면 되고, WebClient에서 쓰던 것과 같은 방식으로 구성한다.
마무리
팀 프로젝트에서 오래된 RestTemplate 코드를 RestClient로 하나씩 바꿔본 적이 있는데, 가장 먼저 체감한 건 코드 리뷰가 편해졌다는 점이었다. RestTemplate은 getForObject, postForEntity, exchange처럼 상황마다 메서드 이름이 달라서 리뷰할 때마다 시그니처를 다시 확인해야 했는데, RestClient는 어떤 요청이든 get()이나 post()로 시작해서 체이닝만 눈으로 따라가면 됐다. 실제로 옮기면서 가장 시간을 많이 쓴 부분은 의외로 타임아웃 설정이었다. 예전 RestTemplate 코드에 타임아웃이 아예 안 걸려 있던 API 클라이언트가 몇 개 나왔고, 그걸 RestClient로 옮기는 김에 하나씩 값을 정하며 정리했다. onStatus()로 에러 처리를 한곳에 모아둘 수 있다는 것도 은근히 크다. 예외 처리가 흩어져 있던 예전 코드보다 어디서 뭘 던지는지 한눈에 보인다. 아직 WebClient만큼 자료가 많지는 않지만, 블로킹 방식이 필요한 프로젝트라면 지금 시작하는 신규 코드는 RestTemplate 대신 RestClient로 짜는 걸 권한다. RestClient와 함께 Spring Boot 3.2/4.x의 다른 변화도 궁금하다면 Spring Boot 4.1 신기능 총정리도 참고할 만하다.
