마이크로서비스 환경에서 서버가 다른 서버의 API를 호출해야 할 때, 어떤 HTTP 클라이언트를 써야 할까요? RestTemplate은 이제 쓰면 안 되는 걸까요?

개념 정의

스프링에서 서버 간 HTTP 통신에 사용하는 세 가지 클라이언트가 있습니다.

  • RestTemplate: 동기 블로킹, 유지보수 모드 (레거시)
  • RestClient: 동기 블로킹, Spring 6.1+ 신규 API
  • WebClient: 비동기 논블로킹, 리액티브 스택 기반

왜 필요한가

모던 애플리케이션에서 외부 API 호출은 필수입니다. 결제 API, 알림 서비스, 외부 데이터 소스 등 다른 서버와 통신해야 하는 상황이 빈번합니다. 적절한 HTTP 클라이언트를 선택하고, 타임아웃·재시도·에러 핸들링을 올바르게 설정하는 것이 중요합니다.

내부 동작

세 클라이언트 비교

구분RestTemplateRestClientWebClient
도입Spring 3.0Spring 6.1Spring 5.0
방식동기 블로킹동기 블로킹비동기 논블로킹
API 스타일메서드 기반Fluent APIFluent API
상태유지보수 모드활발한 개발활발한 개발
의존성spring-webspring-webspring-webflux
추천레거시 유지새 동기 프로젝트비동기/리액티브

코드 예제

RestTemplate (레거시)

JAVA
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(10))
            .build();
    }
}
JAVA
@Service
@RequiredArgsConstructor
public class UserApiClient {
    private final RestTemplate restTemplate;

    public UserResponse getUser(Long id) {
        return restTemplate.getForObject(
            "https://api.example.com/users/{id}",
            UserResponse.class,
            id
        );
    }

이어서 나머지 메서드를 구현합니다.

JAVA
    public UserResponse createUser(CreateUserRequest request) {
        return restTemplate.postForObject(
            "https://api.example.com/users",
            request,
            UserResponse.class
        );
    }
}

RestClient (Spring 6.1+ 권장)

JAVA
@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient() {
        return RestClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("X-Api-Key", "my-api-key")
            .requestFactory(clientHttpRequestFactory())
            .build();
    }

    private ClientHttpRequestFactory clientHttpRequestFactory() {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(5));
        factory.setReadTimeout(Duration.ofSeconds(10));
        return factory;
    }
}
JAVA
@Service
@RequiredArgsConstructor
public class UserApiClient {
    private final RestClient restClient;

    // GET 요청
    public UserResponse getUser(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .body(UserResponse.class);
    }

이어서 나머지 구현 부분입니다.

JAVA
    // POST 요청
    public UserResponse createUser(CreateUserRequest request) {
        return restClient.post()
            .uri("/users")
            .body(request)
            .retrieve()
            .body(UserResponse.class);
    }

이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.

JAVA
    // 리스트 조회
    public List<UserResponse> getUsers() {
        return restClient.get()
            .uri("/users")
            .retrieve()
            .body(new ParameterizedTypeReference<>() {});
    }

    // 응답 상태 포함 조회
    public ResponseEntity<UserResponse> getUserWithStatus(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .toEntity(UserResponse.class);
    }
}

RestClient 에러 핸들링

JAVA
@Bean
public RestClient restClient() {
    return RestClient.builder()
        .baseUrl("https://api.example.com")
        .defaultStatusHandler(HttpStatusCode::is4xxClientError, (request, response) -> {
            String body = new String(response.getBody().readAllBytes());
            throw new ClientApiException("클라이언트 에러: " + response.getStatusCode() + " " + body);
        })
        .defaultStatusHandler(HttpStatusCode::is5xxServerError, (request, response) -> {
            throw new ServerApiException("서버 에러: " + response.getStatusCode());
        })
        .build();
}
JAVA
// 요청별 에러 처리
public UserResponse getUser(Long id) {
    return restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(status -> status.value() == 404, (request, response) -> {
            throw new UserNotFoundException(id);
        })
        .body(UserResponse.class);
}

WebClient (비동기)

JAVA
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .codecs(configurer -> configurer
                .defaultCodecs()
                .maxInMemorySize(10 * 1024 * 1024)) // 10MB
            .build();
    }
}
JAVA
@Service
@RequiredArgsConstructor
public class UserApiClient {
    private final WebClient webClient;

    // 비동기 호출 (Mono)
    public Mono<UserResponse> getUser(Long id) {
        return webClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(UserResponse.class);
    }

이어서 리액티브 방식의 비동기 호출을 구현합니다.

JAVA
    // 동기적으로 사용하고 싶을 때
    public UserResponse getUserSync(Long id) {
        return webClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(UserResponse.class)
            .block(); // 블로킹 — WebFlux 환경에서는 사용 금지
    }

이어서 리액티브 방식의 비동기 호출을 구현합니다.

JAVA
    // 여러 API 동시 호출
    public Mono<OrderDetail> getOrderDetail(Long orderId) {
        Mono<Order> orderMono = webClient.get()
            .uri("/orders/{id}", orderId)
            .retrieve()
            .bodyToMono(Order.class);

        Mono<User> userMono = webClient.get()
            .uri("/users/{id}", orderId)
            .retrieve()
            .bodyToMono(User.class);

        // 두 API를 동시에 호출하고 결과를 조합
        return Mono.zip(orderMono, userMono)
            .map(tuple -> new OrderDetail(tuple.getT1(), tuple.getT2()));
    }
}

재시도 설정

JAVA
// RestClient + Spring Retry
@Retryable(
    retryFor = {ServerApiException.class, ResourceAccessException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2)
)
public UserResponse getUser(Long id) {
    return restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .body(UserResponse.class);
}

이어서 리액티브 방식의 비동기 호출을 구현합니다.

JAVA
// WebClient + Reactor Retry
public Mono<UserResponse> getUser(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(UserResponse.class)
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
            .filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable));
}

선택 가이드

PLAINTEXT
서블릿 기반 프로젝트인가?
├── 예
│   ├── 새 프로젝트 → RestClient (Spring 6.1+)
│   └── 기존 프로젝트 → RestTemplate 유지 가능 (급히 마이그레이션 불필요)
└── WebFlux 기반이거나 비동기 필요?
    └── 예 → WebClient

주의할 점

1. 타임아웃을 설정하지 않으면 외부 서비스 장애가 내 서비스로 전파된다

RestClient나 WebClient의 기본 타임아웃은 무한이거나 매우 깁니다. 외부 API가 응답하지 않으면 Tomcat 스레드가 무한 대기하여, 200개의 스레드가 모두 소진되면 내 서비스 전체가 멈춥니다. 연결 타임아웃(35초)과 읽기 타임아웃(1030초)을 반드시 설정하세요.

2. WebClient를 동기 블로킹 환경에서 .block()으로 사용하면 성능 이점이 없다

서블릿 기반 애플리케이션에서 WebClient.get().retrieve().bodyToMono().block()을 사용하면, 논블로킹의 이점 없이 RestTemplate과 동일하게 스레드를 블로킹합니다. WebFlux/Reactor 스택이 아니라면 Spring 6.1+의 RestClient가 더 적합합니다.

3. 외부 API 응답 에러를 처리하지 않으면 500 에러가 클라이언트에 그대로 전파된다

외부 API가 4xx/5xx 응답을 반환할 때 에러 핸들링을 구현하지 않으면, HttpClientErrorException이나 HttpServerErrorException이 그대로 전파되어 내 API도 500을 반환합니다. .onStatus() 핸들러로 외부 에러를 적절히 변환하여 클라이언트에 의미 있는 응답을 제공해야 합니다.

정리

  • RestTemplate 은 유지보수 모드이므로 새 프로젝트에서는 피합니다
  • RestClient(Spring 6.1+)는 동기 블로킹 + fluent API로, 서블릿 기반 프로젝트에 권장됩니다
  • WebClient 는 비동기 논블로킹이 필요하거나 리액티브 스택을 사용할 때 선택합니다
  • 타임아웃, 재시도, 에러 핸들링은 반드시 설정해야 합니다 — 없으면 장애가 전파됩니다
  • 커넥션 풀을 사용하면 TCP 연결 수립 비용을 줄일 수 있습니다
댓글 로딩 중...