Spring Boot : 3.3.4
Spring 6에서 새롭게 도입된 기능으로 인터페이스를 통해 HTTP API 호출을 간단하게 처리할 수 있도록 지원하는 어노테이션 기반 기능이다.
이 기능은 주로 Spring Web에서 HTTP 클라이언트 역할을 더 직관적이고 선언적으로 사용할 수 있게 만들어준다.
기본적으로 WebClient, RestClient와 통합되어 작용되며 이를 통해 개발자는 인터페이스를 정의하고 그 인터페이스가 API 호출을 자동으로 처리할 수 있도록 한다.
WebClient나 RestClient 등을 사용하여 복잡한 요청 설정을 할 필요 없이 인터페이스 선언만으로 API 호출을 구현할 수 있다.
Spring은 이 인터페이스를 프록시 객체로 구현하여 HTTP 호출을 처리한다.
RestClient를 설정하고 HttpServiceProxyFactory에 주입해서 사용할 인터페이스를 빈으로 생성한다.
// application.yml
test:
url: http://localhost:8080
header-key: TEST_HEADER
header-value: TEST_VALUE
@Getter
@ConfigurationProperties("test")
@RequiredArgsConstructor
public class TestProperties {
private final String url;
private final String headerKey;
private final String headerValue;
}
private RestClient createRestClient(String baseUrl) {
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.messageConverters(
converters -> {
converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance);
converters.add(new MappingJackson2HttpMessageConverter(objectMapper));
})
.requestInterceptor(new RestRequestInterceptor())
.requestFactory(new BufferingClientHttpRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)))
.defaultStatusHandler(
HttpStatusCode::is4xxClientError,
(request, response) -> {
log.error("Client Error Code : {}", response.getStatusCode());
log.error("Client Error Message : {}", new String(response.getBody().readAllBytes()));
throw new ApiRuntimeException(HttpStatus.valueOf(response.getStatusCode().value()));
})
.defaultStatusHandler(
HttpStatusCode::is5xxServerError,
(request, response) -> {
log.error("Server Error Code : {}", response.getStatusCode());
log.error("Server Error Message : {}", new String(response.getBody().readAllBytes()));
throw new ApiRuntimeException(HttpStatus.valueOf(response.getStatusCode().value()));
})
.build();
}
@Bean
public TestClient testClient(TestProperties testProperties){
RestClient restClient = RestClient.builder().baseUrl(testProperties.getUrl()).build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(TestClient.class);
}
HttpInterfaceFactory를 클래스로 만들어 이런식으로 사용할수도 있다.
@Component
public class HttpInterfaceFactory {
public <S> S createClient(Class<S> serviceClass, RestClient restclient) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restclient))
.build()
.createClient(serviceClass);
}
}
@Bean
public TestClient testClient(TestProperties testProperties){
return httpInterfaceFactory.createClient(TestClient.class, createRestClient(testProperties.getUrl()));
}
// 기존 restClient 복사해서 다른 url과 기본 헤더 설정
@Bean
public MemberClient memberClient(MemberProperties memberProperties){
RestClient restClient = this.createRestClient(memberProperties.getUrl())
.mutate().defaultHeader(memberProperties.getCustomAuthHeader(), memberProperties.getCustomAuthValue())
.build();
return httpInterfaceFactory.createClient(MemberClient.class, restClient);
}
요청 값
응답 값
WebClient 사용가능한 응답값
// controller
@RequestMapping("/members")
@RestController
public class MemberController {
@GetMapping
public ResponseEntity<ApiResponse<MemberResponse>> get(@ModelAttribute MemberRequest request) {
return ApiResponse.success(request.toResponse());
}
@PostMapping("/{id}")
public ResponseEntity<ApiResponse<MemberResponse>> post(@PathVariable Long id, @RequestBody MemberRequest request) {
return ApiResponse.success(request.toResponse(id));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<MemberResponse>> put(@PathVariable Long id, @RequestBody MemberRequest request) {
return ApiResponse.success(request.toResponse(id));
}
@PatchMapping("/{id}")
public ResponseEntity<ApiResponse<MemberResponse>> patch(@PathVariable Long id, @RequestBody MemberRequest request) {
return ApiResponse.success(request.toResponse(id));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<MemberResponse>> delete(@PathVariable Long id) {
return ApiResponse.success(MemberResponse.builder().id(id).build());
}
}
// client
@HttpExchange("/members")
public interface TestClient {
@GetExchange
ResponseEntity<ApiResponse<MemberResponse>> get(@RequestParam Long id,
@RequestParam String name,
@RequestParam Integer age,
@RequestParam List<String> hobby,
@RequestParam Map<String, Object> score,
@RequestParam LocalDateTime createdDttm,
@RequestParam LocalDate createdDt);
@PostExchange("/{id}")
ResponseEntity<ApiResponse<MemberResponse>> post(@PathVariable Long id, @RequestBody MemberRequest request);
@PostExchange("/{id}")
ApiResponse<MemberResponse> postBody(@PathVariable Long id, @RequestBody MemberRequest request);
@PutExchange("/{id}")
ResponseEntity<ApiResponse<MemberResponse>> put(@PathVariable Long id, @RequestBody MemberRequest request);
@PatchExchange("/{id}")
ResponseEntity<ApiResponse<MemberResponse>> patch(@PathVariable Long id, @RequestBody MemberRequest request);
@DeleteExchange("/{id}")
ResponseEntity<ApiResponse<MemberResponse>> delete(@PathVariable Long id);
}
// Test
@SpringBootTest
class TestClientTest {
@Autowired
private TestClient testClient;
private MemberRequest request;
@BeforeEach
public void init(){
LocalDateTime now = LocalDateTime.now();
request = MemberRequest.create(1L, "테스트", 20, List.of("영화감상","운동"),
Map.of("수학", 80, "영어", 70), now, now.toLocalDate());
}
@Test
void get() {
Long id = 1L;
String name = "테스트";
Integer age = 20;
List<String> list = List.of("영화감상","운동");
Map<String, Object> map = Map.of("수학", 80, "영어", 70);
LocalDateTime now = LocalDateTime.now();
ResponseEntity<ApiResponse<MemberResponse>> response = testClient.get(id, name, age, list, map, now, now.toLocalDate());
assert response.getBody() != null;
assertTrue(response.getStatusCode().is2xxSuccessful());
assertTrue(response.getBody().isResult());
assertEquals(response.getBody().getData().getId(), id);
assertEquals(response.getBody().getData().getAge(), age);
}
@Test
void post() {
Long id = 1L;
ResponseEntity<ApiResponse<MemberResponse>> response = testClient.post(id, request);
assert response.getBody() != null;
assertTrue(response.getStatusCode().is2xxSuccessful());
assertTrue(response.getBody().isResult());
assertEquals(response.getBody().getData().getId(), id);
assertEquals(response.getBody().getData().getAge(), request.getAge());
ApiResponse<MemberResponse> responseBody = testClient.postBody(id, request);
assertTrue(responseBody.isResult());
assertEquals(responseBody.getData().getId(), id);
assertEquals(responseBody.getData().getAge(), request.getAge());
}
@Test
void put() {
Long id = 1L;
ResponseEntity<ApiResponse<MemberResponse>> response = testClient.put(id, request);
assert response.getBody() != null;
assertTrue(response.getStatusCode().is2xxSuccessful());
assertTrue(response.getBody().isResult());
assertEquals(response.getBody().getData().getId(), id);
assertEquals(response.getBody().getData().getAge(), request.getAge());
}
@Test
void patch() {
Long id = 1L;
ResponseEntity<ApiResponse<MemberResponse>> response = testClient.patch(id, request);
assert response.getBody() != null;
assertTrue(response.getStatusCode().is2xxSuccessful());
assertTrue(response.getBody().isResult());
assertEquals(response.getBody().getData().getId(), id);
assertEquals(response.getBody().getData().getAge(), request.getAge());
}
@Test
void delete() {
Long id = 1L;
ResponseEntity<ApiResponse<MemberResponse>> response = testClient.delete(id);
assert response.getBody() != null;
assertEquals(response.getBody().getData().getId(), id);
}
상단에 @HttpExchange로 기본 path를 지정해놨다. 개별 선언해도 된다.
@ModelAttribute는 지원이 안되어 @RequestParam을 사용했다.
응답값이 ResponseEntity일경우 ResponseEntity를 제거해도 파싱이 된다.
내부적으로 ResponseEntity 객체 자체가 아니여도 Body까지는 파싱을 해주는거 같다.
@RequestHeader, @CookieValue로 요청 헤더와 쿠키를 추가할 수 있다.
파일 요청, 응답
multipart와 json 데이터를 보낼때는 contentType을 multipart로 선언하고 파라미터로 Resource와 객체를 사용해 요청할 수 있다.
파일을 가져올땐 Resource나 byte[]로 응답 받을수 있다.
@PostExchange(value = "/upload", contentType = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<ApiResponse<FileResponse>> multipart(@RequestPart Resource file, @RequestPart FileRequest fileRequest);
@GetExchange("/image/{path}/{fileName}")
Resource getImage(@PathVariable String path,@PathVariable String fileName);
@GetExchange("/image/{path}/{fileName}")
byte[] getImage(@PathVariable String path,@PathVariable String fileName);
클라이언트 별로 기본 url, header 등을 선언해서 인터페이스로 만들고
인터페이스마다 API 스펙을 정의해놓고 사용하면 유용할것 같다.
참고 : https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface