본문으로 건너뛰기
김도현

HTTP는 어떻게 빨라졌나

리소스를 나르는 방법의 변화

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

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

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

1991년의 첫 버전에는 버전 번호가 없었다. 뒤에 나온 버전과 구분하려고 나중에 0.9로 불렸다. 정식 RFC조차 없이 명세라고 할 만한 건 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 덕분에 이미지와 스타일시트도 전송할 수 있게 됐다. 그러면서 페이지 하나를 그리는 데 필요한 파일도 여러 개로 늘었다. 여기서부터 "여러 리소스를 어떻게 나를 것인가"라는 물음이 시작된다.

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

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

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

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

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

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

HTTP/1.1, 연결 재사용

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

이 순차 처리를 깨려고 파이프라이닝(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 연결을 병렬로 열어 리소스를 나눠 받았다.
  • 도메인 샤딩: 리소스를 여러 서브도메인에 흩어 놓아 병렬 연결 수를 더 늘렸다.

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

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

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

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

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

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/20.0초
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립전송재전송 대기

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

TCP 레벨에 남은 HOL 블로킹

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

아래 워터폴은 패킷 유실을 켠 상태다. 여섯 스트림이 같은 지점에서 동시에 멈춘다. 패킷 유실을 끄면 앞의 것과 같아진다.

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

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

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

2022년 표준이 된 HTTP/3(RFC 9114)는 전송 계층을 바꿨다. TCP 대신 QUIC(RFC 9000) 위에서 동작한다. QUIC은 UDP 기반이다. QUIC도 구글이 먼저 배포해 검증한 뒤 IETF가 표준으로 다듬었다. SPDY 때와 같은 순서다.

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/30.0초
index.html
style.css
app.js
hero.webp
icon.svg
font.woff2
연결 수립전송재전송 대기

한눈에 보기

버전핵심 변화해결한 문제명세2
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 열에서 지금 어떤 버전을 쓰는지 확인할 수 있다. h2나 h3가 보인다면 여기까지의 내용이 실제로 동작하고 있다.

DevTools Network 탭의 Protocol

Footnotes

  1. Evolution of HTTP ↩

  2. RFC 9110, 9111, 9112, 9113 등 지금 쓰이는 명세는 HTTP 워킹 그룹의 명세 목록과 IETF Datatracker의 httpbis 워킹 그룹에서 확인할 수 있다. ↩

관련 글