본문 바로가기

카테고리 없음

Apache HttpClient5 살펴보기

최근 HttpClient5 클래스를 튜닝(?) 하면서 알게 된 HttpClient의 다양한 옵션에 대해 이야기해 보려 한다.

 

Apache HttpClient는 아파치에서 개발한 라이브러리로 자바에서 http 통신을 쉽게 할 수 있도록 도와주는 라이브러리이다.

 

 

현시간 기준으로 5.2.1이 가장 많이 사용된다.

 

아래와 같이 상세한 설정이 가능하다. 

public HttpClient httpClient() {
	int maxPool = 200; // 전체 커넥션 풀
	int maxPerRoute = 200; // 호스트 당 커넥션 풀에서 쓸 수 있는 커넥션 개수
	int idleConnectionTimeoutSec = 30; // 유휴 커넥션이 이 시간이 지나면 커넥션 반납
	long requestConnectionTimeoutMilliSec = 5000; // 커넥션 풀로부터 커넥션 받기를 대기하는 시간
	long responseTimeoutMilliSec = 5000; // 서버로부터 응답을 받기 위한 최대 대기 시간
	int retryCount = 1; // 어떠한 예외로 인해 처리하지 못한 요청에 대한 retry 횟수
	long backoff = 1000; // retry 간 back off

	PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
	cm.setMaxTotal(maxPool);
	cm.setDefaultMaxPerRoute(maxPerRoute);
	cm.setDefaultSocketConfig(SocketConfig.DEFAULT);

    return HttpClients.custom()
//                .setBackoffManager(new AIMDBackoffManager()) //서버 혹은 클라이언트에서 감당하지 못할 정도로 부하가 너무 커서 커넥션 풀 사이즈 줄이는 옵션
                .setConnectionBackoffStrategy(new DefaultBackoffStrategy()) //어떤 상황에서 백오프를 할지(위 옵션과 매칭되는 옵션)
                .setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy()) //default 값 서버에서 받은 keep-alive
                .setRetryStrategy(new DefaultHttpRequestRetryStrategy(retryCount, TimeValue.ofMilliseconds(backoff))) // 몇번 재시도 할지
                .setConnectionReuseStrategy(DefaultConnectionReuseStrategy.INSTANCE) //default로 켜져 있는데 일단 명시함
                .setDefaultRequestConfig(
                        RequestConfig.custom()
                                //.setContentCompressionEnabled(true) 서버에서 압축 요청을 처리 가능한지
                                .setResponseTimeout(responseTimeoutMilliSec, TimeUnit.MILLISECONDS)
                                .setConnectionRequestTimeout(requestConnectionTimeoutMilliSec, TimeUnit.MILLISECONDS)
                                .build()
                )
                .setConnectionManager(cm)
                .evictExpiredConnections() // 이거 뭔가 문제 생기면 끄기. proactively evict expired connections from the connection pool using a background thread.
                .evictIdleConnections(TimeValue.ofSeconds(idleConnectionTimeoutSec)) // 유휴 커넥션이 이 시간(초)동안 존재하면 커넥션 종료
                .build();
}

 

스프링 프레임워크에서는 RestTemplate에 이러한 세부적인 설정을 하여 아래와 같이 사용이 가능하다.

@Bean
HttpClient httpClient() {
	int maxPool = 200; // 전체 커넥션 풀
	int maxPerRoute = 200; // 호스트 당 커넥션 풀에서 쓸 수 있는 커넥션 개수
	int idleConnectionTimeoutSec = 30; // 유휴 커넥션이 이 시간이 지나면 커넥션 반납
	long requestConnectionTimeoutMilliSec = 5000; // 커넥션 풀로부터 커넥션 받기를 대기하는 시간
	long responseTimeoutMilliSec = 5000; // 서버로부터 응답을 받기 위한 최대 대기 시간
	int retryCount = 1; // 어떠한 예외로 인해 처리하지 못한 요청에 대한 retry 횟수
	long backoff = 1000; // retry 간 back off

	PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
	cm.setMaxTotal(maxPool);
	cm.setDefaultMaxPerRoute(maxPerRoute);
	cm.setDefaultSocketConfig(SocketConfig.DEFAULT);

    return HttpClients.custom()
//                .setBackoffManager(new AIMDBackoffManager()) //서버 혹은 클라이언트에서 감당하지 못할 정도로 부하가 너무 커서 커넥션 풀 사이즈 줄이는 옵션
                .setConnectionBackoffStrategy(new DefaultBackoffStrategy()) //어떤 상황에서 백오프를 할지(위 옵션과 매칭되는 옵션)
                .setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy()) //default 값 서버에서 받은 keep-alive
                .setRetryStrategy(new DefaultHttpRequestRetryStrategy(retryCount, TimeValue.ofMilliseconds(backoff))) // 몇번 재시도 할지
                .setConnectionReuseStrategy(DefaultConnectionReuseStrategy.INSTANCE) //default로 켜져 있는데 일단 명시함
                .setDefaultRequestConfig(
                        RequestConfig.custom()
                                //.setContentCompressionEnabled(true) 서버에서 압축 요청을 처리 가능한지
                                .setResponseTimeout(responseTimeoutMilliSec, TimeUnit.MILLISECONDS)
                                .setConnectionRequestTimeout(requestConnectionTimeoutMilliSec, TimeUnit.MILLISECONDS)
                                .build()
                )
                .setConnectionManager(cm)
                .evictExpiredConnections() // 이거 뭔가 문제 생기면 끄기. proactively evict expired connections from the connection pool using a background thread.
                .evictIdleConnections(TimeValue.ofSeconds(idleConnectionTimeoutSec)) // 유휴 커넥션이 이 시간(초)동안 존재하면 커넥션 종료
                .build();
}

@Bean
@DependsOn("httpClient")
RestTemplate restTemplate(HttpClient httpClient) {
    HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
	
    return new RestTemplate(requestFactory);
}

/**
 * 스프링 부트(3.2부터 사용 가능한 모던한 방식의 fluent api) <br>
 * webflux의 webclient와 사용방식이 유사하며 블록킹 client임
 *
 * @param httpClient 앞서 정의한 apache httpClient
 * @return
 */
@Bean
@DependsOn("httpClient")
RestClient restClient(HttpClient httpClient) {
    return RestClient.builder()
                .requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
                .build();
}

 

 

다음의 의존을 추가하면 아래와 같은 라이브러리가 생성된다.

implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.1.2'

 

 

 

즉, 아래와 같은 구조로 되어 있는데 (http 버전 2를 위한 코어는 생략) 각 라이브러리의 패키지 구조를 보면 어떠한 역할을 하는지 짐작이 가능하다.

+----------------------------------------------+
|            HttpComponents                    |
|                                              |
|   +----------------------------------------+ |
|   |           HttpClient 5.1.2             | |
|   +----------------------------------------+ |
|                                              |
|   +----------------------------------------+ |
|   |            HttpCore 5.2.3              | |
|   +----------------------------------------+ |
|                                              |
+----------------------------------------------+

 

먼저, HttpClient는 아래와 같은 구조로 되어 있다.

 

다음으로 HttpCore는 아래와 같은 구조로 되어있다.

 

HttpClient: HTTP 클라이언트 구현을 제공하며, HTTP 통신을 수행하는데 필요한 기능들을 포함한다. 내부를 보면 통신을 하기 위해 필용한 바디, 헤더, 인증 등을 위한 코드가 많이 담겨있다.

 

HttpCore: HTTP 프로토콜의 핵심 구성 요소와 파라미터를 제공하는 라이브러리이다. HttpCore는 낮은 수준의 HTTP 처리와 프로토콜 측면을 관리하며, HttpClient에서 활용된다. 

 

 

아래는 HttpCore 라이브러리의 내부에 있는 HttpRequestExecutor의 execute메소드다.

HttpClient 클래스의 execute 메소드를 따라가다 보면 최종적으로 HttpRequestExecutor 클래스의 execute 메소드가 실행된다. 

아래의 메소드가 실행되기까지 앞서 설정한 keep-alive, connection pool, interceptor 등의 코드가 담겨있으니, 어떤식으로 구현이 되어 있는지는 직접 코드로 확인하는 것이 더 좋을 것 같다.

 

public ClassicHttpResponse execute(ClassicHttpRequest request, HttpClientConnection conn, HttpResponseInformationCallback informationCallback, HttpContext context) throws IOException, HttpException {
        Args.notNull(request, "HTTP request");
        Args.notNull(conn, "Client connection");
        Args.notNull(context, "HTTP context");

        try {
            context.setAttribute("http.ssl-session", conn.getSSLSession());
            context.setAttribute("http.connection-endpoint", conn.getEndpointDetails());
            conn.sendRequestHeader(request);
            if (this.streamListener != null) {
                this.streamListener.onRequestHead(conn, request);
            }

            boolean expectContinue = false;
            HttpEntity entity = request.getEntity();
            if (entity != null) {
                Header expect = request.getFirstHeader("Expect");
                expectContinue = expect != null && "100-continue".equalsIgnoreCase(expect.getValue());
                if (!expectContinue) {
                    conn.sendRequestEntity(request);
                }
            }

            conn.flush();
            ClassicHttpResponse response = null;

            while(true) {
                while(true) {
                    while(response == null) {
                        int status;
                        if (expectContinue) {
                            if (conn.isDataAvailable(this.waitForContinue)) {
                                response = conn.receiveResponseHeader();
                                if (this.streamListener != null) {
                                    this.streamListener.onResponseHead(conn, response);
                                }

                                status = response.getCode();
                                if (status == 100) {
                                    response = null;
                                    conn.sendRequestEntity(request);
                                } else {
                                    if (status < 200) {
                                        if (informationCallback != null) {
                                            informationCallback.execute(response, conn, context);
                                        }

                                        response = null;
                                        continue;
                                    }

                                    if (status >= 400) {
                                        conn.terminateRequest(request);
                                    } else {
                                        conn.sendRequestEntity(request);
                                    }
                                }
                            } else {
                                conn.sendRequestEntity(request);
                            }

                            conn.flush();
                            expectContinue = false;
                        } else {
                            response = conn.receiveResponseHeader();
                            if (this.streamListener != null) {
                                this.streamListener.onResponseHead(conn, response);
                            }

                            status = response.getCode();
                            if (status < 100) {
                                throw new ProtocolException("Invalid response: " + new StatusLine(response));
                            }

                            if (status < 200) {
                                if (informationCallback != null && status != 100) {
                                    informationCallback.execute(response, conn, context);
                                }

                                response = null;
                            }
                        }
                    }

                    if (MessageSupport.canResponseHaveBody(request.getMethod(), response)) {
                        conn.receiveResponseEntity(response);
                    }

                    return response;
                }
            }
        } catch (IOException | RuntimeException | HttpException var9) {
            Closer.closeQuietly(conn);
            throw var9;
        }
    }

 

위 코드를 보면 아래의 부분에서 blocking이 일어나는 것을 확인 가능하다.

 

1. conn.sendRequestHeader(request)

 

이 메서드는 HTTP 요청 헤더를 서버로 전송한다. 네트워크 버퍼가 가득 차 있을 경우, 추가 데이터를 버퍼에 쓸 수 있을 때까지 스레드가 블로킹된다. 이는 네트워크 I/O 연산이며, 데이터 전송이 완료될 때까지 대기한다.

 

2. conn.sendRequestEntity(request)

 

요청에 본문(엔티티)이 있는 경우, 이 메서드를 통해 본문 데이터를 서버로 전송한다. sendRequestHeader와 마찬가지로, 네트워크 I/O 작업으로 데이터를 전송하며, 이 경우 역시 블록킹이 일어난다.

 

3. conn.receiveResponseHeader()

 

서버로부터 응답 헤더를 수신하는 과정이다. 이 메서드는 서버가 응답을 전송할 때까지 기다리며 블록킹이 일어난다.

 

4. conn.isDataAvailable(this.waitForContinue)

 

이 메서드는 서버로부터 일정 시간(waitForContinue) 내에 데이터가 도착하는지 확인하며, 지정 시간만큼 블록킹이 일어난다.

 

5. conn.receiveResponseEntity(response)

 

응답 본문(엔티티)을 받는 메서드다. 서버로부터 전체 응답 본문을 수신할 때까지 스레드가 블로킹되며 응답이 크면 블록킹 기간이 길어진다.

 

이번 코드는 메소드가 다른 클래스의 메소드를 호출하고 호출의 연쇄를 거쳐 확인하면 어떻게 작동하는지 이해할 수 있다. 코드량이 많아 시간이 좀 걸릴 뿐이지 새로운 개념이 등장하지 않았기에 이해하며 읽을 수 있었다.

 

다음 글에서는 AsyncHttpClient의 코드가 어떻게 논블록킹하게 구현이 되어 있는지 작성할 예정인데, IOReactor와 Selector 라는 새로운 개념이 나와 코드이해가 약간 난해할 것으로 예상이 된다.