[CS300 #153] HTTP/2 와 HTTP/3 — 한 연결에 여러 요청을, 그리고 TCP 를 떠나기까지
컴퓨터공학 300 주제 시리즈의 153번째 글이다. 전체 지도는 여기.
한 줄 요약
HTTP/2 는 HTTP 메시지를 바이너리 프레임으로 쪼개 한 TCP 연결 위에서 여러 스트림을 동시에 흘린다. HTTP/3 는 같은 생각을 UDP 기반 QUIC 위로 옮겨, TCP 의 패킷 손실 대기 문제까지 걷어 낸다. 메서드·상태 코드·헤더의 의미는 셋 다 같다.
왜 필요한가
웹 페이지 하나가 수십~수백 개 자원을 부른다. HTTP/1.1 은 한 연결에서 응답이 순서대로만 오니, 브라우저는 서버마다 연결을 여러 개 열어 버텼다. 연결이 늘면 핸드셰이크와 혼잡 제어 시작 비용도 늘어난다.
HTTP/2 와 HTTP/3 를 알면 다음을 설명할 수 있다. gRPC 가 왜 HTTP/2 를 요구하는가. 손실이 많은 모바일 망에서 HTTP/2 가 오히려 느려질 수 있는 이유는 무엇인가. 방화벽이 UDP 443 을 막으면 무슨 일이 생기는가.
핵심 개념
HTTP/2: 바이너리 프레이밍과 멀티플렉싱
HTTP/2 의 현재 명세는 RFC 9113 이다. 핵심은 세 단어다. 프레임, 스트림, 연결.
TCP 연결 하나
├─ 스트림 1: HEADERS ─ DATA ─ DATA(END_STREAM)
├─ 스트림 3: HEADERS ─ DATA(END_STREAM)
└─ 스트림 5: HEADERS(END_STREAM)
선 위에는 프레임이 섞여 흐른다: [H1][H3][D1][H5][D3][D1] ...
- 프레임: 모든 데이터는 9바이트 헤더(길이 24비트, 타입 8비트, 플래그 8비트, 스트림 ID 31비트)를 가진 프레임으로 나뉜다.
- 스트림: 요청-응답 한 쌍이 스트림 하나다. 클라이언트가 여는 스트림은 홀수 번호다. 프레임이 스트림 ID 를 달고 있어 서로 섞여 와도 다시 조립할 수 있다.
- 연결: 출발지 하나당 연결 하나로 충분하다.
주요 프레임 타입은 다음과 같다.
| 타입 | 값 | 역할 |
|---|---|---|
| DATA | 0x0 | 본문 |
| HEADERS | 0x1 | 헤더 블록(압축됨) |
| RST_STREAM | 0x3 | 스트림 하나만 취소 |
| SETTINGS | 0x4 | 연결 설정 교환 |
| PING | 0x6 | 생존·RTT 확인 |
| GOAWAY | 0x7 | 연결 종료 예고 |
| WINDOW_UPDATE | 0x8 | 흐름 제어 윈도 확장 |
그 밖의 특징:
- 헤더 압축 HPACK(RFC 7541): 정적·동적 테이블로 반복되는 헤더를 번호 하나로 보낸다. 쿠키처럼 매번 같은 큰 헤더의 비용이 크게 준다.
- 스트림별 흐름 제어: TCP 와 별개로 스트림마다, 연결 전체에 윈도가 있다. 느린 소비자 스트림 하나가 연결 전체를 막지 않게 한다.
- 연결 시작: 클라이언트는 고정된 24바이트 프리페이스
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n를 보내고 SETTINGS 를 교환한다. TLS 위에서는 ALPN(RFC 7301)으로h2를 협상한다. - 서버 푸시: 명세에는 있지만(클라이언트가 SETTINGS 로 끌 수 있다) 실무에서는 거의 쓰이지 않는다. 새로 설계할 때 기대지 않는 편이 맞다.
남은 문제: TCP 의 head-of-line blocking
HTTP/2 는 HTTP 수준의 순서 대기를 없앴지만, 아래의 TCP 는 여전히 바이트 스트림 하나다. 패킷 하나가 사라지면 그 뒤 바이트는 이미 도착했어도 재전송이 올 때까지 응용에 전달되지 않는다. 스트림 1 의 패킷 손실이 스트림 3, 5 까지 멈춘다. 연결을 하나로 합친 탓에 손실의 피해가 오히려 커질 수 있다.
HTTP/3 와 QUIC
HTTP/3(RFC 9114)는 TCP 대신 QUIC(RFC 9000)을 쓴다. QUIC 은 UDP 위에서 다음을 직접 한다.
- 스트림 다중화를 전송 계층에서: 스트림마다 독립적으로 순서를 맞춘다. 한 스트림의 손실이 다른 스트림을 막지 않는다.
- TLS 1.3 통합(RFC 9001): 전송 핸드셰이크와 암호 핸드셰이크를 합쳐, 새 연결은 1-RTT 에 요청을 보낼 수 있다. 재접속 때는 0-RTT 로 첫 패킷에 데이터를 실을 수 있다(대신 재전송 공격 위험이 있어 멱등 요청에만 쓴다).
- 손실 감지와 혼잡 제어(RFC 9002): 사용자 공간에서 구현되므로 운영체제 업데이트 없이 개선할 수 있다.
- 연결 ID: 연결을 IP·포트가 아니라 연결 ID 로 구별한다. 와이파이에서 LTE 로 바뀌어 주소가 달라져도 연결을 이어 갈 수 있다(연결 이전).
- 헤더 압축은 순서 보장이 없는 환경에 맞게 HPACK 대신 QPACK(RFC 9204)을 쓴다.
HTTP/1.1, HTTP/2 HTTP/3
+-----------+ +-----------+
| HTTP | | HTTP/3 |
+-----------+ +-----------+
| TLS | | QUIC | ← 스트림, 신뢰성, 혼잡 제어, TLS 1.3
+-----------+ +-----------+
| TCP | | UDP |
+-----------+ +-----------+
| IP | | IP |
어떻게 HTTP/3 로 바꾸나
클라이언트는 처음부터 서버가 QUIC 을 지원하는지 모른다. 보통 HTTP/1.1 이나 HTTP/2 응답의 Alt-Svc 헤더(RFC 7838)로 “UDP 443 에서 h3 도 된다”는 안내를 받고 다음 연결부터 시도한다. UDP 가 막혀 있으면 조용히 TCP 로 돌아간다.
비교
| 항목 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 전송 | TCP | TCP | QUIC(UDP) |
| 형식 | 텍스트 | 바이너리 프레임 | 바이너리 프레임 |
| 다중화 | 없음(연결 여러 개) | 스트림 | 스트림(전송 계층) |
| 손실 시 영향 | 그 연결 | 연결의 모든 스트림 | 해당 스트림만 |
| 헤더 압축 | 없음 | HPACK | QPACK |
| 암호화 | 선택 | 사실상 TLS | 항상(내장) |
직접 해 보기
HTTP/2 프레임 헤더와 QUIC 의 가변 길이 정수를 직접 인코딩·디코딩해 본다. QUIC 정수 예시는 RFC 9000 부록의 값을 그대로 썼다.
import struct
PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
TYPES = {0: "DATA", 1: "HEADERS", 3: "RST_STREAM", 4: "SETTINGS", 6: "PING", 7: "GOAWAY", 8: "WINDOW_UPDATE"}
def frame(ftype, flags, stream_id, payload):
n = len(payload)
return struct.pack("!BHBBI", n >> 16, n & 0xFFFF, ftype, flags, stream_id & 0x7FFFFFFF) + payload
def parse_frames(buf):
while buf:
hi, lo, ftype, flags, sid = struct.unpack("!BHBBI", buf[:9])
n = (hi << 16) | lo
yield TYPES.get(ftype, hex(ftype)), flags, sid & 0x7FFFFFFF, buf[9:9+n]
buf = buf[9+n:]
# SETTINGS: MAX_CONCURRENT_STREAMS(0x3)=100, INITIAL_WINDOW_SIZE(0x4)=65535
settings = frame(4, 0, 0, struct.pack("!HIHI", 3, 100, 4, 65535))
wire = settings + frame(1, 0x4, 1, b"<hpack>") + frame(1, 0x5, 3, b"<hpack>") + frame(0, 0x1, 1, b"body-1")
print("프리페이스", len(PREFACE), "바이트")
for t, flags, sid, payload in parse_frames(wire):
print(f"{t:8s} stream={sid} flags=0x{flags:x} len={len(payload)}")
# QUIC 가변 길이 정수 (RFC 9000 16절): 앞 2비트가 길이 1/2/4/8 바이트를 정함
def quic_varint_decode(b):
length = 1 << (b[0] >> 6)
v = b[0] & 0x3F
for x in b[1:length]: v = (v << 8) | x
return v, length
def quic_varint_encode(v):
for length, prefix in ((1, 0), (2, 1), (4, 2), (8, 3)):
if v < 1 << (8 * length - 2):
return ((prefix << (8 * length - 2)) | v).to_bytes(length, "big")
for hx in ("25", "7bbd", "9d7f3e7d", "c2197c5eff14e88c"):
v, n = quic_varint_decode(bytes.fromhex(hx))
assert quic_varint_encode(v).hex() == hx
print(f"0x{hx:16s} -> {v} ({n}바이트)")
프리페이스 24 바이트
SETTINGS stream=0 flags=0x0 len=12
HEADERS stream=1 flags=0x4 len=7
HEADERS stream=3 flags=0x5 len=7
DATA stream=1 flags=0x1 len=6
0x25 -> 37 (1바이트)
0x7bbd -> 15293 (2바이트)
0x9d7f3e7d -> 494878333 (4바이트)
0xc2197c5eff14e88c -> 151288809941952652 (8바이트)
SETTINGS 는 연결 전체에 대한 것이라 스트림 0 에 실린다. HEADERS 의 플래그 0x4 는 END_HEADERS, 0x1 은 END_STREAM 이다. 스트림 3 의 요청은 본문이 없어 헤더만으로 끝났고(0x5 = 둘 다), 스트림 1 의 본문은 그 뒤에 왔다. 스트림 1 과 3 이 섞여 흐르는 모습이 곧 멀티플렉싱이다.
현업에서는
- gRPC 는 HTTP/2 의 스트림과 트레일러에 기대므로, 중간 프록시가 HTTP/2 를 끝까지 전달하지 못하면 동작하지 않는다. Ingress 설정에서 백엔드 프로토콜을 따로 지정하는 이유다.
- HTTP/2 는 연결 하나에 요청이 몰리므로 L4 로드 밸런서로는 요청 단위 분산이 안 된다. 연결이 한 백엔드에 고정되기 때문이다. gRPC 부하 분산에 L7 프록시나 클라이언트 측 분산을 쓰는 이유다(157번 글).
- HTTP/3 를 켤 때는 방화벽과 보안 그룹에 UDP 443 을 연다. 막혀 있어도 브라우저가 TCP 로 돌아가므로 기능 장애로는 안 보이고, 이점만 사라진다.
curl --http2,curl --http3(빌드에 따라 지원)로 버전별 동작을 확인할 수 있다.curl -v출력의 ALPN 협상 줄이 어떤 버전이 선택됐는지 알려 준다.
확인 문제
- HTTP/2 에서 여러 요청이 한 연결에 섞여 흘러도 응답을 올바로 조립할 수 있는 이유는?
- HTTP/2 가 손실이 많은 망에서 HTTP/1.1(연결 여러 개)보다 불리할 수 있는 이유는?
- QUIC 이 TCP 위가 아니라 UDP 위에 만들어진 이유를 두 가지 들라.
- 0-RTT 데이터를 멱등 요청에만 써야 하는 이유는?
- 클라이언트가 어떤 서버가 HTTP/3 를 지원한다는 걸 처음 알게 되는 대표적 방법은?
풀이
- 모든 프레임이 스트림 ID 를 갖고 있어, 받는 쪽이 스트림별로 프레임을 모을 수 있기 때문이다.
- 모든 스트림이 TCP 바이트 스트림 하나를 공유하므로, 패킷 하나의 손실이 재전송될 때까지 모든 스트림을 멈추기 때문이다.
- 새 전송 프로토콜은 중간 장비(NAT, 방화벽)를 통과하기 어렵지만 UDP 는 이미 통과하고, 운영체제 커널을 바꾸지 않고 사용자 공간에서 구현·개선할 수 있기 때문이다.
- 0-RTT 데이터는 공격자가 가로채 다시 보낼 수 있어(재전송 공격), 같은 요청이 두 번 처리돼도 문제없는 요청에만 써야 한다.
- HTTP/1.1 이나 HTTP/2 응답에 담긴
Alt-Svc헤더다.
더 읽을거리 (References)
- RFC 9113, HTTP/2: https://www.rfc-editor.org/rfc/rfc9113.html
- RFC 9114, HTTP/3: https://www.rfc-editor.org/rfc/rfc9114.html
- RFC 9000, QUIC: A UDP-Based Multiplexed and Secure Transport: https://www.rfc-editor.org/rfc/rfc9000.html
- RFC 7541, HPACK: Header Compression for HTTP/2: https://www.rfc-editor.org/rfc/rfc7541.html