HTTP/1.1 심화 — 지속 연결·파이프라이닝·HOL 블로킹
HTTP/1.1의 지속 연결(keep-alive)과 Keep-Alive 타임아웃·max, 실패로 끝난 파이프라이닝, 연결당 6개 제한과 도메인 샤딩, 그리고 HTTP/2를 부른 HOL 블로킹의 정체를 nginx 설정 예시와 함께 해설합니다.
지난 글에서 HTTP/1.0과 1.1의 차이를 정리하며 1.1이 기본으로 켠 지속 연결이 핵심 변화라고 짚었다. 그런데 지속 연결만으로는 충분하지 않았다. 1.1은 하나의 연결을 재사용하는 데까지는 성공했지만, 그 연결 위에서 요청을 진짜로 동시에 처리하지는 못했다. 이 글은 지속 연결의 동작과 한계, 그 한계를 넘으려다 실패한 파이프라이닝, 브라우저들이 짜낸 워크어라운드, 그리고 결국 HTTP/2를 불러온 HOL(Head-of-Line) 블로킹까지를 차례로 풀어낸다.
지속 연결 — 연결을 끊지 않는다
HTTP/1.0은 요청 하나마다 TCP 연결을 새로 열고 응답이 끝나면 닫았다. 매 요청마다 TCP 3-way 핸드셰이크(+TLS라면 추가 왕복)를 반복하니, 작은 리소스 수십 개를 받는 웹 페이지에서는 핸드셰이크 비용이 전송 비용을 압도했다.
HTTP/1.1은 연결을 기본으로 유지한다. 한 번 연 연결로 여러 요청·응답을 주고받고, 끝낼 때만 명시적으로 닫는다.
GET /style.css HTTP/1.1
Host: example.com
Connection: keep-alive
HTTP/1.1 200 OK
Content-Length: 1024
Connection: keep-alive
1.1에서는 Connection: keep-alive가 사실상 기본값이라 생략해도 유지된다. 반대로 연결을 끊고 싶을 때 Connection: close를 보낸다. 응답 헤더에 Connection: close가 오면 “이 응답을 마지막으로 닫는다”는 뜻이다.
GET /last HTTP/1.1
Host: example.com
Connection: close
지속 연결에서 한 가지 전제가 중요하다. 응답의 끝을 알 수 없으면 연결을 재사용할 수 없다. 그래서 1.1은 Content-Length 또는 Transfer-Encoding: chunked로 메시지 경계를 명확히 표시한다. 길이를 모르면 클라이언트는 어디까지가 응답인지 알 수 없고, 결국 연결을 닫아 끝을 표시할 수밖에 없다.
Keep-Alive 타임아웃과 max
연결을 영원히 열어두면 서버 자원이 고갈된다. 유휴 연결도 소켓·메모리를 점유하기 때문이다. 그래서 keep-alive에는 두 가지 제한이 따라붙는다.
- timeout — 마지막 요청 이후 이 시간 동안 새 요청이 없으면 연결을 닫는다.
- max — 이 연결로 처리할 수 있는 요청 개수의 상한. 도달하면 닫는다.
Keep-Alive 헤더로 서버가 자신의 정책을 알려줄 수 있다.
HTTP/1.1 200 OK
Connection: keep-alive
Keep-Alive: timeout=5, max=100
여기서 timeout=5는 5초 유휴 후 닫음, max=100은 이 연결로 100개까지 처리 후 닫음을 뜻한다. 클라이언트는 이 값을 보고 “곧 닫힐 연결”에 새 요청을 밀어 넣지 않도록 조율한다.
운영에서는 이 값들의 균형이 중요하다. timeout이 너무 짧으면 재연결 비용이 다시 늘고, 너무 길면 유휴 연결이 쌓여 동시 접속 수를 잡아먹는다. nginx의 keep-alive 관련 설정은 다음과 같다.
http {
# 클라이언트 ↔ nginx: 유휴 연결 75초 유지, 연결당 최대 1000요청
keepalive_timeout 75s;
keepalive_requests 1000;
upstream backend {
server 10.0.0.10:8080;
# nginx ↔ 업스트림: 유휴 연결 풀 32개 유지
keepalive 32;
}
server {
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1; # 업스트림 keep-alive에 필수
proxy_set_header Connection ""; # close 헤더 제거
}
}
}
업스트림으로의 keep-alive는 특히 주의할 점이 있다. proxy_http_version 1.1을 명시하고 Connection 헤더를 비워야 한다. 기본값(1.0)이거나 Connection: close가 그대로 전달되면 매 요청마다 백엔드 연결이 새로 열려 keep-alive가 무력화된다.
파이프라이닝 — 좋은 아이디어, 나쁜 결말
지속 연결은 연결 재사용은 해결했지만 여전히 요청-응답이 한 번에 하나씩 오간다. 요청을 보내고 응답이 다 올 때까지 기다린 뒤에야 다음 요청을 보낸다. 왕복 지연(RTT)이 그대로 쌓인다.
파이프라이닝은 이 대기를 없애려는 시도였다. 응답을 기다리지 않고 요청을 연속으로 먼저 다 보내 버린다.
GET /a.css HTTP/1.1
Host: example.com
GET /b.js HTTP/1.1
Host: example.com
GET /c.png HTTP/1.1
Host: example.com
세 요청을 연달아 보내면 RTT 한 번에 세 요청이 서버에 도착한다. 이론상으로는 멋지다. 그런데 결정적인 제약이 있다. 응답은 요청 순서 그대로, 순서대로 와야 한다. HTTP/1.1에는 응답에 “이건 몇 번째 요청에 대한 답”이라고 표시할 식별자가 없다. 클라이언트는 보낸 순서대로 응답이 온다고 가정할 수밖에 없다.
이 순서 보장 요구가 파이프라이닝을 무너뜨렸다.
- HOL 블로킹 —
a.css생성이 오래 걸리면,b.js·c.png가 서버에서 이미 준비됐어도 먼저 보낼 수 없다. 앞 응답이 끝나야 뒤 응답을 내보낼 수 있다. - 프록시·서버 버그 — 중간 프록시나 일부 서버가 파이프라인된 요청을 잘못 처리하거나, 순서를 어겨 응답을 섞어 보내는 사례가 흔했다.
- 재시도의 어려움 — 연결이 끊기면 어디까지 처리됐는지 알기 어려워 멱등하지 않은 요청의 재시도가 위험했다.
결국 주요 브라우저들은 파이프라이닝을 기본으로 비활성화했다. 켤 수 있는 옵션이 있어도 실제로는 거의 쓰이지 않았다. 파이프라이닝은 명세에는 살아 있지만 현실에서는 죽은 기능이 됐다.
연결당 6개 제한과 도메인 샤딩
파이프라이닝이 막히자 브라우저는 다른 길로 갔다. 연결을 여러 개 동시에 여는 것이다. 요청을 한 연결에 쌓는 대신, 연결을 늘려 병렬화한다.
다만 무한정 열 수는 없다. 서버·네트워크 부담 때문에 브라우저는 호스트(origin)당 동시 연결을 보통 6개로 제한한다. 한 도메인에서 받을 리소스가 6개를 넘으면 나머지는 큐에서 대기한다.
여기서 나온 워크어라운드가 도메인 샤딩이다. 정적 리소스를 여러 서브도메인에 흩어 놓으면, 브라우저는 각 호스트마다 6개씩 따로 연결을 연다.
<!-- 같은 서버를 가리키지만 호스트명을 쪼개 6×N 병렬화 -->
<img src="https://img1.example.com/a.png">
<img src="https://img2.example.com/b.png">
<link href="https://static.example.com/app.css">
img1, img2, static이 모두 같은 서버라도 브라우저 눈에는 별개 호스트라 각각 6개씩 연결을 연다. 동시성이 늘어난다.
하지만 이건 비용이 만만찮은 꼼수다. 호스트가 늘면 그만큼 DNS 조회·TCP 핸드셰이크·TLS 핸드셰이크가 늘고, 각 연결의 TCP 혼잡 제어가 따로 워밍업되어 대역폭을 효율적으로 못 쓴다. 작은 이미지를 한 장으로 합치는 CSS 스프라이트, 파일을 하나로 묶는 번들링도 같은 뿌리에서 나온 회피책이었다. 모두 “요청 수를 줄이거나 연결 수를 늘려서” 1.1의 동시성 한계를 우회하려는 시도였다.
HOL 블로킹 — 1.1의 근본 한계
지금까지 두 번 등장한 HOL(Head-of-Line) 블로킹을 정리하자. 정의는 간단하다. 줄의 맨 앞 항목이 처리되지 않으면 그 뒤가 모두 막히는 현상이다.
HTTP/1.1에서 HOL 블로킹은 두 층위에서 나타난다.
- 단일 연결 안에서 — 파이프라이닝이든 순차 요청이든, 한 연결은 한 번에 하나의 응답만 흘린다. 앞 응답이 느리면(느린 DB 쿼리, 큰 파일) 뒤에 대기 중인 빠른 응답들이 전부 묶인다.
- 연결 6개를 써도 — 병렬 6개로 완화는 되지만 근본 해결은 아니다. 받을 리소스가 50개면 여전히 6개씩 끊어 처리해야 하고, 각 연결 내부에서는 똑같이 HOL 블로킹이 발생한다.
핵심은 이것이 애플리케이션 레벨의 HOL 블로킹이라는 점이다. 한 연결이 응답을 직렬로만 내보낼 수 있기 때문에 생기는, HTTP/1.1 프로토콜 구조 자체의 한계다. 도메인 샤딩·스프라이트·번들링은 모두 이 한계를 회피하려는 임시방편이었을 뿐, 프로토콜이 동시성을 직접 지원하지는 못했다.
왜 HTTP/2가 필요했나
정리하면 HTTP/1.1의 성능 모델은 이런 모순에 갇혀 있었다.
지속 연결로 연결 재사용 ✓
→ 하지만 한 연결은 응답을 직렬로만 처리
파이프라이닝으로 동시 전송 시도 ✗
→ 순서 보장 요구 + HOL 블로킹으로 사실상 폐기
연결 6개 + 도메인 샤딩으로 우회
→ 핸드셰이크·혼잡제어 중복 비용, 근본 해결 아님
리소스 수가 폭발하는 현대 웹 페이지에서 “연결당 직렬, 호스트당 6개”라는 제약은 명백한 병목이었다. 회피책을 아무리 쌓아도 프로토콜이 단일 연결에서 진짜 동시성을 지원하지 않는 한 한계가 분명했다.
그래서 필요한 것은 새 워크어라운드가 아니라 새 프로토콜이었다. 하나의 연결 위에서 여러 요청·응답을 동시에 인터리빙해 흘리고, 응답마다 식별자를 붙여 순서에 묶이지 않게 하는 것 — 바로 HTTP/2의 멀티플렉싱이다. 도메인 샤딩도 스프라이트도 필요 없어지는 그 변화를 다음 글에서 본다.
지난 글: HTTP/1.0 vs HTTP/1.1 — 무엇이 달라졌나
다음 글: HTTP/2 멀티플렉싱 — 하나의 연결로 동시에
읽어주셔서 감사합니다. 😊