필사 모드: HTTP/3와 QUIC 내부 완전 해부 — 0-RTT, Stream Multiplexing, Connection Migration, BBR, WebTransport까지
한국어들어가며 — "TCP는 왜 못 끝냈나"
30년 된 질문: TCP가 1974년 설계 이후 이렇게 오래 살아남은 이유는? 답: **작동하니까**.
그런데 왜 Google, Cloudflare, Meta가 2012년부터 **TCP를 UDP로 대체**하려고 수억 달러를 썼을까? TCP에 근본적 문제가 있기 때문이다. 이 글에서는:
- **HTTP/2의 HOL(Head-of-Line) 블로킹**이 왜 TCP 레벨에선 해결 불가능한가
- **QUIC**이 어떻게 TCP+TLS+HTTP/2의 기능을 UDP 위에서 재설계했나
- **0-RTT**의 원리와 리플레이 공격 위험
- **Connection Migration** — IP가 바뀌어도 연결이 유지되는 마법
- **HTTP/3**의 프레임 구조와 HPACK → QPACK 진화
- **BBR 혼잡제어**가 QUIC에 잘 어울리는 이유
- **WebTransport** — QUIC 위에 쓰는 새 메시징
- 실측 채택률과 현실의 함정
> 이전 글 [DNS 내부 구조](/blog/culture/2026-04-15-dns-internals-recursive-authoritative-edns-dnssec-doh-coredns-happy-eyeballs-deep-dive-guide-2025)에서 UDP 기반 53번 포트의 세계를 봤다. QUIC은 같은 UDP를 쓰지만 **TLS + 신뢰성 + 멀티스트림**까지 다 얹는다.
1. HTTP/1.1 → HTTP/2 → HTTP/3의 흐름
HTTP/1.1 (1997) — 한 번에 하나
- **Keep-Alive**로 TCP 재사용
- 하지만 **순차적 요청**: 한 응답이 끝나야 다음 요청
- 해결책: 브라우저가 도메인당 **6개 TCP 연결** 병렬로 사용
- 문제: 6개 각각 TCP 슬로우 스타트, 6배 CPU
HTTP/2 (2015) — Binary + Multiplexing
- **한 TCP 연결** 안에 여러 요청 병렬 처리 (Streams)
- 헤더 압축 **HPACK**
- 서버 푸시, 프레임 기반
하지만 결정적 문제가 남는다.
HTTP/2의 치명적 약점: TCP HOL Blocking
한 TCP 연결 위에 스트림 10개. 그런데 1번 스트림의 패킷 하나가 **손실되면?** TCP는 순서 보장을 위해 뒤의 모든 패킷을 **버퍼에 쌓아두고 기다린다**. 다른 9개 스트림도 멈춘다.
HTTP/2 멀티플렉싱의 철학("하나가 느려도 나머진 가")이 **TCP 계층에서 깨진다**.
HTTP/3 (2022 RFC 9114) — UDP로 내려가다
TCP를 버리고 UDP 위에 직접 **QUIC**이라는 새 전송 프로토콜을 설계. HTTP/3는 **QUIC 위의 HTTP**다.
2. QUIC의 계층 구조 — "프로토콜 Shift Left"
┌──────────────────────────┐ ┌───────────────────────────┐
│ HTTP/2 │ │ HTTP/3 │
├──────────────────────────┤ ├───────────────────────────┤
│ TLS 1.2/1.3 │ │ QUIC (TLS 1.3 내장) │
├──────────────────────────┤ │ + 스트림 │
│ TCP │ │ + 신뢰성 │
├──────────────────────────┤ │ + 혼잡제어 │
│ IP │ ├───────────────────────────┤
└──────────────────────────┘ │ UDP │
├───────────────────────────┤
│ IP │
└───────────────────────────┘
QUIC의 핵심 선택:
1. **UDP 위에서 구현** — 커널 수정 없이 배포 가능
2. **TLS 1.3 통합** — 별도 핸드셰이크 X, 암호화 기본
3. **스트림이 각자 독립** — 한 스트림 손실이 다른 스트림을 막지 않음
3. QUIC 핸드셰이크 — 1-RTT와 0-RTT
TCP+TLS 1.2의 3-RTT 지옥
1. TCP SYN/SYN-ACK — 1 RTT
2. TLS ClientHello/ServerHello — 1 RTT
3. TLS Key exchange — 1 RTT
= 3 RTT. 아시아↔미국 왕복 약 600ms. **3번이면 1.8초**.
TCP+TLS 1.3 — 2-RTT
TLS 1.3이 1-RTT로 줄였지만, TCP 1-RTT가 남음. 총 2-RTT.
QUIC — 1-RTT (첫 연결)
클라이언트 서버
│ │
│── Initial + ClientHello ──>│
│ │
│<── Initial + ServerHello ─│
│<── Handshake + Cert ──────│
│<── Handshake Finished ────│
│ │
│── Handshake Finished ────>│
│── HTTP Request (1-RTT) ──>│
│ │
│<── HTTP Response ─────────│
TLS 핸드셰이크가 **QUIC 전송 핸드셰이크와 결합**되어 1 RTT.
0-RTT — 두 번째부터는 공짜
이전에 한 번 연결했던 서버라면:
1. 클라이언트가 이전 세션의 **PSK(Pre-Shared Key)**를 기억
2. 첫 패킷에 **암호화된 데이터**를 바로 붙여 보냄
3. RTT 없이 HTTP 요청이 서버에 도달
즉 첫 바이트 지연 = **거의 0 (UDP 지연만)**.
0-RTT의 대가: Replay Attack
- 공격자가 0-RTT 패킷을 캡처
- 나중에 다시 보내면 서버가 **똑같이 처리**할 가능성
- 결제 같은 멱등하지 않은 요청이면 위험
해결:
- 서버가 **0-RTT에는 GET만** 허용
- Single-use 토큰 활용
- 애플리케이션 계층에서 멱등성 설계
4. Stream Multiplexing — HOL 없는 진짜 병렬
QUIC 스트림의 독립성
각 스트림이 **자기만의 전송 상태 머신**을 가진다.
- Stream 3의 패킷 손실 → Stream 3만 재전송 대기
- Stream 5, 7은 **정상 처리**
TCP에서는 OS 커널의 TCP 스택이 한 시퀀스 번호 공간을 관리하므로 불가능. QUIC은 유저스페이스라 자유롭다.
Stream ID 규칙
양방향 스트림 (Client Init): 0, 4, 8, 12, ...
양방향 스트림 (Server Init): 1, 5, 9, 13, ...
단방향 스트림 (Client Init): 2, 6, 10, 14, ...
단방향 스트림 (Server Init): 3, 7, 11, 15, ...
각 방향이 독립된 2비트 식별자를 가진다.
Flow Control 이중 구조
- **스트림 레벨** — 각 스트림별 window
- **연결 레벨** — 전체 연결의 window
둘 다 넘지 않아야 전송 가능. 메모리 사용 제어의 핵심.
5. Connection Migration — 와이파이에서 LTE로 매끄럽게
TCP의 제약
TCP 연결은 **(src IP, src port, dst IP, dst port)** 4튜플로 정의. 클라이언트 IP가 바뀌면(와이파이 → 4G) 연결 끊김. 재연결에 RTT × N 소비.
QUIC의 해법: Connection ID
QUIC은 패킷에 **Connection ID**를 심는다. IP/포트가 바뀌어도 같은 Connection ID면 같은 연결.
플로우:
1. 와이파이에서 QUIC 연결 성립 (Conn ID = X)
2. 와이파이 끊김 → 모바일 LTE로 전환, 클라이언트 IP 변경
3. 클라이언트가 **같은 Conn ID**로 패킷 발송
4. 서버가 "아, 이건 기존 연결. 새 IP로 응답하겠음"
5. 사용자는 스트리밍이 끊기지 않음
Path Validation
위장된 IP로 다른 사용자 연결을 훔치지 못하게, 서버가 새 경로에 **PATH_CHALLENGE/PATH_RESPONSE**로 왕복 테스트. 응답 정상이면 경로 수용.
이 기능 덕에 모바일에서 YouTube/Meet 같은 실시간 스트림이 끊김 없이 유지된다.
6. 패킷 구조와 암호화
QUIC 패킷의 두 형태
**Long Header (핸드셰이크)**
+-+-+-+-+-+-+-+-+
|1|1| Type |...|
+-+-+-+-+-+-+-+-+
| Version (32) |
+---------------+
| DCID Length |
| DCID | (목적지 Connection ID)
+---------------+
| SCID Length |
| SCID | (출발지 Connection ID)
+---------------+
| Packet Number |
| Payload (암호화)|
+---------------+
**Short Header (데이터)**
+-+-+-+-+-+-+-+-+
|0|1|S|R|R|K|PP| (Spin, Reserved, Key phase, PN length)
+---------------+
| DCID |
+---------------+
| Packet Number |
| Payload (암호화)|
+---------------+
"모든 것이 암호화된다"
QUIC은 Connection ID를 제외한 **거의 모든 헤더 필드**까지 암호화. 네트워크 중간 장비가 QUIC 내부를 뜯어볼 수 없다. 장점: 프라이버시, 프로토콜 진화의 자유. 단점: 기존 DPI(Deep Packet Inspection)가 먹통.
Header Protection
Packet Number 필드까지 암호화된다. 이를 위해 **AEAD(AES-GCM, ChaCha20-Poly1305)** 기반의 별도 header protection 키. 이 덕에 중간에 패킷 번호를 추적해 fingerprint도 못 만듦.
7. HTTP/3 프레임과 QPACK
HTTP/3 프레임
HTTP/3는 QUIC 위에서 8개 정도의 프레임만 정의.
DATA - 본문
HEADERS - HTTP 헤더
CANCEL_PUSH - 서버 푸시 취소
SETTINGS - 설정 교환
PUSH_PROMISE - 푸시 안내
GOAWAY - 연결 종료 통지
MAX_PUSH_ID - 푸시 ID 한계
HTTP/2의 **WINDOW_UPDATE, PRIORITY, PING** 등은 QUIC으로 이동(QUIC이 이미 처리).
QPACK — HPACK의 QUIC 버전
**HPACK**(HTTP/2)은 동적 헤더 테이블을 쌍방 동기화로 관리. 문제: **순서 의존성**. 한 프레임이 늦게 오면 다음 프레임 압축 해제 불가 → HOL 재출현.
**QPACK**은 동적 테이블 업데이트와 헤더 참조를 분리. 별도 **Encoder Stream / Decoder Stream**으로 동기화. 다른 요청 스트림의 지연 없이 압축 효과 유지.
8. 혼잡 제어 — BBR이 왜 QUIC과 궁합인가
전통 Loss-based CC (Cubic)
"패킷 손실 = 혼잡 신호". 모뎀 시대엔 합리적이었다. 현대 무선망에선:
- 손실이 혼잡과 **무관**한 경우 많음 (RF 잡음)
- 버퍼블로트(bufferbloat)로 **지연만 증가**, 손실은 늦게
BBR (Bottleneck Bandwidth + RTT) — 2016 Google
"혼잡의 본질은 **bottleneck 대역폭**과 **왕복 지연**의 곱". BBR은:
1. 지속적으로 **max bandwidth** 추정
2. 지속적으로 **min RTT** 추정
3. `BDP = bandwidth × RTT`만큼만 in-flight 유지
손실이 아닌 **지연 증가**를 혼잡 신호로 사용. 버퍼블로트 환경에서 **월등한 throughput**.
QUIC이 BBR과 잘 맞는 이유
- QUIC은 유저스페이스에서 CC 알고리즘 교체 자유
- QUIC은 패킷마다 정확한 RTT 샘플 가능 (별도 ACK 없음)
- TCP는 커널 재컴파일 필요하지만 QUIC은 라이브러리 업데이트면 끝
Google은 YouTube/Search에서 QUIC+BBRv3로 **throughput +10~15%**, **rebuffering -18%** 보고.
9. WebTransport — QUIC 위의 새 메시징
WebSocket의 한계
- TCP 기반 → HOL blocking
- 한 연결이 멀티 스트림 아님
- 재연결 시 전체 상태 복원
WebTransport (2023 W3C Draft)
브라우저가 **QUIC 스트림과 데이터그램에 직접 접근**.
const transport = new WebTransport("https://example.com/chat");
await transport.ready;
// 신뢰성 스트림
const stream = await transport.createUnidirectionalStream();
const writer = stream.getWriter();
writer.write(new TextEncoder().encode("hello"));
// 비신뢰성 데이터그램 (실시간 음성/게임)
transport.datagrams.writable.getWriter().write(packet);
용도:
- 게임(순서 중요 X, 속도 중요)
- 실시간 공동작업
- 미디어 스트리밍
WebRTC가 P2P에 강점이라면 WebTransport는 **서버-클라이언트**에 최적.
10. HTTP/3 채택률과 현실
실측 데이터 (2025 기준)
- **Cloudflare 트래픽**: HTTP/3 **35~40%**
- **Google 자체 서비스** (YouTube 등): **75%+**
- **Meta (Facebook/Instagram)**: 대부분 QUIC
- **상위 1000 사이트 중 HTTP/3 지원**: 약 30%
브라우저 지원:
- Chrome 87+, Firefox 88+, Safari 14+, Edge 87+ 모두 지원
- 모바일에서 채택이 빠름 (네트워크 스위칭 이득 큼)
서버 구현:
- **nginx** — 1.25+ 실험적 지원
- **Envoy** — 완전 지원
- **Caddy** — 기본 ON
- **Cloudflare** — 완전 운영
- **HAProxy** — 2.6+ 지원
"왜 아직 100%가 아닌가"
- **UDP 방화벽 차단** — 사내 네트워크가 UDP/443을 막음
- **중간 장비** (NAT, 로드밸런서)의 QUIC 미지원
- **CPU 오버헤드** — 유저스페이스 처리 비용 (TCP는 커널에서 최적화된 지) ~+30% CPU
- **관측성 도구 부족** — 암호화 때문에 기존 pcap 분석 어려움
11. QUIC 구현체들
Go
- **quic-go** — 가장 활발, Cloudflare 기여
- **Caddy**가 이걸 씀
Rust
- **quinn** — 프로덕션급, Cloudflare pingora
- **quiche** — Cloudflare 자체, FFI로 C에서도 사용
C/C++
- **lsquic** — LiteSpeed, 매우 빠름
- **msquic** — Microsoft, Windows 커널 지원
- **ngtcp2** — 라이브러리, nginx가 사용 중
성능 경쟁
벤치마크마다 다르지만 **msquic, quiche, lsquic**이 상위권. 단일 코어 **1M req/s** 달성 사례도.
12. 운영 이슈들 — 현장에서 마주치는 문제
이슈 1: UDP 버퍼 튜닝
리눅스 기본 `net.core.rmem_max` 212KB로는 QUIC 부하에 부족. 운영 권장:
net.core.rmem_max = 7500000
net.core.wmem_max = 7500000
이슈 2: 세그멘테이션 오프로드
**GSO (Generic Segmentation Offload)** / **GRO (Receive)** 활성화로 QUIC 처리량 2~3배 향상. 커널 5.0+, NIC 지원 필수.
이슈 3: 방화벽 정책
- UDP 플러드로 오인 → 드롭
- 해결: QUIC 트래픽 별도 레이트 리밋 정책
- Cloudflare/Fastly는 스캐닝 공격 패턴 탐지로 구분
이슈 4: 관측성
- **qlog** — QUIC 전용 구조화 로그 포맷
- **qvis** — qlog 시각화 도구
- Wireshark 4.0+ QUIC 파싱 지원 (TLS 키 로그 필요)
이슈 5: Anycast + Connection ID
- Anycast로 가까운 노드로 라우팅되지만, BGP 변화 시 다른 노드로 갈 수 있음
- 이때 Connection ID 기반 **stateless reset** 또는 **로드밸런서의 routing consistent hash**로 해결
13. QUIC vs WebSocket vs SSE 선택 가이드
| 요구사항 | 추천 |
|---|---|
| 단방향 서버→클라이언트 실시간 알림 | SSE (간단, HTTP/2 이상) |
| 양방향 메시징, 낮은 지연 | WebSocket 또는 WebTransport |
| 대량 병렬 스트림 | WebTransport |
| 실시간 미디어 (순서 < 지연) | WebTransport 데이터그램 또는 WebRTC |
| 모바일 + 연결 안정성 | WebTransport |
| 브라우저 호환성 최대 | WebSocket (당분간) |
14. 실전 체크리스트 12가지
1. **CDN부터 QUIC ON** — 서버 구현은 CDN이 대신 해준다
2. **Alt-Svc 헤더 설정** — 브라우저에게 "나 HTTP/3도 돼"를 알림
3. **0-RTT는 GET에만** — 결제 등 비멱등은 금지
4. **UDP 버퍼 튜닝 / GSO ON** — 안 하면 성능 절반
5. **BBRv3 고려** — CUBIC보다 현대 네트워크에 유리
6. **방화벽에 UDP/443 명시 허용** — 사내 네트워크 잊지 말기
7. **qlog 기본 ON** — 디버깅 어렵지 않게
8. **Connection Migration 테스트** — 모바일 앱 QA 시 와이파이↔LTE 전환
9. **성능은 TCP 대비 반드시 측정** — CPU 오버헤드가 실제로 병목
10. **L4 LB는 Connection ID 인식 필요** — 아니면 Migration 깨짐
11. **QPACK 동적 테이블 사이즈** — 메모리 vs 압축률 트레이드오프
12. **WebTransport PoC**부터 — 기존 WebSocket을 한 번에 바꾸지 말기
다음 글 예고 — WebAssembly의 세계
HTTP/3가 "전송 계층의 근본 재설계"라면, **WebAssembly**는 "브라우저 런타임의 근본 재설계"다. 다음 글에서는:
- **WebAssembly의 탄생** — asm.js에서 Wasm 1.0까지
- Wasm의 바이트코드 구조와 **실행 모델**
- 왜 **sandbox**가 강력한가 — Capability-based security
- **WASI** — 브라우저 밖의 Wasm (Docker의 대항마?)
- **Wasmtime, Wasmer, WasmEdge** 런타임 비교
- **Component Model** — 언어 중립 모듈 시스템
- 엣지 컴퓨팅 (Cloudflare Workers, Fastly)의 Wasm 전략
- GC 제안과 Wasm의 미래
"브라우저에서 C++을 돌린다"는 낭만이 어떻게 **백엔드를 바꾸는 현실**이 됐는지 짚어보자.
> **"Wasm은 플랫폼 중립 바이트코드라는 JVM의 꿈을 30년 뒤에 다시 시도하는 프로젝트다. 이번엔 브라우저가 기본이라는 점이 다르다."**
현재 단락 (1/222)
30년 된 질문: TCP가 1974년 설계 이후 이렇게 오래 살아남은 이유는? 답: **작동하니까**.