HTTP의 진화 (1) - HTTP/1.0에서 HTTP/2까지 - soo:bak
작성일 :
웹의 언어
웹 브라우저로 페이지를 열면 수십 개의 파일이 다운로드됩니다. HTML, CSS, JavaScript, 이미지 등. 이 파일들을 어떻게 빠르게 받아올 수 있을까요?
네트워크 통신의 원리 (3)에서 HTTP(HyperText Transfer Protocol)의 기본 구조를 살펴보았습니다. 1991년 팀 버너스리(Tim Berners-Lee)가 설계한 HTTP는 처음에는 단순한 문서 전송만을 위한 프로토콜이었습니다.
30년이 지난 지금, 웹은 복잡한 애플리케이션 플랫폼이 되었습니다. HTTP도 성능 문제를 해결하며 함께 진화해왔습니다.
HTTP/0.9: 시작
최초의 HTTP는 단순했습니다. 파일 하나를 요청하고 받는 것이 전부였습니다.
1
2
3
4
5
6
7
요청:
GET /index.html
응답:
<html>
...페이지 내용...
</html>
GET 메서드만 있었고, 헤더도 없었습니다. HTML 파일만 전송할 수 있었고, 파일 하나를 받으면 연결이 끊어졌습니다.
HTTP/1.0: 확장
1996년 HTTP/1.0이 등장했습니다. 헤더가 추가되어 파일 종류(Content-Type)나 크기(Content-Length)를 알 수 있게 되었습니다.
1
2
3
4
5
6
7
8
9
10
11
요청:
GET /page.html HTTP/1.0
Host: example.com
User-Agent: Mozilla/5.0
응답:
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...
POST 메서드가 추가되어 데이터를 보낼 수 있게 되었고, 상태 코드(200 성공, 404 없음, 500 서버 오류)로 결과를 알 수 있게 되었습니다. HTML뿐 아니라 이미지, 비디오도 전송할 수 있게 되었습니다.
HTTP/1.0의 문제: 연결당 하나의 요청
1
2
3
4
5
요청 1 ──[TCP 연결]──► 응답 1 ──[연결 종료]
요청 2 ──[TCP 연결]──► 응답 2 ──[연결 종료]
요청 3 ──[TCP 연결]──► 응답 3 ──[연결 종료]
웹페이지 하나를 표시하려면 여러 파일이 필요합니다. 예를 들어 쇼핑몰 메인 페이지라면:
1
2
3
4
5
6
7
index.html (1개)
style.css 등 (CSS 3개)
app.js 등 (JavaScript 5개)
logo.png, 상품 이미지 등 (이미지 30개)
폰트 파일 (2개)
────────────────────────
총 41개 파일
HTTP/1.0에서는 파일 하나를 받을 때마다 TCP 연결을 새로 맺어야 했습니다. 41개 파일이면 41번 연결을 맺고 끊습니다.
TCP 연결에는 시간이 걸립니다. RTT(Round Trip Time)는 패킷이 서버에 갔다가 돌아오는 시간입니다. 서울에서 미국 서버까지 RTT가 약 150ms라고 가정합니다.
3-way 핸드셰이크: 1 RTT
TCP 연결을 맺으려면 클라이언트와 서버가 3번 메시지를 주고받아야 합니다.
1
2
3
클라이언트 ─── SYN ──────► 서버
클라이언트 ◄── SYN-ACK ─── 서버
클라이언트 ─── ACK ──────► 서버 (데이터 전송 시작)
SYN을 보내고 SYN-ACK를 받는 데 1 RTT(150ms)가 걸립니다. 데이터를 보내기도 전에 150ms가 지나갑니다.
TLS 핸드셰이크: 1~2 RTT 추가
HTTPS를 사용하면 TCP 연결 후에 TLS 핸드셰이크가 필요합니다. 암호화 방식을 협상하고 키를 교환합니다.
TLS 버전에 따라 필요한 RTT가 다릅니다:
1
2
3
4
5
6
7
8
9
10
TLS 1.2: 2 RTT
클라이언트 ─── ClientHello ──────────► 서버
클라이언트 ◄── ServerHello, 인증서 ─── 서버
클라이언트 ─── 키 교환 ────────────► 서버
클라이언트 ◄── 완료 ─────────────── 서버
TLS 1.3: 1 RTT
클라이언트 ─── ClientHello + 키 공유 ─► 서버
클라이언트 ◄── ServerHello + 키 공유 ── 서버
(바로 암호화 통신 시작)
TLS 1.3은 첫 메시지에 키 정보를 함께 보내서 왕복 횟수를 줄였습니다.
1
2
3
4
TCP 연결: 1 RTT (150ms)
TLS 협상: 1 RTT (TLS 1.3) 또는 2 RTT (TLS 1.2)
─────────────────────────────
총: 2~3 RTT (300~450ms)
파일 하나 받기 전에 300~450ms가 지나갑니다.
TCP 슬로우 스타트: 연결 초기에는 느림
TCP는 연결을 맺을 때 네트워크 상태를 모릅니다. 대역폭이 1Gbps인지 10Mbps인지, 중간에 혼잡한 구간이 있는지 알 수 없습니다.
처음부터 대량의 데이터를 보내면 네트워크가 감당하지 못할 수 있습니다. 그래서 TCP는 조심스럽게 시작합니다. 적은 양을 보내고, 잘 도착하면 양을 두 배로 늘립니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
연결 시작
│
▼
1번째 RTT: 14KB 전송 → 성공
│
▼
2번째 RTT: 28KB 전송 → 성공
│
▼
3번째 RTT: 56KB 전송 → 성공
│
▼
4번째 RTT: 112KB 전송 ...
100KB 파일을 예로 들면:
- 1번째 RTT: 14KB 전송 (누적 14KB)
- 2번째 RTT: 28KB 전송 (누적 42KB)
- 3번째 RTT: 56KB 전송 (누적 98KB)
- 4번째 RTT: 2KB 전송 (완료)
TCP 연결 설정(1 RTT)과 TLS 협상(1~2 RTT)을 더하면, 100KB 파일 하나를 받는 데 5~6 RTT가 필요합니다. 150ms RTT 기준으로 750~900ms입니다.
연결을 유지하면 전송량이 계속 늘어나 최대 속도에 도달합니다. 하지만 HTTP/1.0은 파일마다 연결을 끊습니다. 다음 파일에서 다시 14KB부터 시작해야 합니다.
41개 파일을 받는다면, 41번의 연결 설정과 41번의 슬로우 스타트가 발생합니다. 연결을 유지했다면 이미 최대 속도로 받을 수 있었을 파일들을 매번 느린 속도로 시작합니다.
HTTP/1.1: 지속 연결
1997년 HTTP/1.1이 등장했습니다. 가장 큰 변화는 지속 연결(Keep-Alive)입니다.
1
2
3
4
5
6
7
8
HTTP/1.0: 파일마다 연결
[TCP 연결] 요청1 → 응답1 [연결 종료]
[TCP 연결] 요청2 → 응답2 [연결 종료]
[TCP 연결] 요청3 → 응답3 [연결 종료]
HTTP/1.1: 하나의 연결 유지
[TCP 연결]─────────────────────────────────────►
요청1 → 응답1 → 요청2 → 응답2 → 요청3 → 응답3
하나의 TCP 연결로 여러 파일을 요청합니다.
- TCP 연결 설정: 41번 → 1번
- TLS 핸드셰이크: 41번 → 1번
- 슬로우 스타트: 41번 → 1번 (연결 유지로 최대 속도 유지)
연결 설정 시간이 크게 줄었습니다.
파이프라이닝(Pipelining)
지속 연결에서도 문제가 있습니다. 응답이 올 때까지 다음 요청을 보내지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
지속 연결 (파이프라이닝 없음):
클라이언트 서버
│ │
│ ──────── 요청1 ────────► │
│ │ 처리
│ ◄──────── 응답1 ──────── │
│ │
│ ──────── 요청2 ────────► │
│ │ 처리
│ ◄──────── 응답2 ──────── │
│ │
|←─ 1 RTT ─→|←─ 1 RTT ─→|
파일 2개: 2 RTT (300ms)
파일 10개: 10 RTT (1.5초)
파일이 아무리 작아도 요청마다 1 RTT가 필요합니다.
파이프라이닝은 응답을 기다리지 않고 요청을 연속으로 보냅니다.
1
2
3
4
5
6
7
8
9
10
11
파이프라이닝:
클라이언트 서버
│ │
│ ──────── 요청1 ────────► │
│ ──────── 요청2 ────────► │
│ ──────── 요청3 ────────► │
│ │ 처리
│ ◄──────── 응답1 ──────── │
│ ◄──────── 응답2 ──────── │
│ ◄──────── 응답3 ──────── │
파이프라이닝 없이 파일 3개를 받으면, 각 요청마다 응답을 기다려야 합니다. 요청 사이사이에 대기 시간이 생깁니다.
파이프라이닝을 사용하면 요청을 연속으로 보내므로 대기 시간이 사라집니다. 서버는 요청이 도착하는 대로 처리하고, 응답을 연속으로 보냅니다.
Head-of-Line(HOL) Blocking
하지만 파이프라이닝은 실패했습니다.
HTTP/1.1은 텍스트 기반입니다. 응답에 “이것은 요청 2의 응답”이라는 표시가 없습니다. 클라이언트는 첫 번째로 도착한 응답을 첫 번째 요청의 응답으로, 두 번째로 도착한 응답을 두 번째 요청의 응답으로 매칭합니다. 그래서 서버는 응답을 반드시 요청 순서대로 보내야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
클라이언트 서버
│ │
│ ─── 요청1 (큰 이미지) ───► │
│ ─── 요청2 (작은 CSS) ────► │
│ ─── 요청3 (작은 JS) ─────► │
│ │
│ 요청1: 이미지 읽는 중... (오래 걸림)
│ 요청2: CSS 준비 완료 (대기)
│ 요청3: JS 준비 완료 (대기)
│ │
│ 요청1 처리 완료 │
│ │
│ ◄─────── 응답1 (이미지) ─── │
│ ◄─────── 응답2 (CSS) ────── │
│ ◄─────── 응답3 (JS) ─────── │
CSS와 JS는 이미 준비되었지만 이미지가 완료될 때까지 보낼 수 없습니다. 대기열 맨 앞의 요청이 뒤의 모든 요청을 막습니다. 이것이 Head-of-Line Blocking입니다.
느린 요청 하나가 있으면 빠른 요청들도 함께 느려집니다. 파이프라이닝으로 대기 시간을 줄이려 했지만, HOL Blocking 때문에 오히려 성능이 나빠질 수 있습니다. 결국 대부분의 브라우저는 파이프라이닝을 사용하지 않고, 지속 연결만 사용합니다.
HTTP/1.1의 한계
병렬 연결로 우회
파이프라이닝은 하나의 연결에서 여러 요청을 보내는 방식이었습니다. 하지만 응답 순서를 지켜야 해서 HOL Blocking이 발생했습니다.
브라우저는 다른 방법을 택했습니다. 연결 하나에서 여러 요청을 보내는 대신, 연결 자체를 여러 개 여는 것입니다. 연결이 4개면 4개의 요청을 동시에 처리할 수 있습니다. 연결끼리는 서로 독립적이므로 한 연결이 느려도 다른 연결에 영향을 주지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
클라이언트 서버
│ │
연결1 │ ─── 요청1 (큰 이미지) ───► │
연결2 │ ─── 요청2 (CSS) ────────► │
연결3 │ ─── 요청3 (JS) ─────────► │
연결4 │ ─── 요청4 (폰트) ────────► │
│ │
│ 연결1: 이미지 처리 중...
│ 연결2: CSS 완료
│ 연결3: JS 완료
│ 연결4: 폰트 완료
│ │
연결2 │ ◄─────── 응답2 (CSS) ───── │
연결3 │ ◄─────── 응답3 (JS) ────── │
연결4 │ ◄─────── 응답4 (폰트) ──── │
│ │
│ 연결1: 이미지 완료 │
연결1 │ ◄─────── 응답1 (이미지) ── │
각 연결은 독립적입니다. 연결1에서 이미지가 느려도 연결2, 3, 4는 영향받지 않습니다. CSS, JS, 폰트는 먼저 도착합니다.
브라우저는 도메인당 6~8개의 연결을 동시에 열 수 있습니다. 하지만 한계가 있습니다.
연결이 6개면 연결 설정 비용도 6배입니다. 앞서 살펴본 것처럼 연결 하나당 TCP 핸드셰이크(1 RTT)와 TLS 핸드셰이크(1~2 RTT)가 필요합니다. 6개 연결을 동시에 열면 네트워크에 부하가 걸립니다.
슬로우 스타트도 6번 발생합니다. 하나의 연결을 유지했다면 이미 최대 속도에 도달했을 텐데, 6개 연결 모두 14KB부터 다시 시작합니다.
파일이 41개인데 연결이 6개면, 6개씩 처리하고 나머지는 대기해야 합니다. 연결 수의 제한 때문에 완전한 병렬 처리가 불가능합니다.
도메인 샤딩(Domain Sharding)
연결 수 제한을 우회하는 방법입니다. 브라우저는 “도메인당” 6개로 제한하므로, 도메인을 늘리면 연결도 늘어납니다.
1
2
3
4
5
6
example.com → 연결 6개
images.example.com → 연결 6개
scripts.example.com → 연결 6개
styles.example.com → 연결 6개
─────────────────────────────────
총 24개 연결 확보
실제로는 같은 서버를 가리키지만, 브라우저 입장에서는 4개의 다른 도메인입니다.
하지만 도메인마다 추가 비용이 발생합니다. 브라우저는 도메인 이름(images.example.com)을 IP 주소로 변환해야 합니다. 이 DNS 조회에 시간이 걸립니다. 그리고 도메인마다 TCP/TLS 연결을 새로 맺어야 합니다. 연결 수를 늘리려다 연결 설정 비용이 더 커질 수 있습니다.
헤더 중복
HTTP/1.1은 텍스트 기반입니다. 요청마다 헤더를 전부 보내야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
GET /page1.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
Accept: text/html,application/xhtml+xml,...
Accept-Language: en-US,en;q=0.9,ko;q=0.8
Cookie: session=abc123; preferences=...
GET /page2.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... ← 똑같음
Accept: text/html,application/xhtml+xml,... ← 똑같음
Accept-Language: en-US,en;q=0.9,ko;q=0.8 ← 똑같음
Cookie: session=abc123; preferences=... ← 똑같음
헤더 크기는 보통 500바이트~2KB입니다. Cookie는 헤더에 포함되어 전송됩니다. 웹사이트가 로그인 정보, 사용자 설정, 광고 트래킹 등 여러 쿠키를 저장하면 Cookie 헤더가 커집니다.
1
2
파일 41개 요청:
헤더 1KB × 41개 = 41KB (헤더만)
41KB 중 대부분은 똑같은 내용입니다. User-Agent, Accept, Cookie 등이 41번 반복됩니다. 바뀌는 건 요청 경로(/page1.html, /page2.html)뿐입니다.
HTTP 바디는 gzip으로 압축할 수 있지만, 헤더는 압축 없이 텍스트 그대로 전송됩니다. 슬로우 스타트 구간에서는 14KB밖에 못 보내는데, 헤더만 1KB를 차지하면 실제 데이터는 13KB만 보낼 수 있습니다.
HTTP/2: 근본적 재설계
HTTP/1.1의 문제를 정리하면:
- HOL Blocking으로 파이프라이닝 실패
- 병렬 연결은 연결 설정 비용이 큼
- 헤더가 압축 없이 반복 전송
2015년 HTTP/2가 이 문제들을 해결하기 위해 등장했습니다. Google이 자체 개발한 SPDY 프로토콜의 경험을 바탕으로 표준화되었습니다.
핵심 변화: 이진 프레이밍
HTTP/1.x는 텍스트 기반입니다. 요청과 응답을 줄바꿈 문자(\r\n)로 구분합니다.
1
2
3
GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
\r\n
텍스트 형식에는 두 가지 문제가 있습니다.
첫째, 파싱이 느립니다. 컴퓨터는 문자를 하나씩 읽으며 줄바꿈(\r\n)을 찾아야 합니다. “여기서 헤더가 끝나고 바디가 시작된다”를 알려면 빈 줄(\r\n\r\n)을 찾아야 합니다. 데이터 크기도 미리 알 수 없어서 Content-Length 헤더를 파싱해야 알 수 있습니다.
둘째, 응답을 구분할 수 없습니다. HTTP/1.1 응답은 이렇게 생겼습니다:
1
2
3
4
HTTP/1.1 200 OK\r\n
Content-Type: text/css\r\n
\r\n
body { color: black; }
이 응답이 요청 1의 응답인지, 요청 2의 응답인지 표시가 없습니다. 그래서 클라이언트는 “먼저 도착한 응답 = 첫 번째 요청의 응답”으로 간주할 수밖에 없습니다. 서버가 응답 순서를 바꿔서 보내면 클라이언트가 잘못 매칭합니다. 이것이 HOL Blocking의 원인이었습니다.
HTTP/2는 이진(binary) 프레이밍으로 이 두 문제를 해결합니다.
모든 데이터를 프레임(frame)이라는 작은 단위로 나누고, 각 프레임 앞에 9바이트 헤더를 붙입니다.
1
2
3
4
5
6
7
8
9
HTTP/2 프레임:
┌─────────────────────────────────────┐
│ 프레임 헤더 (9바이트, 고정 크기) │
│ - 길이 (3바이트): 프레임 데이터 크기 │
│ - 타입 (1바이트): HEADERS, DATA 등 │
│ - 스트림 ID (4바이트): 요청 번호 │
├─────────────────────────────────────┤
│ 프레임 데이터 │
└─────────────────────────────────────┘
파싱 문제 해결: 프레임 헤더가 항상 9바이트입니다. 처음 9바이트를 읽으면 “이 프레임의 데이터가 몇 바이트인지” 바로 알 수 있습니다. 줄바꿈을 찾을 필요가 없습니다.
응답 구분 문제 해결: 프레임 헤더에 스트림 ID가 있습니다. 스트림 ID가 2인 프레임은 요청 2의 응답입니다. 서버가 응답을 어떤 순서로 보내든 클라이언트는 스트림 ID를 보고 올바른 요청에 매칭할 수 있습니다.
응답 순서를 지키지 않아도 되므로 HOL Blocking이 발생하지 않습니다.
HTTP/2 스트림과 다중화
HTTP/2에서 스트림(Stream)은 하나의 요청-응답 쌍입니다. 요청 1과 응답 1은 스트림 1, 요청 2와 응답 2는 스트림 2입니다.
다중화(Multiplexing)
HTTP/1.1에서는 HOL Blocking을 피하기 위해 연결을 여러 개 열었습니다. 연결 6개면 동시에 6개를 처리할 수 있지만, 연결마다 TCP/TLS 핸드셰이크와 슬로우 스타트가 필요했습니다.
HTTP/2는 하나의 TCP 연결에서 여러 스트림을 동시에 처리합니다. 이것이 다중화입니다.
1
2
3
4
5
6
7
8
9
HTTP/1.1: 연결 여러 개
연결1 ─────────────────► 요청1/응답1
연결2 ─────────────────► 요청2/응답2
연결3 ─────────────────► 요청3/응답3
(연결마다 핸드셰이크, 슬로우 스타트)
HTTP/2: 연결 하나, 스트림 여러 개
연결 ──[S1][S2][S3][S1][S2][S3][S1]...──►
(하나의 연결에서 프레임이 섞여서 전송)
연결이 하나이므로 TCP/TLS 핸드셰이크는 한 번만 합니다. 슬로우 스타트도 한 번만 겪으면 됩니다. 연결이 최대 속도에 도달하면 모든 스트림이 그 속도를 공유합니다.
프레임마다 스트림 ID가 있으므로 섞여서 도착해도 클라이언트가 올바르게 조립할 수 있습니다.
HTTP/1.1과 비교:
앞서 살펴본 HTTP/1.1의 HOL Blocking 상황을 떠올려 봅시다. 큰 이미지, 작은 CSS, 작은 JS를 요청했을 때:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTTP/1.1 (순서 보장 필요):
클라이언트 서버
│ │
│ ─── 요청1 (큰 이미지) ───► │
│ ─── 요청2 (CSS) ─────────► │
│ ─── 요청3 (JS) ──────────► │
│ │
│ 요청1: 이미지 읽는 중...
│ 요청2: CSS 완료 (대기)
│ 요청3: JS 완료 (대기)
│ │
│ ◄────────── 응답1 (이미지) ──── │ ← 순서대로
│ ◄────────── 응답2 (CSS) ─────── │ 보내야 함
│ ◄────────── 응답3 (JS) ──────── │
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HTTP/2 (다중화):
클라이언트 서버
│ │
│ ─── 요청1 (큰 이미지) ───► │
│ ─── 요청2 (CSS) ─────────► │
│ ─── 요청3 (JS) ──────────► │
│ │
│ 요청1: 이미지 읽는 중...
│ 요청2: CSS 완료 → 바로 전송
│ 요청3: JS 완료 → 바로 전송
│ │
│ ◄────────── 응답2 [S2] ──────── │ ← CSS 먼저
│ ◄────────── 응답3 [S3] ──────── │ ← JS 다음
│ ◄────────── 응답1 일부 [S1] ─── │
│ ◄────────── 응답1 나머지 [S1] ─ │ ← 이미지 마지막
HTTP/1.1은 응답에 “이 응답은 요청 2의 것”이라는 표시가 없어서 순서를 지켜야 했습니다. HTTP/2는 프레임마다 스트림 ID([S1], [S2], [S3])가 있어서 순서와 상관없이 보내도 됩니다. 서버는 준비된 응답부터 바로 보냅니다.
HTTP/2 프레임 유형
HTTP/2의 프레임은 용도에 따라 여러 종류가 있습니다.
데이터 전송
| 타입 | 용도 |
|---|---|
| HEADERS | 요청/응답 헤더 전송. 모든 스트림은 HEADERS 프레임으로 시작 |
| DATA | 실제 데이터(HTML, 이미지 등) 전송. HEADERS 이후에 전송 |
스트림 제어
| 타입 | 용도 |
|---|---|
| PRIORITY | 스트림 우선순위 지정. CSS는 이미지보다 먼저 받아야 함 |
| RST_STREAM | 스트림 취소. 사용자가 이미지 로딩 중 페이지를 떠나면 전송 중단 |
| PUSH_PROMISE | 서버 푸시 알림. “이 리소스도 곧 보낼 예정” |
연결 관리
| 타입 | 용도 |
|---|---|
| SETTINGS | 연결 설정. 최대 동시 스트림 수, 프레임 크기 등 협상 |
| PING | 연결 상태 확인. 연결이 살아있는지, 지연 시간 측정 |
| GOAWAY | 연결 종료 알림. 서버 재시작 전 “새 요청 보내지 마세요” |
| WINDOW_UPDATE | 흐름 제어. 수신 버퍼 여유 공간 알림 |
하나의 요청-응답은 여러 프레임으로 구성됩니다:
1
2
요청: [HEADERS]
응답: [HEADERS] [DATA] [DATA] [DATA] ...
큰 파일은 여러 DATA 프레임으로 나뉩니다. 다중화에서 살펴본 것처럼 이 프레임들 사이에 다른 스트림의 프레임이 끼어들 수 있습니다.
스트림 우선순위
브라우저가 화면을 그리려면 파일들 사이에 의존 관계가 있습니다.
1
2
3
4
5
6
7
HTML 파싱
│
├──► CSS 로드 ──► 화면 렌더링
│ │
└──► JS 로드 ────────┘
│
이미지 로드 (나중에 채워도 됨)
HTML이 없으면 어떤 CSS와 JS가 필요한지 모릅니다. CSS가 없으면 화면을 그릴 수 없습니다. 반면 이미지는 나중에 도착해도 빈 공간만 남기고 나머지는 먼저 보여줄 수 있습니다.
브라우저는 이 의존 관계에 따라 우선순위를 설정합니다:
| 파일 유형 | 우선순위 | 이유 |
|---|---|---|
| HTML | 높음 | 다른 파일의 목록을 알려줌 |
| CSS | 높음 | 없으면 화면을 그릴 수 없음 |
| JavaScript | 중간 | 페이지 동작에 필요하지만 렌더링을 막지 않을 수 있음 |
| 이미지 | 낮음 | 늦게 도착해도 레이아웃에 영향 적음 |
서버는 우선순위가 높은 스트림에 더 많은 대역폭을 할당합니다. 같은 시간에 CSS와 이미지를 보낼 수 있다면 CSS를 먼저 보냅니다.
HPACK: 헤더 압축
앞서 HTTP/1.1의 헤더 중복 문제를 살펴보았습니다. 41개 요청에서 User-Agent, Accept, Cookie 같은 헤더가 41번 반복되어 수십 KB가 낭비되었습니다.
HTTP/2는 HPACK으로 헤더를 압축합니다.
정적 테이블
HTTP 트래픽을 분석하면 대부분의 요청에서 같은 헤더가 반복됩니다. HPACK은 자주 사용되는 헤더 61개를 미리 번호로 정의해 둡니다.
| 인덱스 | 헤더 | 값 |
|---|---|---|
| 2 | :method | GET |
| 3 | :method | POST |
| 4 | :path | / |
| 16 | accept-encoding | gzip, deflate |
| … | … | … |
이 테이블은 HTTP/2 표준에 정의되어 있습니다. 클라이언트와 서버 모두 같은 테이블을 가지고 있으므로 첫 요청부터 바로 인덱스를 사용할 수 있습니다.
1
2
3
4
5
HTTP/1.1:
:method: GET\r\n (12바이트)
HTTP/2:
[2] (1바이트)
12바이트가 1바이트로 줄어듭니다. 정적 테이블에 있는 헤더는 인덱스 번호만 보내면 됩니다.
동적 테이블
정적 테이블은 :method: GET처럼 모든 웹사이트에서 공통으로 쓰는 헤더만 담고 있습니다. 하지만 Cookie 값이나 커스텀 헤더는 웹사이트마다 다릅니다. 이런 헤더는 동적 테이블에 추가됩니다.
동적 테이블은 연결이 시작될 때 비어 있습니다. 새 헤더가 전송될 때마다 테이블에 추가됩니다.
1
2
3
4
5
6
7
8
9
첫 번째 요청:
Cookie: session=abc123... (전체 전송, 약 50바이트)
→ 동적 테이블에 저장 (인덱스 62)
두 번째 요청:
Cookie: session=abc123... → [62] (1바이트)
세 번째 요청:
Cookie: session=abc123... → [62] (1바이트)
앞서 살펴본 것처럼 Cookie 헤더는 수백 바이트가 될 수 있습니다. 첫 요청에서만 전체를 보내고, 이후 40번의 요청에서는 1바이트씩만 보냅니다. 연결을 유지하는 동안 테이블이 쌓여서 압축 효율이 점점 높아집니다.
허프만 인코딩
정적 테이블에도 없고, 동적 테이블에도 없는 새 문자열은 어떻게 할까요? 이때 허프만 인코딩으로 압축합니다.
허프만 인코딩은 자주 나오는 문자에 짧은 비트를, 드물게 나오는 문자에 긴 비트를 할당합니다.
1
2
3
4
5
6
7
일반 ASCII:
'e' = 01100101 (8비트)
'x' = 01111000 (8비트)
허프만 (HPACK 정의):
'e' = 00101 (5비트) ← 자주 사용되므로 짧음
'x' = 1111110 (7비트) ← 드물게 사용되므로 김
HTTP 헤더에서 자주 나오는 문자(e, t, a, o, i, n, s 등)는 5~6비트로 표현됩니다. 평균적으로 문자열 길이가 약 30% 줄어듭니다.
HPACK 압축 효과
세 가지 기법을 조합한 결과:
| 기법 | 역할 |
|---|---|
| 정적 테이블 | 공통 헤더를 인덱스로 대체 |
| 동적 테이블 | 반복되는 헤더를 인덱스로 대체 |
| 허프만 인코딩 | 새 문자열을 압축 |
실제 측정에서 헤더 크기가 85~90% 감소합니다. 앞서 살펴본 41개 요청의 경우, HTTP/1.1에서 41KB였던 헤더가 HTTP/2에서는 4~6KB로 줄어듭니다.
서버 푸시
서버가 클라이언트의 요청 없이 먼저 리소스를 보낼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
일반적인 흐름:
클라이언트 서버
│ │
│ ─── GET /index.html ───► │
│ ◄─── index.html ───────── │
│ │
│ (HTML 파싱: style.css, script.js 필요)│
│ │
│ ─── GET /style.css ────► │
│ ─── GET /script.js ────► │
│ ◄─── style.css ────────── │
│ ◄─── script.js ────────── │
│ │
|←──────── 2 RTT ────────→|
브라우저는 HTML을 받아서 파싱해야 어떤 CSS/JS가 필요한지 알 수 있습니다. HTML을 받는 데 1 RTT, CSS/JS를 요청하고 받는 데 1 RTT가 추가로 필요합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
서버 푸시:
클라이언트 서버
│ │
│ ─── GET /index.html ───► │
│ │
│ ◄─ PUSH_PROMISE (style.css 보낼 예정)
│ ◄─ PUSH_PROMISE (script.js 보낼 예정)
│ ◄─── index.html ───────── │
│ ◄─── style.css ────────── │
│ ◄─── script.js ────────── │
│ │
|←──────── 1 RTT ────────→|
서버는 index.html을 보내면서 “style.css와 script.js도 필요할 것”이라고 판단하고 함께 보냅니다. 클라이언트가 CSS/JS를 요청하기 전에 이미 도착합니다. 1 RTT가 절약됩니다.
하지만 실제로는 잘 사용되지 않습니다.
캐시 문제: 사용자가 페이지를 다시 방문하면 style.css가 이미 브라우저 캐시에 있습니다. 서버는 이를 알 수 없어서 또 푸시합니다. 이미 가진 파일을 다시 받으므로 대역폭이 낭비됩니다.
판단의 어려움: 어떤 파일을 푸시해야 할까요? index.html이 요청되면 style.css를 푸시해야 할까요, main.css를 푸시해야 할까요? 페이지마다 필요한 파일이 다르고, 서버가 이를 정확히 알기 어렵습니다. 잘못 푸시하면 필요 없는 파일을 보내는 것입니다.
구현 차이: 브라우저마다 서버 푸시를 처리하는 방식이 달랐습니다. Chrome은 2022년에 서버 푸시 지원을 제거했습니다.
결국 이론적으로는 1 RTT를 절약하지만, 실제로는 캐시된 파일을 중복 전송하거나 불필요한 파일을 보내는 문제가 더 컸습니다. HTTP/3 표준에도 서버 푸시가 포함되어 있지만 사용을 권장하지 않습니다.
HTTP/2의 한계: TCP HOL Blocking
HTTP/2는 HTTP 레벨의 HOL Blocking을 해결했습니다. 스트림 ID 덕분에 응답 순서를 지키지 않아도 됩니다. 스트림 1이 느려도 스트림 2, 3은 먼저 완료됩니다.
하지만 TCP 레벨의 HOL Blocking이 남아있습니다.
TCP는 신뢰성 있는 전송을 보장합니다. 패킷이 손실되면 재전송하고, 순서가 바뀌면 원래 순서대로 정렬합니다. 이 순서 보장이 문제가 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
서버 클라이언트
│ │
│ ─── 패킷1 ───► │ → 도착
│ ─── 패킷2 ───► │ → 도착
│ ─── 패킷3 ───X (손실) │
│ ─── 패킷4 ───► │ → 도착
│ ─── 패킷5 ───► │ → 도착
│ │
│ 클라이언트 TCP 수신 버퍼: │
│ [1][2][ ? ][4][5] │
│ ↑ │
│ 패킷3을 기다리는 중 │
│ │
│ → 패킷 4, 5를 HTTP/2에 │
│ 전달하지 않음 │
패킷 4, 5는 이미 도착했습니다. 하지만 TCP는 패킷 3이 올 때까지 패킷 4, 5를 애플리케이션(HTTP/2)에 전달하지 않습니다. 순서대로 전달해야 하기 때문입니다.
앞서 살펴본 것처럼 HTTP/2는 하나의 TCP 연결에서 여러 스트림을 다중화합니다. 프레임들이 섞여서 전송됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
서버 클라이언트
│ │
│ ─── 패킷1 [S1 데이터] ───► │ → 도착
│ ─── 패킷2 [S2 데이터] ───► │ → 도착
│ ─── 패킷3 [S1 데이터] ───X (손실) │
│ ─── 패킷4 [S3 데이터] ───► │ → 도착
│ ─── 패킷5 [S2 데이터] ───► │ → 도착
│ │
│ TCP 수신 버퍼: │
│ [S1][S2][ ? ][S3][S2] │
│ │
│ HTTP/2 입장: │
│ - 스트림 2: 완료 가능 │
│ - 스트림 3: 완료 가능 │
│ - 스트림 1: 패킷3 필요 │
│ │
│ TCP 입장: │
│ - 패킷3이 올 때까지 전부 대기 │
패킷 3은 스트림 1의 데이터입니다. HTTP/2 입장에서는 스트림 2, 3을 먼저 완료할 수 있습니다. 하지만 TCP는 스트림을 모릅니다. TCP에게는 하나의 바이트 흐름일 뿐입니다. 패킷 3이 올 때까지 패킷 4, 5도 전달하지 않습니다.
HTTP/2는 HTTP 레벨에서 스트림을 독립적으로 설계했지만, TCP 위에서 동작하므로 TCP의 순서 보장에 묶여 있습니다.
이 문제를 해결하려면 TCP 자체를 바꿔야 합니다. HTTP/3은 TCP 대신 QUIC를 사용합니다. Part 2에서 살펴봅니다.
HTTP/2 도입 시 고려사항
TLS 필수
HTTP/2 표준은 암호화 없이도 동작하도록 정의되어 있습니다. 하지만 모든 주요 브라우저는 HTTPS에서만 HTTP/2를 지원합니다.
인터넷에는 오래된 프록시, 방화벽, 로드밸런서가 많습니다. 이 장비들은 HTTP/1.1의 텍스트 형식을 기대합니다. HTTP/2의 이진 프레이밍을 만나면 잘못 해석하거나 차단할 수 있습니다. TLS로 암호화하면 중간 장비가 내용을 볼 수 없으므로 이 문제가 발생하지 않습니다.
ALPN(Application-Layer Protocol Negotiation)
클라이언트와 서버가 HTTP/2를 지원하는지 어떻게 알 수 있을까요?
앞서 살펴본 TLS 핸드셰이크에서 협상합니다. ClientHello 메시지에 “나는 HTTP/2, HTTP/1.1을 지원해”라고 포함하고, 서버가 ServerHello에서 “HTTP/2로 하자”라고 응답합니다. TLS 연결이 완료되면 바로 HTTP/2로 통신을 시작합니다. 프로토콜 협상을 위한 추가 왕복이 필요 없습니다.
HTTP/1.1 최적화 기법은 불필요
HTTP/1.1의 한계를 우회하기 위한 기법들이 HTTP/2에서는 불필요하거나 오히려 해롭습니다.
도메인 샤딩: 앞서 살펴본 것처럼 도메인을 여러 개로 나누어 연결 수를 늘리는 기법입니다. HTTP/2는 하나의 연결에서 다중화하므로 연결을 늘릴 필요가 없습니다. 오히려 도메인마다 별도의 TCP/TLS 연결이 생겨서 핸드셰이크 비용만 늘어납니다.
이미지 스프라이트: 여러 작은 이미지를 하나의 큰 이미지로 합치는 기법입니다. HTTP/1.1에서는 요청 수를 줄이기 위해 사용했습니다. HTTP/2는 다중화로 요청이 많아도 빠르므로 개별 이미지로 요청해도 됩니다. 스프라이트를 사용하면 작은 아이콘 하나가 필요해도 전체 이미지를 받아야 합니다.
CSS/JS 인라인: CSS나 JavaScript를 별도 파일로 두지 않고 HTML에 직접 넣는 기법입니다. 요청 수를 줄일 수 있지만, 별도 파일이 아니므로 브라우저가 캐시할 수 없습니다. HTTP/2에서는 요청 수가 문제가 아니므로 캐싱 가능한 별도 파일이 낫습니다.
HTTP/1.1에서 HTTP/2로
HTTP/2는 HTTP의 의미(semantics)는 그대로 유지하고 전송 방식만 바꿨습니다.
1
2
3
4
5
6
7
8
9
10
11
12
의미 (같음):
- 메서드: GET, POST, PUT, DELETE ...
- 상태 코드: 200, 404, 500 ...
- 헤더: Content-Type, Cookie, Authorization ...
전송 (다름):
| | HTTP/1.1 | HTTP/2 |
|--|---------|--------|
| 형식 | 텍스트 | 이진 |
| 구분 | 줄바꿈 | 프레임 헤더 |
| 전송 | 순차적 | 다중화 |
| 헤더 | 반복 전송 | HPACK 압축 |
웹 애플리케이션은 “GET /users를 요청하고 JSON 응답을 받는다”처럼 HTTP의 의미를 사용합니다. 이 부분은 HTTP/1.1과 HTTP/2가 같습니다. 전송 방식의 차이는 웹 서버(Nginx, Apache)와 브라우저가 처리합니다. 애플리케이션 코드는 대부분 수정 없이 동작합니다.
마무리
웹페이지 하나에 수십 개의 파일이 필요합니다. HTTP는 이 파일들을 빠르게 전송하기 위해 진화해왔습니다.
- HTTP/1.0: 파일마다 TCP 연결을 새로 맺음. 핸드셰이크와 슬로우 스타트가 반복되어 느림
- HTTP/1.1: 지속 연결로 하나의 TCP 연결 유지. 하지만 응답 순서를 지켜야 해서 HOL Blocking 발생
- HTTP/2: 이진 프레이밍과 스트림 ID로 응답 순서 제약 해결. 하나의 연결에서 다중화. HPACK으로 헤더 압축
- 남은 문제: TCP 자체가 순서를 보장하므로 패킷 손실 시 모든 스트림이 대기
HTTP/2는 HTTP 레벨의 문제는 해결했지만 TCP 위에서 동작하는 한 TCP의 제약을 벗어날 수 없습니다.
Part 2에서는 TCP 대신 UDP 기반의 QUIC를 사용하는 HTTP/3을 살펴봅니다. 요청-응답이 아닌 양방향 실시간 통신을 위한 WebSocket도 함께 다룹니다.
관련 글