Spring HTTP 클라이언트의 진화
Spring에서 외부 API를 호출하는 방식은 RestTemplate → WebClient → RestClient + HTTP Interface로 진화해 왔습니다. Spring Boot 3.2+에서는 RestClient(동기)와 WebClient(비동기/리액티브), 그리고 선언적 HTTP Interface를 상황에 맞게 선택할 수 있습니다.
이 글에서는 각 클라이언트의 특성, 실전 설정, 에러 핸들링, 재시도/타임아웃 전략, HTTP Interface의 선언적 패턴까지 다룹니다.
WebClient: 비동기/리액티브 HTTP 클라이언트
기본 설정
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(WebClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
// 커넥션 풀 및 타임아웃 설정
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(5))
.addHandlerLast(new WriteTimeoutHandler(5)))
))
// 응답 버퍼 크기 제한 (기본 256KB → 1MB)
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(1024 * 1024))
.build();
}
}
CRUD 요청 패턴
@Service
@RequiredArgsConstructor
public class UserApiClient {
private final WebClient webClient;
// GET — Mono (단일)
public Mono<UserDto> getUser(Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(UserDto.class);
}
// GET — Flux (목록)
public Flux<UserDto> getAllUsers() {
return webClient.get()
.uri("/users")
.retrieve()
.bodyToFlux(UserDto.class);
}
// POST
public Mono<UserDto> createUser(CreateUserRequest request) {
return webClient.post()
.uri("/users")
.bodyValue(request)
.retrieve()
.bodyToMono(UserDto.class);
}
// PUT with header
public Mono<UserDto> updateUser(Long id, UpdateUserRequest request, String token) {
return webClient.put()
.uri("/users/{id}", id)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.bodyValue(request)
.retrieve()
.bodyToMono(UserDto.class);
}
// DELETE — 응답 바디 없음
public Mono<Void> deleteUser(Long id) {
return webClient.delete()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(Void.class);
}
}
에러 핸들링
public Mono<UserDto> getUserWithErrorHandling(Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
// 상태 코드별 에러 처리
.onStatus(HttpStatusCode::is4xxClientError, response -> {
if (response.statusCode() == HttpStatus.NOT_FOUND) {
return Mono.error(new UserNotFoundException(id));
}
return response.bodyToMono(ErrorResponse.class)
.flatMap(body -> Mono.error(
new ApiClientException(response.statusCode(), body.getMessage())));
})
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new ApiServerException("외부 API 서버 오류")))
.bodyToMono(UserDto.class)
// 타임아웃
.timeout(Duration.ofSeconds(3))
// 폴백
.onErrorResume(TimeoutException.class, ex ->
Mono.error(new ApiTimeoutException("API 응답 시간 초과")));
}
RestClient: Spring 6.1+ 동기 클라이언트
WebClient의 fluent API를 동기 환경에서 사용하고 싶을 때 RestClient가 정답입니다. RestTemplate의 후계자로, 블로킹 방식이지만 API가 훨씬 직관적입니다.
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.requestInterceptor((request, body, execution) -> {
// 공통 로깅 인터셉터
log.debug("→ {} {}", request.getMethod(), request.getURI());
ClientHttpResponse response = execution.execute(request, body);
log.debug("← {}", response.getStatusCode());
return response;
})
.build();
}
}
@Service
@RequiredArgsConstructor
public class UserApiClient {
private final RestClient restClient;
public UserDto getUser(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new ApiClientException(response.getStatusCode());
})
.body(UserDto.class);
}
public List<UserDto> getAllUsers() {
return restClient.get()
.uri("/users")
.retrieve()
.body(new ParameterizedTypeReference<List<UserDto>>() {});
}
public UserDto createUser(CreateUserRequest request) {
return restClient.post()
.uri("/users")
.body(request)
.retrieve()
.body(UserDto.class);
}
}
HTTP Interface: 선언적 API 클라이언트
Spring 6에서 도입된 HTTP Interface는 Feign처럼 인터페이스 선언만으로 API 클라이언트를 생성합니다. 별도 라이브러리 의존 없이 Spring 네이티브로 동작합니다.
// 인터페이스 선언
public interface UserApi {
@GetExchange("/users/{id}")
UserDto getUser(@PathVariable Long id);
@GetExchange("/users")
List<UserDto> getAllUsers();
@GetExchange("/users")
List<UserDto> searchUsers(@RequestParam String name,
@RequestParam(required = false) String email);
@PostExchange("/users")
UserDto createUser(@RequestBody CreateUserRequest request);
@PutExchange("/users/{id}")
UserDto updateUser(@PathVariable Long id,
@RequestBody UpdateUserRequest request);
@DeleteExchange("/users/{id}")
void deleteUser(@PathVariable Long id);
// 리액티브도 지원
@GetExchange("/users/{id}")
Mono<UserDto> getUserReactive(@PathVariable Long id);
}
@Configuration
public class HttpInterfaceConfig {
// RestClient 기반 (동기)
@Bean
public UserApi userApi(RestClient restClient) {
RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(adapter)
.build();
return factory.createClient(UserApi.class);
}
// WebClient 기반 (리액티브)
@Bean
public UserApi userApiReactive(WebClient webClient) {
WebClientAdapter adapter = WebClientAdapter.create(webClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(adapter)
.build();
return factory.createClient(UserApi.class);
}
}
이제 UserApi를 주입받아 일반 메서드 호출처럼 사용할 수 있습니다. 구현체는 Spring이 프록시로 자동 생성합니다.
재시도 전략: Resilience4j 연동
Spring Cloud Gateway처럼 외부 API 호출에도 재시도와 서킷브레이커가 필수입니다.
@Configuration
public class ResilienceConfig {
@Bean
public WebClient resilientWebClient(WebClient.Builder builder) {
// Retry 설정
Retry retry = Retry.backoff(3, Duration.ofMillis(500))
.maxBackoff(Duration.ofSeconds(3))
.filter(throwable ->
throwable instanceof WebClientResponseException.ServiceUnavailable
|| throwable instanceof TimeoutException)
.doBeforeRetry(signal ->
log.warn("재시도 #{}: {}", signal.totalRetries(), signal.failure().getMessage()));
WebClient webClient = builder
.baseUrl("https://api.example.com")
.build();
// WebClient에 retry 적용하는 유틸
return webClient.mutate()
.filter((request, next) ->
next.exchange(request)
.retryWhen(retry))
.build();
}
}
선택 가이드
- RestClient: Spring MVC 동기 앱에서 외부 API 호출 → 기본 선택
- WebClient: WebFlux 리액티브 앱, 또는 동기 앱에서 비동기 호출이 필요한 경우
- HTTP Interface: API가 여러 개이고 선언적 패턴 선호 시 → RestClient/WebClient 위에 구축
- RestTemplate: 레거시. 신규 프로젝트에서는 사용하지 않음
테스트: MockWebServer
@SpringBootTest
class UserApiClientTest {
private MockWebServer mockServer;
private UserApiClient client;
@BeforeEach
void setUp() throws IOException {
mockServer = new MockWebServer();
mockServer.start();
RestClient restClient = RestClient.builder()
.baseUrl(mockServer.url("/").toString())
.build();
client = new UserApiClient(restClient);
}
@Test
void getUser_정상응답() {
mockServer.enqueue(new MockResponse()
.setBody("""
{"id": 1, "name": "홍길동", "email": "hong@example.com"}
""")
.setHeader("Content-Type", "application/json"));
UserDto user = client.getUser(1L);
assertThat(user.getName()).isEqualTo("홍길동");
RecordedRequest request = mockServer.takeRequest();
assertThat(request.getPath()).isEqualTo("/users/1");
}
@Test
void getUser_404_예외발생() {
mockServer.enqueue(new MockResponse().setResponseCode(404));
assertThrows(UserNotFoundException.class, () -> client.getUser(999L));
}
@AfterEach
void tearDown() throws IOException {
mockServer.shutdown();
}
}
마무리
Spring Boot 3.2+에서는 RestClient가 동기 HTTP 클라이언트의 표준, WebClient가 비동기/리액티브의 표준입니다. 그 위에 HTTP Interface를 얹으면 Feign 없이도 선언적 API 클라이언트를 구축할 수 있습니다. Method Security와 결합하여 API 호출 권한을 제어하고, Resilience4j로 재시도/서킷브레이커를 적용하면 안정적인 외부 API 통합이 완성됩니다.