웹의 바닥을 받치는 프로토콜인데, HTTP는 1.0, 1.1, 2, 3까지 네 번이나 다시 만들어졌다. 처음 이 흐름을 정리하면서 가장 궁금했던 건 "도대체 매번 뭐가 그렇게 불만이었길래"였는데, 파고들수록 답이 거의 하나로 모였다. HOL 블로킹(Head-of-Line Blocking, 줄 맨 앞 막힘) 이다.
줄을 선 맨 앞 사람이 막히면, 뒤에 선 사람들은 자기 차례가 됐어도 못 움직인다. 은행 창구 하나에 줄 섰는데 앞사람이 복잡한 일로 10분을 잡아먹으면, 통장 정리만 하면 되는 내 차례도 그냥 같이 묶여서 기다리는 상황. HTTP의 역사는 이 "맨 앞 막힘"이 한 계층씩 아래로 내려가고, 그때마다 그걸 잡으러 따라 내려간 기록에 가깝다. 그리고 마지막엔 TCP라는 토대까지 갈아엎는다.
순서대로 따라가 보면, 왜 그렇게까지 했는지가 자연스럽게 이해된다.
들어가기 전에: 용어 두 개만
- TCP / UDP: 데이터를 실제로 인터넷에 흘려보내는 "배송 방식"이다. HTTP는 그 위에 얹혀서 "무엇을 주고받을지"를 정하는 규칙이고, 배송 자체는 아래에 있는 TCP나 UDP가 한다. TCP는 "빠진 거 없이, 순서대로" 책임지고 배달하는 꼼꼼한 택배. UDP는 "일단 던지고 끝", 순서나 분실은 신경 안 쓰는 방식이다.
- RTT (Round-Trip Time): 메시지를 보내고 답이 돌아오기까지의 왕복 시간. 통화로 치면 "여보세요" 하고 "응 들려" 들릴 때까지다. 네트워크에서 이 왕복을 몇 번 하느냐가 곧 체감 속도라, 뒤에서 계속 등장한다.
1.0 — 요청 하나에 연결 하나
HTTP/1.0은 요청할 때마다 TCP 연결을 새로 맺고, 응답을 받으면 바로 끊었다. 이미지 열 개가 박힌 페이지를 열면, 택배기사를 불러서 → 박스 하나 받고 → 돌려보내는 일을 열 번 반복하는 셈이다.
문제는 연결을 새로 맺는 게 공짜가 아니라는 점이다. TCP는 연결 전에 3-way handshake라는 인사를 주고받는다. "연결할게(SYN)" → "그래 하자(SYN-ACK)" → "좋아 시작(ACK)". 통화 시작 전에 "여보세요 / 네 들려요 / 자 시작합시다"를 꼭 한 번 거치는 것과 같다. 이 인사에 RTT가 든다.
즉 1.0은 데이터를 짧게 주고받기 위해 매번 인사 비용을 새로 치렀다. 정작 일보다 인사에 시간을 더 쓰는 비효율이 눈에 보였다.
1.1 — 연결은 재사용했는데, 줄을 서야 했다
1.1은 keep-alive를 도입했다. 한 번 부른 택배기사를 돌려보내지 않고 대기시켜서, 다음 박스도 같은 기사에게 맡기는 방식이다. 인사(handshake)를 매번 새로 안 해도 되니 연결 비용 문제는 거의 사라졌다.
대신 새로운 막힘이 생겼다. 1.1은 한 연결에서 요청을 보낸 순서대로만 응답을 받을 수 있다. 식당에서 주문 세 개를 넣었는데, 1번 음식이 늦게 나오면 2·3번이 다 만들어졌어도 1번 뒤에서 대기하는 것과 같다. 앞 요청(Head of Line)이 막히면 뒤가 통째로 밀린다. 이게 HTTP 레벨 HOL 블로킹이다.
참고: 1.1에도 "파이프라이닝"이라는, 요청을 한꺼번에 몰아 보내는 기능이 있긴 했다. 하지만 응답은 여전히 보낸 순서대로 돌려받아야 해서 막힘이 그대로 남았고, 결국 거의 쓰이지 않았다.
당시 브라우저들은 이걸 우회하려고 한 사이트에 연결을 6개쯤 동시에 열었다. 창구를 6개로 늘려 줄을 나눈 셈인데, 창구마다 똑같이 줄을 서야 하니 근본 해결은 아니었다.
2 — 멀티플렉싱, 그런데 한 층 더 아래에서
HTTP/2의 핵심은 멀티플렉싱(multiplexing) 이다. 연결 하나 위에 스트림(stream) 이라는 독립된 통로를 여러 개 만들고, 요청·응답을 작은 조각(프레임)으로 잘게 쪼갠 뒤 라벨을 붙여 섞어 보낸다.
택배로 비유하면, 박스 세 개를 잘게 부숴서 "이건 1번 짐, 이건 2번 짐" 하고 스티커를 붙인 다음 한 트럭에 섞어 싣는 것이다. 도착해서 스티커별로 다시 모으면 된다. 순서대로 도착할 필요가 없으니, 1번 짐이 좀 늦어도 2·3번 짐은 먼저 처리된다. 1.1의 "줄 서기" 문제가 풀렸다.
여기까진 깔끔하다. 문제는 이 스트림들이 결국 전부 하나의 TCP 연결을 타고 흐른다는 데 있다.
TCP는 "빠진 거 없이 순서대로"가 일이다. 중간 조각 하나가 유실되면, 그 뒤에 멀쩡히 도착한 조각들도 위로 안 올려보내고 빠진 조각이 다시 올 때까지 붙잡아 둔다. 그런데 결정적으로, TCP는 우리가 붙인 스티커(어느 스트림 짐인지)를 볼 줄 모른다. TCP 입장에선 모든 게 그냥 번호 순서대로 흘러야 하는 한 줄짜리 컨베이어 벨트일 뿐이다.
그래서 벨트 중간의 박스 하나(예: 1번 스트림 조각)가 빠지면, 그 뒤에 실려 있던 2번·3번 스트림 조각까지 멀쩡한데도 벨트 위에서 같이 멈춰 선다. HTTP 계층에서 아무리 스티커를 잘 붙여 나눠놔도, 그 밑의 전송 계층이 다시 한 줄로 취급하니 헛수고가 되는 거다. 이게 TCP HOL 블로킹이고, HTTP/2가 끝내 못 넘은 벽이다. 특히 패킷 유실이 잦은 모바일·불안정 네트워크에서 두드러진다.
3 — 그럼 TCP를 안 쓰면 되잖아
HTTP/3의 답은 단순하면서도 과감하다. TCP를 빼고, UDP 위에 올린 QUIC이라는 새 전송 프로토콜을 쓴다.
여기서 자연스러운 의문. UDP는 "순서도 분실도 신경 안 쓴다"고 했는데, 그런 걸 어떻게 믿고 쓰지? 핵심은 이거다. QUIC이 TCP가 하던 신뢰성 일(빠진 조각 다시 받기, 순서 맞추기)을 UDP 위에서 직접, 그것도 스트림 단위로 다시 구현했다는 점이다.
차이를 그림으로 보면 이렇다. TCP는 컨베이어 벨트가 한 줄이라 중간이 막히면 다 멈췄다. QUIC은 벨트를 스트림마다 여러 줄로 나눈다. 1번 줄에서 박스가 빠지면 1번 줄만 잠깐 멈추고, 2·3번 줄은 신경 안 쓰고 계속 흐른다. 전송 계층 차원에서 스트림이 진짜로 독립적이 된 것이고, 그래서 TCP HOL 블로킹이 구조적으로 안 생긴다.
이게 가능한 이유가 흥미롭다. HTTP/2에서는 "어느 스트림 짐인지" 라벨이 HTTP 계층에 있어서, 정작 배송을 맡은 TCP는 그 라벨을 못 봤다. QUIC은 이 스트림 라벨을 아예 전송 계층으로 내려버렸다. 배송 담당이 직접 라벨을 읽으니, 줄을 나눠 처리할 수 있게 된 것이다. (참고로 이 때문에 "HTTP/2를 그냥 QUIC 위에 얹기"는 안 됐다. 스트림 개념이 HTTP와 QUIC 양쪽에 겹쳐 충돌하기 때문이다. 그래서 HTTP는 스트림 관리를 QUIC에 넘기고 HTTP/3로 새로 정리됐다 — 새 버전이 필요했던 진짜 이유다.)
덤으로 따라오는 두 가지도 꽤 크다.
0-RTT 연결 재개. QUIC은 TLS(암호화) 인사를 연결 인사와 하나로 합쳐서, 보통 한 번 왕복(1-RTT)이면 연결이 선다. 게다가 한 번 붙어본 서버라면, 단골 식당에서 "늘 먹던 걸로"가 통하듯 핸드셰이크 왕복 없이 첫 요청부터 데이터를 바로 실어 보낼 수 있다(0-RTT). 재방문 지연이 확 줄어든다. (다만 0-RTT 데이터는 같은 요청이 중복 전송될 수 있는 보안 위험이 있어, 결제처럼 "한 번만 일어나야 하는" 작업엔 쓰지 않는 게 원칙이다.)
연결 마이그레이션. TCP는 연결을 "IP + 포트" 조합으로 식별한다. 그래서 Wi-Fi에서 LTE로 넘어가 IP가 바뀌면 연결이 끊어진다. QUIC은 IP가 아니라 Connection ID라는 별도 회원번호로 연결을 식별한다. 전화번호(IP)가 바뀌어도 회원번호로 같은 사람인 걸 알아보는 셈이라, IP가 바뀌어도 세션이 유지된다. 지하철에서 영상 보다가 네트워크가 전환돼도 안 끊기는 게 이 덕이다. 모바일에서 체감이 제일 큰 부분이다.
표로 정리
| 버전 | 연결 방식 | 전송 계층 | 푼 것 | 남은 것 |
|---|---|---|---|---|
| 1.0 | 요청마다 새 연결 | TCP | — | 매 요청 handshake 비용 |
| 1.1 | keep-alive 재사용 | TCP | 연결 비용 | HTTP 레벨 HOL |
| 2 | 멀티플렉싱 | TCP | HTTP 레벨 HOL | TCP HOL |
| 3 | 멀티플렉싱 | QUIC(UDP) | TCP HOL | UDP 차단 환경, CPU 부하 |
정리하며
쭉 보면 HTTP의 진화는 "맨 앞 막힘"을 한 계층씩 아래로 밀면서 끝까지 쫓아간 과정이다. 연결 비용(1.0→1.1), HTTP 레벨 막힘(1.1→2), TCP 레벨 막힘(2→3). 마지막엔 전송 계층을 통째로 바꿔서라도 잡았다.
물론 3도 공짜는 아니다. UDP를 막아둔 일부 회사·공공 네트워크에선 아예 안 붙을 수 있고, 신뢰성 로직이 OS 커널이 아니라 애플리케이션 쪽에서 돌기 때문에 CPU를 좀 더 먹는 편이다. 그래도 트래픽이 모바일로 쏠리는 지금 흐름에선 연결 마이그레이션과 0-RTT가 주는 값이 분명하다.
다음에 개발자 도구 네트워크 탭에서 프로토콜 칸이 h2인지 h3인지 눈에 들어오면, 그 글자 하나 차이가 어디서 왔는지 한 번쯤 떠올려 봐도 좋겠다.