스프링 애플리케이션에서 외부 API를 RestTemplate으로 부르다가, 트래픽이 몰리는 시간대마다 응답 지연이 튀는 걸 본 적이 있다. 추적해 보니 매 요청마다 TCP 핸드셰이크가 새로 일어나고 있었다. 클라이언트 쪽에 connection pool을 붙이고 keep-alive를 살리니 지표가 안정됐다. 그때까지 "HTTP/1.1은 기본으로 keep-alive잖아"라고 막연히 알고 있었는데, 실제로는 그 위에 클라이언트의 connection pool이 따로 필요했다.
이 글은 HTTP/1.1의 persistent connection이 RFC 9112 §9에서 어떻게 정의되는지, 그리고 그 위에서 클라이언트의 connection pool이 무엇을 책임지는지 정리한 노트다.
persistent connection은 "응답 후에도 TCP를 닫지 않고 다음 요청에 재사용하는 것"이다.
RFC 9112 §9.1에 따르면 HTTP/1.1의 기본 동작은 persistent다. 명시적으로 Connection: close 헤더를 보내거나, 상대가 보낼 때까지는 연결이 유지된다. HTTP/1.0 시절에는 반대였기 때문에 Connection: keep-alive 헤더를 명시적으로 보냈고, 그 흔적이 이름으로 남았다.
persistent가 필요한 이유는 TCP/TLS 핸드셰이크 비용이다. 평균 RTT 30ms 환경에서 TCP는 3-way handshake로 ~1 RTT, TLS 1.2는 ~2 RTT가 추가된다. 짧은 요청 하나에 100ms 가까이 핸드셰이크에만 쓰는 셈이다. 같은 호스트로 100번 호출한다면 그 비용을 100배로 곱해야 한다. persistent connection은 첫 요청에서 만든 연결을 그대로 끌고 가서 이 비용을 한 번만 낸다.
여기서 흔히 헷갈리는 게 persistent ≠ pool이라는 점이다. RFC가 정의하는 건 "한 connection을 재사용할 수 있다"는 프로토콜 규칙이고, 그 연결을 어떻게 관리(생성·재사용·만료)할지는 클라이언트 구현 영역이다. 클라이언트가 별도 관리를 안 하면, 라이브러리에 따라 매번 새 연결을 열어버린다.
RFC 9112 §9.3은 persistent connection 위에서 요청을 파이프라이닝(pipelining), 즉 응답을 기다리지 않고 다음 요청을 보낼 수 있다고 정의한다. 다만 같은 §에 "응답은 요청과 동일한 순서로 와야 한다(HOL ordering)"는 제약이 있어서, 실제로는 head-of-line blocking 때문에 거의 쓰이지 않는다. HTTP/2의 multiplexing이 이 제약을 푼다.
연결 종료는 §9.6에 정의된다. 어느 쪽이든 Connection: close를 보내면 그 응답을 끝으로 닫는다. 이게 없어도 서버는 idle 타임아웃이나 max-request 제한으로 닫을 수 있다. 클라이언트가 닫힌 connection을 모르고 재사용하려고 쓰면 그 요청은 실패한다 — 이 경우 §9.5는 idempotent 요청이라면 재전송해도 된다고 명시한다. (POST 같은 non-idempotent는 안 됨.)
connection pool은 대충 다음을 관리한다.
[ Pool ]
├── host:port 별 idle connection 리스트
├── max-per-route, max-total 제한
├── idle 타임아웃 (서버가 닫기 전에 회수)
├── validate-after-inactivity (꺼낼 때 stale 체크)
└── eviction thread (만료된 connection 정리)
한 줄로 정리하면, "같은 host:port에 대해 미리 열어둔 연결을 들고 있다가, 새 요청이 오면 꺼내 쓰고, 끝나면 다시 넣는다"이다. Apache HttpClient의 PoolingHttpClientConnectionManager, OkHttp의 ConnectionPool, JDK 11+ HttpClient 내부 풀이 모두 같은 추상화다.
여기서 자주 헷갈리는 두 가지.
(a) 서버 idle timeout이 더 짧으면: 서버는 닫았는데 풀은 아직 살아있다고 믿는다. 다음 요청을 그 connection에 실어 보내면 RST가 올라온다. 이걸 막으려고 풀은 보통 "꺼낼 때 살아있는지 한 번 확인(validate)"하거나, "마지막 사용 시각으로부터 N초 지났으면 미리 버림(evict-idle)" 정책을 둔다.
(b) max-per-route가 너무 작으면: 같은 호스트로 동시에 가는 요청이 풀 한계를 넘는 순간 나머지는 대기 큐로 밀린다. 모니터링 그래프에서는 "외부 API 응답이 느림"으로 보이지만 실제로는 자기 풀에서 줄을 서고 있는 것이다.
Spring 환경에서 RestTemplate에 Apache HttpClient 5 기반 풀을 붙이는 가장 작은 예시다.
@Bean
public RestTemplate restTemplate() {
PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder
.create()
.setMaxConnTotal(200) // 전체 최대 connection 수
.setMaxConnPerRoute(50) // host:port 당 최대 connection 수
.setValidateAfterInactivity(TimeValue.ofSeconds(2)) // 꺼낼 때 stale 체크 임계치
.build();
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(pool)
.evictIdleConnections(TimeValue.ofSeconds(30)) // 30초 idle 넘으면 회수
.build();
return new RestTemplate(new HttpComponentsClientHttpRequestFactory(client));
}
값들은 외워둘 게 아니라 "서버 idle timeout보다 클라이언트 evict-idle을 더 짧게"라는 원칙을 기억하는 게 중요하다. 서버가 60초에 닫는다면 클라이언트는 30초쯤에 미리 버리면 안전하다.
엣지 케이스 하나 — non-idempotent 요청의 재시도. RFC 9112 §9.5는 "서버가 닫은 줄 모르고 보낸 첫 요청이 실패했을 때, GET이라면 같은 connection이 죽은 것뿐이니 새 연결에 재전송해도 된다"고 명시한다. 하지만 POST는 서버가 받았는지 안 받았는지 알 수 없으므로 자동 재시도하면 안 된다. Apache HttpClient는 기본적으로 idempotent 요청에 한해 한 번 자동 재시도한다.
HTTP/1.1 persistent는 프로토콜의 약속, connection pool은 클라이언트의 구현이다. 둘 다 있어야 진짜 재사용이 일어난다.
다음에 더 파고들 만한 주제: HTTP/2 multiplexing이 어떻게 HOL blocking을 푸는지(RFC 9113), 그리고 같은 connection 위에서 stream을 어떻게 관리하는지.
PoolingHttpClientConnectionManager Javadoc