선언적 HTTP 클라이언트: @HttpExchange와 RestClient
인터페이스만 선언했는데 호출이 된다?
외부 API 호출 코드는 계속 진화했습니다.
RestTemplate: 오래 썼지만 이제 유지보수 모드(신규 기능 없음).WebClient: 논블로킹·리액티브. 강력하지만 MVC 앱에선 다소 과합니다.RestClient(Spring 6.1 / Boot 3.2+):WebClient의 친숙한 플루언트 API를 동기로 쓰는 클라이언트. MVC 앱의 새 기본값.
그리고 그 위에, 호출 스펙을 인터페이스에 애너테이션으로만 선언하면 구현체가 알아서 생기는 @HttpExchange가 있습니다. 여기서 자연스럽게 떠오르는 질문이 이 글의 본론입니다 — “구현 클래스를 한 줄도 안 짰는데 어떻게 호출이 되지?” 답은 자동 구성 글에서 본 것과 같은 단어, 프록시입니다.
한눈에 보기: 호출 한 번의 왕복
client.getById(42) 한 줄이 실제로는 프록시 → 인자 해석/URL 빌드 → 어댑터 → HTTP → 역직렬화의 왕복을 거칩니다. 파랑이 나가는 요청, 초록이 객체로 돌아오는 응답입니다.
sequenceDiagram
participant S as BreweryService
participant P as 프록시(BreweryClient)
participant A as HttpExchangeAdapter
participant R as 원격 API
S->>P: getById(42)
P->>P: HttpServiceMethod로 인자 해석 + URL 빌드
P->>A: HttpRequestValues 전달
A->>R: GET /breweries/42
R-->>A: 200 + JSON
A-->>P: 응답 본문
P-->>S: Brewery 객체 (역직렬화)
RestClient: 먼저 명령형부터
선언형으로 가기 전에, 토대인 RestClient를 봅니다.
1
2
3
4
5
6
RestClient client = RestClient.create("https://api.example.com");
Brewery brewery = client.get()
.uri("/breweries/{id}", id)
.retrieve()
.body(Brewery.class);
플루언트하고 읽기 좋습니다. 하지만 호출이 많아지면 URL·매핑·헤더가 코드 곳곳에 흩어집니다. 그래서 호출 스펙을 한곳에 선언하는 방식이 등장합니다.
@HttpExchange: 인터페이스로 선언
1
2
3
4
5
6
7
8
9
@HttpExchange("/breweries")
public interface BreweryClient {
@GetExchange("/{id}")
Brewery getById(@PathVariable Long id);
@PostExchange
Brewery create(@RequestBody BreweryRequest request);
}
@PathVariable·@RequestBody 등 Spring MVC 컨트롤러에서 쓰던 그 애너테이션을 그대로 씁니다. 다만 의미가 거울처럼 뒤집힙니다 — 컨트롤러에선 “들어오는 요청에서 값 추출”, HTTP 인터페이스에선 “나가는 요청에 값 주입“입니다.
소스 한 겹: 구현체는 누가 만드나
핵심은 HttpServiceProxyFactory입니다. 동작 원리는 @Transactional(AOP 프록시)이나 Spring Data 리포지토리(RepositoryFactorySupport 프록시)와 완전히 같은 계열입니다 — 인터페이스만 주고 구현은 프록시가 채웁니다.
1
2
3
4
5
6
7
RestClient restClient = RestClient.create("https://api.example.com");
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient)) // 백엔드 어댑터 주입
.build();
BreweryClient client = factory.createClient(BreweryClient.class); // 런타임 프록시 생성
createClient() 내부에서 벌어지는 일:
- 인터페이스의 메서드를 부팅(프록시 생성) 시점에 한 번 파싱해 각각
HttpServiceMethod로 만든다. 이때@GetExchange·@PathVariable같은 메타데이터가 분석된다. - Spring AOP의
ProxyFactory가 인터페이스 기반 프록시를 만들고,MethodInterceptor가 모든 호출을 가로챈다. - 호출이 들어오면 인터셉터가 미리 만들어 둔
HttpServiceMethod로 위임한다. 인자는HttpServiceArgumentResolver구현들(PathVariableArgumentResolver,RequestParamArgumentResolver,RequestHeaderArgumentResolver,RequestBodyArgumentResolver…)이 해석해HttpRequestValues(URL·헤더·바디의 집합)를 빌드한다. - 완성된
HttpRequestValues를HttpExchangeAdapter(6.1+, 과거HttpClientAdapter)에 넘겨 실제 전송하고, 응답을 메서드의 반환 타입으로 역직렬화한다.
메서드 파싱이 호출마다가 아니라 프록시 생성 시 한 번이라는 점이 중요합니다. 자동 구성이 시작 시점에 무거운 일을 끝내 두는 것과 같은 철학 — 런타임 호출은 최대한 가볍게.
어댑터: 동기·비동기 백엔드 교체
위 4단계에서 실제 전송을 맡는 HttpExchangeAdapter만 갈아끼우면 같은 인터페이스가 다른 엔진 위에서 돕니다.
| 어댑터 | 백엔드 | 스타일 | 권장 반환 타입 |
|---|---|---|---|
RestClientAdapter | RestClient | 동기 | Brewery, List<Brewery> |
WebClientAdapter | WebClient | 논블로킹 | Mono<Brewery>, Flux<Brewery> |
RestTemplateAdapter | RestTemplate | 동기(레거시 호환) | Brewery |
⚠️ 어댑터와 반환 타입이 맞아야 합니다. WebClientAdapter인데 Brewery(블로킹 타입)를 반환하면 프록시가 내부에서 block()을 호출합니다 — 이벤트 루프 위에서라면 위험합니다(MVC vs WebFlux의 블로킹 함정과 동일). 동기 앱이면 RestClientAdapter + 일반 타입이 정답입니다.
Spring Boot 4 / Framework 7: 자동 등록
위처럼 직접 factory.createClient(...)를 호출해 @Bean으로 등록할 수도 있지만, Framework 7부터는 @ImportHttpServices로 그룹을 선언하면 프록시가 자동으로 Bean 등록됩니다(Feign의 @EnableFeignClients에 대응).
1
2
3
4
5
6
7
8
9
@Configuration
@ImportHttpServices(group = "brewery", types = BreweryClient.class)
public class HttpClientConfig {
@Bean
RestClient breweryRestClient(RestClient.Builder builder) {
return builder.baseUrl("https://api.example.com").build();
}
}
이제 BreweryClient를 그냥 주입받아 씁니다.
1
2
3
4
5
6
7
8
9
@Service
@RequiredArgsConstructor
public class BreweryService {
private final BreweryClient breweryClient;
public Brewery find(Long id) {
return breweryClient.getById(id); // 선언만 했는데 호출됨
}
}
Boot 4는 그룹별로 사용할 클라이언트·baseUrl·타임아웃을 설정과 연결해 줍니다. 즉 “프록시를 어떤 어댑터 위에 올릴지”를 자바 코드 대신 설정으로 관리할 수 있습니다.
Feign과 무엇이 다른가
@HttpExchange (Spring core) | @FeignClient (Spring Cloud OpenFeign) | |
|---|---|---|
| 의존성 | 별도 없음(spring-web 내장) | spring-cloud-starter-openfeign |
| 백엔드 | RestClient/WebClient/RestTemplate 교체 | 자체 Feign 엔진 |
| 서비스 디스커버리·LB | 기본 없음(필요 시 Spring Cloud LoadBalancer 조합) | 통합 제공 |
| 리액티브 | WebClientAdapter로 지원 | 제한적 |
마이크로서비스에서 유레카·로드밸런싱이 꼭 필요한 게 아니라면, 의존성 없이 코어로 끝나는 @HttpExchange가 점점 기본 선택이 됩니다.
타임아웃·에러·인터셉터
클라이언트의 견고함은 어댑터 아래의 RestClient/WebClient 설정에서 나옵니다.
1
2
3
4
5
6
7
8
9
10
RestClient.builder()
.baseUrl("https://api.example.com")
.requestInterceptor((req, body, ex) -> { // 공통 헤더·로깅
req.getHeaders().add("X-Trace", traceId());
return ex.execute(req, body);
})
.defaultStatusHandler(HttpStatusCode::is5xxServerError, (req, res) -> {
throw new BreweryUnavailableException(); // 5xx 매핑
})
.build();
- 타임아웃: Boot에서는
spring.http.client.connect-timeout/read-timeout으로 전역 설정(내부적으로ClientHttpRequestFactorySettings). - 에러:
retrieve()는 기본적으로 4xx/5xx에서RestClientResponseException(하위HttpClientErrorException/HttpServerErrorException)을 던집니다. 세밀한 제어는onStatus(...)또는.exchange()로.
실무 함정
- 메서드에 교환 애너테이션이 없으면 프록시 생성이 실패합니다. 보조 메서드가 필요하면
default메서드로 두세요(프록시가 인터페이스 default 구현을 그대로 호출).Object의equals/hashCode/toString도 통과됩니다. baseUrl누락:@HttpExchange의 경로는 상대 경로입니다. 밑단RestClient/WebClient에baseUrl을 안 주면URI is not absolute류로 터집니다.- 예외 변환 잊기: 호출부는
getById()가 평범한 메서드처럼 보여 try-catch를 빼먹기 쉽지만, 4xx/5xx는 런타임 예외로 올라옵니다. 전역 처리는 예외 처리 글의@ControllerAdvice로. - 블로킹/리액티브 혼동: 위 어댑터 표 참고. 반환 타입이 곧 실행 모델입니다.
테스트: 실제 호출 없이
MockRestServiceServer를 RestClient.Builder에 바인딩하면 네트워크 없이 프록시까지 검증할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.example.com/breweries/42"))
.andRespond(withSuccess("{\"id\":42,\"name\":\"Quokka\"}", MediaType.APPLICATION_JSON));
BreweryClient client = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(builder.baseUrl("https://api.example.com").build()))
.build()
.createClient(BreweryClient.class);
assertThat(client.getById(42L).name()).isEqualTo("Quokka");
server.verify();
면접/리뷰 단골 질문
- Q.
@HttpExchange인터페이스의 구현체는 누가, 언제 만드나? →HttpServiceProxyFactory가 프록시 생성 시점에 메서드를HttpServiceMethod로 파싱하고, Spring AOP 프록시 +MethodInterceptor가 호출을 가로채 어댑터로 위임한다.@Transactional·Repository와 같은 프록시 계열. - Q. 같은 인터페이스를 동기/비동기로 쓰려면? →
HttpExchangeAdapter만RestClientAdapter↔WebClientAdapter로 교체. 단 반환 타입을 어댑터에 맞춰야 한다. - Q. Feign 대신
@HttpExchange를 쓰는 이유는? → 추가 의존성 없이 코어로 해결되고 백엔드 교체가 자유롭다. 디스커버리·로드밸런싱이 필요하면 Spring Cloud LoadBalancer를 더한다.
정리
RestTemplate은 졸업, MVC에선 RestClient가 기본. 그 위에@HttpExchange인터페이스로 선언하면 구현체가 자동 생성된다.- 그 “자동 생성”의 정체는
HttpServiceProxyFactory가 만드는 AOP 프록시 —@Transactional·Spring Data와 같은 계열. 메서드는 생성 시 한 번 파싱돼HttpServiceMethod가 된다. - 실제 전송은
HttpExchangeAdapter(RestClient/WebClient/RestTemplate)가 담당하므로 백엔드를 자유롭게 교체한다. 반환 타입은 어댑터와 맞춰야 한다. - Boot 4 / Framework 7의
@ImportHttpServices로 Feign 같은 자동 등록이 가능하고, 의존성이 없어 코어만으로 끝난다. - 함정은 대부분 baseUrl 누락·예외 변환 누락·블로킹/리액티브 혼동에서 나온다.
관련 글: 이 프록시 메커니즘의 다른 사례는 @Transactional 함정과 Spring Data JPA 기초에서, 호출 에러의 전역 처리는 REST API 예외 처리에서 다룹니다.
