RestClient란?
Spring 6.1(Boot 3.2)에서 도입된 RestClient는 RestTemplate의 후계자다. WebClient의 플루언트 API를 동기 방식으로 제공하며, 블로킹 HTTP 클라이언트의 새로운 표준이다. Virtual Thread와 조합하면 WebClient의 논블로킹 없이도 높은 동시성을 달성할 수 있다.
기본 CRUD 요청
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com/v1")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("X-API-Key", "my-api-key")
.requestFactory(new JdkClientHttpRequestFactory()) // JDK HttpClient 사용
.build();
}
}
@Service
@RequiredArgsConstructor
public class UserApiClient {
private final RestClient restClient;
// GET: 단건 조회
public UserDto getUser(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(UserDto.class);
}
// GET: 목록 조회 (ParameterizedTypeReference)
public List<UserDto> getUsers(String status) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("status", status)
.queryParam("limit", 100)
.build())
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
// POST: 생성
public UserDto createUser(CreateUserRequest request) {
return restClient.post()
.uri("/users")
.body(request)
.retrieve()
.body(UserDto.class);
}
// PUT: 수정
public UserDto updateUser(Long id, UpdateUserRequest request) {
return restClient.put()
.uri("/users/{id}", id)
.body(request)
.retrieve()
.body(UserDto.class);
}
// DELETE
public void deleteUser(Long id) {
restClient.delete()
.uri("/users/{id}", id)
.retrieve()
.toBodilessEntity();
}
// 응답 전체 (헤더, 상태 코드 포함)
public ResponseEntity<UserDto> getUserWithHeaders(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.toEntity(UserDto.class);
}
}
에러 처리: status handler
@Service
public class OrderApiClient {
private final RestClient restClient;
public OrderApiClient(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://order-api.example.com")
// 글로벌 에러 핸들러
.defaultStatusHandler(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new ExternalServiceException(
"Order API 서버 에러: " + response.getStatusCode()
);
})
.build();
}
public OrderDto getOrder(String orderId) {
return restClient.get()
.uri("/orders/{id}", orderId)
.retrieve()
// 요청별 에러 핸들러 (글로벌보다 우선)
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
throw new OrderNotFoundException(orderId);
}
if (response.getStatusCode() == HttpStatus.FORBIDDEN) {
throw new OrderAccessDeniedException(orderId);
}
// 응답 바디 읽기
String body = new String(response.getBody().readAllBytes());
throw new OrderApiException("주문 조회 실패: " + body);
})
.body(OrderDto.class);
}
// 에러 응답을 DTO로 파싱
public OrderDto createOrder(CreateOrderRequest request) {
return restClient.post()
.uri("/orders")
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, response) -> {
ObjectMapper mapper = new ObjectMapper();
ErrorResponse error = mapper.readValue(
response.getBody(), ErrorResponse.class
);
throw new OrderValidationException(error.getErrors());
})
.body(OrderDto.class);
}
}
onStatus()는 RestTemplate의 ResponseErrorHandler보다 훨씬 직관적이다. 상태 코드별로 다른 예외를 던지고, 응답 바디를 에러 DTO로 파싱할 수 있다.
Interceptor: 로깅·인증·재시도
// 요청/응답 로깅 Interceptor
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();
log.info("→ {} {} body={}bytes",
request.getMethod(), request.getURI(), body.length);
ClientHttpResponse response = execution.execute(request, body);
log.info("← {} {}ms status={}",
request.getURI(),
System.currentTimeMillis() - start,
response.getStatusCode());
return response;
}
}
// Bearer Token 자동 주입 Interceptor
public class BearerTokenInterceptor implements ClientHttpRequestInterceptor {
private final TokenProvider tokenProvider;
public BearerTokenInterceptor(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String token = tokenProvider.getAccessToken();
request.getHeaders().setBearerAuth(token);
return execution.execute(request, body);
}
}
// RestClient에 Interceptor 등록
@Bean
public RestClient restClient(RestClient.Builder builder, TokenProvider tokenProvider) {
return builder
.baseUrl("https://api.example.com")
.requestInterceptor(new BearerTokenInterceptor(tokenProvider))
.requestInterceptor(new LoggingInterceptor())
.build();
}
Exchange: 저수준 응답 제어
// exchange()로 응답을 완전 제어
public <T> ApiResponse<T> callWithMetrics(String path, Class<T> type) {
return restClient.get()
.uri(path)
.exchange((request, response) -> {
HttpStatusCode status = response.getStatusCode();
HttpHeaders headers = response.getHeaders();
// 커스텀 헤더에서 메타데이터 추출
String requestId = headers.getFirst("X-Request-Id");
String rateLimit = headers.getFirst("X-RateLimit-Remaining");
if (status.is2xxSuccessful()) {
ObjectMapper mapper = new ObjectMapper();
T body = mapper.readValue(response.getBody(), type);
return new ApiResponse<>(body, requestId,
Integer.parseInt(rateLimit));
}
if (status == HttpStatus.TOO_MANY_REQUESTS) {
String retryAfter = headers.getFirst("Retry-After");
throw new RateLimitExceededException(
Integer.parseInt(retryAfter));
}
throw new ApiException("Unexpected status: " + status);
});
}
// 파일 다운로드: 스트리밍
public void downloadFile(String fileId, OutputStream out) {
restClient.get()
.uri("/files/{id}/download", fileId)
.exchange((request, response) -> {
response.getBody().transferTo(out);
return null;
});
}
exchange()는 retrieve()와 달리 자동 에러 처리가 적용되지 않는다. 모든 상태 코드를 직접 핸들링해야 하지만, 응답 헤더·스트리밍 등 세밀한 제어가 가능하다.
타임아웃과 커넥션 풀
@Bean
public RestClient restClient(RestClient.Builder builder) {
// JDK HttpClient 기반 설정
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
JdkClientHttpRequestFactory requestFactory =
new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(10));
return builder
.baseUrl("https://api.example.com")
.requestFactory(requestFactory)
.build();
}
// Apache HttpClient 5 기반 (커넥션 풀링)
@Bean
public RestClient restClientWithPool(RestClient.Builder builder) {
PoolingHttpClientConnectionManager connManager =
PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(100) // 전체 최대 커넥션
.setMaxConnPerRoute(20) // 호스트당 최대 커넥션
.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(3))
.setSocketTimeout(Timeout.ofSeconds(10))
.build())
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connManager)
.setDefaultRequestConfig(RequestConfig.custom()
.setResponseTimeout(Timeout.ofSeconds(10))
.build())
.build();
return builder
.baseUrl("https://api.example.com")
.requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
.build();
}
프로덕션에서는 Apache HttpClient 5의 커넥션 풀링을 사용한다. JDK HttpClient는 간단하지만 커넥션 풀 세부 제어가 제한적이다. HikariCP 커넥션 풀과 유사하게 maxConnTotal과 maxConnPerRoute를 튜닝해야 한다. 관련 내용은 Spring Boot HikariCP 커넥션 풀 글을 참고하자.
테스트: MockRestServiceServer
@SpringBootTest
class UserApiClientTest {
@Autowired
private UserApiClient userApiClient;
private MockRestServiceServer mockServer;
@Autowired
private RestClient.Builder restClientBuilder;
@BeforeEach
void setup() {
// RestClient에 MockServer 바인딩
MockRestServiceServer.MockRestServiceServerBuilder serverBuilder =
MockRestServiceServer.bindTo(restClientBuilder);
mockServer = serverBuilder.build();
}
@Test
void getUser_성공() {
mockServer.expect(requestTo("/users/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id": 1, "name": "Alice", "email": "alice@test.com"}
""", MediaType.APPLICATION_JSON));
UserDto user = userApiClient.getUser(1L);
assertThat(user.getName()).isEqualTo("Alice");
mockServer.verify();
}
@Test
void getUser_404_예외() {
mockServer.expect(requestTo("/users/999"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThrows(OrderNotFoundException.class,
() -> userApiClient.getUser(999L));
}
@Test
void createUser_요청바디_검증() {
mockServer.expect(requestTo("/users"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.name").value("Bob"))
.andExpect(jsonPath("$.email").value("bob@test.com"))
.andRespond(withSuccess("""
{"id": 2, "name": "Bob", "email": "bob@test.com"}
""", MediaType.APPLICATION_JSON));
CreateUserRequest request = new CreateUserRequest("Bob", "bob@test.com");
UserDto result = userApiClient.createUser(request);
assertThat(result.getId()).isEqualTo(2L);
mockServer.verify();
}
}
RestTemplate → RestClient 마이그레이션
// ❌ RestTemplate (레거시)
@Deprecated
public UserDto getUserOld(Long id) {
return restTemplate.getForObject("/users/{id}", UserDto.class, id);
}
// ✅ RestClient (신규)
public UserDto getUser(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(UserDto.class);
}
// ❌ RestTemplate: exchange로 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<UserDto> response = restTemplate.exchange(
"/users/{id}", HttpMethod.GET, entity, UserDto.class, id);
// ✅ RestClient: 체이닝으로 깔끔
ResponseEntity<UserDto> response = restClient.get()
.uri("/users/{id}", id)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.toEntity(UserDto.class);
// 기존 RestTemplate을 RestClient로 감싸기 (점진적 마이그레이션)
RestClient restClient = RestClient.create(existingRestTemplate);
RestClient.create(restTemplate)로 기존 RestTemplate의 설정(interceptor, error handler, request factory)을 그대로 가져올 수 있어, 점진적 마이그레이션이 가능하다. RestTemplate의 에러 처리 방식과 비교는 Spring WebClient·RestClient 심화 글에서 다루고 있다.
마무리
| 기능 | RestTemplate | RestClient | WebClient |
|---|---|---|---|
| API 스타일 | 메서드 기반 | 플루언트 체이닝 | 플루언트 체이닝 |
| 실행 모델 | 동기 | 동기 | 비동기/리액티브 |
| 에러 처리 | ResponseErrorHandler | onStatus 체이닝 | onStatus 체이닝 |
| HTTP Interface | ❌ | ✅ | ✅ |
| 상태 | 유지보수 모드 | 권장 | 리액티브 전용 |
RestClient는 Spring의 동기 HTTP 클라이언트 표준이다. WebClient의 직관적 API를 블로킹 컨텍스트에서 사용할 수 있고, Virtual Thread와 조합하면 논블로킹 못지않은 성능을 달성한다. 신규 프로젝트에서는 RestTemplate 대신 RestClient를 선택하는 것이 정답이다.