Spring @HttpExchange 선언적 클라이언트

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 응답 시 기본적으로 예외를 던집니다. RestClientdefaultStatusHandler로 세밀하게 제어합니다.

@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 커스텀 전략과 결합하여 탄력적인 서비스 간 통신을 구축하는 것을 권장합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux