지금까지 대표적으로 사용했던 HTTP 클라이언트 모듈은 WebClient와 RestTemplate가 대표적으로 사용되었다. 하지만 RestTemplate은 이제 maintenance 모드로 변경되었고 (deprecated는 아니다) WebClient는 강력하지만 단순한 동기 호출에는 좀 과한 면이 있었다.
이런 문제를 조금 더 개선해 줄 수 있는 HTTP 클라이언트 모듈이 Spring Framework 6.1부터 소개된 RestClient다. (Spring Boot 3.2부터 사용)
RestClient는 RestTemplate의 직관적인 API 디자인과 WebClient와 같이 fluent API를 결합하여 동기 방식의 HTTP 통신을 위한 새로운 표준을 제시한다. 이번 포스팅에서는 RestClient 사용법에 대해서 정리하고자 한다.
디펜던시
RestClient는 spring boot 3.2부터 사용 가능하며 spring-boot-starter-web에 포함되어 있다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
Http Message Conversion
spring-web 모듈에는 InputStream 및 OutputStream을 통해 HTTP 요청 및 응답 본문을 읽고 쓰는 HttpMessageConverter 인터페이스가 포함되어 있다. HttpMessageConverter는 RestTemplate, RestClient, WebClient, @RequestBody, @ResponseBody와 같이 body에 대한 직렬화, 역직렬화가 필요한 곳에서 사용된다. 다음 링크는 Spring Boot Auto Configuration에 의해서 자동으로 생성되는 HttpMessageConverter 빈 인스턴스에 대한 설명을 하고 있다.
RestClient에 대한 내용을 작성하기에 앞서 해당 링크를 우선 참고하면 도움이 될 것 같다.
RestClient와 HttpClient
RestClient는 Spring에서 제공하는 고수준의 REST API 클라이언트다. 이는 실제 전송 계층에서 전송을 담당하는 HttpClient를 호출하기 위한 추상화 계층이라고 할 수 있다.
RestClient를 통해서 서버와 통신을 할 때 실제로 내부에서는 다음과 같은 절차를 거친다. 물론 RestTemplate, WebClient 역시 고수준의 추상화 계층이고 내부적으로는 HttpClient 구현체가 전송 계층을 담당하도록 설계되어 있다.
RestClient -> RestClientAdapter -> ClientHttpRequestFactory -> HttpClient 구현체
즉 RestClient는 HTTP 클라이언트(HttpClient)를 직접 생성하거나 소유하지 않는다. 대신에 Spring의 추상화인 ClientHttpRequestFactory를 통해서 실제 전송 계층을 위임한다.
애플리케이션 클래스패스에 사용 가능한 라이브러리에 따라서 함께 사용할 HTTP 클라이언트를 자동으로 감지하는데 다음과 같은 우선순위를 가진다.
- Apache HttpClient
- Jetty HttpClient
- Reactor Netty HttpClient
- JDK client (java.net.http.HttpClient)
- Simple JDK client (java.net.HttpURLConnection)
클래스패스에 여러 클라이언트가 존재하고 전역 구성이 제공되지 않을 경우 가장 우선순위가 높은 클라이언트가 사용된다.
전역 HttpClient 설정
자동 감지된 HTTP 클라이언트가 요구 사항을 충족하지 않을 경우 spring.http.client.factory 속성을 사용하여 특정 팩토리를 선택할 수 있다. 예를 들어, 클래스패스에 Apache HttpClient가 있지만 Jetty의 HttpClient를 선호하는 경우 다음을 추가할 수 있다.
spring.http.client.factory=jetty
모든 클라이언트에 적용될 기본값을 변경하기 위해 속성을 설정할 수도 있다. 예를 들어 타임아웃 설정이나 리디렉션 추적 여부를 변경할 수 있다.
spring.http.client.connect-timeout=2s
spring.http.client.read-timeout=1s
spring.http.client.redirects=dont-follow
RestClient 생성
RestClient는 static create 메서드 중에서 하나를 사용하여 생성한다. 또한 builder를 사용하여 추가 옵션을 지정한 빌더를 얻을 수도 있다. 예를 들어 사용할 HTTP 라이브러리 지정, 사용할 메시지 변환기 지정, 기본 URI 설정, 기본 경로 변수 설정, 기본 요청 헤더 설정등을 인스턴스 생성 과정에서 할 수 있다. RestClient 인스턴스는 thread safe 하다. RestClient를 빈으로 생성하고 생성 시 기본적인 정보를 세팅해 두면 좋을 것 같다.
// static create를 이용한 생성.
RestClient defaultClient = RestClient.create();
// builder()를 이용한 생성
RestClient customClient = RestClient.builder()
.requestFactory(new HttpComponentsClientHttpRequestFactory())
.messageConverters(converters -> converters.add(new MyCustomMessageConverter()))
.baseUrl("https://example.com")
.defaultUriVariables(Map.of("variable", "foo"))
.defaultHeader("My-Header", "Foo")
.defaultCookie("My-Cookie", "Bar")
.requestInterceptor(myCustomInterceptor)
.requestInitializer(myCustomInitializer)
.build();
RestClient 빈 생성하기
RestClient는 스레드 안전하므로 싱글톤으로 동작하는 빈을 생성하여 필요한 곳에 주입하여 사용할 수 있다. 다음은 RestClient 빈을 생성하는 방법을 소개한다.
직접 생성
다음과 같이 @Baen 메서드 내에서 직접 RestClient 인스턴스를 생성하는 방법이다.
이 경우에는 아래 설명하는 RestClientCustomizer에 지정된 설정이 반영되지 않는다.
@Configuration
public class RestClientConfig {
@Bean
public RestClient myRestClient() {
return RestClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("User-Agent", "MyApp/1.0")
.defaultHeader("Authorization", "Bearer " + token)
.build();
}
}
직접 RestClient.builder()를 사용하여 RestClient 빈 인스턴스를 생성하는 방식이다.
customizer 사용
생성되는 모든 RestClient 빈에 공통 정책(로깅, 타임아웃, 커넥션 등)을 적용하려는 경우 customizer를 사용한다. customizer를 통해서 설정된 항목은 RestClient.builder에 반영된다.
customizer는 다음과 같은 흐름으로 적용된다.
주의해야 할 부분은 RestClientCustomizer는 RestClient.Builder에 적용되는 것이다. RestClient.Builder는 Spring Boot Auto Configuration에 의해서 자동 생성되지만 RestClient는 자동 생성되지 않는다. RestClient.Builder 빈을 주입받아 RestClient 빈을 직접 생성해 줘야 한다.
RestClientCustomizer -> RestClient.Builder -> RestClient
다음은 RestClient.Builder에 Authorization, User-Agent, Accept 헤더를 공통적으로 적용한다.
@Configuration
public class RestClientCommonConfig {
@Bean
public RestClientCustomizer authHeaderCustomizer() {
return builder -> builder
.defaultHeader("Authorization", "Bearer my-shared-access-token")
.defaultHeader("User-Agent", "MyCompanyService/1.0")
.defaultHeader("Accept", "application/json");
}
// RestClientCustomizer가 적용된 builder가 주입된다.
@Bean
public RestClient restClient( RestClient.Builder builder ) {
return builder.baseUrl("https://api.example.com").build();
}
}
builder에서 지정된 defaultHeader는 RestClient.Builder에 반영이 되고 RestClient.Builder를 통해서 생성되는 모든 RestClient에 적용된다. 즉 RestClient.Builder 빈을 통해서 RestClient 빈을 생성하는 경우 RestClientCustomizer 설정이 적용된다.
다음은 JDK11 이상부터 제공되는 내장된 HttpClient 인스턴스 빈을 생성하고 이 빈을 JdkClientHttpRequestFactory에 적용 후 RestClientCustomizer에 적용한다.
package org.example.spring.restclient.sample.configuration;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
@Configuration
public class RestClientConfiguration {
@Bean
public HttpClient httpClient() {
return HttpClient.newBuilder()
.connectTimeout( Duration.ofSeconds( 3 ) )
.followRedirects( HttpClient.Redirect.NORMAL )
.version( HttpClient.Version.HTTP_2 )
.build();
}
@Bean
public JdkClientHttpRequestFactory clientHttpRequestFactory( HttpClient httpClient ) {
JdkClientHttpRequestFactory jdkClientHttpRequestFactory = new JdkClientHttpRequestFactory( httpClient );
jdkClientHttpRequestFactory.setReadTimeout( Duration.ofSeconds( 5 ) );
return jdkClientHttpRequestFactory;
}
@Bean
public RestClientCustomizer restClientCustomizer( JdkClientHttpRequestFactory requestFactory ) {
return builder ->
builder.requestFactory( requestFactory )
.defaultHeader( "Authorization", "Bearer " + System.getenv( "BEARER_TOKEN" ) )
.defaultHeader( "User-Agent", System.getenv( "USER_AGENT" ) )
.defaultHeader( "Accept", "application/json" );
}
// 사용자 서비스 API 클라이언트
@Bean
public RestClient userApiClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com/users") // 서비스별 Base URL 지정
.build();
}
// 주문 서비스 API 클라이언트
@Bean
public RestClient orderApiClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com/orders")
.build();
}
// 결제 서비스 API 클라이언트
@Bean
public RestClient paymentApiClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com/payments")
.build();
}
}
반복하여 설명하지만 RestClientCustomizer 빈은 RestClient.Builder 빈에 적용된다. 따라서 RestClient.Builder를 통해서 생성되는 모든 RestClient 빈에는 RestClientCustomizer가 공통적으로 적용된다.
위 샘플 코드는 HttpClient 설정(타임아웃, 버전등)을 RestClient.Builder 빈에 공유한다. 별도의 RestClient빈에는 baseUrl만 지정하면 바로 사용 가능하다.
RestClient 사용
Path Variable 지정
RestClient는 RestTemplate처럼 URI 템플릿 변수를 직접 치환할 수 있다.
User user = RestClient.create()
.get()
.uri("https://api.example.com/users/{id}", 12345) // 순서대로 치환
.retrieve()
.body(User.class);
Map을 사용하여 이름 기반으로 치환할 수도 있다.
Map<String, Object> uriVariables = Map.of("userId", 12345);
User user = RestClient.create()
.get()
.uri("https://api.example.com/users/{userId}", uriVariables)
.retrieve()
.body(User.class);
UriBuilder를 사용하여 PathVariable을 구성할 수 있다.
User user = RestClient.create()
.get()
.uri(uriBuilder -> uriBuilder
.path("/users/{id}")
.build(12345))
.retrieve()
.body(User.class);
Query Parameter 지정
uri()에 직접 하드코드 방식으로 지정할 수 있지만 권장하진 않는다.
List<User> users = RestClient.create()
.get()
.uri("https://api.example.com/users?active=true&limit=10")
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
UriBuilder를 사용하여 Query Parameter를 구성할 수 있다. (권장 방식)
List<User> users = RestClient.create()
.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("active", true)
.queryParam("limit", 10)
.queryParam("sort", "name")
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
UriBuilder는 같은 이름의 파라미터를 여러 번 지정할 수 있다.
List<String> roles = List.of("ADMIN", "USER");
List<User> users = RestClient.create()
.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("role", roles.toArray()) // role=ADMIN&role=USER
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
PathVariable + Query Parameter 혼합
UriBuilder를 사용하여 혼합하여 구성할 수 있다.
UserDetail detail = RestClient.create()
.get()
.uri(uriBuilder -> uriBuilder
.path("/users/{id}")
.queryParam("include", "profile")
.queryParam("include", "roles")
.build(12345))
.retrieve()
.body(UserDetail.class);
Request headers and body
header(String, String), headers(Consumer <HttpHeaders>)를 사용하거나 accept(MediaType), acceptCharset(Charset...)과 같은 편의 메서드를 통해 요청 헤더를 추가할 수 있다. 본문을 포함할 수 있는 HTTP 요청 (POST, PUT, PATCH)의 경우 contentType(MediaType) 및 contentLength(long)과 같은 메서드를 사용할 수 있다.
요청 본문 자체는 내부적으로 HTTP 메시지 변환을 사용하는 body(Object)로 설정할 수 있고 또는 ParameterizedTypeReference를 사용하여 요청 본문을 설정할 수 있으며 이를 통해 제너릭을 활용할 수 있다. 마지막으로 본문을 OutputStream에 기록하는 콜백 함수로 설정할 수도 있다.
ParameterizedTypeReference란?
ParameterizedTypeReference는 Spring Framework에서 제네릭 타입 정보를 런타임에 보존하기 위해 사용하는 클래스다.
타입 소거
- Java의 제네릭은 컴파일 타임에만 존재하고 런타임에는 타입 정보가 소거된다.
//컴파일 타임
List <User> users = new ArrayList<User>();
//런타임 - 타입 정보 소실 (User 정보가 사라짐)
List<User> users = new ArrayList<>();
문제가 되는 경우
// List<User>.class는 불가능
List<User> users = restClient.get().uri("/users").retrieve().body(List<User>.class);
// 타입 정보 손실
List<User> users = restClient.get().uri("/users").retrieve().body(List.class) // List<Object>로 인식됨
위와 같은 문제로 인한 경우 ParameterizedTypeReference를 사용한다.
List<User> users = restClient.get().uri("/users").retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
ParameterizedTypeReference에 대한 익명 클래스를 생성하여 제네릭 타입 정보를 보존하도록 하는 것이다.
header + JSON Body
가장 흔한 케이스로 POST 요청 시 Content-Type, Authorization 등의 헤더와 DTO 형태의 JSON body를 함께 전송하는 방식이다.
UserRequest requestBody = new UserRequest("test", "developer");
UserResponse response = restClient.post()
.uri("https://api.example.com/users")
.contentType(MediaType.APPLICATION_JSON) // Content-Type 설정
.accept(MediaType.APPLICATION_JSON) // Accept 설정
.header("Authorization", "Bearer " + accessToken) // Custom Header
.body(requestBody) // Body 직렬화 (자동 JSON 변환)
.retrieve()
.body(UserResponse.class); // 응답 역직렬화
body(Object) 메서드는 내부적으로 HttpMessageConverter를 통해 자동으로 JSON 직렬화된다.
Map<String, Object>로 Body를 직접 구성
Map<String, Object> payload = Map.of(
"name", "test",
"role", "developer"
);
String result = restClient.post()
.uri("https://api.example.com/users")
.contentType(MediaType.APPLICATION_JSON)
.headers(headers -> {
headers.setBearerAuth("your-jwt-token");
headers.add("X-Custom-Header", "example");
})
.body(payload)
.retrieve()
.body(String.class);
headers()는 Consumer<HttpHeaders>를 파라미터로 받는다.
간다한 헤더 설정은 .header(key, value) 설정으로 가능하다.
Form URL Encoded 전송 (application/x-www-form-urlencoded)
Map 형태를 사용하되, Content-Type을 폼 형식으로 지정한다.
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("username", "user1");
formData.add("password", "secret");
String tokenResponse = restClient.post()
.uri("https://auth.example.com/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.accept(MediaType.APPLICATION_JSON)
.body(formData)
.retrieve()
.body(String.class);
내부적으로 FormHttpMessageConverter가 자동으로 사용된다.
Raw Body (문자열 직접 지정)
이미 직렬화된 JSON 문자열을 직접 전송할 수 있다.
String rawJson = """
{
"email": "test@example.com",
"active": true
}
""";
String result = restClient.post()
.uri("https://api.example.com/register")
.contentType(MediaType.APPLICATION_JSON)
.body(rawJson)
.retrieve()
.body(String.class);
Binary Body (예: 파일 업로드)
InputStream 또는 byte[]를 통해서 body를 전송할 수 있다.
Path filePath = Path.of("/tmp/sample.pdf");
byte[] fileBytes = Files.readAllBytes(filePath);
String response = restClient.post()
.uri("https://api.example.com/upload")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Filename", filePath.getFileName().toString())
.body(fileBytes)
.retrieve()
.body(String.class);
이 방식은 multipart 업로드가 아닌 raw binary 전송이다.
OutputStream을 이용한 파일 업로드
Path filePath = Path.of("/tmp/large-video.mp4");
client.post()
.uri("https://api.example.com/upload")
.contentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM)
.body(outputStream -> {
try (InputStream inputStream = Files.newInputStream(filePath)) {
inputStream.transferTo(outputStream); // Input → OutputStream 복사
}
})
.retrieve()
.toBodilessEntity(); // 응답 본문이 필요 없을 때
업로드 파일 전체를 메모리에 올리지 않고 스트림을 통해 바로 전송하기 때문에 대용량 파일 업로드에 적합하다.
Multipart/Form-Data (파일 + JSON 전송)
MultipartBodyBuilder를 이용하여 간단히 구성할 수 있다.
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", new FileSystemResource("/tmp/photo.png"));
builder.part("meta", "{\"author\":\"myname\"}", MediaType.APPLICATION_JSON);
String result = restClient.post()
.uri("https://api.example.com/upload")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(builder.build())
.retrieve()
.body(String.class);
Retrieving the Response (응답 가져오기)
기본 body(Class<T>) 사용
가장 단순하고 일반적인 방식이다. 응답의 Content-Type(application/json, text/plain 등)에 따라 HttpMessageConverter가 자동으로 직렬화/역직렬화를 수행한다.
User user = restClient.get()
.uri("https://api.example.com/users/1")
.retrieve()
.body(User.class); // JSON → User 객체
Content-Type이 JSON이면 ObjectMapper를 통해서 자동 변환된다.
body(ParameterizedTypeReference<T>) 사용 (제네릭 컬렉션 대응)
응답이 리스트 혹은 맵 형태일 경우 자바의 타입 소거 문제를 해결하기 위해 ParameterizedTypeReference를 사용한다.
List<User> users = restClient.get()
.uri("https://api.example.com/users")
.retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
List<User> 혹은 Map<String, Object>와 같은 복합 타입도 안전하게 역직렬화된다.
Spring의 ParameterizedTypeReference는 내부적으로 TypeReference를 유지해 준다.
exchange() 혹은 toEntity()로 Raw Body 직접 처리
응답 헤더, 상태 코드 등을 함께 다루면서 직접 body를 파싱 하고자 할 때 유용하다.
ResponseEntity<String> response = restClient.get()
.uri("https://api.example.com/raw")
.retrieve()
.toEntity(String.class);
if (response.getStatusCode().is2xxSuccessful()) {
String rawJson = response.getBody();
User user = new ObjectMapper().readValue(rawJson, User.class);
}
String 타입으로 body payload를 받은 뒤 직접 Jackson/Gson으로 역직렬화할 수 있다.
비표준 응답(JSON + Base64, JSON + Hex 등)을 처리할 때 자주 사용된다.
OutputStream을 통한 body 수신
대용량 파일이나 스트리밍 응답을 받을 때는 OutputStream을 직접 받을 수 있다. 이 방식은 메모리에 전체 body를 올리지 않고 스트림을 통해 순차적으로 데이터를 기록하기 때문에 OutOfMemoryError를 방지할 수 있다.
restClient.get()
.uri("https://example.com/large-file.zip")
.retrieve()
.body((inputStream, headers) -> {
try (OutputStream outputStream =
new FileOutputStream("/tmp/large-file.zip")) {
inputStream.transferTo(outputStream);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return null; // body()는 반환값이 필요하므로 null 리턴
});
body(BiFunction<InputStream, HttpHeaders, T>) 형태를 지원한다. 즉, 입력 스트림을 직접 읽고 처리할 수 있다.
exchange()를 이요하여 저수준 방식으로 처리할 수 있다.
restClient.get()
.uri("https://example.com/large-file.zip")
.exchange((request, response) -> {
try (InputStream in = response.body();
OutputStream out = new FileOutputStream("/tmp/large-file.zip")) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
return null;
});
보다 세밀한 제어가 필요한 경우에는 exchange()를 이용해서 ClientHttpResponse를 직접 다룬다.
exchange()는 상태코드, 헤더, body 스트림을 완전히 제어할 수 있다.
일반적인 retrieve()보다 저수준이며 비동기/스트리밍 처리에도 적합하다.
Error Handling (에러 처리)
기본적으로 RestClient는 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());
})
.body(String.class);
4XX 응답 코드가 발생한 경우 MyCustomRuntimeException을 발생시키도록 한다.
Exchange
고급 시나리오에서는 RestClient가 retrieve() 대신에 exchange() 메서드를 통해 기본 HTTP 요청 및 응답에 대한 접근을 제공한다. exchange() 사용 시 상태 핸들러는 적용되지 않는다. exchange() 메서드가 이미 전체 응답에 대한 접근을 제공하므로 필요한 오류 처리를 직접 수행할 수 있기 때문이다.
Pet result = restClient.get()
.uri("https://petclinic.example.com/pets/{id}", id)
.accept(APPLICATION_JSON)
.exchange((request, response) -> {
if (response.getStatusCode().is4xxClientError()) {
throw new MyCustomRuntimeException(response.getStatusCode(), response.getHeaders());
}
else {
Pet pet = convertResponse(response);
return pet;
}
});
exchange 메서드는 request, response를 제공하는 람다 함수를 파라미터로 request와 response에 대한 처리를 동적으로 가능하도록 했다.
RestTemplate에서 RestClient 마이그레이션
https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#_migrating_from_resttemplate_to_restclient 링크를 참고하면 도움이 될 것이다.
끝.
참고 링크
https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
'스프링부트' 카테고리의 다른 글
| Spring Boot 3의 선언형 HTTP 클라이언트 - HTTP Interface란? (0) | 2025.10.22 |
|---|---|
| Spring Boot Actuator - 6. actuator + prometheus + grafana (0) | 2025.10.13 |
| Spring Boot Actuator - 5. 사용자 정의 Endpoint 만들기 (0) | 2025.09.27 |
| Spring Boot Actuator - 4. Endpoint 커스텀 (0) | 2025.09.24 |
| Spring Boot Actuator - 3. Actuator 보안 (0) | 2025.09.23 |
댓글