Keep-Alive와 파이프라이닝 — 영속 연결의 원리와 한계
HTTP/1.1 영속 연결이 절약하는 비용, Connection·Keep-Alive 헤더의 동작, 파이프라이닝이 실패한 이유와 머리줄 블로킹, 브라우저의 6연결 우회와 서버 튜닝까지 해설합니다.
지난 글까지 HTTP의 의미론(메서드, 캐시, CORS)을 다뤘다면, 이번 글부터는 전송 효율 이야기다. 웹 페이지 하나를 여는 데 수십 개의 요청이 나가는데, 요청마다 TCP 연결을 새로 맺는다면 어떻게 될까? HTTP/1.1이 이 문제를 풀기 위해 도입한 영속 연결(Keep-Alive)과, 야심차게 시도했다가 실패한 파이프라이닝, 그리고 그 실패가 남긴 머리줄 블로킹 문제를 정리한다.
연결 하나의 비용
HTTP는 TCP 위에서 동작한다. TCP 연결을 새로 맺으려면 3-way 핸드셰이크에 1 RTT가 들고, HTTPS라면 TLS 핸드셰이크가 추가로 1~2 RTT를 더 쓴다. RTT가 100ms인 모바일 환경이라면 바이트 한 줄 받기도 전에 200~300ms가 사라진다.
비용은 왕복 시간만이 아니다. 새 TCP 연결은 혼잡 윈도(cwnd)가 작은 상태에서 시작해 슬로 스타트로 천천히 속도를 올린다. 연결을 매번 끊으면 매번 이 워밍업을 처음부터 다시 한다. 서버 입장에서도 연결 생성·소멸은 소켓, 메모리, (TLS라면) 키 협상 CPU를 소모한다.
HTTP/1.0의 기본 동작이 정확히 이 최악의 패턴이었다. 요청 하나, 응답 하나, 연결 종료.
Keep-Alive — 연결을 재사용하자
HTTP/1.1은 영속 연결을 기본값으로 뒤집었다. 별도 헤더 없이도 응답 후 연결이 유지되고, 같은 연결로 다음 요청을 보낼 수 있다.
# HTTP/1.0 — 명시적으로 요청해야 유지
GET /a.css HTTP/1.0
Connection: keep-alive
# HTTP/1.1 — 기본이 유지. 끊고 싶을 때만 선언
GET /a.css HTTP/1.1
# 이 응답을 끝으로 연결을 닫겠다는 선언 (어느 쪽이든 가능)
HTTP/1.1 200 OK
Connection: close
Connection 헤더는 홉 단위(hop-by-hop) 헤더라는 점도 기억하자. 클라이언트↔프록시 구간과 프록시↔서버 구간의 연결 유지 여부는 각각 독립적으로 협상된다.
서버 측 타임아웃 튜닝
연결을 무한정 유지하면 유휴 소켓이 서버 자원을 점유한다. 그래서 서버들은 유휴 시간과 요청 수에 상한을 둔다.
# nginx
keepalive_timeout 65s; # 유휴 연결 유지 시간
keepalive_requests 1000; # 연결당 최대 요청 수 (기본 1000)
# 업스트림(백엔드) 방향 keep-alive는 별도 설정
upstream backend {
server 10.0.0.2:8080;
keepalive 32; # 유지할 유휴 연결 풀 크기
}
server {
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # close 전파 방지
}
}
마지막 두 줄이 실무에서 자주 빠지는 부분이다. nginx는 기본으로 백엔드에 HTTP/1.0 + Connection: close로 접속하므로, 리버스 프록시 뒤의 keep-alive는 명시적으로 켜야 한다. 이걸 빼먹으면 프록시↔백엔드 구간에서 요청마다 새 연결이 생기고, 고부하 시 TIME_WAIT 소켓이 폭증한다.
# keep-alive가 실제로 동작하는지 확인 — 같은 연결 재사용 여부
curl -sv https://example.com/a https://example.com/b 2>&1 | grep -i 're-using\|connect'
# * Connected to example.com ...
# * Re-using existing connection with host example.com ← 재사용 확인
직렬 처리의 한계
keep-alive로 연결은 재사용하지만, HTTP/1.1의 요청-응답은 여전히 직렬이다. 요청을 보내면 그 응답을 다 받기 전에는 다음 요청을 보낼 수 없다. 요청 30개면 최소 30 RTT가 순차적으로 쌓인다.
이걸 풀려던 첫 시도가 파이프라이닝이다.
파이프라이닝 — 왜 실패했나
파이프라이닝은 응답을 기다리지 않고 요청 여러 개를 연달아 보내는 기법이다. 이론상 30개 요청의 전송 지연이 1 RTT 수준으로 줄어든다. HTTP/1.1 표준에 들어 있고, 명세상 서버는 지원해야 한다. 그런데 현실에서는 모든 주요 브라우저가 끄고 출시했고, 지금은 구현 자체가 제거됐다. 왜일까?
첫째, 머리줄 블로킹(Head-of-Line Blocking). HTTP/1.1 응답에는 “이건 몇 번째 요청의 응답”이라는 식별자가 없다. 클라이언트는 보낸 순서 그대로 응답이 온다고 가정할 수밖에 없고, 서버도 반드시 순서대로 응답해야 한다. 첫 요청이 느린 DB 쿼리라면, 뒤의 요청들이 이미 처리 완료됐어도 응답을 내보낼 수 없다. 행렬 맨 앞 사람이 막히면 줄 전체가 멈추는 것이다.
둘째, 중간 장비 호환성. 클라이언트와 서버 사이의 오래된 프록시·로드밸런서 중에는 파이프라인된 요청을 잘못 처리하는 구현이 많았다. 응답 순서가 뒤섞이면 다른 사용자의 응답이 내 요청에 매칭되는 수준의 사고가 난다.
셋째, 재시도의 모호함. 파이프라인 중간에 연결이 끊기면, 어디까지 처리됐는지 알 수 없다. GET이야 다시 보내면 되지만, 비멱등 요청이 섞여 있으면 안전한 재시도가 불가능하다. 그래서 명세조차 “비멱등 메서드는 파이프라인하지 말라”고 제한했다.
브라우저의 우회 — 도메인당 6개 연결
파이프라이닝이 막히자 브라우저들은 무식하지만 확실한 방법을 택했다. 연결을 여러 개 열어 병렬화하는 것이다. 현대 브라우저는 호스트당 최대 6개의 동시 TCP 연결을 연다.
요청 30개, 호스트당 6연결:
연결 1: 요청 1 → 7 → 13 → 19 → 25
연결 2: 요청 2 → 8 → 14 → 20 → 26
...
→ 직렬 30 RTT가 병렬로 ~5 RTT 수준까지 단축
여기서 파생된 게 HTTP/1.1 시대의 성능 기법들이다.
- 도메인 샤딩:
img1.example.com,img2.example.com처럼 호스트를 쪼개 6개 제한을 우회 (호스트가 다르면 연결 한도도 별도). - 번들링/스프라이팅: 파일 수 자체를 줄여 요청 수를 줄임 (JS 번들, CSS 스프라이트).
하지만 연결 6개는 근본 해결이 아니다. 핸드셰이크와 슬로 스타트를 6번 치르고, 각 연결 안에서는 여전히 직렬 + 머리줄 블로킹이다. “하나의 연결에서 응답 순서를 섞을 수 있어야 한다”는 숙제는 응답에 식별자를 붙인 HTTP/2의 멀티플렉싱이 풀게 되는데, 이는 HTTP/2를 다루는 글에서 자세히 살펴본다.
정리
기법 | 핸드셰이크 | 요청 병렬성 | 한계
──────────────────────────────────────────────────────────────
비영속 (1.0) | 요청마다 | 없음 | RTT·슬로스타트 낭비
Keep-Alive (1.1) | 1회 | 없음 (직렬) | 연결당 1요청씩 대기
파이프라이닝 | 1회 | 요청만 병렬 | 응답 순서 고정 → HOL
6개 동시 연결 | 6회 | 연결 수만큼 | 자원 낭비, 근본 미해결
HTTP/2 멀티플렉싱| 1회 | 완전 병렬 | (다음 단계의 이야기)
- HTTP/1.1의 기본은 영속 연결이다.
Connection: close가 예외 선언이다. - 리버스 프록시 환경에서는 업스트림 방향 keep-alive를 직접 켜야 한다.
- 파이프라이닝은 응답 식별자가 없는 HTTP/1.1의 구조적 한계(순서 고정 → HOL 블로킹) 때문에 죽었다.
- 브라우저의 호스트당 6연결은 임시방편이고, 진짜 해법은 HTTP/2 멀티플렉싱이다.
다음 글에서는 영속 연결의 전제 조건이 되는 질문 — “응답이 어디서 끝나는지 어떻게 아는가”에 답하는 청크 전송 인코딩을 다룬다.
지난 글: CORS 프리플라이트 완전 정복 — OPTIONS 요청과 인증 정보 규칙
다음 글: 청크 전송 인코딩 완전 정복 — Transfer-Encoding: chunked
읽어주셔서 감사합니다. 😊