1. HOL blocking 의 두 종류
HOL(Head-of-Line) blocking 은 먼저 처리되어야 하는 데이터가 지연되면, 그 뒤에 이미 준비된 데이터까지 함께 지연되는 현상이다. 근데 이 현상이 서로 다른 두 레이어에서 각각 발생한다. 이걸 먼저 이해해야 뒷 내용이 헷갈리지 않을 것 같다.
- HTTP 레벨 HOL blocking: 앞 응답이 완성될 때까지 뒤 응답을 보낼 수 없는 문제. HTTP/1.1 의 문제이고, HTTP/2 가 해결했다.
- TCP 레벨 HOL blocking: 패킷 하나가 유실되면, 재전송이 도착할 때까지 그 뒤에 이미 도착한 데이터까지 전부 대기하는 문제. HTTP/2 에 남은 문제이고, HTTP/3 가 해결했다.

그리고 두 경우 모두 "실패"가 아니라 "지연"이다. TCP 는 신뢰성 있는 프로토콜이라 유실된 패킷은 재전송으로 복구된다. 뒤 요청이 실패하는 게 아니라, 복구될 때까지 기다리는 것이다. 처음에는 "앞 요청이 누락되면 뒤 요청이 전부 실패한다"고 잘못 알고 있었다.
이제 버전 순서대로, 각 버전이 어느 HOL 을 해결했는지 보자.
2. HTTP/1.0: 연결당 요청 하나
HTTP/1.0 은 요청 하나마다 TCP 연결을 새로 맺는다. 요청 → 응답 → 연결 종료. 다음 요청은 3-way handshake 부터 다시 시작한다.
왜 연결을 재사용하지 않았을까? 연결 종료 자체가 "응답이 여기까지"라는 신호였기 때문이다.
클라이언트 입장에서 생각해보자. 응답 바이트가 계속 흘러들어오는데, 어디까지가 이번 응답의 바디인지 어떻게 알까? 이걸 message framing(메시지 경계 구분) 문제라고 한다. HTTP/1.0 의 답은 단순했다. 서버가 바디를 다 보내면 연결을 닫고(FIN), 클라이언트는 연결이 닫히는 걸 보고 응답이 끝났다는 걸 알 수 있게 된다.

이 설계에서 연결 재사용은 구조적으로 불가능하다. 응답의 끝 = 연결 닫힘인데, 연결을 유지하면 클라이언트가 영원히 응답의 끝을 모른다. 즉 "연결당 요청 하나"는 별도의 금지 규칙이 아니라 framing 방식의 부산물이다.
HTTP/1.1 은 framing 을 "연결 종료"에서 "메시지 스스로 길이 선언"으로 바꿨다.
Content-Length: 5000은 "바디는 정확히 5,000바이트"라는 선언이다. 클라이언트는 세면서 읽고, 다 세면 끝.Transfer-Encoding: chunked는 길이를 미리 모르는 동적 응답용이다. 조각 단위로 보내고 크기 0 조각으로 끝을 표시한다.
연결 종료라는 신호가 필요 없어졌고, 그제서야 persistent connection(keep-alive)이 기본값이 될 수 있었다. 흔히 "1.1 은 keep-alive 를 도입해서 연결을 재사용한다"고 설명하는데, 정확히는 keep-alive 가 원인이 아니라 framing 문제를 푼 뒤에야 가능해진 결과다.
3. HTTP/1.1: 연결 재사용, 여전한 순차 처리
연결을 재사용하게 됐으니 이제 여러 요청을 연달아 보낼 수 있을까? 아니다. HTTP/1.1 은 연결 하나에서 한 번에 하나의 요청만 처리 중일 수 있다. 요청 → 응답 완료 → 다음 요청. "하나의 연결에서 여러 메시지를 주고받을 수 있다"는 것은 순차 재사용이지, 병렬이 아니다.
그렇다면 이 제약은 어디서 오는 걸까? 처음에는 막연히 TCP 제약이라고 생각했는데, 틀렸다. TCP 는 full-duplex 바이트 스트림이라, 응답을 기다리는 중에도 클라이언트가 다음 요청 바이트를 계속 써 보내는 걸 전혀 막지 않는다. TCP 눈에는 요청이 하나든 열 개든 그냥 바이트 덩어리일 뿐이고, 개수 제한 같은 건 없다.
메시지 포맷의 제약
제약은 HTTP/1.1 메시지 포맷 자체에 있다. 응답 메시지를 보자.
HTTP/1.1 200 OK
Content-Length: 1234
(body...)어디에도 "이 응답은 몇 번째 요청에 대한 것"이라는 ID 가 없다. 응답과 요청을 짝지을 유일한 근거는 연결 위에서의 순서뿐이다. 그래서 스펙이 응답을 요청 순서대로 보내도록 강제할 수밖에 없다. 순서가 곧 식별자이기 때문이다.
또 하나, 응답 하나가 연결을 통째로 점유한다. 응답 A 를 보내는 도중에 응답 B 의 바이트를 끼워 넣을 방법이 포맷상 존재하지 않는다. "지금 이 바이트는 A 것, 다음 바이트는 B 것"이라고 표시할 수단이 없으니까. 즉 interleaving 자체가 불가능한 포맷이다.
pipelining 의 한계
사실 스펙에는 pipelining 이라는 기능이 있었다. 응답을 기다리지 않고 요청 여러 개를 연달아 보내는 것. 근데 요청을 미리 보내둔다고 해서 응답 순서 제약이 사라지는 건 아니다. 위에서 봤듯 응답 메시지에는 어느 요청에 대한 것인지 표시할 식별자가 없어서, 응답은 여전히 요청 순서대로만 보낼 수 있다. 그래서 1번 응답 생성이 3초 걸리면, 2, 3번 응답이 서버에서 이미 준비됐어도 3초를 기다린다.

이게 HTTP 레벨 HOL blocking 이다. 패킷 유실이 하나도 없는 완벽한 네트워크에서도 발생한다는 점에 주목하자. 네트워크 탓이 아니라 순전히 프로토콜 포맷의 제약이다.
pipelining 은 이 HOL 문제 + 중간 프록시 호환성 문제 때문에 브라우저들이 결국 기본 비활성화했다. 그래서 실무에서 HTTP/1.1 의 동시성은 브라우저가 호스트당 TCP 연결을 6개씩 병렬로 여는 것으로 해결했다. 여기에 더해 이미지 서버를 img1.example.com, img2.example.com 으로 쪼개 연결 수를 늘리는 domain sharding 같은 우회책도 등장했다. 프로토콜 차원의 병렬성이 없으니 연결 개수로 보완한 것이다.
4. HTTP/2: stream 기반 multiplexing
순서가 곧 식별자라서 생기는 문제이므로, HTTP/2 는 진짜 식별자를 도입했다.
frame 과 stream ID
HTTP/2 는 메시지를 frame 이라는 작은 단위로 쪼갠다. 그리고 모든 frame 헤더에 stream ID 라는 이름표를 붙인다.

stream 은 요청/응답 교환 1회에 부여되는 논리적 채널이다. /a.css 요청과 그 응답이 stream 1, /b.js 교환이 stream 3, 이런 식. 물리적 실체가 있는 게 아니라 "같은 ID 를 단 frame 들의 묶음"이다. 수신 측이 ID 별로 분류해서 조립하면 마치 독립 채널 여러 개가 있는 것처럼 동작한다.
interleaving
두 버전이 바이트 스트림을 어떻게 채우는지 비교해보자.
[HTTP/1.1: 통짜 메시지를 순서대로 이어 쓰기]
소켓에 쓰인 바이트 스트림 시간 →
┌──────────┬───────────────────┬──────────┬─────────┐
│ A 헤더 │ A 바디 │ B 헤더 │ B 바디 │
└──────────┴───────────────────┴──────────┴─────────┘
└───────── 응답 A (통짜) ──────┘└───── 응답 B ───────┘
※ A 를 끝까지 쓰기 전엔 B 를 시작할 수 없음
※ 수신측은 "위치(순서)"로 어느 요청의 응답인지 판단
[HTTP/2: 이름표 붙은 조각을 끼워 쓰기]
소켓에 쓰인 바이트 스트림 시간 →
┌──────────┬──────────┬────────┬────────┬────────┬────────┐
│HEADERS(1)│HEADERS(3)│DATA(3) │DATA(1) │DATA(3) │DATA(1) │
└──────────┴──────────┴────────┴────────┴────────┴────────┘
A 것 B 것 B 것 A 것 B 것 A 것
※ 준비된 frame 부터 자유로운 순서로 (interleaving)
※ 수신측은 "stream ID"로 분류해서 조립여기서 섞이는 단위는 바이트가 아니라 frame 이다. frame 하나는 중간에 쪼개지지 않고 통째로 쓰이며, 각 frame 이 자기 stream ID 를 달고 있다. 그래서 수신 측이 같은 ID 의 frame 들을 모으면 하나의 stream, 즉 하나의 요청/응답이 복원된다.
응답 A 생성이 3초 걸려도 이제 문제없다. 서버는 그동안 준비된 B, C 의 frame 을 먼저 소켓에 쓰면 된다. 앞의 pipelining 도식에서 본 3초 대기가 사라진다. HTTP 레벨 HOL blocking 해결. 연결도 호스트당 1개면 충분해져서, 연결 6개와 domain sharding 도 필요 없어졌다.
정리하면, multiplexing 의 본질은 "잘게 쪼갬(frame) + 조각마다 이름표(stream ID)"이고, 이는 "순서 = 식별자" 구조를 "ID = 식별자" 구조로 바꾼 것이다.
동시 stream 의 상한
다만 연결당 동시 stream 수가 무제한인 것은 아니다. handshake 직후 교환하는 SETTINGS frame 의 SETTINGS_MAX_CONCURRENT_STREAMS 값으로 협상되며, 대부분의 서버가 100~128 수준으로 설정한다 (nginx 기본값 128).
실무에서 이 값이 체감되는 대표적인 곳이 gRPC 다. gRPC 는 HTTP/2 기반이라, 채널(연결) 하나의 동시 요청이 이 상한을 넘으면 클라이언트 단에서 대기가 발생한다. 트래픽이 많은 서비스에서 gRPC 채널을 여러 개 두는(채널 풀) 이유가 이것이다.
연결 쏠림 문제
연결 수가 줄어든 것이 인프라 관점에서는 새로운 문제를 만들기도 했다. L4 로드밸런서는 연결 단위로 트래픽을 분산하는데, HTTP/2 클라이언트는 연결을 1개만 유지하므로 그 연결이 붙은 서버 한 대로 모든 요청이 몰린다.
Kubernetes 환경에서 gRPC 트래픽이 특정 파드로 쏠리는 문제가 정확히 이것이다. 그래서 gRPC 는 연결이 아닌 요청 단위로 분산할 수 있는 L7 프록시(Envoy 등)나 client-side 로드밸런싱을 함께 사용한다.
5. HTTP/2 의 한계: TCP 레벨 HOL blocking
HTTP 레벨 HOL 은 해결됐지만, TCP 레벨 HOL 이 남아 있다. 위치를 먼저 확인하면, HTTP/2 는 애플리케이션 레이어의 변화이고 그 밑은 여전히 TCP 연결 하나다.
여기서 중요한 사실 하나. 패킷과 HTTP 메시지는 아무 대응 관계가 없다. TCP 는 애플리케이션이 써 넣은 바이트 스트림을 자기 마음대로 MSS(Maximum Segment Size, 보통 ~1460바이트) 단위로 잘라 패킷(세그먼트)에 담는다. 절단면은 frame 경계와 무관하다. 한 패킷에 여러 stream 의 frame 조각이 섞여 담기기도 하고, frame 하나가 패킷 여러 개에 걸치기도 한다.
그리고 TCP 가 하는 유일한 약속은 "써 넣은 바이트를 빠짐없이, 순서 그대로 상대 애플리케이션에 전달한다"이다. 이 순서 보장이 문제를 만든다.

TCP 입장에서는 stream 이고 뭐고 없다. 하나의 바이트 스트림에 구멍이 났으니 못 넘겨주는 것뿐이다. 애플리케이션 레이어에서 아무리 stream 을 분리해놨어도, 전송 레이어가 그 존재를 모르니 유실 하나에 모든 stream 이 같이 멈춘다.
그리고 패킷 유실률이 높은 네트워크에서는 HTTP/2 가 HTTP/1.1 보다 오히려 느릴 수 있다. 1.1 은 연결이 6개라 유실이 나면 6개 중 1개만 멈추는데, 2 는 연결이 1개라 유실 하나에 전부 멈추기 때문이다.
6. HTTP/3: QUIC 의 stream 단위 복구
해법은 전송 계층이 stream 을 직접 아는 것이다. stream 별로 순서 보장을 따로 하면, A 의 구멍이 B 를 막을 이유가 없다.
ossification 과 UDP
문제는 TCP 를 고칠 수 없다는 점이다. TCP 는 전 세계 모든 OS 커널과, 인터넷 경로 곳곳의 중간 장비(NAT, 방화벽, 로드밸런서)에 구현이 박혀 있다. 이 장비들은 TCP 헤더를 들여다보고 심지어 고쳐 쓰기까지 해서, TCP 에 새 기능을 넣으면 어딘가의 장비가 "모르는 형식"이라며 패킷을 버린다. 프로토콜이 굳어서 진화 불가능해진 이 현상을 ossification(경화)이라고 부른다.
그래서 UDP 를 선택했다. "UDP 라서 빠르다"가 아니다. UDP 는 "IP + 포트 번호" 그 이상 아무것도 아닌 프로토콜이라 굳어버릴 내용물 자체가 없고, 이미 전 세계 장비가 통과시켜 준다. 이 위에 재전송, 순서 보장, 혼잡 제어, 그리고 stream 까지 전부 새로 구현한 전송 프로토콜이 QUIC 이고, 그 위의 얇은 HTTP 매핑이 HTTP/3 다.

stream 단위 유실 복구
QUIC 은 순서 보장과 유실 복구를 연결 전체가 아니라 stream 단위로 한다. 각 stream 이 독립된 순번(offset) 체계를 갖는다.
유실의 피해가 해당 stream 하나로 격리된다. 이게 HTTP/3 의 핵심이다.

패킷 번호와 offset 의 분리
QUIC 설계에서 눈여겨볼 부분이 하나 더 있다. TCP 의 시퀀스 번호는 두 역할을 겸직한다. "이 바이트는 스트림의 몇 번째 위치"(데이터의 주소)이면서, 동시에 유실 감지와 ACK 의 기준(발송 추적)이다. 겸직이라 재전송 때 문제가 생긴다.

RTT 는 재전송 타이머와 혼잡 제어의 기초 입력이라 오염되면 실제 문제로 이어진다. RTT 를 실제보다 길게 측정하면 유실 판정이 늦어져 재전송도 늦어지고, 실제보다 짧게 측정하면 아직 도착 중인 패킷을 유실로 오판해서 불필요한 재전송이 발생한다. 혼잡 제어도 잘못된 RTT 를 기준으로 전송 속도를 조절하게 된다. TCP 는 "재전송된 세그먼트의 RTT 샘플은 버린다"는 회피책을 쓰는데, 정작 네트워크가 나쁠 때(= 재전송이 잦을 때) RTT 정보를 못 얻는 아이러니가 있다.
QUIC 은 두 역할을 분리했다. 패킷 번호는 발송 사건의 일련번호로 단조 증가하며 재사용하지 않고, 데이터의 주소는 별도의 stream ID + offset 이 담당한다. 유실된 패킷의 데이터는 "재전송"되는 게 아니라 새 번호의 새 패킷에 다시 실려 간다.

"어떤 패킷이 죽었는가"(발송 장부)와 "어떤 데이터가 어디 것인가"(주소 장부)를 분리한 이 구조가, 위에서 본 "유실 복구를 stream 별로 격리"할 수 있는 기반이기도 하다.
이 외에도 HTTP/3 에는 handshake 를 1-RTT(재방문 시 0-RTT)로 줄인 것, 연결을 IP 가 아닌 Connection ID 로 식별해서 와이파이 → LTE 전환에도 연결이 안 끊기는 것(connection migration) 등이 있는데, 이쪽은 TLS 와 얽혀 있어서 다음 편에서 다루려고 한다.
실무 배포 지형
HTTP/2, 3 이 표준이 된 지 오래지만, 실제 서비스에서 이 프로토콜들이 적용되는 구간은 대부분 브라우저와 CDN/로드밸런서 사이까지다. 로드밸런서와 백엔드 서버 사이(오리진 구간)는 지금도 대부분 HTTP/1.1 keep-alive 로 통신한다. nginx 만 해도 upstream 방향의 HTTP/2 를 지원하지 않는다 (gRPC 프록시 제외).
이유는 이 글의 내용과 그대로 이어진다. 데이터센터 내부망은 저지연·저유실 환경이라 HOL blocking 개선의 이득이 작고, HTTP/1.1 커넥션 풀로 충분하기 때문이다. 즉 1.1 시대의 "연결 여러 개" 전략은 데이터센터 안에서는 여전히 현역이고, 백엔드 서버가 여전히 HTTP/1.1 요청을 받고 있는 것은 이상한 일이 아니다.
7. Spring 에서 만나는 동시성 제약
마지막으로 지금까지의 내용이 Spring 백엔드에서 실제로 드러나는 지점을 정리한다. Spring 세계의 기본 HTTP 버전은 서버도 클라이언트도 거의 전부 HTTP/1.1 이다. 내장 Tomcat/Netty 는 1.1 로 요청을 받고(server.http2.enabled=true 와 TLS 설정으로 HTTP/2 를 켤 수 있지만, 앞서 본 것처럼 h2 를 엣지에서 끝내는 구성이 많아 그대로 두는 경우가 대부분이다), RestTemplate / RestClient / WebClient 의 기본 구성도 모두 1.1 이다. JDK 11 의 java.net.http.HttpClient 정도가 예외적으로 기본이 HTTP/2 다. 즉 이 글에서 본 1.1 의 제약들은 백엔드 코드의 일상에 그대로 적용된다.
커넥션 풀과 동시 요청 수
서버가 다른 서버로 HTTP 호출을 할 때는 요청마다 연결을 새로 맺지 않는다. handshake 비용 때문에(2장), 클라이언트 라이브러리들은 keep-alive 로 살려둔 연결을 커넥션 풀에 보관하고 재사용한다. 풀 속 연결의 생애는 이렇다.
[커넥션 풀 속 연결의 생애]
트래픽 없음: 풀 [ ] ← 연결 0개 (미리 만들지 않음)
첫 요청: 풀 [c1] ← 수요가 생성 (handshake 1회)
동시 요청 5건: 풀 [c1, c2] + 대기 3건 ← 상한(여기선 2)까지 성장, 초과는 대기
꾸준한 트래픽: 풀 [c1, c2] 계속 재사용 ← handshake 없이 수천 건 처리
한산한 새벽: idle timeout 경과 → 풀 [ ] ← 시간이 축소
다음 첫 요청: 다시 handshake 부터규칙은 성장은 수요가, 축소는 시간이 결정한다로 요약된다.
- 생성: 미리 만들지 않는다. 빌릴 연결이 없고 상한 미만일 때 그때 만든다.
- 정리: 일정 시간 안 쓰인 연결은 idle timeout 으로, 오래된 연결은 최대 수명(TTL)으로 닫는다. TTL 은 오래 사는 연결이 DNS 를 재조회하지 않아 상대 재배포 시 옛 인스턴스에 트래픽이 고착되는 문제를 막는 장치다.
- 주의: 상대 서버와 중간 LB 도 각자의 timeout 으로 연결을 끊는다. 클라이언트의 유휴 유지 시간이 더 길면 이미 죽은 연결을 재사용하다 간헐적인 connection reset 을 만나므로, 상대보다 짧게 잡는다.
여기서 3장의 내용이 그대로 적용된다. 연결 하나는 시간축으로는 수천 건의 요청을 나르지만, HTTP/1.1 이므로 어느 한 순간에 싣고 있는 요청은 1개다. 그래서 풀은 연결을 스레드에게 통째로, 배타적으로 빌려주고 반납받는다. 결국 다운스트림으로의 동시 요청 수 = 풀의 연결 수라는 등식이 성립한다. 브라우저의 "연결 6개"에 해당하는 숫자가, 백엔드에서는 내가 쓰는 클라이언트의 풀 상한 설정인 것이다.
이 등식이 부하 상황에서 어떻게 동작하는지 보자. 풀 상한이 2, 다운스트림 응답 시간이 100ms, 유입이 초당 50건이라고 하면:
[연결 2개에 요청이 몰릴 때]
유입: 초당 50건 커넥션 풀 (상한 2)
│ ┌───────────────────┐
├─▶ 풀 대기 큐 │ c1 ──▶ 다운스트림 │ 100ms/건
│ (초당 +30건씩 자람, │ c2 ──▶ 다운스트림 │ 100ms/건
│ 스레드째 블로킹) └───────────────────┘
▼ 처리량 상한: 2 ÷ 0.1s = 초당 20건
워커 스레드 고갈
→ 다운스트림과 무관한 요청까지 전체 장애로 전파동시에 나가는 요청은 2건뿐이고, 처리량 상한은 2 ÷ 0.1초 = 초당 20건으로 고정된다. 유입이 이를 넘는 순간부터 초과분은 실패하는 게 아니라 풀 앞에서 스레드째 대기한다. 문제는 그 스레드가 톰캣 워커 스레드라는 것. 대기가 쌓이는 만큼 워커 스레드가 잠기고, maxThreads 가 소진되면 다운스트림 호출과 무관한 요청까지 전부 처리 불가가 된다. 다운스트림 하나의 지연이 서비스 전체 장애로 승격되는 전형적인 경로다.
대응은 세 층으로 정리할 수 있다.
- 용량 산정: 필요한 연결 수를 목표 처리량 × 다운스트림 응답 시간으로 역산 (초당 50건 × 0.1초 = 최소 5개 + 여유분). 애초에 대기가 생기지 않게 하는 것이 첫 번째.
- 풀 대기 타임아웃 설정: connect timeout, read timeout 과 별개로 "풀에서 연결을 빌릴 때까지"의 제한인 connection request timeout 이 있다. 즉시 거절이 아니라 대기 시간의 상한선이고, 부하 처리 전략이 아니라 스레드 고갈로 인한 전파를 차단하는 안전장치다. 이 세 번째 타임아웃의 존재를 모르면 위 시나리오를 막을 방법이 없다.
- 실패 후처리 설계: 타임아웃으로 끊어낸 요청의 처리 방식은 상황에 따라 다르다. 멱등 요청이면 재시도, 대체 응답(캐시나 기본값)으로 폴백, 다운스트림 장애가 원인이면 서킷브레이커로 호출 차단.
그리고 HTTP/2 라면 이 산수 자체가 바뀐다. 연결당 동시 요청이 1개가 아니라 stream 상한(기본 ~100)만큼이라, 연결 2개로도 동시 200건이 가능하다. 4장에서 언급한 gRPC 채널이 이 모델로 동작한다.
요청당 스레드 모델과 HTTP/2
서블릿 컨테이너의 "요청 하나 = 워커 스레드 하나" 모델도 HTTP/1.1 의 제약 위에 세워진 설계다. 연결당 요청이 한 번에 하나씩만 들어오니, 서버는 "동시 요청 수 ≈ 연결 수"라는 산수를 전제로 스레드풀을 산정할 수 있었다. 프로토콜의 제약이 서버에게는 무료 방어막이었던 셈이다.
HTTP/2 는 이 방어막을 걷어낸다. 연결 하나로 활성 요청 100개가 동시에 들어올 수 있으므로, 이론상 클라이언트 한 명이 연결 하나로 워커 스레드 100개를 점유할 수 있다. 스레드풀 산정의 단위가 연결에서 stream 으로 바뀐 것이다.
프로토콜의 동시성 모델이 바뀌면, 그 위에 세운 서버 모델의 전제도 함께 바뀐다. 이 글에서 따라온 1.0 → 1.1 → 2 → 3 의 변화가 프로토콜 안에서 끝나는 게 아니라, 커넥션 풀 설정과 스레드풀 산정 같은 백엔드의 일상적인 결정에까지 이어져 있는 것이다.
8. 정리
| 버전 | 연결당 동시 요청 | HTTP 레벨 HOL | TCP 레벨 HOL | 동시성 확보 방법 |
|---|---|---|---|---|
| 1.0 | 1개 후 종료 | 해당 없음 | 있음 | 연결 여러 개 |
| 1.1 | 1개 (순차 재사용) | 있음 | 있음 | TCP 연결 6개 병렬 |
| 2 | 협상된 상한까지 (기본 ~100개) | 해결 | 있음 | 연결 1개로 충분 |
| 3 | 협상된 상한까지 | 해결 | 해결 (stream 단위 복구) | 연결 1개 |
한 줄로 압축하면,
1.1 은 맑은 날에도 줄을 서고 (포맷 탓), 2 는 맑은 날엔 자유롭지만 비 오는 날(패킷 유실)엔 전원이 같이 멈추고 (TCP 탓), 3 은 비 와도 젖은 stream 만 멈춘다 (QUIC).
각 단계의 교훈을 다시 짚으면,
- HTTP/1.0 → 1.1: "연결당 하나"의 원인은 handshake 비용이 아니라 framing 이었다
- HTTP/1.1 → 2: "한 번에 하나"의 원인은 TCP 가 아니라 ID 없는 메시지 포맷이었다
- HTTP/2 → 3: 마지막 병목은 애플리케이션이 아니라 stream 을 모르는 전송 계층이었고, TCP 를 못 고치니 UDP 위에 새로 지었다
매 단계의 핵심은 문제가 어느 레이어에 있는지 정확히 짚는 것이었다. "1.1 의 병렬성은 TCP 레이어 때문"이라고 뭉뚱그려 알고 있었다면, 레이어를 나눠서 다시 정리해보는 걸 추천한다.
다음 편에서는 이 글에서 미뤄둔 TLS 를 다룬다. QUIC 이 handshake 를 1-RTT 로 줄일 수 있었던 이유가 TLS 1.3 과의 통합에 있어서, TLS handshake 가 어떤 문제를 어떻게 푸는지 기초 개념부터 정리할 예정이다.