[Spring] RestClient, HttpInterface 적용법

klmin·2024년 10월 20일
0

Spring Boot : 3.3.4

HttpInterface

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);
}

    
  • baseUrl : 기본 url 지정
  • defaultHeader : 기본 header 설정
  • messageConverters : converters 설정
  • requestInterceptor : 인터셉터 설정
  • requestFactory : factory 설정
  • defaultStatusHandler : 오류발생시 핸들러 설정
  • mutate : 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

https://blog.leaphop.co.kr/blogs/70/RestClient__HttpInterface_%EA%B3%A0%EC%9C%A0%EB%AA%85%EC%82%AC%EA%B0%80_%EB%90%98%EB%8B%A4

https://mangkyu.tistory.com/291

profile
웹 개발자

0개의 댓글