HTTP Interface Client — 선언적 HTTP 호출로 Feign을 대체하는 방법
마이크로서비스에서 다른 서비스의 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, 헤더 등 설정 (클래스/메서드) | - |
@GetExchange | GET 요청 | GET |
@PostExchange | POST 요청 | POST |
@PutExchange | PUT 요청 | PUT |
@PatchExchange | PATCH 요청 | PATCH |
@DeleteExchange | DELETE 요청 | DELETE |
인터페이스 정의
// 사용자 서비스 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 엔진입니다. 리액티브 의존성 없이 동기 방식으로 동작합니다.
@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로 교체하면 됩니다.
// 리액티브 인터페이스 — 반환 타입만 다르다
@HttpExchange("/api/users")
public interface ReactiveUserServiceClient {
@GetExchange
Flux<UserResponse> getAll();
@GetExchange("/{id}")
Mono<UserResponse> getById(@PathVariable Long id);
}
// 설정 — RestClientAdapter 대신 WebClientAdapter 사용
WebClientAdapter adapter = WebClientAdapter.create(webClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
OpenFeign과의 비교
| 구분 | OpenFeign | HTTP Interface Client |
|---|---|---|
| 소속 | Spring Cloud | Spring Framework (코어) |
| 추가 의존성 | spring-cloud-starter-openfeign | 없음 |
| 인터셉터 | Feign 전용 RequestInterceptor | Spring의 ClientHttpRequestInterceptor |
| 에러 핸들링 | ErrorDecoder | RestClient의 StatusHandler |
| 로드밸런싱 | @FeignClient(name=...) + Eureka | RestClient에 @LoadBalanced 적용 |
| 리액티브 지원 | 제한적 | WebClient 기반 완전 지원 |
| 관찰성 | 별도 설정 | Micrometer 자동 통합 |
| GraalVM | 제한적 | 네이티브 이미지 지원 |
Feign이 나쁜 도구는 아닙니다. 다만 Spring 생태계가 자체적으로 선언적 HTTP 호출을 지원하면서, 별도 의존성과 학습 비용이 줄어든 것이 핵심입니다. 새 프로젝트라면 HTTP Interface Client를, 기존 Feign 프로젝트는 점진적으로 마이그레이션하는 전략이 합리적입니다.
마이그레이션 체크리스트
@FeignClient인터페이스를@HttpExchange로 변환- Feign
RequestInterceptor→ SpringClientHttpRequestInterceptor로 교체 ErrorDecoder→ RestClientdefaultStatusHandler()로 교체- Feign 전용 설정 제거 (
feign.client.config등) - 테스트 코드 업데이트 (
@AutoConfigureWireMock유지 가능)
에러 핸들링
RestClient의 defaultStatusHandler()를 활용하면 HTTP 상태 코드별 에러 처리를 깔끔하게 설정할 수 있습니다.
@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를 사용하므로, 로깅이나 인증 토큰 주입이 자연스럽습니다.
// 요청·응답 로깅 인터셉터
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 없이 스프링 테스트 인프라만으로 충분합니다.
@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);
}
}
실전에서 사용하기
서비스 계층에서는 인터페이스를 일반 빈처럼 주입받아 바로 사용합니다.
@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에서 자동 구성이 강화될 예정이므로, 지금 도입하면 향후 마이그레이션도 수월