※ 여기 있는 내용은 구글링을 통해 수집한 정보를 바탕으로 기존의 소스들을 재구성한 내용입니다.
참고한 소스의 URL은 글 하단에 표기하였습니다.
기존의 운영되던 프로젝트를 확인하다 레거시 쪽에 API를 호출하는 부분을 살펴 보게 되었습니다.
한 화면에 여러 결과를 한꺼번에 모아 보여주다 보니 페이지 접속 시 API 호출이 5번 발생하게 되더군요.
Apache의 Http Component 4.5.X를 사용하고 있는데,
좀 더 자세히 살펴 보니 API를 호출할 때 마다 Apache HttpClient를 생성하고 자원을 반환하고 있었습니다.
표 1) 기존 소스
CloseableHttpClient httpClient = null;
CloseableHttpResponse response = null;
try {
URI uri = new URIBuilder()
.setScheme("http")
.setHost("HOST_정보")
.setPath("URI_경로")
.build();
HttpPost httpPost = new HttpPost(uri);
httpClient = HttpClients.createMinimal();
response = httpClient.execute(httpPost);
...
} catch (URISyntaxException | IOException exception) {
throw new ApiCallException("API 조회에 실패하였습니다.", exception);
} finally {
if (response != null) {
try {
response.close();
} catch (IOException ignore) {
LOGGER.debug("HttpResponse Close 에러", ignore);
}
}
if (httpClient != null) {
try {
httpClient.close();
} catch (IOException ignore) {
LOGGER.debug("HttpClient Close 에러", ignore);
}
}
}
여기서는 HttpClients.createMinimal()를 사용하고 있는데, createMinimal()의 소스를 보면
HttpClient를 객체를 새로 생성하고 PoolingHttpClientConnectionManager를 등록하고 있습니다.
표 2) HttpClients 소스 일부
public static CloseableHttpClient createMinimal() {
return new MinimalHttpClient(new PoolingHttpClientConnectionManager());}
}
결국 API 호출 후 HttpClient 자원을 반환하면서 Connection Pool 도 같이 반환하게 되어
결과적으로 Connection Pool을 전혀 사용하지 못 하는 상황이 되었습니다.
우선 PoolingHttpClientConnectionManager를 등록해서 사용할 수 있도록 Customizing 된 HttpClient가 필요했습니다.
표 3) HttpClient 빈 등록
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient myHttpClient() {
return HttpClients.custom()
.build();
}
}
먼저 HttpClient 객체를 재사용할 수 있도록 Bean으로 등록하였습니다.
이어서 해당 HttpClient에서 사용할 PoolingHttpClientConnectionMananger 등록이 필요했습니다.
표 4) PoolingHttpClietConnectionManager 추가
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient myHttpClient() {
return HttpClients.custom()
.setConnectionManager(poolingHttpClientConnectionManager())
.build();
}
private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
return connectionManager;
}
}
HttpClient에 Pooling Connection Manager까지 추가했습니다.
그런데 PoolingHttpClientConnectionManager는 기본값으로 Max Connection은 20, Route 당 Connection 수는 2였습니다.
실제 운영환경에서는 접속자가 많아질 경우 Connection 할당이 되지 않는 경우가 생길 거 같네요.
적당한 값으로 조정했습니다.
표 5) Max Connection 수 및 Route 당 Connection 수 할당
@Configuration
public class HttpClientConfig {
private static final int MAX_CONNECTIONS_PER_ROUTE = 10;
private static final int MAX_CONNECTIONS_TOTAL = 100;
@Bean
public CloseableHttpClient myHttpClient() {
return HttpClients.custom()
.setConnectionManager(poolingHttpClientConnectionManager())
.build();
}
private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL);
return connectionManager;
}
}
이렇게 해서 Pooling Connection Mananger가 적용된 HttpClient Bean이 만들어졌습니다.
이제 호출해서 잘 쓰기만 하면 되겠네요.
저희 서비스는 Spring 환경에서 구축되었기 때문에 HttpClient Bean을 Injection 하여 사용하였습니다.
표 6) Customizing된 HttpClient Bean을 이용한 API 호출
private CloseableHttpClient myHttpClient;
@Autowired
public void setMyHttpClient(CloseableHttpClient myHttpClient) {
this.myHttpClient = myHttpClient;
}
...
// 실제 비즈니스 코드 부분
CloseableHttpResponse response = null;
try {
URI uri = new URIBuilder()
.setScheme("http")
.setHost("HOST_정보")
.setPath("URI_경로")
.build();
HttpPost httpPost = new HttpPost(uri);
response = myHttpClient.execute(httpPost);
...
} catch (URISyntaxException | IOException exception) {
throw new ApiCallException("API 조회에 실패하였습니다.", exception);
} finally {
if (response != null) {
try {
response.close();
} catch (IOException ignore) {
LOGGER.debug("HttpResponse Close 에러", ignore);
}
}
}
이제 Connection Pool이 적용된 HttpClient를 사용하여 API를 호출하게 되었습니다.
이제 매번 Socket Open을 하지 않아도 되게 되었습니다.
여기까지만 해도 코드는 잘 작동합니다.
그런데 관련 내용을 좀 더 찾다 보니 Idle Connection에 대한 자원 반환 이슈가 있더군요.
Socket 통신을 할 경우 정상적으로 종료되지 않을 경우 TIME_WAIT에 걸리며 자원이 제대로 반환되지 않는 현상이 발생하게 되고
TIME_WAIT이 점점 늘어나다 보면 나중에는 할당할 수 있는 자원이 남지 않게 되지요.
만료되거나 비정상적으로 종료된 Connection에 대한 반환 작업이 필요했습니다.
주기적으로 사용하지 않는 Connection을 반환하기 위해서는
스프링의 스케줄링 기능을 이용하여 자원 반환을 지속 수행하도록 해야 했습니다.
물론 앞서 만들었던 Connection Manager도 Bean으로 등록하여
자원 반환 Thread에서 Injection 하여 사용하는 것이 필요했습니다.
표 7) 사용하지 않는 Connection 반환 스케줄러 추가
@Configuration
@EnableScheduling
public class HttpClientConfig {
private static final int MAX_CONNECTIONS_PER_ROUTE = 10;
private static final int MAX_CONNECTIONS_TOTAL = 100;
private static final int IDLE_TIMEOUT = 30 * 1000;
@Bean
public CloseableHttpClient myHttpClient() {
return HttpClients.custom()
.setConnectionManager(poolingHttpClientConnectionManager())
.build();
}
@Bean
public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL);
return connectionManager;
}
@Bean
public Runnable idleConnectionMonitor(final PoolingHttpClientConnectionManager connectionManager) {
return new Runnable() {
@Override
@Scheduled(fixedDelay = 30 * 1000)
public void run() {
try {
if (connectionManager != null) {
LOGGER.info("{} : 만료 또는 Idle 커넥션 종료.", Thread.currentThread().getName());
connectionManager.closeExpiredConnections();
connectionManager.closeIdleConnections(IDLE_TIMEOUT, TimeUnit.MILLISECONDS);
} else {
LOGGER.info("{} : ConnectionManager가 없습니다.", Thread.currentThread().getName());
}
} catch (Exception e) {
LOGGER.error(Thread.currentThread().getName() + " : 만료 또는 Idle 커넥션 종료 중 예외 발생.", e);
}
}
};
}
}
ConnectionManager를 검사하여 이미 만료되었거나 Idle 상태인 Connection들을 종료해 주는 Bean을 만들어 30초(fixedDelay) 주기로 수행되도록 하였습니다.
IDLE_TIMEOUT은 30초로 설정하여 30초 동안 Connection을 재사용하지 않으면 Idle Connection으로 판단하여 종료되도록 하였습니다.
이제 Spring의 Scheduling을 이용할 Bean도 만들었으니 추가로 Scheduling을 관리하는 Configuration도 필요하겠죠.
표 8) Spring Scheduing Configuration
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
private static final int POOL_SIZE = 10;
@Override
public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(POOL_SIZE);
taskScheduler.setThreadNamePrefix("my-scheduled-task-pool-");
taskScheduler.initialize();
scheduledTaskRegistrar.setTaskScheduler(taskScheduler);
}
}
이렇게 HttpClientConfig와 SchedulingConfig를 @Configuration으로 등록하고
비즈니스 로직에서 HttpClient Bean을 사용하여 Connection Pool을 사용하여 HttpClient를 사용하는 방법을 정리해 봤습니다.
구글링을 통해 수집한 정보를 제 나름대로 해석하여
필요에 맞게 가공하여 정리했기에 완벽하지 않습니다.
완전하지는 않지만 작업한 내용을 정리하기 위해 우선 기록해 둡니다.
수정되는 내용이 발생하거나 의견을 주시면 계속해서 수정, 반영해 나가도록 하겠습니다.
감사합니다.
참고
Tutorial - Connection management http://hc.apache.org/httpcomponents-client-ga/tutorial/html/connmgmt.html
HttpClient Examples - Threaded request execution https://hc.apache.org/httpcomponents-client-4.5.x/httpclient/examples/org/apache/http/examples/client/ClientMultiThreadedExecution.java
Apapche HttpComponent 제대로 사용하기 https://inyl.github.io/programming/2017/09/14/http_component.html
Spring RestTemplate + HttpClient configuration example https://howtodoinjava.com/spring-boot2/resttemplate/resttemplate-httpclient-java-config/
How to Schedule Tasks with Spring Boot https://www.callicoder.com/spring-boot-task-scheduling-with-scheduled-annotation/