View
HTTP Keep-Alive를 켜라는 글은 도처에 널려 있다. 다만 정작 "얼마나 차이가 나는지" 한국어로 수치를 제시한 자료는 거의 없다. 이론 글만 읽다가 답답해서 직접 실행했다. 평문 HTTP, HTTPS, mTLS 세 시나리오 × fresh/keepalive 두 모드 × 동시성 1/8/32 = 18케이스 매트릭스로 1000회씩 호출했고, 결론부터 말하면 mTLS에서 keepalive를 사용하지 않으면 치명적이다.

결론부터 제시한다
핵심 한 줄로 정리한다. conc=1 기준 keepalive가 fresh 대비 RPS 비율은 평문 HTTP가 2.80배, HTTPS가 7.65배, mTLS가 9.60배다. 시나리오가 무거워질수록 keep-alive의 이득은 기하급수로 벌어진다.
| 시나리오 | fresh RPS | keepalive RPS | keepalive/fresh |
| http | 2,504.8 | 7,000.5 | **2.80×** |
| https | 787.6 | 6,025.4 | **7.65×** |
| mtls | 538.7 | 5,172.1 | **9.60×** |
mTLS fresh는 conc=32에서 RPS가 1,053까지 떨어진다. 동일한 mTLS에서 keepalive를 켜면 conc=8에서 27,285 RPS를 기록한다. 25배가 넘는 차이다. 이것이 핵심이다. HTTP Keep-Alive는 평문에서도 의미가 있고, TLS가 들어가는 순간 필수가 되며, mTLS라면 켜지 않는 것 자체가 잘못이다.
측정을 시작한 이유
mTLS를 운영하다가 RPS가 도무지 나오지 않아 의심하기 시작했다. "TLS handshake가 비싸다"는 말은 모두가 하지만, 정확히 얼마나 비싼지, 클라이언트 인증서까지 더해지면 또 얼마나 더 비싼지 한국어로 깔끔하게 정리된 데이터가 없었다. 영어권에서는 Cloudflare 블로그나 Stripe 엔지니어링 글이 잘 정리되어 있는 반면, 한국어 자료는 입문 수준에서 멈춰 있다.
각설하고, 변수를 모두 고정하고 핸드셰이크 비용만 분리해서 측정하기로 했다. 동일 컨테이너 내 loopback이므로 네트워크 RTT 변수는 제거되고, HTTP/1.1을 강제하여 HTTP/2 multiplexing 효과도 분리된다. 순수하게 TCP 3-way + TLS handshake + 인증서 검증 비용만 보는 셋업이다.
핸드셰이크 비용이 누적되는 메커니즘
TCP 3-way handshake 비용
평문 HTTP라도 매번 새 connection을 열면 SYN → SYN-ACK → ACK 왕복이 한 번 발생한다. loopback이라 RTT 자체는 작지만, conc가 올라가면 ephemeral port를 할당하고 해제하는 비용이 누적된다. fresh 모드에서 800 요청에 accept 카운트가 정확히 800인 점이 그 증거다.
TLS 1.2 / 1.3 handshake가 더해지는 경우
TLS 1.2는 2 RTT, TLS 1.3은 1 RTT가 추가된다. 더 무거운 부분은 RTT 자체가 아니라 인증서 파싱과 키 교환 CPU다. fresh 모드 https는 동일한 conc=1에서도 평문 대비 RPS가 1/3로 떨어진다(2,504 → 787).
mTLS 클라이언트 인증서 검증까지 포함되는 경우
mTLS는 서버가 클라이언트 인증서까지 검증한다. 양측 모두 인증서 파싱 + 체인 검증 + 서명 검증이 발생한다. fresh 모드 mtls의 conc=32에서 CPU user+sys가 3.62초를 기록한다. 동일한 800 요청을 keepalive로 처리하면 0.387초다. CPU만 9배 가까운 차이다.
실험 환경 — 변수를 모두 고정하고 핸드셰이크만 측정한다
| 항목 | 값 |
| 실험 ID | http-keepalive-effect |
| 런타임 | golang:1.22-bookworm |
| 메모리 | 512 MB |
| CPU | 2.0 |
| 네트워크 | deny (loopback only) |
| 타임아웃 | 300 s |
| 실행 시각 | 2026-05-04T01:16:21 → 2026-05-04T01:16:43 |
| 총 실행 시간 | 22.16s |
| 결과 | PASS · exit 0 |
| 산출물 | result.json (8282B) |
설계는 다음과 같이 잡았다.
- 단일 컨테이너 loopback: 네트워크 RTT 변수 제거. 순수 핸드셰이크 비용만 본다.
- 3 시나리오: http(평문), https(서버 TLS), mtls(상호 TLS).
- 2 모드: fresh(Transport.DisableKeepAlives=true)와 keepalive(pooled connection).
- 3 동시성: conc=1, 8, 32.
- 케이스당 800 요청 + 워밍업 50: 워밍업 노이즈 제거.
- HTTP/1.1 강제: HTTP/2 connection coalescing 효과와 분리한다.
Go net/http 클라이언트의 핵심 부분만 발췌하면 다음과 같다.
// fresh 모드
tr := &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: tlsCfg,
ForceAttemptHTTP2: false,
}
// keepalive 모드
tr := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: tlsCfg,
ForceAttemptHTTP2: false,
}
DisableKeepAlives=true 이 한 줄이 매 요청마다 새 connection을 열게 만드는 스위치다. 실서비스에서 모르고 켜 두면 그대로 무너진다.
평문 HTTP 결과 — 차이는 존재하지만 예상보다 작다
평문 HTTP는 TLS가 없으므로 TCP handshake만 절약하면 끝이다. 차이가 작을 것이라 예상했지만, 실측해 보니 conc=1에서도 2.8배의 차이가 나타났다.
| mode | conc | rps | p50 (ms) | p99 (ms) | p99.9 (ms) | accepts |
| fresh | 1 | 2,504.8 | 0.370 | 0.731 | 1.110 | 800 |
| fresh | 8 | 13,633.2 | 0.386 | 3.241 | 4.442 | 800 |
| fresh | 32 | 7,314.9 | 2.122 | **45.863** | 46.799 | 800 |
| keepalive | 1 | 7,000.5 | 0.127 | 0.327 | 0.668 | 0 |
| keepalive | 8 | 56,862.6 | 0.089 | 1.348 | 1.594 | 8 |
| keepalive | 32 | 46,648.0 | 0.370 | 3.302 | 3.864 | 39 |
흥미로운 포인트는 두 가지다.
- conc=32 fresh의 p99 폭주: 평균은 4.27ms 수준으로 보이지만 p99에서 45.8ms로 튄다. ephemeral port 회전과 backlog queue 때문이다. 평균만 보면 절대로 잡아낼 수 없는 꼬리지연이다.
- accepts 카운트가 결정적 증거: fresh는 모든 conc에서 800(=요청수)이다. keepalive는 1/8/32 → 0/8/39로 동시성 수준에 수렴한다. connection이 풀에서 재사용되고 있다는 직접적인 증거다.
평문에서도 conc가 올라가면 fresh는 TIME_WAIT 누적으로 인한 ephemeral port 고갈 위험까지 떠안는다. loopback이라 터지지 않았을 뿐, 실서비스에서는 실제로 사고가 발생한다.
HTTPS 결과 — 여기서부터 차이가 명확해진다
TLS handshake가 들어가면 fresh 비용이 본격적으로 폭발한다.
| mode | conc | rps | p50 (ms) | p99 (ms) | p99.9 (ms) | cpu (user+sys, s) |
| fresh | 1 | 787.6 | 1.193 | 2.244 | 2.600 | 1.214 |
| fresh | 8 | 2,071.6 | 3.407 | 15.730 | 25.888 | 2.042 |
| fresh | 32 | 2,071.6 | 13.842 | 38.460 | 43.258 | 2.103 |
| keepalive | 1 | 6,025.4 | 0.155 | 0.378 | 0.835 | 0.192 |
| keepalive | 8 | 26,831.7 | 0.128 | 3.535 | 6.915 | 0.176 |
| keepalive | 32 | 9,106.1 | 0.585 | 24.448 | 32.222 | 0.469 |
동일한 conc=1에서 fresh 787 RPS → keepalive 6,025 RPS, 무려 7.65배다. p50도 1.193ms → 0.155ms로 8배 단축된다. handshake 한 번이 평문 대비 얼마나 무거운지 그대로 드러난다.
p99.9 꼬리지연을 보면 fresh conc=8이 25.8ms를 기록하는데, keepalive conc=8은 6.9ms다. 평균 지연만 보면 둘 다 한 자릿수 ms처럼 보여 "비슷한가?" 싶지만 꼬리에서 4배의 차이가 난다. 평균만 보면 속는다.
mTLS 결과 — keepalive를 사용하지 않으면 치명적이다
여기가 진정한 하이라이트다. mTLS는 서버 인증서 + 클라이언트 인증서 양쪽 모두를 검증해야 한다. 매 요청마다 인증서 체인 파싱과 서명 검증이 풀로 돌아간다.
| mode | conc | rps | p50 (ms) | p99 (ms) | p99.9 (ms) | cpu (user+sys, s) |
| fresh | 1 | 538.7 | 1.749 | 3.419 | 4.505 | 2.004 |
| fresh | 8 | 1,804.8 | 4.018 | 10.616 | 12.248 | 2.468 |
| fresh | 32 | **1,053.0** | 26.320 | 85.840 | **130.521** | **3.617** |
| keepalive | 1 | 5,172.1 | 0.186 | 0.367 | 0.600 | 0.226 |
| keepalive | 8 | 27,285.5 | 0.121 | 3.216 | 5.191 | 0.181 |
| keepalive | 32 | 11,469.9 | 0.567 | 28.485 | 37.941 | 0.387 |
mtls fresh conc=32의 RPS가 1,053이다. 동일 인프라에서 keepalive로 가면 11,469다. 약 11배의 차이다. 그리고 p99.9가 130.5ms다. 응답 시간이 100ms를 넘는 사용자가 0.1% 발생한다는 의미인데, 트래픽이 1만 RPS라면 매초 10명씩 100ms를 넘는 응답을 받는다.
stderr에는 다음과 같은 로그가 다수 발생한다.
2026/05/03 16:16:40 http: TLS handshake error from 127.0.0.1:45426: EOF
2026/05/03 16:16:40 http: TLS handshake error from 127.0.0.1:45518: EOF
2026/05/03 16:16:40 http: TLS handshake error from 127.0.0.1:45534: read: connection reset by peer
fresh 모드에서 동시성이 32까지 올라가면서 서버 backlog가 넘쳐 TLS handshake 도중 끊기는 현상이다. errors 카운트는 0이지만, 서버 로그에는 핸드셰이크 실패가 연달아 기록된다. 실서비스라면 클라이언트 retry 폭주로 이어진다.
Keep-Alive는 CPU 비용도 함께 절감한다
지연만 늘어나는 것이 아니라 CPU도 함께 소진된다. conc=8, 800 요청 처리분 기준 CPU 누적 비교다.
| 시나리오 | fresh CPU (s) | keepalive CPU (s) | 절감 비율 |
| http | 0.343 | 0.091 | **3.77×** 적음 |
| https | 2.042 | 0.176 | **11.6×** 적음 |
| mtls | 2.468 | 0.181 | **13.6×** 적음 |
mTLS fresh는 평문 fresh 대비 CPU를 7배 더 사용한다. keepalive를 켜면 평문 0.091초 → mTLS 0.181초로 2배만 더 사용한다. 즉, 인증서 검증 CPU는 handshake 단계에서 거의 모두 발생하며, 데이터 전송 자체는 그다지 비싸지 않다. handshake만 재사용으로 처리하면 mTLS도 거의 평문 수준의 CPU로 운용할 수 있다는 의미다.
그렇다면 언제 keep-alive를 켜야 하는가
케이스별 권장 사항을 정리한다.
- 단발성 요청 (스크립트, cron): 그래도 켜 두는 것이 맞다. 동일 도메인에 두 번 이상 호출하면 이득을 본다. 손해 볼 일은 거의 없다.
- 지속적 클라이언트 (백엔드 → 백엔드 호출): 무조건 켠다. connection pool 크기는 동시성 수준의 1.5배 정도로 잡으면 안전하다.
- HTTPS 호출 (외부 API 콜): 무조건 켠다. TLS handshake 한 번을 절약하는 것이 RPS 7배의 차이를 만든다.
- mTLS 서비스 메시 (Istio, Linkerd 등): 켜는 것 외에 답이 없다. 켜지 않으면 RPS가 한 자릿수로 떨어진다. 사이드카 설정 점검이 필수다.
HTTP Keep-Alive 사용 시 흔한 함정 5가지
keep-alive를 켰다고 끝이 아니다. 함정이 몇 가지 있다.
- pool 크기 미스매치: MaxIdleConnsPerHost의 기본값은 Go에서 2다. 동시성이 32인데 pool이 2라면 효과가 없다. 30개는 여전히 fresh로 열려야 한다. conc 수준에 맞춰 명시적으로 키워야 한다.
- idle timeout 미스매치: 서버 idle timeout이 클라이언트보다 짧으면, 클라이언트가 살아 있다고 믿는 connection이 서버에서는 이미 닫혀 있다. 다음 요청에서 RST를 받고 재시도해야 한다. 클라이언트 idle timeout < 서버 idle timeout 으로 설정해야 안전하다.
- HTTP/2로 가면 또 다른 이야기다: HTTP/2는 multiplexing이므로 connection 하나로 여러 stream을 동시에 처리한다. 이때 connection 수가 적기 때문에 fresh/keepalive의 차이가 더 극적으로 벌어진다. 다음 글에서 다룰 예정이다.
- TIME_WAIT와 CLOSE_WAIT를 혼동하지 말 것: TIME_WAIT는 능동적으로 닫은 쪽에서 60초 정도 대기한다. ephemeral port를 잡아먹는 것은 TIME_WAIT다. fresh 모드는 클라이언트가 매번 능동 close를 수행하므로 TIME_WAIT가 쌓인다.
- LB 앞단의 keepalive 설정: L7 LB(NGINX, HAProxy, Envoy)를 앞에 두면 LB ↔ upstream 사이의 keep-alive를 별도로 설정해야 한다. 클라이언트 ↔ LB에만 keep-alive를 켜고 LB ↔ upstream이 fresh라면 LB 뒤에서 다시 무너진다. NGINX의 upstream { keepalive 64; }와 같은 설정을 빠뜨리지 말 것.
결론 — 숫자가 명확하다
핵심 takeaway를 다섯 가지로 정리한다.
- HTTP Keep-Alive는 평문에서도 RPS가 2.8배 차이가 난다. 단발 호출이 아니라면 무조건 켜야 한다.
- HTTPS에서는 7.65배, mTLS에서는 9.60배의 차이. TLS가 들어갈수록 keep-alive는 필수가 된다.
- mTLS fresh conc=32는 RPS가 1,053까지 떨어진다. 동일 인프라가 keepalive에서는 11,469를 기록한다.
- p99.9 꼬리지연은 fresh에서 폭주한다. 평균만 보면 속는다. mTLS fresh conc=32는 130ms를 기록한다.
- CPU 비용도 최대 13.6배까지 차이가 난다. 지연만 늘어나는 것이 아니다. 서버 비용도 그대로 늘어난다.
이론 글은 도처에 널려 있지만 한국어로 mTLS 핸드셰이크 비용을 직접 측정한 자료는 거의 없어 18케이스를 모두 실행했다. HTTP Keep-Alive는 옵션이 아니라 디폴트다. 끄는 것이 의식적인 결정이 되어야 하며, 켜는 것이 결정이 되어서는 안 된다.
다음 글에서는 HTTP/2 ALPN과 connection coalescing이 이 그림을 어떻게 바꾸는지 비교할 예정이다. 동일한 mTLS라도 HTTP/2로 가면 또 다른 트레이드오프가 발생하는데, 이는 별도 측정으로 다뤄야 한다.
참고 자료
'Backend' 카테고리의 다른 글
| Bloom Filter로 DB 호출을 99% 차단해보았다 - set·dict·SQLite와 메모리 58MB→1.2MB 직접 비교 (0) | 2026.05.05 |
|---|---|
| Redis 캐시 stampede를 진짜로 막는 법 — single-flight, XFetch, 분산 lock 직접 돌려본 결과 (0) | 2026.05.04 |
| 로그 적재에 큐(Queue)가 진정 필요한가? 5만 건으로 비교했다 (0) | 2026.05.03 |
| SSE 스트림은 어떤 언어가 안정적인가? Go·Python·Node·Rust를 User 1000명까지 굴려본 결과 (0) | 2026.05.03 |
| gRPC vs REST, 내부 통신에는 왜 모두 gRPC를 쓰는가 (0) | 2026.04.28 |
