Application

Http client

팅리엔 2024. 9. 24. 10:45

RestTemplate

  • spring 3.0 ~
  • synchronous, blocking
  • 1 thread per 1 request
  • thread safe
  • 여러 요청을 병렬로 처리하고 싶다면 connection pool을 사용한다. (수신 서버가 Keep-Alive를 지원해야 한다.)
  • RestTemplate 생성 시 어떤 HttpClient, ClientHttpRequestFactory를 사용할 것인지 선택할 수 있다. (default: SimpleClientHttpRequestFactory, 그 외 Apache HttpComponents, Netty, OkHttp)
  • https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
@Bean
public RestTemplate restTemplate() {
  HttpClient httpClient = HttpClientBuilder.create()
    .setMaxConnTotal(100) // 최대 오픈되는 커넥션 수
    .setMaxConnPerRoute(5) // IP,포트 1쌍에 대해 수행 할 연결 수
    .build();

  HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
  factory.setConnectTimeout(5000); // 연결시간초과, ms
  factory.setReadTimeout(5000); // 읽기시간초과, ms
  factory.setHttpClient(httpClient);

  return new RestTemplate(factory);
}
restTemplate.getInterceptors().add(addHeaderFunction);
Todo todo = restTemplate.getForEntity("<https://jsonplaceholder.typicode.com/todos/1>", Todo.class);
  • RestTemplate은 ClientHttpRequestFactory 구현체를 통해 요청을 처리한다. 구현체 중HttpComponentsClientHttpRequestFactory을 사용하면 커넥션 pool 설정을 할 수 있다. (connectTimeout, socketTimeout, connectionRequestTimeout)

 

 

 

 

 

WebClient

@Bean
public WebClient webClient() {
  return WebClient.builder().build();
}
webClient.mutate()
  .defaultHeaders(addHeaderFunction)
  .build();

Mono todoMono = webClient
  .post()
  .uri("<https://jsonplaceholder.typicode.com/todos/1>")
  .bodyValue(todoRequest)
  .retrieve()
  .bodyToMono(Todo.class);

todoMono
  .subscribe(
    todo -> {
      System.out.println(todo.getTitle());
    },
    error -> {
      System.out.println(error.getMessage());
    }
  );

 

장점

  • 논블로킹 방식으로 여러 동시 요청을 효율적으로 처리한다.
  • 선언적 스타일(메서드 체인)로 코드를 작성할 수 있다.

 

단점

  • spring mvc에서 API 호출을 위해 webflux 의존성을 모두 추가해줘야 한다.
  • 반환값으로 Flux, Mono를 반환한다. 이를 다루기 위해 subscribe나 map을 사용해야 한다. 이런 선언적 스타일은 RestTemplate과는 달라 이질감이 든다. (block을 통해 RestTemplate처럼 원하는 결과 객체를 받을 수 있지만 이는 블로킹 방식이므로 안티 패턴이다. 하지만 Spring MVC를 쓰며 block은 불가피하다.)

 

 

 

 

 

RestClient

@Bean
public RestClient restClient() {
  return RestClient.builder().build();
}
restClient.mutate()
  .defaultHeaders(addHeaderFunction)
  .build();

Todo todo = restClient
  .post()
  .uri("<https://jsonplaceholder.typicode.com/todos/1>")
  .body(todoRequest)
  .retrieve()
  .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
    throw new MyCustomRuntimeException(response.getStatusCode(), response.getHeaders());
  })
  .body(Todo.class);

 

스레드풀 사용하기

implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.3'
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
@Slf4j
@Component
public class LoggingHttpRequestInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        logRequest(request, body);
        ClientHttpResponse response = execution.execute(request, body);
        logResponse(response);
        return response;
    }
    private void logRequest(HttpRequest request, byte[] body) {
        log.info("===== HTTP Request =====");
        log.info("URI         : {}", request.getURI());
        log.info("Method      : {}", request.getMethod());
        log.info("Headers     : {}", request.getHeaders());
        log.info("Request body: {}", new String(body, StandardCharsets.UTF_8));
    }
    private void logResponse(ClientHttpResponse response) throws IOException {
        StringBuilder inputStringBuilder = new StringBuilder();
        try (BufferedReader bufferedReader = new BufferedReader(
                new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
            String line = bufferedReader.lines().collect(Collectors.joining("\n"));
            inputStringBuilder.append(line);
        }
        log.info("===== HTTP Response =====");
        log.info("Status code  : {}", response.getStatusCode());
        log.info("Status text  : {}", response.getStatusText());
        log.info("Headers      : {}", response.getHeaders());
        log.info("Response body: {}", inputStringBuilder);
    }
}
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.core5.util.TimeValue;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Slf4j
@Configuration
public class PostRestClientConfig {
    @Value("${post.baseurl}")
    private String baseUrl;
    // requestFactory 미설정 시 SimpleClientHttpRequestFactory가 사용된다.
    // 커넥션풀 사용을 위해 HttpComponentsClientHttpRequestFactory를 사용한다.
    @Bean
    RestClient postRestClient(ClientHttpRequestFactory postRequestFactory,
                              LoggingHttpRequestInterceptor loggingHttpRequestInterceptor) {
        return RestClient.builder()
                .baseUrl(baseUrl)
                .requestFactory(postRequestFactory)
                .requestInterceptor(loggingHttpRequestInterceptor)
                .defaultHeaders(headers -> {
                    headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
                    headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
                })
//                .messageConverters(converters -> converters.add(new MyCustomMessageConverter()))
//                .defaultUriVariables(Map.of("variable", "foo"))
//                .requestInitializer(myCustomInitializer)
                .build();
    }
    // HttpComponentsClientHttpRequestFactory requires Apache HttpComponents 5.1 or higher, as of Spring 6.0
    // gradle 추가 [implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.3']
    @Bean
    BufferingClientHttpRequestFactory postRequestFactory() {
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setMaxTotal(10); // 최대 커넥션 수
        HttpClient httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .evictIdleConnections(TimeValue.ofMilliseconds(60_000)) // 해당 시간동안 사용되지 않은 커넥션 정리
                .build();
        HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
        clientHttpRequestFactory.setConnectTimeout(5_000); // 커넥션 최대 시간
        clientHttpRequestFactory.setConnectionRequestTimeout(5_000); // 커넥션풀에서 사용 가능한 연결을 가져오기 위해 대기하는 최대 시간
        return new BufferingClientHttpRequestFactory(clientHttpRequestFactory); // 요청/응답 내용을 여러 번 읽을 수 있도록 설정
    }
}
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
@RequiredArgsConstructor
public class PostClient {
    @Qualifier("postRestClient")
    private final RestClient restClient;
    public void getPost(Long id) {
        Post post = restClient
                .get()
                .uri("/posts/" + id)
                .retrieve()
                .body(Post.class);
        System.out.println(post);
    }
}

 

 

 

 

HttpInterface

  • spring 6.0 ~
  • Client를 추상화했다. Feign과 비슷한데 Feign은 FeignClient를 사용하고, HttpClient는 특정 구현체에 종속되지 않는다.
  • @HttpExchange 어노테이션이 적용된 메서드를 가진 인터페이스의 구현체를 자동으로 만들어준다. (마치 JpaRepository처럼)
interface TodoApi {
  @GetExchange("/todos/{id}")
  Todo getTodoById(@PathVariable Long id);
}
@Bean
RestClientTest.TodoApi todoApi(RestClient.Builder restClientBuilder) {
  RestClient restClient = restClientBuilder
    .baseUrl("<https://jsonplaceholder.typicode.com>")
    .build();

  HttpServiceProxyFactory httpServiceProxyFactory = HttpServiceProxyFactory
    .builderFor(RestClientAdapter.create(restClient))
    .build();

  return httpServiceProxyFactory.createClient(RestClientTest.TodoApi.class);
}
@Autowired
TodoApi todoApi;

public void test() {
  Todo todo = todoApi.getTodoById(1 L);
}

 

 

 

 


 

 

 

 

병렬 실행 사례

  • 한 API 진행 중 다른 서버의 API를 여러 개 호출하는 경우가 있다. *[시작 → A 서버 호출 → B 서버 호출 → C 서버 호출 → 끝]*처럼 순차적으로 실행될 필요가 없다면 *[시작 → (A 서버 호출, B 서버 호출, C 서버 호출) → 끝]*처럼 외부 API 호출을 병렬로 실행하면 시간 절약이 될 것이다.
  • WebClient
Mono<AResponse> AMono = webClient.get().retrieve().bodyToMono(AResponse.class).subscribeOn(Schedulers.boundedElastic());
Mono<BResponse> BMono = webClient.get().retrieve().bodyToMono(BResponse.class).subscribeOn(Schedulers.boundedElastic());
Mono<CResponse> CMono = webClient.get().retrieve().bodyToMono(CResponse.class).subscribeOn(Schedulers.boundedElastic());

// Mono, Flux는 Future와 다르게 실행 '가능성'을 의미하는 것이다. 아직 실행된 게 아니다!
// subscribeOn은 어느 스레드에서 subscribe 할 것인지 지정하는 메서드이고, 
// elastic scheduler는 구독마다 다른 스레드에서 동작되도록 보장한다.

Tuple3<AResponse, BResponse, CResponse> responses = Mono.zip(AMono, BMono, CMono).block();
// block으로 최종적 결과를 동기로 처리하지만 각각의 Mono는 스케쥴러에 의해 비동기적으로 수행된다.
  • RestClient
@Test
public void test2() {
  RestClient restClient = RestClient.builder().baseUrl("<https://jsonplaceholder.typicode.com/todos/1>").build();

  CompletableFuture AFuture = CompletableFuture.supplyAsync(() -> restClient
    .get()
    .retrieve()
    .body(Todo.class));

  CompletableFuture BFuture = CompletableFuture.supplyAsync(() -> restClient
    .get()
    .retrieve()
    .body(Todo.class));

  // CompletableFuture로 여러 비동기 작업을 조합할 수 있다.
  CompletableFuture.allOf(AFuture, BFuture).join();
}

 

 

 

 

 

참고

https://incheol-jung.gitbook.io/docs/q-and-a/spring/resttemplate

https://treecode.tistory.com/115