RestClient둜 μ™ΈλΆ€ API ν˜ΈμΆœν•˜κΈ°

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.*둜 μ“΄λ‹€.

νƒ€μž„μ•„μ›ƒμ΄ μ™œ ν•„μš”ν•œμ§€λŠ” 이전 κΈ€μ—μ„œ λ‹€λ€˜λ‹€.
이제 μ½”λ“œλ‘œ μ§œλŠ” 방법도 κ°–μΆ°μ‘ŒμœΌλ‹ˆ, λ‹€μŒ 연동 μž‘μ—…μ—μ„œλŠ” λ°”λ‘œ κΊΌλ‚΄ μ“°λ©΄ λœλ‹€.


참고 좜처