http.maxConnections라는 시스템 프로퍼티를 설정해야하는데 설정을 위해 자주 사용하던 properties(yml)에는 설정할 수 없다보니 다른 방법으로 설정을 해줘야하고, 그러다 보면 설정을 파악하려면 한 군데(properties 또는 yml)만 집중해서는 파악할 수 없는 내용도 있다보니 실수할 여지가 발생할 수 있다.
KeepAliveCache가 static 변수이다보니 서로 다른 SimpleClientHttpRequestFactory여도 동일한 커넥션 풀을 참조한다.
route(프로토콜, 호스트, 포트) 별 커넥션 풀은 설정할 수 있지만 토탈 커넥션 풀은 제한이 없다.
SimpleClientHttpRequestFactory가 뭐지??
RestTemplate의 기본 생성자를 사용하면 ClientHttpRequestFactory를 별도로 초기화하지 않으므로 기본값인 SimpleClientHttpRequestFactory를 사용한다.
Represents a client-side HTTP request. Created via an implementation of the ClientHttpRequestFactory. A ClientHttpRequest can be executed, receiving a ClientHttpResponse which can be read from.
ClientHttpRequest는 클라이언트 측면의 HttpRequest이며, ClientHttpRequestFactory 구현체에 의해 생성된다. ClientHttpRequest는 실행될 수 있으머, ClientHttpResponse를 받아서 읽을 수 있다. 대충 해석해보면 그냥 팩토리로 request 만들어서 서버로 전송하고 응답받을 수 있다는 내용 같다.
/** * Opens and returns a connection to the given URL. * <p>The default implementation uses the given {@linkplain #setProxy(java.net.Proxy) proxy} - * if any - to open a connection. * @param url the URL to open a connection to * @param proxy the proxy to use, may be {@code null} * @return the opened connection * @throws IOException in case of I/O errors */ protected HttpURLConnection openConnection(URL url, @Nullable Proxy proxy)throws IOException { URLConnectionurlConnection= (proxy != null ? url.openConnection(proxy) : url.openConnection()); if (!(urlConnection instanceof HttpURLConnection)) { thrownewIllegalStateException( "HttpURLConnection required for [" + url + "] but got: " + urlConnection); } return (HttpURLConnection) urlConnection; } // ... }
SimpleClientHttpRequestFactory는 정말로 커넥션 풀을 사용하지 않을까?
내 머릿 속 어딘가에서는 SimpleClientHttpRequestFactory는 커넥션 풀을 사용하지 않는다고 기억을 하고 있다. 이 말 뜻은 매번 커넥션을 맺고 끊는다는 것인데 Keep-Alive 메커니즘을 전혀 따르지 않는 것으로 보였다.
정말 이 말이 사실일까 싶어서 테스트를 해보았다. 우선 로컬에 간단한 서버를 띄워야하니 컨트롤러를 추가하자.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) classRestTemplateConnectionPoolTest( @LocalServerPort privateval port: Int ) { @Test fun `총 12개의 요청을 두 번에 끊어서 동시에 6개씩 전송`() { val threadCount = 6 val threadPool = Executors.newFixedThreadPool(threadCount) val futures = mutableListOf<CompletableFuture<String?>>()
val restTemplate = RestTemplate() val total = threadCount * 2 for (i in1..total) { futures.add(CompletableFuture.supplyAsync( // 와이어샤크의 패킷 캡쳐를 위해 일부러 private IP를 직접 박음 { restTemplate.getForObject("http://192.168.0.144:${port}", String::class.java) }, threadPool )) }
futures.forEach { it.join() }
// spring-boot-starter-web 모듈의 기본 내장 서버인 embedded tomcat의 // 기본 Keep-Alive 헤더의 timeout 파라미터 값인 60초 이후에 커넥션이 끊기는지 확인하기 위해 서버 종료를 딜레이 시킴. Thread.sleep(70_000) } }
와이어샤크를 통해 패킷 캡쳐를 해보니 6개의 커넥션이 동시에 맺혀지고 있다.
커넥션 풀을 사용하지 않는다면 모든 커넥션이 종료돼야하는데 하나의 커넥션만 종료되고 있다. 가장 처음 응답을 받은 소켓(50322 포트)이 닫혔다. 그리고 다음에 또 6개의 요청을 보내야하는데 커넥션이 하나 모자르므로 소켓(50324 포트)을 하나 더 열어서 커넥션을 맺었다.
위와 동일하게 50324 포트는 응답을 받자마자 바로 커넥션이 끊겼다. 그리고 나머지 5개의 커넥션은 Keep-Alive의 timeout 파라미터인 60초 이후에 커넥션이 끊기기 시작했다.
SimpleClientHttpRequestFactory와 커넥션 풀
위 테스트를 토대로 SimpleClientHttpRequestFactory가 커넥션 풀을 사용은 하는 것 같은데 최대 5개가 아닐까 의심이 들었다.
그래서 다시 한 번 RestTemplate의 getForObject 메서드에 브레이크 포인트를 걸고 쫓아가보았다.
publicclassHttpClientextendsNetworkClient { /* where we cache currently open, persistent connections */ protectedstaticKeepAliveCachekac=newKeepAliveCache(); // ... publicstatic HttpClient New(URL url, Proxy p, int to, HttpURLConnection httpuc)throws IOException { return New(url, p, to, true, httpuc); } // ... publicstatic HttpClient New(URL url, Proxy p, int to, boolean useCache, HttpURLConnection httpuc)throws IOException { if (p == null) { p = Proxy.NO_PROXY; } HttpClientret=null; /* see if one's already around */ if (useCache) { ret = kac.get(url, null); // ... } if (ret == null) { ret = newHttpClient(url, p, to); } else { // ... } return ret; } // ... }
kac.get(url, null) - KeepAliveCache에 이미 커넥션이 존재하는지 확인하고 없으면 새로운 커넥션을 맺고 있다. protected static KeepAliveCache kac = new KeepAliveCache();에서 보다싶이 KeepAliveCache는 static 변수이다보니 어플리케이션 전역에서 공유되는 자원이다. (즉, 서로 다른 SimpleClientHttpRequestFactory를 가진 RestTemplate이라도 커넥션 풀을 공유한다는 소리다.) 캐시에 이미 맺어진 커넥션이 캐시에 존재한다면 그걸 사용하고, 아니면 다시 tcp 커넥션을 맺는다. 이제 KeepAliveCache가 어떻게 생겨먹었는지 보자.
KeepAliveKeykey=newKeepAliveKey(url, obj); ClientVectorv=super.get(key); if (v == null) { // nothing in cache yet returnnull; } return v.get(); } // ... }
classKeepAliveKey { privateStringprotocol=null; privateStringhost=null; privateintport=0; privateObjectobj=null; // additional key, such as socketfactory
/** * Constructor * * @param url the URL containing the protocol, host and port information */ publicKeepAliveKey(URL url, Object obj) { this.protocol = url.getProtocol(); this.host = url.getHost(); this.port = url.getPort(); this.obj = obj; } /** * Determine whether or not two objects of this type are equal */ @Override publicbooleanequals(Object obj) { if ((obj instanceof KeepAliveKey) == false) returnfalse; KeepAliveKeykae= (KeepAliveKey)obj; return host.equals(kae.host) && (port == kae.port) && protocol.equals(kae.protocol) && this.obj == kae.obj; }
/** * The hashCode() for this object is the string hashCode() of * concatenation of the protocol, host name and port. */ @Override publicinthashCode() { Stringstr= protocol+host+port; returnthis.obj == null? str.hashCode() : str.hashCode() + this.obj.hashCode(); } }
classClientVectorextendsjava.util.Stack<KeepAliveEntry> { // ... synchronized HttpClient get() { if (empty()) { returnnull; } else { // Loop until we find a connection that has not timed out HttpClienthc=null; longcurrentTime= System.currentTimeMillis(); do { KeepAliveEntrye= pop(); if ((currentTime - e.idleStartTime) > nap) { e.hc.closeServer(); } else { hc = e.hc; } } while ((hc== null) && (!empty())); return hc; } } // ... }
classKeepAliveEntry { HttpClient hc; long idleStartTime;
/* * Ensure that we have connected to the server. Record * exception as we need to re-throw it if there isn't * a status line. */ Exceptionexc=null; try { getInputStream(); } catch (Exception e) { exc = e; } // ... } // ... }
publicclassHttpClientextendsNetworkClient { // ... volatilebooleankeepingAlive=false; /* this is a keep-alive connection */ volatileboolean disableKeepAlive;/* keep-alive has been disabled for this connection - this will be used when recomputing the value of keepingAlive */ intkeepAliveConnections= -1; /* number of keep-alives left */
/**Idle timeout value, in milliseconds. Zero means infinity, * iff keepingAlive=true. * Unfortunately, we can't always believe this one. If I'm connected * through a Netscape proxy to a server that sent me a keep-alive * time of 15 sec, the proxy unilaterally terminates my connection * after 5 sec. So we have to hard code our effective timeout to * 4 sec for the case where we're using a proxy. *SIGH* */ intkeepAliveTimeout=0; // ...
/** Parse the first line of the HTTP request. It usually looks something like: "HTTP/1.0 <number> comment\r\n". */ publicbooleanparseHTTP(MessageHeader responses, ProgressSource pi, HttpURLConnection httpuc) throws IOException { /* If "HTTP/*" is found in the beginning, return true. Let * HttpURLConnection parse the mime header itself. * * If this isn't valid HTTP, then we don't try to parse a header * out of the beginning of the response into the responses, * and instead just queue up the output stream to it's very beginning. * This seems most reasonable, and is what the NN browser does. */
try { serverInput = serverSocket.getInputStream(); if (capture != null) { serverInput = newHttpCaptureInputStream(serverInput, capture); } serverInput = newBufferedInputStream(serverInput); return (parseHTTPHeader(responses, pi, httpuc)); } // ... } // ... privatebooleanparseHTTPHeader(MessageHeader responses, ProgressSource pi, HttpURLConnection httpuc) throws IOException { /* If "HTTP/*" is found in the beginning, return true. Let * HttpURLConnection parse the mime header itself. * * If this isn't valid HTTP, then we don't try to parse a header * out of the beginning of the response into the responses, * and instead just queue up the output stream to it's very beginning. * This seems most reasonable, and is what the NN browser does. */
keepAliveConnections = -1; keepAliveTimeout = 0; // ... HeaderParserp=newHeaderParser(responses.findValue("Keep-Alive")); /* default should be larger in case of proxy */ keepAliveConnections = p.findInt("max", usingProxy?50:5); keepAliveTimeout = p.findInt("timeout", usingProxy?60:5); // ... } // ... }
Keep-Alive 헤더를 파싱해서 max(커넥션 재활용 가능 횟수), timeout(응답 이후 커넥션 유지 기간) 파라미터의 값을 가져오고 있는데 proxy를 쓰지 않는다는 가정하에 둘 다 기본값이 5이다. 그리고 이번에는 finished 메서드를 봐보자.
/* return it to the cache as still usable, if: * 1) It's keeping alive, AND * 2) It still has some connections left, AND * 3) It hasn't had a error (PrintStream.checkError()) * 4) It hasn't timed out * * If this client is not keepingAlive, it should have been * removed from the cache in the parseHeaders() method. */ publicvoidfinished() { if (reuse) /* will be reused */ return; keepAliveConnections--; poster = null; if (keepAliveConnections > 0 && isKeepingAlive() && !(serverOutput.checkError())) { /* This connection is keepingAlive && still valid. * Return it to the cache. */ putInKeepAliveCache(); } else { closeServer(); } } // ... protectedsynchronizedvoidputInKeepAliveCache() { if (inCache) { assertfalse : "Duplicate put to keep alive cache"; return; } inCache = true; kac.put(url, null, this); } // ... }
keepAliveConnections(max 파라미터)에서 하나 까고 커넥션 재사용 횟수가 아직 남아있다면 KeepAliveCache에 집어넣고 있다.
publicclassKeepAliveCache extendsHashMap<KeepAliveKey, ClientVector> implementsRunnable { // ... /** * Register this URL and HttpClient (that supports keep-alive) with the cache * @param url The URL contains info about the host and port * @param http The HttpClient to be cached */ publicsynchronizedvoidput(final URL url, Object obj, HttpClient http) { // ... KeepAliveKeykey=newKeepAliveKey(url, obj); ClientVectorv=super.get(key);
if (v == null) { intkeepAliveTimeout= http.getKeepAliveTimeout(); v = newClientVector(keepAliveTimeout > 0? keepAliveTimeout*1000 : LIFETIME); v.put(http); super.put(key, v); } else { v.put(http); } } }
classClientVectorextendsjava.util.Stack<KeepAliveEntry> { // ... /* return a still valid, unused HttpClient */ synchronizedvoidput(HttpClient h) { if (size() >= KeepAliveCache.getMaxConnections()) { h.closeServer(); // otherwise the connection remains in limbo } else { push(newKeepAliveEntry(h, System.currentTimeMillis())); } } // ... }
ClientVector(커넥션 풀)의 사이즈가 KeepAliveCache의 maxConnections보다 작지 않으면 커넥션을 바로 끊고 있다. 그게 아니면 커넥션 풀에 여유가 있다는 거니 밀어넣고 있다.
/* maximum # keep-alive connections to maintain at once * This should be 2 by the HTTP spec, but because we don't support pipe-lining * a larger value is more appropriate. So we now set a default of 5, and the value * refers to the number of idle connections per destination (in the cache) only. * It can be reset by setting system property "http.maxConnections". */ staticfinalintMAX_CONNECTIONS=5; staticintresult= -1; staticintgetMaxConnections() { if (result == -1) { result = java.security.AccessController.doPrivileged( newsun.security.action.GetIntegerAction("http.maxConnections", MAX_CONNECTIONS)) .intValue(); if (result <= 0) result = MAX_CONNECTIONS; } return result; } }
커넥션 풀의 최대 사이즈는 기본값이 5이고, http.maxConnections이라는 시스템 프로퍼티를 사용한다는 것을 알 수 있다.
결론
사실 맨 상단에 있는 N줄 요약이 결론이나 다름없다. 다만 왜 커넥션이 5개가 넘어가면 커넥션을 바로 끊었는지, route(프로토콜, 호스트, 포트)가 다르다면 커넥션이 5개가 넘어가도 왜 커넥션이 유지되었는지 알게 되어 좋았다. 하지만 SimpleClientHttpRequestFactory는 다양한 단점 때문에 실무에서 쓸만한 수준이 아닌데 괜히 깊게 판 것 같아서 시간이 좀 아깝다는 생각도 많이 들었다. (앞으로 좀 쓸 데 없어보이면 적당히만 파보고 더 가치있는 것을 딥하게 파야겠다.)