HTTP는 어떻게 빨라졌나

웹 페이지 하나를 열면 HTML만 오지 않습니다. CSS, 자바스크립트, 이미지, 폰트까지 수십 개의 리소스를 함께 받아야 화면이 완성됩니다.

HTTP 버전마다 달라진 것은 이 리소스를 나르는 방식입니다. 목표는 늘 같았습니다. 기다리는 시간을 줄이는 것입니다.

DevTools Network 탭의 Waterfall

각 버전이 언제 나왔는지 먼저 큰 흐름만 보겠습니다.

  1. 1991
    HTTP/0.9한 줄 요청
  2. 1996
    HTTP/1.0헤더·상태 코드
  3. 1997
    HTTP/1.1keep-alive
  4. 2015
    HTTP/2멀티플렉싱
  5. 2022
    HTTP/3QUIC

HTTP/0.9, 한 줄짜리 프로토콜#

1991년의 첫 버전에는 버전 번호가 없었습니다. 뒤에 나온 버전과 구분하려고 나중에 0.9로 불리게 됐습니다. 정식 RFC조차 없이 명세라고 할 만한 건 1991년 당시 구현을 적어 둔 W3C 문서 한 장뿐입니다.

The client sends a document request consisting of a line of ASCII characters terminated by a CR LF (carriage return, line feed) pair. […] The response to a simple GET request is a message in hypertext mark-up language ( HTML ).

The Original HTTP as defined in 1991, W3C

요청은 한 줄이고 쓸 수 있는 메서드는 GET 하나입니다. 이미 서버에 연결한 뒤 보내는 것이라 프로토콜과 호스트, 포트는 적지 않습니다.

GET /mypage.html

응답은 HTML 본문이 전부입니다.

<html>
A very simple HTML page
</html>

헤더가 없으니 콘텐츠 타입을 알릴 방법이 없어 HTML 외의 형식은 보낼 수 없습니다. 상태 코드도 없어서 오류가 나면 설명을 담은 HTML 파일을 만들어 그대로 내려보냈습니다.

받을 리소스가 HTML 하나뿐이니 여러 파일을 어떻게 나눠 받을지의 문제는 아직 없습니다. 이 문제는 페이지가 이미지와 스타일을 갖추기 시작한 다음 버전부터 생겨납니다.

HTTP/1.0, 요청 하나에 연결 하나#

1996년의 HTTP/1.0(RFC 1945)은 헤더와 상태 코드, 버전 표기를 도입했습니다. Content-Type 덕분에 이미지와 스타일시트도 전송할 수 있게 됐습니다. 그러면서 페이지 하나를 그리는 데 필요한 파일도 여러 개로 늘었습니다. 여기서부터 "여러 리소스를 어떻게 나를 것인가"라는 물음이 시작됩니다.

The server responds with a status line, including the message's protocol version and a success or error code, followed by a MIME-like message containing server information, entity metainformation, and possible body content.

RFC 1945, §1.3

연결 방식은 단순합니다. 요청 하나마다 TCP 연결을 새로 맺고 응답을 받으면 바로 끊습니다. 리소스가 10개면 연결을 10번 맺습니다.

[연결1] SYN → SYN-ACK → ACK → 요청 → 응답 → 종료
[연결2] SYN → SYN-ACK → ACK → 요청 → 응답 → 종료
[연결3] ...

3-way handshake는 왕복(RTT)을 한 번 더 씁니다. 리소스가 늘어날수록 실제 데이터를 주고받기 전에 handshake로 쓰는 시간이 쌓입니다.

아래 워터폴은 DevTools 네트워크 탭을 시각화한 것입니다. 회색 막대는 연결 수립, 색이 있는 막대는 실제 전송입니다. 연결은 두 개까지 동시에 여는 것으로 잡았습니다. 한쪽 응답이 끝나 연결이 닫혀야 그 자리에 다음 연결이 들어섭니다. font.woff2hero.webp 전송이 끝날 때까지 handshake를 시작하지 못하는 이유입니다.

HTTP/1.0
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립 전송
2.7

회색 막대가 차지하는 비중이 곧 handshake로 낭비하는 시간입니다. 다음 버전의 과제는 이 회색을 줄이는 것입니다.

HTTP/1.1, 연결 재사용#

이듬해 나온 HTTP/1.1(RFC 2068, 이후 널리 쓰인 RFC 2616으로 개정)은 연결 재사용(keep-alive)을 기본값으로 삼았습니다. 한 번 맺은 TCP 연결을 끊지 않고 여러 요청에 재사용하므로 handshake 비용을 매번 치르지 않습니다. 다만 한 연결에서는 여전히 응답을 받아야 다음 요청을 보낼 수 있습니다.

A significant difference between HTTP/1.1 and earlier versions of HTTP is that persistent connections are the default behavior of any HTTP connection.

RFC 2616, §8.1.2

이 순차 처리를 깨려고 파이프라이닝(pipelining)도 도입됐습니다. 응답을 기다리지 않고 요청을 연달아 보내는 방식입니다. 다만 서버가 요청받은 순서대로만 응답해야 한다는 제약이 있었습니다.

A server MUST send its responses to those requests in the same order that the requests were received.

RFC 2616, §8.1.2.2

앞선 요청의 응답이 느리면 뒤따르는 응답이 전부 대기합니다. 이를 HOL 블로킹(Head-of-Line Blocking)이라고 부릅니다. 결국 파이프라이닝은 대부분의 브라우저에서 기본 비활성화됐습니다. 한 연결은 한 번에 요청 하나인 채로 남았습니다. 실무에서는 다른 방법을 썼습니다.

  • 여러 연결 열기: 브라우저는 한 도메인에 6개 정도의 TCP 연결을 병렬로 열어 리소스를 나눠 받았습니다.
  • 도메인 샤딩: 리소스를 여러 서브도메인에 흩어 놓아 병렬 연결 수를 더 늘렸습니다.

6개는 명세가 정한 개수가 아닙니다

RFC 2616 §8.1.4는 오히려 연결을 두 개로 묶으라고 권고했습니다. "A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy." 서버와 중간 프록시의 부담을 줄이려는 기준이었습니다.

브라우저들은 이 권고를 지키지 않았습니다. 두 개로는 페이지가 너무 느렸습니다. 2014년 RFC 7230 §6.4은 결국 이 숫자를 명세에서 뺐습니다. 특정 상한을 못박는 것이 현실에 맞지 않는다고 인정하고, 연결을 열 때 보수적으로 판단하라는 원칙만 남겼습니다.

아래 워터폴은 앞 절과 비교하기 쉽도록 연결을 계속 두 개로 두었습니다.

HTTP/1.1
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립 전송
1.9

두 연결의 첫 요청에만 회색이 남고 이후로는 같은 연결을 재사용합니다. handshake 비용은 줄었지만 "한 연결에 한 번에 하나"라는 제약과 HOL 블로킹은 그대로입니다. 브라우저가 연결을 6개씩 여는 것도, 도메인을 쪼개는 것도 결국 이 제약을 우회하려는 임시방편이었습니다.

HTTP/2, 하나의 연결에 여러 스트림#

18년 만인 2015년에 나온 HTTP/2(RFC 7540)는 연결을 여러 개 여는 대신 하나의 연결 안에서 여러 요청을 동시에 처리합니다. 이 방식을 멀티플렉싱(multiplexing)이라고 부릅니다. 백지에서 나온 것은 아닙니다. 구글이 2009년에 공개한 뒤 자사 서비스에 적용하며 검증한 SPDY 프로토콜이 사실상의 초안이 됐습니다. 그 아이디어를 표준으로 다듬은 것이 HTTP/2입니다.

HTTP/2 enables a more efficient use of network resources and a reduced perception of latency by introducing header field compression and allowing multiple concurrent exchanges on the same connection.

RFC 7540, Abstract

HTTP/1.x까지 텍스트였던 메시지를 바이너리 프레임(frame) 단위로 쪼개고 각 프레임에 어느 스트림(stream)에 속하는지 번호를 붙입니다. 여러 스트림의 프레임을 한 연결에 뒤섞어 보내도 받는 쪽에서 번호로 다시 조립할 수 있습니다.

스트림 1 · index.html 하나의 TCP 연결 스트림 3 · style.css 스트림 5 · hero.webp 프레임 1 3 5 1 5 3 5

응답 순서에 얽매이지 않으므로 애플리케이션 레벨의 HOL 블로킹이 사라집니다. 브라우저가 연결을 6개씩 열거나 도메인을 샤딩할 이유도 없어집니다. 다른 개선도 함께 들어왔습니다.

  • 헤더 압축(HPACK): 매 요청 반복되는 헤더(쿠키, User-Agent 등)를 압축하고 중복을 참조로 대체합니다.
  • 스트림 우선순위: 어떤 리소스를 먼저 받을지 지정할 수 있습니다.
  • 서버 푸시: 요청하지 않은 리소스도 서버가 미리 보냅니다. 실효성 논란으로 이후 사실상 폐기됐습니다.
HTTP/2
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립 전송 재전송 대기
1.2

여섯 리소스가 하나의 연결 위에서 나란히 전송됩니다. handshake는 한 번뿐이고 순서를 기다리는 구간도 없습니다. 그러나 문제는 한 층 아래에 남아 있습니다.

TCP 레벨에 남은 HOL 블로킹#

멀티플렉싱으로 여러 스트림을 한 TCP 연결에 실어도 TCP는 이 데이터를 하나의 순서 있는 바이트 흐름으로 봅니다. 중간에 패킷 하나가 유실되면 재전송돼 도착할 때까지 뒤에 온 모든 패킷이 대기합니다. 유실과 무관한 다른 스트림의 데이터까지 함께 멈춥니다.

아래 워터폴은 패킷 유실을 켠 상태입니다. 여섯 스트림이 같은 지점에서 동시에 멈춥니다. 체크박스를 끄면 앞의 것과 같아집니다.

HTTP/2
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립 전송 재전송 대기
1.6

연결을 하나로 합친 것이 패킷 손실에는 오히려 불리하게 작용합니다. 스트림을 나눈 것은 애플리케이션 레벨이지만 정작 데이터를 나르는 TCP는 그 구분을 모르기 때문입니다. TCP를 쓰는 한 애플리케이션 레벨에서는 해결할 수 없는 문제였습니다.

HTTP/3, QUIC으로 전송 계층 교체#

2022년 표준이 된 HTTP/3(RFC 9114)는 전송 계층을 바꿨습니다. TCP 대신 QUIC(RFC 9000) 위에서 동작합니다. QUIC은 UDP 기반입니다. HTTP/2가 SPDY에서 왔듯 QUIC도 구글이 먼저 배포해 검증한 뒤 IETF가 표준으로 다듬었습니다. 현장에서 먼저 굴려 보고 표준이 뒤따르는 흐름이 두 번 반복된 셈입니다.

By providing reliability at the stream level and congestion control across the entire connection, QUIC has the capability to improve the performance of HTTP compared to a TCP mapping.

RFC 9114, §1.2

HTTP/2 HTTP/3 TLS TCP IP QUIC (TLS 1.3 내장) UDP IP

QUIC은 스트림을 전송 계층에서 직접 다룹니다. 각 스트림이 독립적이라 한 스트림에서 패킷이 유실돼도 다른 스트림은 영향받지 않습니다. TCP 레벨의 HOL 블로킹이 사라집니다.

  • 연결 수립이 빠름: TCP handshake와 TLS handshake를 하나로 합쳐 연결과 암호화 협상을 함께 처리합니다. 재접속 시에는 0-RTT로 데이터를 곧바로 보낼 수 있습니다. 다만 0-RTT로 보낸 데이터는 재전송 공격에 노출될 수 있어 상태를 바꾸지 않는 요청에만 씁니다.
  • 연결 마이그레이션: Connection ID로 연결을 식별해 Wi-Fi에서 셀룰러로 네트워크가 바뀌어도 연결이 끊기지 않습니다.
  • 암호화 기본 내장: TLS 1.3이 프로토콜에 통합돼 있습니다.

아래 워터폴에서 패킷 유실을 켜 보면 HTTP/2와 달리 유실이 난 스트림 하나만 멈추고 나머지 다섯 스트림은 그대로 진행합니다.

HTTP/3
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립 전송 재전송 대기
1.0

UDP는 신뢰성이 없는 프로토콜입니다. 그 위에서도 안정적인 전송이 가능한 건 QUIC이 재전송·순서 보장·혼잡 제어처럼 TCP가 하던 일을 UDP 위에 다시 구현했기 때문입니다. 커널에 박혀 바꾸기 어려운 TCP 대신, 애플리케이션 영역에서 자유롭게 개선할 수 있는 UDP를 밑바탕으로 고른 것입니다.

함께 정리된 명세#

버전이 늘면서 명세 자체도 복잡해졌습니다. 무엇이 어느 문서에 있는지 흩어져 있었고 같은 내용이 버전마다 반복됐습니다. 2022년, IETF는 HTTP 명세를 통째로 다시 정리했습니다.

핵심은 버전과 무관한 부분과 버전별 전송 형식을 분리한 것입니다. 메서드·상태 코드·헤더처럼 모든 버전이 공유하는 의미론(semantics)과 캐싱을 별도 문서로 모으고 그 위에 각 버전의 전송 방식만 따로 얹었습니다.

  • RFC 9110: HTTP Semantics, 버전과 무관한 공통 규칙
  • RFC 9111: HTTP Caching
  • RFC 9112: HTTP/1.1, 텍스트 기반 전송
  • RFC 9113: HTTP/2, 바이너리 프레임과 HPACK
  • RFC 9114: HTTP/3, QUIC 위의 전송

9110~9112가 이전의 7230~7235를, 9113이 7540을 대체했습니다. 7230~7235 자체가 2014년에 RFC 2616을 쪼개 정리한 문서들이고, 9114는 HTTP/3의 첫 명세라 대체한 문서가 없습니다. 지금 HTTP를 인용할 때는 이 9110번대를 봐야 합니다. 오래 인용돼 온 RFC 2616은 이미 대체된 문서입니다.

버전별 최신 명세와 진행 중인 초안은 HTTP 워킹 그룹의 명세 목록에 모여 있습니다. 표준화 과정과 논의 기록은 IETF Datatracker의 httpbis 워킹 그룹 페이지에서 따라갈 수 있습니다.

한눈에 보기#

버전핵심 변화해결한 문제명세
HTTP/0.9한 줄 요청, GET(출발점)W3C 문서 (1991)
HTTP/1.0헤더와 상태 코드HTML만 보낼 수 있던 한계RFC 1945
HTTP/1.1keep-alive연결 수립 비용RFC 2068 → 9112
HTTP/2멀티플렉싱, 헤더 압축애플리케이션 레벨 HOL 블로킹RFC 7540 → 9113
HTTP/3QUIC(UDP 기반)TCP 레벨 HOL 블로킹, 연결 지연RFC 9114

네트워크 탭의 Protocol 열에서 지금 어떤 버전을 쓰는지 확인할 수 있습니다. h2h3가 보인다면 여기까지의 내용이 실제로 동작하고 있는 것입니다.

DevTools Network 탭의 Protocol

참고 자료#