마이크로서비스에서 다른 서비스의 API를 호출할 때, 매번 RestClient로 URL을 조립하고 응답을 파싱하는 코드를 반복 작성하고 계신가요? 인터페이스 하나만 선언하면 구현체가 알아서 만들어진다면 어떨까요?

개념 정의

HTTP Interface Client 는 Spring 6.1(Boot 3.2)부터 제공하는 선언적 HTTP 클라이언트 입니다. Java 인터페이스에 @HttpExchange 어노테이션을 붙이면, 스프링이 프록시 구현체를 자동으로 생성합니다. OpenFeign과 비슷한 개발 경험을 제공하면서도, Spring 생태계에 네이티브로 통합됩니다.

왜 등장했는가

기존에 선언적 HTTP 호출이 필요하면 OpenFeign 을 사용했습니다. 하지만 Feign은 Spring Cloud에 의존하고, 독자적인 인터셉터와 에러 핸들러 체계를 가집니다. 스프링 자체 기능과 겹치는 부분이 많았습니다.

  • RestTemplate은 유지보수 모드, RestClient/WebClient는 명령형 코드가 반복됨
  • Feign은 Spring Cloud Netflix에서 시작해 Spring Cloud OpenFeign으로 이어졌지만, 별도 의존성이 필요
  • Spring 진영에서 "프레임워크 자체에서 선언적 HTTP 호출을 지원하자"는 방향이 잡힘

@HttpExchange 어노테이션 패밀리

인터페이스 레벨과 메서드 레벨에서 사용할 수 있는 어노테이션입니다.

어노테이션역할대응하는 HTTP 메서드
@HttpExchange공통 URL, 헤더 등 설정 (클래스/메서드)-
@GetExchangeGET 요청GET
@PostExchangePOST 요청POST
@PutExchangePUT 요청PUT
@PatchExchangePATCH 요청PATCH
@DeleteExchangeDELETE 요청DELETE

인터페이스 정의

JAVA
// 사용자 서비스 API를 선언적으로 정의
@HttpExchange(url = "/api/users", accept = MediaType.APPLICATION_JSON_VALUE)
public interface UserServiceClient {

    // 전체 사용자 목록 조회
    @GetExchange
    List<UserResponse> getAll();

    // ID로 사용자 조회
    @GetExchange("/{id}")
    UserResponse getById(@PathVariable Long id);

    // 사용자 생성
    @PostExchange
    UserResponse create(@RequestBody CreateUserRequest request);

    // 사용자 수정
    @PutExchange("/{id}")
    UserResponse update(@PathVariable Long id, @RequestBody UpdateUserRequest request);

    // 사용자 삭제
    @DeleteExchange("/{id}")
    void delete(@PathVariable Long id);

    // 검색 — 쿼리 파라미터 사용
    @GetExchange("/search")
    List<UserResponse> search(@RequestParam String name, @RequestParam int page);
}

Spring MVC에서 쓰던 @PathVariable, @RequestBody, @RequestParam을 그대로 사용할 수 있다는 점이 학습 비용을 크게 줄여줍니다.

RestClient 기반 설정 (Boot 3.2+)

Boot 3.2부터는 RestClient 가 기본 HTTP 엔진입니다. 리액티브 의존성 없이 동기 방식으로 동작합니다.

JAVA
@Configuration
public class HttpClientConfig {

    // RestClient 기반 HTTP Interface 프록시 생성
    @Bean
    public UserServiceClient userServiceClient(RestClient.Builder builder) {
        RestClient restClient = builder
                .baseUrl("https://api.example.com")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer {token}")
                .requestInterceptor(new LoggingInterceptor()) // 스프링 인터셉터 그대로 사용
                .build();

        // 프록시 팩토리로 인터페이스 구현체 생성
        RestClientAdapter adapter = RestClientAdapter.create(restClient);
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(adapter)
                .build();

        return factory.createClient(UserServiceClient.class);
    }
}

WebClient 기반 설정 (리액티브)

리액티브 환경에서는 WebClient를 사용합니다. 반환 타입을 Mono/Flux로 바꾸고, 어댑터만 WebClientAdapter로 교체하면 됩니다.

JAVA
// 리액티브 인터페이스 — 반환 타입만 다르다
@HttpExchange("/api/users")
public interface ReactiveUserServiceClient {

    @GetExchange
    Flux<UserResponse> getAll();

    @GetExchange("/{id}")
    Mono<UserResponse> getById(@PathVariable Long id);
}
JAVA
// 설정 — RestClientAdapter 대신 WebClientAdapter 사용
WebClientAdapter adapter = WebClientAdapter.create(webClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();

OpenFeign과의 비교

구분OpenFeignHTTP Interface Client
소속Spring CloudSpring Framework (코어)
추가 의존성spring-cloud-starter-openfeign없음
인터셉터Feign 전용 RequestInterceptorSpring의 ClientHttpRequestInterceptor
에러 핸들링ErrorDecoderRestClient의 StatusHandler
로드밸런싱@FeignClient(name=...) + EurekaRestClient에 @LoadBalanced 적용
리액티브 지원제한적WebClient 기반 완전 지원
관찰성별도 설정Micrometer 자동 통합
GraalVM제한적네이티브 이미지 지원

Feign이 나쁜 도구는 아닙니다. 다만 Spring 생태계가 자체적으로 선언적 HTTP 호출을 지원하면서, 별도 의존성과 학습 비용이 줄어든 것이 핵심입니다. 새 프로젝트라면 HTTP Interface Client를, 기존 Feign 프로젝트는 점진적으로 마이그레이션하는 전략이 합리적입니다.

마이그레이션 체크리스트

  1. @FeignClient 인터페이스를 @HttpExchange로 변환
  2. Feign RequestInterceptor → Spring ClientHttpRequestInterceptor로 교체
  3. ErrorDecoder → RestClient defaultStatusHandler()로 교체
  4. Feign 전용 설정 제거 (feign.client.config 등)
  5. 테스트 코드 업데이트 (@AutoConfigureWireMock 유지 가능)

에러 핸들링

RestClient의 defaultStatusHandler()를 활용하면 HTTP 상태 코드별 에러 처리를 깔끔하게 설정할 수 있습니다.

JAVA
@Bean
public UserServiceClient userServiceClient(RestClient.Builder builder) {
    RestClient restClient = builder
            .baseUrl("https://api.example.com")
            // 4xx 에러 처리
            .defaultStatusHandler(
                    HttpStatusCode::is4xxClientError,
                    (request, response) -> {
                        // 응답 본문에서 에러 메시지 추출
                        String body = new String(response.getBody().readAllBytes());
                        throw new ClientException("클라이언트 에러: " + body);
                    })
            // 5xx 에러 처리
            .defaultStatusHandler(
                    HttpStatusCode::is5xxServerError,
                    (request, response) -> {
                        throw new ServerException("외부 서비스 장애 발생");
                    })
            .build();

    return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(restClient))
            .build()
            .createClient(UserServiceClient.class);
}

인터셉터 활용

Spring의 표준 ClientHttpRequestInterceptor를 사용하므로, 로깅이나 인증 토큰 주입이 자연스럽습니다.

JAVA
// 요청·응답 로깅 인터셉터
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 {
        log.info(">>> {} {}", request.getMethod(), request.getURI());
        ClientHttpResponse response = execution.execute(request, body);
        log.info("<<< {} {}", response.getStatusCode(), request.getURI());
        return response;
    }
}

동적 토큰 주입도 같은 패턴입니다. intercept() 안에서 request.getHeaders().setBearerAuth(token)만 호출하면 됩니다. Feign의 RequestInterceptor와 달리 Spring 전체에서 공유할 수 있는 표준 인터페이스라는 점이 장점입니다.

MockRestServiceServer로 테스트

RestClient 기반이므로 MockRestServiceServer로 외부 호출을 모킹할 수 있습니다. 별도의 WireMock 없이 스프링 테스트 인프라만으로 충분합니다.

JAVA
@SpringBootTest
class UserServiceClientTest {

    private UserServiceClient client;
    private MockRestServiceServer mockServer;

    @BeforeEach
    void setUp() {
        // MockRestServiceServer와 연결된 RestClient 생성
        RestClient.Builder builder = RestClient.builder()
                .baseUrl("https://api.example.com");

        mockServer = MockRestServiceServer.bindTo(builder).build();

        RestClientAdapter adapter = RestClientAdapter.create(builder.build());
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(adapter)
                .build();

        client = factory.createClient(UserServiceClient.class);
    }

    @Test
    void 사용자_조회_성공() {
        // 모킹된 응답 설정
        mockServer.expect(requestTo("https://api.example.com/api/users/1"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess("""
                        {
                            "id": 1,
                            "name": "홍길동",
                            "email": "hong@example.com"
                        }
                        """, MediaType.APPLICATION_JSON));

        // 실행
        UserResponse user = client.getById(1L);

        // 검증
        assertThat(user.name()).isEqualTo("홍길동");
        mockServer.verify();
    }

    @Test
    void 서버_에러_처리() {
        mockServer.expect(requestTo("https://api.example.com/api/users/999"))
                .andRespond(withServerError());

        // 5xx 에러 시 커스텀 예외가 발생하는지 확인
        assertThatThrownBy(() -> client.getById(999L))
                .isInstanceOf(ServerException.class);
    }
}

실전에서 사용하기

서비스 계층에서는 인터페이스를 일반 빈처럼 주입받아 바로 사용합니다.

JAVA
@Service
@RequiredArgsConstructor
public class OrderService {

    private final UserServiceClient userClient;

    public OrderResponse createOrder(CreateOrderRequest request) {
        // 메서드 호출처럼 간결하게 외부 API 호출
        UserResponse user = userClient.getById(request.userId());
        return new OrderResponse(user, request.amount());
    }
}

Boot 4.0에서 달라지는 점

Spring Boot 4.0(Spring Framework 7)에서는 HTTP Interface Client가 더 강화될 예정입니다.

  • **자동 구성 개선 **: @HttpExchange 인터페이스를 스캔해서 자동으로 빈 등록 (현재는 수동 HttpServiceProxyFactory 설정 필요)
  • @RegisterHttpClient: 명시적인 클라이언트 등록 어노테이션 추가 논의 중
  • **Observability 기본 내장 **: Micrometer 트레이싱이 기본으로 활성화
  • **Virtual Thread 통합 **: RestClient + Virtual Thread 조합으로 동기 코드에서도 높은 동시성 확보

Boot 3.x에서 HTTP Interface Client를 도입해두면, 4.0으로 업그레이드할 때 설정이 더 간소화되는 방향으로 마이그레이션할 수 있습니다.

RestClient vs WebClient, 어떤 걸 기반으로 할까

상황선택
서블릿 기반 (Boot 3.2+)RestClient
WebFlux 기반 리액티브 앱WebClient
동기 코드인데 논블로킹이 필요WebClient + block() (비추, 차라리 Virtual Thread)
Virtual Thread 사용 중RestClient (가장 자연스러운 조합)

대부분의 서블릿 기반 프로젝트에서는 RestClient를 선택하면 됩니다. 리액티브 의존성을 추가할 필요가 없고, Virtual Thread와도 잘 맞습니다.

정리

  • HTTP Interface Client는 Spring 6.1+에서 제공하는 ** 선언적 HTTP 클라이언트**
  • @HttpExchange 인터페이스만 정의하면 프록시가 자동 생성
  • RestClient(동기) 또는 WebClient(리액티브) 위에서 동작
  • Spring 생태계의 인터셉터, 에러 핸들링, Micrometer 관찰성과 자연스럽게 통합
  • OpenFeign의 역할을 대체하며, 별도 의존성 없이 사용 가능
  • Boot 4.0에서 자동 구성이 강화될 예정이므로, 지금 도입하면 향후 마이그레이션도 수월
댓글 로딩 중...