RestClientλ‘ μΈλΆ API νΈμΆνκΈ° Spring Boot 4μ RestClientλ‘ μΈλΆ APIλ₯Ό νΈμΆνλ λ². νμμμ·μλ¬ νΈλ€λ§·μ¬μλλ₯Ό μ½λλ‘ κ΅¬ννκ³ RestTemplateκ³Ό λΉκ΅νλ€.
λ€μ΄κ°λ©°
μ΄μ κΈμμ μΈλΆ API μ°λ μ νμμμμ λ°λμ μ€μ ν΄μΌ νλ€λ μ΄μΌκΈ°λ₯Ό νλ€.
κ·Έ κΈμ "μ"μ μ§μ€νκ³ μ€μ μ½λλ λ§μ§ μμλ€.
μ΄λ² κΈμ κ·Έ μ°μ₯μ μΌλ‘, μ΄λ»κ² ꡬννλμ§λ₯Ό μ½λ μ€μ¬μΌλ‘ μ 리νλ€.
Spring Boot 3.2 / Spring Framework 6.1λΆν° RestClientκ° λμ
λλ€.RestTemplateμ λ체νκΈ° μν λκΈ°(Synchronous) HTTP ν΄λΌμ΄μΈνΈλ‘, λ©μλ 체μ΄λ κΈ°λ°μ fluent APIλ₯Ό μ 곡νλ€.
Spring Boot 4.0μμλ νλ‘νΌν° λ€μ΄λ°κΉμ§ μ 리λλ©΄μ μ¬μ€μ RestTemplate λμ RestClientλ₯Ό μ°μ§ μμ μ΄μ κ° μμ΄μ‘λ€.
μ΄ κΈμμλ RestClient λΉ λ±λ‘ → νμμμ → μλ¬ νΈλ€λ§ → λ‘κΉ
μΈν°μ
ν° → μ¬μλκΉμ§ μ 체λ₯Ό ꡬννλ€.
RestTemplate vs RestClient
λ¨Όμ λ§μ΄κ·Έλ μ΄μ κ΄μ μμ λ ν΄λΌμ΄μΈνΈλ₯Ό λΉκ΅νλ€.
| νλͺ© | RestTemplate | RestClient |
|---|---|---|
| λμ μμ | Spring 3.0 | Spring 6.1 / Boot 3.2 |
| API μ€νμΌ | λ©μλ κΈ°λ° (getForObject, postForEntity) |
fluent 체μ΄λ (.get().uri().retrieve()) |
| μλ¬ νΈλ€λ§ | ResponseErrorHandler ꡬν체 λ±λ‘ |
.onStatus() μΈλΌμΈ νΈλ€λ¬ |
| νμμμ μ€μ | RequestFactory μ§μ μμ± ν μ£Όμ
|
ClientHttpRequestFactorySettings (κ°κ²°) |
| μΈν°μ ν° | ClientHttpRequestInterceptor |
ClientHttpRequestInterceptor (λμΌ) |
| Spring 6.1+ | μ μ§λ³΄μ λͺ¨λ (μ κ· κΈ°λ₯ μμ) | 곡μ κΆμ₯ |
RestTemplateμ΄ deprecatedλ 건 μλμ§λ§, 곡μμ μΌλ‘ μ κ· κΈ°λ₯ μΆκ°λ μλ€.
μ νλ‘μ νΈλΌλ©΄ μ²μλΆν° RestClientλ‘ μμνλ κ² λ§λ€.
μμ‘΄μ±
RestClientλ spring-boot-starter-webμ ν¬ν¨λμ΄ μμ΄ λ³λ μμ‘΄μ±μ΄ νμ μλ€.
μ¬μλ(@Retryable)λ₯Ό μν΄ spring-retryλ§ μΆκ°νλ€.
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.retry:spring-retry")
implementation("org.springframework:spring-aspects") // @Retryable AOP νμ
}
μ 체 ꡬ쑰
ꡬνν λ μ΄μ΄λ λ€μκ³Ό κ°λ€.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Service Layer β
β UserApiClient / PaymentApiClient ... β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β RestClient νΈμΆ
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β RestClient Bean β
β β
β βββββββββββββββββββ ββββββββββββββββββββββββββββββββ β
β β LoggingIntercep β β ClientHttpRequestFactory β β
β β -tor β β (Connect / Read Timeout) β β
β ββββββββββ¬βββββββββ ββββββββββββββββ¬ββββββββββββββββ β
β β β β
β βββββββββββββ¬βββββββββββββββββ β
βββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββ
β HTTP μμ²
βββββββββββΌβββββββββββ
β External API β
βββββββββββ¬βββββββββββ
β μλ΅
βββββββββββββββΌβββββββββββββββ
β onStatus Handler β
β 4xx → ClientException β
β 5xx → ServerException β
β ↓ 5xx β
β @Retryable (retry) β
ββββββββββββββββββββββββββββββ
RestClientConfig — λΉ λ±λ‘κ³Ό νμμμ
application.yml (Spring Boot 4.0 λ°©μ)
Spring Boot 4.0μμ HTTP ν΄λΌμ΄μΈνΈ νλ‘νΌν° λ€μμ€νμ΄μ€κ° μ 리λλ€.spring.http.client.*λ deprecatedλκ³ spring.http.clients.*λ‘ ν΅ν©λλ€.
# application.yml
spring:
http:
clients:
connect-timeout: 3s # μ°κ²° νμμμ
read-timeout: 10s # μ½κΈ° νμμμ
νλ‘νΌν°λ‘ μ μ μ€μ νκ³ μΆμ λ μ΄λ€.
λ€λ§ μΈλΆ APIλ³λ‘ νμμμμ λ¬λ¦¬ κ°μ Έκ°μΌ νλ€λ©΄ μλ μ½λ λ°©μμ μ΄λ€.
RestClientConfig.java
// src/main/java/com/example/config/RestClientConfig.java
package com.example.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestFactories;
import org.springframework.http.client.ClientHttpRequestFactorySettings;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
public class RestClientConfig {
/**
* κ²°μ API μ μ© ν΄λΌμ΄μΈνΈ — νμμμμ μ§§κ² κ°μ Έκ°λ€.
* κ²°μ μ°λμ μλ΅μ΄ λ¦μ΄μ§λ μκ° μ€λ λκ° λ¬Άμ΄κΈ° λλ¬Έμ΄λ€.
*/
@Bean("paymentRestClient")
public RestClient paymentRestClient(
LoggingInterceptor loggingInterceptor,
@Value("${external.payment.base-url}") String baseUrl
) {
return RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(buildRequestFactory(Duration.ofSeconds(3), Duration.ofSeconds(5)))
.requestInterceptor(loggingInterceptor)
.build();
}
/**
* μ¬μ©μ API μ μ© ν΄λΌμ΄μΈνΈ — μλ΅μ΄ λ릴 μ μμ΄ μ½κΈ° νμμμμ λλνκ² μ‘λλ€.
*/
@Bean("userRestClient")
public RestClient userRestClient(
LoggingInterceptor loggingInterceptor,
@Value("${external.user.base-url}") String baseUrl
) {
return RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(buildRequestFactory(Duration.ofSeconds(3), Duration.ofSeconds(15)))
.requestInterceptor(loggingInterceptor)
.build();
}
private ClientHttpRequestFactory buildRequestFactory(
Duration connectTimeout,
Duration readTimeout
) {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(connectTimeout)
.withReadTimeout(readTimeout);
// Spring Boot 4 κΈ°λ³Έ ν©ν 리λ JDK HttpClient
// Apache HttpComponentsλ‘ λ°κΎΈλ €λ©΄ ClientHttpRequestFactories.get(HttpComponentsClientHttpRequestFactory.class, settings)
return ClientHttpRequestFactories.get(settings);
}
}
# application.yml
external:
payment:
base-url: ${PAYMENT_API_URL}
user:
base-url: ${USER_API_URL}
컀μ€ν μμΈ ν΄λμ€
μλ¬ νΈλ€λ§μμ λμ§ μμΈλ₯Ό λ¨Όμ μ μνλ€.
4xx(ν΄λΌμ΄μΈνΈ μλ¬)μ 5xx(μλ² μλ¬)λ₯Ό ꡬλΆνλ κ² ν΅μ¬μ΄λ€.
// src/main/java/com/example/exception/ExternalApiException.java
package com.example.exception;
import org.springframework.http.HttpStatusCode;
public class ExternalApiException extends RuntimeException {
private final HttpStatusCode statusCode;
public ExternalApiException(HttpStatusCode statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
public HttpStatusCode getStatusCode() {
return statusCode;
}
}
// src/main/java/com/example/exception/ExternalApiClientException.java
package com.example.exception;
import org.springframework.http.HttpStatusCode;
/**
* 4xx — μλͺ»λ μμ², μΈμ¦ μ€ν¨ λ±. μ¬μλν΄λ λμΌν κ²°κ³Όκ° λμ¨λ€.
*/
public class ExternalApiClientException extends ExternalApiException {
public ExternalApiClientException(HttpStatusCode statusCode, String message) {
super(statusCode, message);
}
}
// src/main/java/com/example/exception/ExternalApiServerException.java
package com.example.exception;
import org.springframework.http.HttpStatusCode;
/**
* 5xx — μΌμμ μΈ μλ² μ€λ₯. μ¬μλ λμμ΄λ€.
*/
public class ExternalApiServerException extends ExternalApiException {
public ExternalApiServerException(HttpStatusCode statusCode, String message) {
super(statusCode, message);
}
}
λ‘κΉ μΈν°μ ν°
μμ²/μλ΅μ λ‘κ·Έλ‘ λ¨κΈ°λ μΈν°μ
ν°λ€.
μ΄μ νκ²½μμ μΈλΆ API μ°λ λ¬Έμ λ₯Ό μΆμ ν λ μμΌλ©΄ λ΅λ΅ν κΈ°λ₯μ΄λ€.
// src/main/java/com/example/config/LoggingInterceptor.java
package com.example.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
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 {
logRequest(request, body);
long start = System.currentTimeMillis();
ClientHttpResponse response = execution.execute(request, body);
logResponse(request, response, System.currentTimeMillis() - start);
return response;
}
private void logRequest(HttpRequest request, byte[] body) {
log.info("[RestClient] → {} {} | body: {}",
request.getMethod(),
request.getURI(),
body.length > 0 ? new String(body, StandardCharsets.UTF_8) : "(empty)"
);
}
private void logResponse(HttpRequest request, ClientHttpResponse response, long elapsedMs) throws IOException {
log.info("[RestClient] ← {} {} | status: {} | elapsed: {}ms",
request.getMethod(),
request.getURI(),
response.getStatusCode(),
elapsedMs
);
}
}
μλ¬ νΈλ€λ§ — onStatus
RestClientλ .retrieve().onStatus()λ‘ μν μ½λλ³ νΈλ€λ¬λ₯Ό μΈλΌμΈμΌλ‘ λ±λ‘νλ€.RestTemplateμμ ResponseErrorHandlerλ₯Ό λ³λ ꡬνμ²΄λ‘ λ±λ‘νλ λ°©μλ³΄λ€ ν¨μ¬ κ°κ²°νλ€.
| λ°©μ | RestTemplate | RestClient |
|---|---|---|
| μλ¬ νΈλ€λ¬ λ±λ‘ | setErrorHandler(ResponseErrorHandler) |
.onStatus(predicate, handler) 체μ΄λ |
| μν μ½λ 쑰건 | hasError(HttpStatusCode) μ€λ²λΌμ΄λ |
HttpStatusCode::is4xxClientError λλ€ |
| μλ΅ λ°λ μ κ·Ό | handleError(ClientHttpResponse)μμ μ²λ¦¬ |
handler λ λ²μ§Έ νλΌλ―Έν° ClientHttpResponseλ‘ μ κ·Ό |
μ€μ λ‘ μ΄λ»κ² μ°λμ§ μλ UserApiClientμμ 보μ.
UserApiClient ꡬν
// src/main/java/com/example/client/UserApiClient.java
package com.example.client;
import com.example.exception.ExternalApiClientException;
import com.example.exception.ExternalApiServerException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.nio.charset.StandardCharsets;
@Component
public class UserApiClient {
private final RestClient restClient;
public UserApiClient(@Qualifier("userRestClient") RestClient restClient) {
this.restClient = restClient;
}
/**
* μ¬μ©μ λ¨κ±΄ μ‘°ν.
* 5xx μλ¬ μ μ΅λ 3ν(μ΄κΈ° ν¬ν¨) μ¬μλ, μ§μ λ°±μ€ν μ μ©.
*/
@Retryable(
retryFor = ExternalApiServerException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2.0, maxDelay = 5000)
)
public UserResponse getUser(Long userId) {
return restClient.get()
.uri("/users/{id}", userId)
.retrieve()
.onStatus(status -> status.value() == 404, (req, res) -> {
throw new ExternalApiClientException(res.getStatusCode(),
"User not found: " + userId);
})
.onStatus(status -> status.is4xxClientError(), (req, res) -> {
String body = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
throw new ExternalApiClientException(res.getStatusCode(), body);
})
.onStatus(status -> status.is5xxServerError(), (req, res) -> {
String body = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
// ExternalApiServerException → @Retryableμ΄ κ°μ§ν΄ μ¬μλ
throw new ExternalApiServerException(res.getStatusCode(), body);
})
.body(UserResponse.class);
}
/**
* μ¬μ©μ μμ±.
* POSTλ λ©±λ±νμ§ μμΌλ―λ‘ μ¬μλ μμ΄ κ·Έλλ‘ μ€ν¨λ₯Ό μ ννλ€.
*/
public UserResponse createUser(CreateUserRequest request) {
return restClient.post()
.uri("/users")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.onStatus(status -> status.is4xxClientError(), (req, res) -> {
String body = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
throw new ExternalApiClientException(res.getStatusCode(), body);
})
.onStatus(status -> status.is5xxServerError(), (req, res) -> {
String body = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
throw new ExternalApiServerException(res.getStatusCode(), body);
})
.body(UserResponse.class);
}
/**
* μ¬μ©μ μμ .
* μλ΅ λ°λκ° μμ λλ .toBodilessEntity()λ₯Ό μ¬μ©νλ€.
*/
public void deleteUser(Long userId) {
restClient.delete()
.uri("/users/{id}", userId)
.retrieve()
.onStatus(status -> status.is4xxClientError(), (req, res) -> {
throw new ExternalApiClientException(res.getStatusCode(),
"Delete failed for userId: " + userId);
})
.onStatus(status -> status.is5xxServerError(), (req, res) -> {
throw new ExternalApiServerException(res.getStatusCode(),
"Server error while deleting userId: " + userId);
})
.toBodilessEntity();
}
/**
* getUser μ¬μλ μμ§ ν μ΅μ’
μ€ν¨ μ²λ¦¬.
* @Recover λ©μλ μκ·Έλμ²: 첫 λ²μ§Έ νλΌλ―Έν°κ° μ¬μλ μμΈ, λλ¨Έμ§λ μλ³Έ νλΌλ―Έν°μ λμΌν΄μΌ νλ€.
*/
@Recover
public UserResponse recoverGetUser(ExternalApiServerException e, Long userId) {
throw new IllegalStateException(
"External user API unavailable after retries. userId=" + userId, e
);
}
}
DTO μ μ
// src/main/java/com/example/client/UserResponse.java
package com.example.client;
public record UserResponse(
Long id,
String email,
String name,
String role
) {}
// src/main/java/com/example/client/CreateUserRequest.java
package com.example.client;
public record CreateUserRequest(
String email,
String name
) {}
@EnableRetry λ±λ‘
@Retryableμ AOP κΈ°λ°μ΄λΌ λ©μΈ ν΄λμ€λ μ€μ ν΄λμ€μ @EnableRetryλ₯Ό μ μΈν΄μΌ νλ€.
// src/main/java/com/example/Application.java
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
@SpringBootApplication
@EnableRetry
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
μ 체 μμ² νλ¦
ꡬνν νλ¦μ μ 리νλ©΄ λ€μκ³Ό κ°λ€.
μλΉμ€ λ μ΄μ΄
β
βββ UserApiClient.getUser(userId) νΈμΆ
β
βΌ
RestClient (userRestClient λΉ)
β
βββ LoggingInterceptor
β βββ μμ²/μλ΅ λ‘κ·Έ κΈ°λ‘ + μμ μκ° μΈ‘μ
β
βββ ClientHttpRequestFactory
β βββ Connect Timeout: 3s
β βββ Read Timeout: 15s
β
βββ GET /users/{id} βββββββββββββββββββββββββββββββΊ External User API
β β
βββββββββββββββββββββββββββββββββββββββββββββββββ HTTP μλ΅
β
βββ [200 OK]
β βββ .body(UserResponse.class) → μμ§λ ¬ν → λ°ν
β
βββ [404 Not Found]
β βββ onStatus → ExternalApiClientException (μ¬μλ μμ)
β
βββ [4xx]
β βββ onStatus → ExternalApiClientException (μ¬μλ μμ)
β
βββ [5xx]
β βββ onStatus → ExternalApiServerException
β β
β βββ @Retryable κ°μ§
β βββ 1μ°¨ μ¬μλ (500ms ν)
β βββ 2μ°¨ μ¬μλ (1000ms ν)
β βββ μμ§ → @Recover → IllegalStateException
β
βββ [Timeout]
βββ ResourceAccessException → νΈμΆλΆλ‘ μ ν
RestTemplate → RestClient λ§μ΄κ·Έλ μ΄μ μμ½
κΈ°μ‘΄ μ½λλ₯Ό κ΅μ²΄ν λ μμ£Ό μ°μ΄λ ν¨ν΄λ§ νλ‘ μ 리νλ€.
| RestTemplate (ꡬ) | RestClient (μ ) |
|---|---|
restTemplate.getForObject(url, ResponseType.class) |
.get().uri(url).retrieve().body(ResponseType.class) |
restTemplate.getForEntity(url, ResponseType.class) |
.get().uri(url).retrieve().toEntity(ResponseType.class) |
restTemplate.postForObject(url, req, ResponseType.class) |
.post().uri(url).body(req).retrieve().body(ResponseType.class) |
restTemplate.exchange(url, method, entity, type) |
.method(method).uri(url).body(...).retrieve()... |
restTemplate.delete(url) |
.delete().uri(url).retrieve().toBodilessEntity() |
setErrorHandler(new MyErrorHandler()) |
.onStatus(predicate, handler) 체μ΄λ |
setInterceptors(List.of(interceptor)) |
.requestInterceptor(interceptor) 체μ΄λ |
URI λ³μ μΉνμ λ ν΄λΌμ΄μΈνΈ λͺ¨λ λμΌνκ² μ§μνλ€.
// RestTemplate
restTemplate.getForObject("/users/{id}", UserResponse.class, userId);
// RestClient — λ°©λ² 1: κ°λ³ μΈμ
restClient.get().uri("/users/{id}", userId)...
// RestClient — λ°©λ² 2: λλ€ (쿼리 νλΌλ―Έν°κ° λ§μ λ)
restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("email", email)
.queryParam("page", page)
.build())
...
λ§μΉλ©°
RestClientλ‘ λμ΄μ€λ©΄μ μλ¬ νΈλ€λ§κ³Ό νμμμ μ€μ μ΄ λμ λκ² κ°κ²°ν΄μ‘λ€.ResponseErrorHandler ꡬν체λ₯Ό λ³λ ν΄λμ€λ‘ λ§λ€κ³ , RestTemplate λΉμ .setErrorHandler()λ‘ μ£Όμ
νλ νλ¦μ΄ .onStatus() ν μ€λ‘ μμΆλλ€.
μ€μν ν¬μΈνΈλ₯Ό λ€μ μ§μΌλ©΄:
ClientHttpRequestFactorySettings.defaults()λ‘ νμμμμ μ μΈνκ³ , μΈλΆ APIλ³ λΉμ λΆλ¦¬ν΄μ νμμμμ λ€λ₯΄κ² κ΄λ¦¬νλ€.- 4xxλ μ¬μλνμ§ μλλ€. μλͺ»λ μμ²μ 100λ² λ€μ 보λ΄λ κ²°κ³Όλ κ°λ€.
- POSTμ²λΌ λ©±λ±νμ§ μμ μμ²μλ
@Retryableμ λΆμ΄μ§ μλλ€. κ°μ μ£Όλ¬Έμ΄ λ λ² μμ±λ μ μλ€. @Recoverλ©μλμ νλΌλ―Έν° μμλ₯Ό λ°λμ μ§ν¨λ€. 첫 λ²μ§Έκ° μμΈ, λλ¨Έμ§λ μλ³Έ λ©μλμ νλΌλ―Έν°μ λμΌν΄μΌ Spring Retryκ° λ§€ννλ€.- Spring Boot 4.0μμ
spring.http.client.*νλ‘νΌν°λ deprecatedλλ€.spring.http.clients.*λ‘ μ΄λ€.
νμμμμ΄ μ νμνμ§λ μ΄μ κΈμμ λ€λ€λ€.
μ΄μ μ½λλ‘ μ§λ λ°©λ²λ κ°μΆ°μ‘μΌλ, λ€μ μ°λ μμ
μμλ λ°λ‘ κΊΌλ΄ μ°λ©΄ λλ€.
μ°Έκ³ μΆμ²
- Spring Framework 6.1 RestClient Docs — https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
- Spring Boot 4.0 Configuration Changelog — https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Configuration-Changelog
- Spring Retry Docs — https://docs.spring.io/spring-retry/docs/current/reference/html/
- RFC 7231: HTTP/1.1 Semantics — https://datatracker.ietf.org/doc/html/rfc7231