Spring @HttpExchange란?
Spring Framework 6.1+에서 도입된 @HttpExchange는 인터페이스 선언만으로 HTTP 클라이언트를 생성하는 메커니즘입니다. Feign Client와 유사하지만 Spring 네이티브이며, RestClient, WebClient, RestTemplate 중 원하는 백엔드를 선택할 수 있습니다. 기존 @FeignClient의 Spring Cloud 의존성 없이 동일한 선언적 패턴을 사용할 수 있어, 마이크로서비스 간 통신의 새로운 표준이 되고 있습니다.
1. 기본 인터페이스 정의
// client/UserApiClient.java
@HttpExchange(url = "/api/users", accept = "application/json")
public interface UserApiClient {
@GetExchange
List<UserDto> findAll();
@GetExchange("/{id}")
UserDto findById(@PathVariable Long id);
@PostExchange
UserDto create(@RequestBody CreateUserRequest request);
@PutExchange("/{id}")
UserDto update(@PathVariable Long id, @RequestBody UpdateUserRequest request);
@DeleteExchange("/{id}")
void delete(@PathVariable Long id);
// 쿼리 파라미터
@GetExchange("/search")
List<UserDto> search(
@RequestParam String name,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "0") int page
);
// 헤더 전달
@GetExchange("/me")
UserDto getCurrentUser(@RequestHeader("Authorization") String token);
}
@HttpExchange는 클래스 레벨에서 공통 설정을, 메서드 레벨에서 개별 엔드포인트를 선언합니다. Spring MVC의 @RequestMapping과 동일한 어노테이션(@PathVariable, @RequestParam, @RequestBody)을 사용합니다.
2. 프록시 Bean 등록: RestClient 백엔드
@Configuration
public class HttpClientConfig {
@Bean
public UserApiClient userApiClient() {
RestClient restClient = RestClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader("X-API-Key", "my-api-key")
.requestInterceptor(new LoggingInterceptor())
.build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(UserApiClient.class);
}
// WebClient 백엔드 (리액티브)
@Bean
public UserApiClient reactiveUserApiClient() {
WebClient webClient = WebClient.builder()
.baseUrl("https://api.example.com")
.build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(WebClientAdapter.create(webClient))
.build();
return factory.createClient(UserApiClient.class);
}
}
| 백엔드 | 동기/비동기 | 반환 타입 | 적합한 상황 |
|---|---|---|---|
| RestClient | 동기 | T, List<T>, ResponseEntity<T> | Spring MVC (권장) |
| WebClient | 비동기 | Mono<T>, Flux<T> | WebFlux 리액티브 |
| RestTemplate | 동기 | T, ResponseEntity<T> | 레거시 호환 |
3. 고급 반환 타입
@HttpExchange("/api/orders")
public interface OrderApiClient {
// ResponseEntity: 상태 코드 + 헤더 접근
@GetExchange("/{id}")
ResponseEntity<OrderDto> findByIdWithHeaders(@PathVariable String id);
// Optional: 404 시 empty 반환 (에러 아님)
@GetExchange("/{id}")
Optional<OrderDto> findByIdOptional(@PathVariable String id);
// void: 응답 본문 무시
@PostExchange("/{id}/cancel")
void cancel(@PathVariable String id);
// 리액티브 (WebClient 백엔드)
@GetExchange
Flux<OrderDto> streamAll();
@GetExchange("/{id}")
Mono<OrderDto> findByIdReactive(@PathVariable String id);
}
4. 커스텀 에러 핸들링
@HttpExchange는 4xx/5xx 응답 시 기본적으로 예외를 던집니다. RestClient의 defaultStatusHandler로 세밀하게 제어합니다.
@Bean
public UserApiClient userApiClient(ObjectMapper mapper) {
RestClient restClient = RestClient.builder()
.baseUrl("https://api.example.com")
.defaultStatusHandler(
HttpStatusCode::is4xxClientError,
(request, response) -> {
String body = new String(
response.getBody().readAllBytes()
);
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
throw new ResourceNotFoundException(
"Resource not found: " + request.getURI()
);
}
if (response.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
String retryAfter = response.getHeaders()
.getFirst("Retry-After");
throw new RateLimitException(retryAfter);
}
ApiError error = mapper.readValue(body, ApiError.class);
throw new ExternalApiException(
response.getStatusCode().value(), error
);
}
)
.defaultStatusHandler(
HttpStatusCode::is5xxServerError,
(request, response) -> {
throw new ExternalServiceUnavailableException(
"Service unavailable: " + response.getStatusCode()
);
}
)
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(UserApiClient.class);
}
5. 인터셉터: 인증·로깅·메트릭
// 인증 토큰 자동 주입 인터셉터
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);
}
}
// 요청/응답 로깅 인터셉터
@Slf4j
public class LoggingInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution) throws IOException {
long start = System.currentTimeMillis();
log.debug("→ {} {}", request.getMethod(), request.getURI());
ClientHttpResponse response = execution.execute(request, body);
long duration = System.currentTimeMillis() - start;
log.debug("← {} {} ({}ms)",
response.getStatusCode().value(),
request.getURI().getPath(),
duration
);
return response;
}
}
// 메트릭 인터셉터
@Component
@RequiredArgsConstructor
public class MetricsInterceptor implements ClientHttpRequestInterceptor {
private final MeterRegistry registry;
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
Timer.Sample sample = Timer.start(registry);
String status = "unknown";
try {
ClientHttpResponse response = execution.execute(request, body);
status = String.valueOf(response.getStatusCode().value());
return response;
} catch (IOException e) {
status = "io_error";
throw e;
} finally {
sample.stop(Timer.builder("http.client.requests")
.tag("method", request.getMethod().name())
.tag("uri", request.getURI().getPath())
.tag("status", status)
.register(registry));
}
}
}
6. 팩토리 패턴: 다중 서비스 클라이언트
@Configuration
@RequiredArgsConstructor
public class ApiClientFactory {
private final TokenProvider tokenProvider;
private final MeterRegistry registry;
// 공통 RestClient 빌더
private RestClient.Builder baseBuilder(String baseUrl, String serviceName) {
return RestClient.builder()
.baseUrl(baseUrl)
.requestInterceptor(new BearerTokenInterceptor(tokenProvider))
.requestInterceptor(new MetricsInterceptor(registry))
.defaultHeader("X-Service-Name", serviceName)
.requestFactory(clientHttpRequestFactory());
}
@Bean
public UserApiClient userApiClient(
@Value("${services.user.url}") String url) {
return createClient(url, "user-service", UserApiClient.class);
}
@Bean
public OrderApiClient orderApiClient(
@Value("${services.order.url}") String url) {
return createClient(url, "order-service", OrderApiClient.class);
}
@Bean
public PaymentApiClient paymentApiClient(
@Value("${services.payment.url}") String url) {
return createClient(url, "payment-service", PaymentApiClient.class);
}
private <T> T createClient(String url, String name, Class<T> clientType) {
RestClient restClient = baseBuilder(url, name).build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(clientType);
}
private ClientHttpRequestFactory clientHttpRequestFactory() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3));
factory.setReadTimeout(Duration.ofSeconds(10));
return factory;
}
}
7. @HttpExchange vs Feign vs RestClient 비교
| 항목 | @HttpExchange | OpenFeign | RestClient 직접 |
|---|---|---|---|
| 의존성 | spring-web (내장) | spring-cloud-openfeign | spring-web (내장) |
| 선언적 | ✅ 인터페이스 | ✅ 인터페이스 | ❌ 명령형 |
| 리액티브 | ✅ WebClient 백엔드 | ⚠️ 제한적 | ❌ 동기만 |
| 서킷브레이커 | 인터셉터로 구현 | 내장 지원 | 인터셉터로 구현 |
| 서비스 디스커버리 | 수동 URL 또는 커스텀 | Eureka/K8s 내장 | 수동 URL |
| 테스트 | MockRestServiceServer | WireMock/Mock Bean | MockRestServiceServer |
8. 테스트: MockRestServiceServer
@SpringBootTest
class UserApiClientTest {
@Autowired
private UserApiClient userApiClient;
private MockRestServiceServer mockServer;
@BeforeEach
void setup() {
// RestClient 기반 mock 설정
RestClient.Builder builder = RestClient.builder()
.baseUrl("https://api.example.com");
mockServer = MockRestServiceServer.bindTo(builder).build();
userApiClient = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(builder.build()))
.build()
.createClient(UserApiClient.class);
}
@Test
void findById_성공() {
mockServer.expect(requestTo("/api/users/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(
"""
{"id": 1, "name": "John", "email": "john@test.com"}
""",
MediaType.APPLICATION_JSON
));
UserDto user = userApiClient.findById(1L);
assertThat(user.name()).isEqualTo("John");
mockServer.verify();
}
@Test
void findById_404_예외() {
mockServer.expect(requestTo("/api/users/999"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> userApiClient.findById(999L))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void create_성공() {
mockServer.expect(requestTo("/api/users"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json("""
{"name": "Jane", "email": "jane@test.com"}
"""))
.andRespond(withSuccess("""
{"id": 2, "name": "Jane", "email": "jane@test.com"}
""", MediaType.APPLICATION_JSON));
var request = new CreateUserRequest("Jane", "jane@test.com");
UserDto created = userApiClient.create(request);
assertThat(created.id()).isEqualTo(2L);
}
}
마무리
Spring @HttpExchange는 Feign Client의 Spring Cloud 의존성 없이 인터페이스 기반 선언적 HTTP 클라이언트를 구현하는 Spring 네이티브 솔루션입니다. RestClient/WebClient 백엔드를 자유롭게 선택할 수 있고, 인터셉터 체인으로 인증·로깅·메트릭을 계층화할 수 있습니다. Spring RestClient 에러 처리로 백엔드 동작을 이해하고, RetryTemplate 커스텀 전략과 결합하여 탄력적인 서비스 간 통신을 구축하는 것을 권장합니다.