왜 HTTP/2와 HTTP/3를 공부하게 되었나
웹사이트 성능 개선하다가 "HTTP/2 활성화하면 빨라집니다"라는 말을 봤습니다. "지금도 HTTP 쓰는데, HTTP/2가 뭐가 다르지?" 그냥 넘어가려다가, Chrome DevTools의 Network 탭을 열었습니다.
Performance 탭에서 Waterfall을 보는데, 파일 20개가 줄 서서 순차적으로 다운로드되고 있더라고요. 마치 은행 창구 하나만 열려 있고 20명이 줄 서 있는 느낌이었습니다. "왜 파일 20개가 줄 서서 다운로드되는 거야? 동시에 받으면 안 되나?" 이게 제 출발점이었습니다.
그때 처음으로 브라우저가 실제로 웹페이지를 어떻게 로딩하는지 시각적으로 봤고, 이 Waterfall 차트가 왜 폭포처럼 길게 늘어지는지 궁금했습니다. 나중에 알고 보니 이게 바로 HTTP/1.1의 근본적인 한계였습니다.
처음엔 뭐가 이해가 안 갔나
HTTP 버전 업그레이드가 이렇게 복잡할 줄 몰랐습니다.
- HTTP/1.1 → HTTP/2 → HTTP/3: 숫자만 올라가는 건가? 새 기능 몇 개 추가된 거 아닌가?
- "Multiplexing"이 뭐길래 빨라진다는 거지?: 영어로는 다중화라는데, 도대체 뭘 다중화한다는 건지
- HTTP/3가 UDP를 쓴다고?: UDP는 신뢰성 없는 프로토콜인데, 웹에서 UDP를 쓴다고? 파일 손실되면 어떡하지?
- 0-RTT가 뭐야?: Round Trip Time이 0이라는 게 가능한가? 물리 법칙을 거스르나?
처음엔 단순히 "최신 버전이니까 더 빠르겠지" 정도로만 생각했는데, 파고들수록 각 버전이 해결하려는 문제가 완전히 다르더라고요. 왜 바꿨는지 동기가 명확히 안 와닿았습니다. 그냥 "이론상 빠르다"는 설명만 봐서는 실감이 안 났습니다.
깨달음의 순간 - "고속도로 차선"
그러다가 이 비유를 듣고 단번에 이해했습니다:
"HTTP/1.1: 1차선 고속도로. 차(파일) 하나씩만 지나갈 수 있음. 앞 차가 느리면 뒤차도 다 느림. (Head-of-Line Blocking)
HTTP/2: 다차선 고속도로(Multiplexing). 한 도로(TCP 연결)에 여러 차선. HTML, CSS, JS가 동시에 달릴 수 있음.
HTTP/3: 터널(UDP) 고속도로. 공사 중이어도 다른 차선은 계속 달림. 패킷 하나 잃어버려도 전체가 멈추지 않음."
이게 핵심이었습니다! 결국 HTTP 진화의 역사는 "어떻게 하면 여러 파일을 더 빨리, 더 효율적으로 전송할까?"라는 문제를 푸는 과정이었던 거죠. 이 비유를 듣고 나니까 왜 HTTP/2가 Multiplexing을 도입했는지, 왜 HTTP/3가 UDP를 선택했는지가 명확하게 와닿았습니다.
HTTP/1.1의 한계 - 왜 이렇게 느렸나
Head-of-Line Blocking (줄 서기 문제)
HTTP/1.1의 가장 큰 문제는 한 번에 하나의 요청만 처리할 수 있다는 점입니다. 마치 은행 창구 하나만 열려 있는 상황과 같습니다.
[요청 순서]
1. index.html
2. style.css
3. script.js
4. image.png
[기존 HTTP/1.1]
index.html (2초) ━━━━━━
style.css (1초) ━━
script.js (1초) ━━
image.png (3초) ━━━━━━
총 7초
파일 하나씩 순차적으로만 다운로드됩니다. 앞 파일이 느리면 뒤 파일들이 다 기다려야 합니다. 이게 바로 Head-of-Line Blocking입니다. 맨 앞(Head)에 있는 요청이 느리면 뒤에 줄 선(Line) 모든 요청이 블로킹(Blocking)됩니다.
제가 처음 본 Chrome DevTools의 Waterfall이 길게 늘어진 이유가 바로 이것 때문이었습니다.
HTTP/1.1 시대의 Workaround - 개발자들의 꼼수
개발자들은 이 한계를 극복하기 위해 온갖 꼼수를 썼습니다. 지금 생각하면 웃긴데, 당시엔 이게 "Best Practice"였습니다.
1. Domain Sharding (도메인 쪼개기)
브라우저는 보통 도메인당 6개 TCP 연결을 동시에 엽니다. 그래서 개발자들은 리소스를 여러 도메인으로 쪼갰습니다.
연결 1 (example.com): index.html
연결 2 (static1.example.com): style.css
연결 3 (static2.example.com): script.js
연결 4 (cdn1.example.com): image1.png
연결 5 (cdn2.example.com): image2.png
연결 6 (cdn3.example.com): image3.png
이렇게 하면 도메인이 3개니까 최대 18개 연결을 쓸 수 있었습니다. 하지만 이것도 비효율적입니다:
- DNS lookup × 3회 (각 도메인마다)
- TCP handshake × 18회 (시간 낭비)
- TLS handshake × 18회
- 서버 부담 증가 (연결 관리 오버헤드)
2. CSS Sprite Sheets (이미지 합치기)
작은 아이콘 50개를 로딩하려면 50번의 HTTP 요청이 필요했습니다. 그래서 50개 아이콘을 하나의 큰 이미지로 합쳐서, CSS background-position으로 잘라 쓰는 방식이 유행했습니다.
.icon-home {
background: url('sprites.png') 0 0;
}
.icon-user {
background: url('sprites.png') -20px 0;
}
유지보수 지옥이었지만, 어쩔 수 없었습니다.
3. CSS/JS Concatenation (파일 합치기)
CSS 파일 10개, JS 파일 15개를 각각 하나씩 합쳐서 bundle.css, bundle.js 두 파일로 만들었습니다. Webpack, Rollup 같은 번들러가 등장한 이유 중 하나가 바로 이겁니다.
문제는 파일 하나만 수정해도 전체 번들을 다시 다운로드해야 했습니다. 캐싱 효율이 최악이었죠.
이 모든 Workaround가 HTTP/2 이후엔 불필요해졌습니다. 지금 이해했다: 예전 개발 방식들이 왜 저렇게 복잡했는지. 프로토콜의 한계를 애플리케이션 레벨에서 우회하려다 보니 복잡도가 폭발했던 겁니다.
HTTP/2의 개선사항 - 드디어 병렬 처리
2015년에 HTTP/2가 등장했습니다. Google의 SPDY 프로토콜을 기반으로 만들어졌고, 핵심 목표는 "한 연결로 모든 걸 해결하자"였습니다.
1. Multiplexing (병렬 처리) - 진짜 다차선 고속도로
HTTP/2의 가장 큰 혁신입니다. 하나의 TCP 연결에서 여러 요청과 응답을 동시에 처리할 수 있습니다.
[HTTP/2 Multiplexing]
하나의 TCP 연결에서:
index.html ━━━━━━
style.css ━━
script.js ━━
image.png ━━━━━━
총 3초 (가장 긴 파일 기준)
어떻게 가능한가? Stream과 Frame이라는 개념을 도입했습니다.
Stream과 Frame의 구조
HTTP/2는 데이터를 작은 조각(Frame)으로 쪼개서 보냅니다. 각 요청/응답은 하나의 Stream으로 관리됩니다.
[하나의 TCP 연결]
┌─────────────────────────────────────┐
│ Frame 1 (Stream 1: index.html) │
│ Frame 2 (Stream 2: style.css) │
│ Frame 3 (Stream 1: index.html) │
│ Frame 4 (Stream 3: script.js) │
│ Frame 5 (Stream 2: style.css) │
│ Frame 6 (Stream 1: index.html) │
└─────────────────────────────────────┘
서버는 Frame을 번갈아가며 보내고, 클라이언트는 Stream ID를 보고 조합합니다. 마치 편지를 찢어서 보내는데 각 조각에 번호가 적혀 있어서 나중에 맞출 수 있는 것과 같습니다.
Stream Prioritization (우선순위)
모든 Stream이 똑같이 중요한 건 아닙니다. HTML은 빨리 로딩해야 하고, 배너 광고 이미지는 나중에 와도 됩니다.
HTTP/2는 Stream에 우선순위를 부여할 수 있습니다:
[Stream Priority]
Stream 1 (index.html) : Priority 256 (highest)
Stream 2 (style.css) : Priority 220
Stream 3 (script.js) : Priority 220
Stream 4 (ad-banner.jpg) : Priority 2 (lowest)
브라우저가 우선순위를 설정하면, 서버는 중요한 리소스부터 먼저 보냅니다.
Flow Control (흐름 제어)
Stream마다 독립적인 flow control이 있습니다. 느린 클라이언트가 한 Stream을 처리하는 동안, 다른 Stream은 계속 데이터를 받을 수 있습니다.
Stream 1: [Window Size: 65535 bytes] ✓ 받을 준비 됨
Stream 2: [Window Size: 0 bytes] ✗ 아직 처리 중
Stream 3: [Window Size: 32768 bytes] ✓ 절반 준비 됨
이 모든 게 하나의 TCP 연결에서 일어납니다. 이제 Domain Sharding이 불필요해진 이유를 이해했습니다.
2. Binary Framing (바이너리 프레이밍) - 텍스트를 버리다
HTTP/1.1은 텍스트 기반 프로토콜입니다:
GET /index.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: text/html,application/xhtml+xml
Accept-Encoding: gzip, deflate, br
사람이 읽기 편하지만, 컴퓨터가 파싱하기엔 비효율적입니다. 줄바꿈(\r\n)을 찾아야 하고, 헤더 이름과 값을 분리해야 하고, 대소문자 구분도 해야 합니다.
HTTP/2는 바이너리로 전환했습니다:
[HTTP/2 Binary Frame]
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
- 파싱 빠름: 고정 길이 필드라 바로 읽을 수 있음
- 압축 효율 좋음: 바이너리는 중복 패턴이 많아 압축 효과가 큼
- 오버헤드 감소: 불필요한 공백, 줄바꿈 제거
처음엔 "사람이 못 읽으면 디버깅 어렵지 않나?" 걱정했는데, Chrome DevTools가 알아서 해석해주니까 문제없더라고요.
3. Server Push: 선물같은 기능... 이었으면 좋았을 것을
Server Push는 이론상 굉장히 매력적인 기능이었습니다.
[클라이언트]
GET /index.html
[서버]
여기 index.html
(아, 이 페이지엔 style.css도 필요하겠네?)
style.css도 같이 줄게! (미리 푸시)
클라이언트가 요청하기 전에 서버가 미리 보내줍니다. RTT를 절약할 수 있는 아름다운 아이디어였습니다.
Server Push가 실패한 이유
하지만 현실은 달랐습니다. Chrome은 2022년에 Server Push 지원을 완전히 제거했습니다. 왜일까요?
-
캐시 문제: 서버는 클라이언트의 캐시 상태를 모릅니다. 이미
style.css가 캐시에 있는데도 또 보내면 낭비입니다. -
우선순위 충돌: 서버가 "중요하다고 생각해서" 보낸 리소스가 클라이언트 입장에선 낮은 우선순위일 수 있습니다.
-
복잡도: 어떤 리소스를 푸시할지 결정하는 로직이 복잡합니다. 잘못하면 오히려 성능 저하.
-
103 Early Hints가 더 나음: 서버가 푸시하는 대신, "이런 리소스가 필요할 거야"라고 힌트만 주면 브라우저가 알아서 요청합니다. 캐시 확인도 하고, 우선순위도 브라우저가 제어합니다.
HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script
HTTP/1.1 200 OK
Content-Type: text/html
...
받아들였습니다: 좋은 아이디어가 항상 현실에서 성공하는 건 아니라는 것을. Server Push는 HTTP/2의 실패한 실험이었지만, 그 과정에서 더 나은 대안(Early Hints)을 찾았습니다.
4. Header Compression (HPACK): 헤더의 중복을 제거하다
HTTP 헤더는 매 요청마다 반복됩니다. 특히 쿠키가 크면 헤더만 수 KB씩 됩니다.
GET /page1.html
Host: example.com
User-Agent: Mozilla/5.0...
Cookie: session=abc123; user_id=456; preferences=...
Accept-Encoding: gzip, deflate, br
GET /page2.html
Host: example.com
User-Agent: Mozilla/5.0...
Cookie: session=abc123; user_id=456; preferences=...
Accept-Encoding: gzip, deflate, br
거의 똑같은 헤더를 반복해서 보냅니다.
HPACK의 작동 원리
HPACK은 Static Table + Dynamic Table로 헤더를 압축합니다.
Static Table: 자주 쓰는 헤더를 미리 정의해둔 표
Index | Header Name | Header Value
------|-------------------|-------------
1 | :authority |
2 | :method | GET
3 | :method | POST
4 | :path | /
...
15 | accept-encoding | gzip, deflate
...
Dynamic Table: 연결 중에 나온 헤더를 저장하는 표
첫 요청:
Host: example.com → Dynamic Table에 저장 (Index 62)
User-Agent: Mozilla/5.0... → Dynamic Table에 저장 (Index 63)
두 번째 요청:
Host: example.com → "Index 62 사용" (2바이트면 끝)
User-Agent: Mozilla/5.0... → "Index 63 사용"
실제 압축 효과:
[압축 전]
GET /api/users HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...
Cookie: sessionId=abc123def456; userId=789; preferences=dark_mode,lang_ko
Accept: application/json
Accept-Encoding: gzip, deflate, br
→ 약 250바이트
[HPACK 압축 후]
82 86 84 41 8c f1 e3 c2 e5 f2 3a 6b a0 ab 90 f4 ff
→ 약 30바이트 (88% 감소!)
이제 이해했다: HTTP/2가 왜 모바일 환경에서 특히 효과적인지. 헤더 압축으로 대역폭을 크게 절약하니까요.
HTTP/3 (QUIC)의 혁신 - UDP를 선택한 이유
HTTP/2가 완벽해 보였는데, 왜 HTTP/3가 나왔을까요? HTTP/2에도 해결 못한 근본적인 문제가 있었습니다. 바로 TCP 자체의 한계.
문제 1 - TCP의 Head-of-Line Blocking
HTTP/2는 애플리케이션 레벨에서 Multiplexing을 하지만, 여전히 TCP 위에서 동작합니다. TCP는 패킷 순서를 보장하기 때문에, 패킷 하나가 손실되면 모든 Stream이 멈춥니다.
[HTTP/2 over TCP]
Stream 1: HTML ━━━━━━
Stream 2: CSS ━━
Stream 3: JS ━━
Stream 4: Image ━━━━━━
[TCP 패킷 레벨]
Packet 1: HTML chunk 1 ✓
Packet 2: CSS chunk 1 ✓
Packet 3: HTML chunk 2 ✗ 손실!
Packet 4: JS chunk 1 ✓ (도착했지만 대기)
Packet 5: Image chunk 1 ✓ (도착했지만 대기)
→ Packet 3 재전송 완료할 때까지 Packet 4, 5도 블록됨
HTTP/2는 Stream을 분리했지만, TCP는 전체를 하나로 봅니다. Stream 1의 패킷이 손실되면 Stream 2, 3, 4도 다 기다립니다. 이게 TCP의 Head-of-Line Blocking입니다.
문제 2 - 연결 설정이 너무 느림
TCP + TLS 연결 설정:
[TCP 3-way Handshake]
클라이언트 → 서버: SYN
서버 → 클라이언트: SYN-ACK
클라이언트 → 서버: ACK
→ 1.5 RTT
[TLS 1.2 Handshake]
클라이언트 → 서버: ClientHello
서버 → 클라이언트: ServerHello, Certificate
클라이언트 → 서버: KeyExchange, Finished
→ 1.5 RTT
총 3 RTT (약 300ms @ 100ms latency)
페이지 하나 보려고 300ms를 기다려야 합니다. 모바일 네트워크에선 latency가 더 크니까 500ms 이상 걸리기도 합니다.
문제 3 - Connection Migration 불가
TCP는 연결을 (소스 IP, 소스 포트, 목적지 IP, 목적지 포트) 4개 값으로 식별합니다. IP가 바뀌면 연결이 끊깁니다.
[상황: 지하철에서 유튜브 시청]
WiFi (192.168.1.100) → 동영상 스트리밍 중
↓ 터널 진입, WiFi 끊김
LTE (10.20.30.40) → IP 변경!
→ TCP 연결 끊김 → 재연결 → 버퍼링...
실제로 지하철 타면서 유튜브 보면 터널 들어갈 때마다 끊기는 이유입니다.
해결 - QUIC (UDP 기반 프로토콜)
Google은 과감하게 UDP를 선택했습니다. "UDP는 신뢰성이 없는데?" 맞습니다. 그래서 UDP 위에 신뢰성을 직접 구현했습니다.
왜 UDP를 선택했나?
-
OS 커널 수정 불필요: TCP는 OS 커널에 구현되어 있어서 수정하려면 OS 업데이트가 필요합니다. UDP는 애플리케이션 레벨에서 구현 가능합니다.
-
중간 장비 간섭 없음: 방화벽, NAT 같은 중간 장비들이 TCP 패킷을 분석하고 수정합니다. UDP는 단순해서 간섭이 적습니다.
-
빠른 진화 가능: 프로토콜 개선이 필요하면 애플리케이션 업데이트만 하면 됩니다.
QUIC이 UDP 위에 추가한 것들
UDP는 거의 아무것도 안 합니다. 패킷 전송만 할 뿐, 손실 복구, 순서 보장, 혼잡 제어를 하지 않습니다. QUIC은 이 모든 걸 직접 구현했습니다.
[QUIC Stack]
┌─────────────────────────────────┐
│ HTTP/3 (애플리케이션 레이어) │
├─────────────────────────────────┤
│ QUIC (전송 레이어) │
│ - 신뢰성 보장 (재전송) │
│ - 순서 보장 (Stream별) │
│ - 혼잡 제어 │
│ - 암호화 (TLS 1.3 내장) │
├─────────────────────────────────┤
│ UDP (단순 패킷 전송) │
└─────────────────────────────────┘
HTTP/3의 핵심 개선사항
1. 0-RTT 연결 설정 - 즉시 데이터 전송
QUIC은 TLS 1.3을 내장하고, 0-RTT 연결을 지원합니다.
[첫 연결 - 1-RTT]
클라이언트 → 서버: ClientHello (암호화 협상)
서버 → 클라이언트: ServerHello + 암호화된 응답
→ 1 RTT
[재연결 - 0-RTT]
클라이언트 → 서버: 이전 세션 티켓 + 암호화된 HTTP 요청
서버 → 클라이언트: 암호화된 HTTP 응답
→ 0 RTT! 첫 패킷에 데이터 포함
재연결 시 즉시 요청을 보낼 수 있습니다. RTT를 완전히 제거한 겁니다.
0-RTT의 보안 위험: Replay Attack
하지만 0-RTT에는 치명적인 보안 문제가 있습니다. Replay Attack입니다.
[Replay Attack 시나리오]
1. Alice → 서버: "계좌 A에서 B로 $100 송금" (0-RTT)
2. 공격자가 이 패킷을 복사
3. 공격자 → 서버: (같은 패킷 재전송)
4. 서버: "OK, $100 송금 완료" (또!)
→ $200 송금됨
0-RTT 패킷은 암호화되어 있지만, 공격자가 내용을 모른 채로도 재전송할 수 있습니다.
Replay Attack 방어책
QUIC은 여러 방어 메커니즘을 씁니다:
- Idempotent (멱등성) 요청만 0-RTT 허용: GET 요청은 OK, POST 요청은 1-RTT로 강제
- Server Replay Protection: 서버가 최근 본 패킷 ID를 기억하고, 중복 감지
- 타임스탬프: 오래된 패킷은 거부
// Node.js에서 0-RTT 허용 범위 설정
const http3Server = require('http3');
http3Server.createServer({
allowEarlyData: true,
maxEarlyData: 16384, // 0-RTT로 받을 최대 바이트
earlyDataCallback: (req) => {
// GET, HEAD만 0-RTT 허용
if (req.method !== 'GET' && req.method !== 'HEAD') {
return false;
}
return true;
}
});
정리해본다: 0-RTT는 성능과 보안의 트레이드오프입니다. 안전한 요청(멱등성)만 0-RTT로 보내고, 중요한 요청(송금, 결제)는 1-RTT로 보내는 게 합리적입니다.
2. Stream 독립성 - 진짜 병렬 처리
QUIC은 TCP와 달리 Stream별로 독립적인 순서 보장을 합니다.
[QUIC Stream Independence]
Stream 1: HTML ━━━━━━ ✓
Stream 2: CSS ━━ ✓
Stream 3: JS ✗ 패킷 손실! → 재전송 중
Stream 4: Image ━━━━━━ ✓
→ Stream 3만 멈춤, Stream 1, 2, 4는 계속 진행
Stream 3의 패킷 손실이 다른 Stream에 영향을 주지 않습니다. TCP의 Head-of-Line Blocking을 완전히 해결했습니다.
3. Connection Migration: 네트워크 전환에도 끊기지 않음
QUIC은 연결을 Connection ID로 식별합니다. IP나 포트가 바뀌어도 Connection ID가 같으면 연결이 유지됩니다.
[QUIC Connection Migration]
WiFi (IP: 192.168.1.100, Connection ID: 0x1a2b3c4d)
→ Stream 1: 동영상 다운로드 중...
[WiFi 끊김, LTE로 전환]
LTE (IP: 10.20.30.40, Connection ID: 0x1a2b3c4d)
→ 같은 Connection ID
→ Stream 1: 계속 다운로드 (끊김 없음!)
실제 시나리오: 지하철에서 스마트폰으로 유튜브 시청
[기존 HTTP/2 over TCP]
역 안 (WiFi) → 동영상 스트리밍
터널 진입 → WiFi 끊김 → TCP 연결 끊김
터널 내 (LTE) → 재연결 (3 RTT) → 버퍼링 3초
→ 사용자: "아 왜 끊겨!"
[HTTP/3 over QUIC]
역 안 (WiFi) → 동영상 스트리밍
터널 진입 → WiFi 끊김 → Connection ID 유지
터널 내 (LTE) → 즉시 재개
→ 사용자: "어? 안 끊기네?"
이거 받아들였을 때 진짜 놀랐습니다. "네트워크 전환에도 연결 유지"라는 게 가능하다니. 모바일 시대에 딱 맞는 프로토콜이었습니다.
적용 - 내 사이트에서 HTTP/2와 HTTP/3 쓰기
HTTP 버전 확인하기
먼저 내 사이트가 어떤 HTTP 버전을 쓰는지 직접 확인해봤다.
방법 1: Chrome DevTools
- Chrome DevTools 열기 (F12)
- Network 탭 클릭
- 헤더 영역에서 우클릭 → "Protocol" 컬럼 추가
- 페이지 새로고침
Name Status Type Protocol
index.html 200 document h2
style.css 200 stylesheet h2
script.js 200 script h2
image.png 200 png h2
h2는 HTTP/2, h3는 HTTP/3입니다.
방법 2 - curl 커맨드
# HTTP/2 테스트
curl -I --http2 https://example.com
# HTTP/3 테스트 (curl 7.72.0+)
curl -I --http3 https://example.com
# 자세한 정보 보기
curl -I --http2 -v https://example.com 2>&1 | grep "ALPN"
# ALPN: server accepted h2 → HTTP/2
# ALPN: server accepted h3 → HTTP/3
HTTP/2 활성화 (Nginx)
제 사이트는 Nginx를 씁니다. HTTP/2 활성화는 한 줄이면 됩니다.
server {
listen 443 ssl http2; # http2 활성화
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# HTTP/2 Server Push (선택사항, 비추천)
# http2_push /style.css;
# http2_push /script.js;
location / {
root /var/www/html;
index index.html;
}
}
주의: http2_push는 위에서 설명했듯이 실패한 기능입니다. 쓰지 마세요.
설정 후 재시작:
sudo nginx -t # 설정 검증
sudo systemctl reload nginx
HTTP/3 활성화 (Nginx 1.25.0+)
Nginx 1.25.0부터 HTTP/3를 실험적으로 지원합니다. 컴파일 옵션에 --with-http_v3_module이 필요합니다.
server {
listen 443 ssl http2;
listen 443 quic reuseport; # HTTP/3 활성화
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# HTTP/3 지원 알림 (Alt-Svc 헤더)
add_header Alt-Svc 'h3=":443"; ma=86400';
location / {
root /var/www/html;
index index.html;
}
}
Alt-Svc 헤더는 "이 서버는 HTTP/3도 지원해요"라고 브라우저에게 알립니다. 브라우저는 다음 요청부터 HTTP/3를 시도합니다.
HTTP/3 활성화 (Cloudflare)
제 사이트는 Cloudflare를 씁니다. Cloudflare는 자동으로 HTTP/3를 지원합니다. 별도 설정 불필요!
Cloudflare 대시보드:
- 내 사이트 선택
- Network 탭
- HTTP/3 (with QUIC) → ON
# Cloudflare가 HTTP/3 지원하는지 확인
curl -I --http3 https://codemapo.com
# HTTP/3 200 ✓
Node.js에서 HTTP/2 서버 만들기
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('/path/to/key.pem'),
cert: fs.readFileSync('/path/to/cert.pem')
});
server.on('stream', (stream, headers) => {
const path = headers[':path'];
console.log(`Stream ID ${stream.id}: ${path}`);
if (path === '/') {
stream.respond({
'content-type': 'text/html',
':status': 200
});
stream.end('<h1>HTTP/2 Server</h1>');
} else if (path === '/data') {
stream.respond({
'content-type': 'application/json',
':status': 200
});
stream.end(JSON.stringify({ message: 'Multiplexing works!' }));
}
});
server.listen(8443, () => {
console.log('HTTP/2 server running on https://localhost:8443');
});
브라우저에서 https://localhost:8443을 열면 HTTP/2로 서빙됩니다. DevTools에서 h2 확인 가능합니다.
성능 비교 - 실제로 얼마나 빠를까?
제가 실제로 측정한 결과입니다 (Next.js 사이트, 파일 20개, Cloudflare CDN).
측정 환경
- 사이트: codemapo.com
- 파일: HTML 1개, CSS 3개, JS 5개, 이미지 11개 (총 20개)
- 네트워크: 4G LTE (latency ~50ms)
- 브라우저: Chrome 120
결과
| 프로토콜 | 로딩 시간 | 비고 |
|---|---|---|
| HTTP/1.1 | 2.8초 | 파일 20개 순차 로딩 (6개 연결) |
| HTTP/2 | 1.2초 | Multiplexing 효과 (1개 연결) |
| HTTP/3 | 1.0초 | 0-RTT 재연결, Stream 독립성 |
HTTP/2는 HTTP/1.1 대비 2.3배 빠름 HTTP/3는 HTTP/2 대비 1.2배 빠름
Waterfall 비교
HTTP/1.1: 계단식 (순차 로딩)
index.html ━━━━━━
style1.css ━━
style2.css ━━
script.js ━━━━
image1.png ━━━
HTTP/2: 병렬 로딩
index.html ━━━━━━
style1.css ━━
style2.css ━━
script.js ━━━━
image1.png ━━━
image2.png ━━━
...
HTTP/3: 병렬 + 빠른 재연결
(캐시에서 재방문)
0-RTT 연결 (즉시) → 모든 파일 병렬 로딩
이 차이가 와닿았습니다. 숫자만 봐선 몰랐는데, Waterfall 차트로 보니까 명확하더라고요.
정리하면 - HTTP 진화의 본질
HTTP 진화의 핵심을 정리해본다:
1. HTTP/1.1 → HTTP/2: 병렬 처리의 시작
문제: Head-of-Line Blocking, 파일마다 TCP 연결 필요 해결: Multiplexing (한 연결로 모든 파일), Binary Framing (효율적 파싱), HPACK (헤더 압축) 효과: 성능 2~3배 향상, Domain Sharding/Sprite Sheet 불필요
2. HTTP/2 → HTTP/3: TCP의 한계 극복
문제: TCP의 Head-of-Line Blocking, 느린 연결 설정, Connection Migration 불가 해결: QUIC (UDP 기반), Stream 독립성, 0-RTT 연결, Connection ID 효과: 모바일 환경에서 특히 빠름, 네트워크 전환 시 끊김 없음
3. 일반 사용자는 신경 안 써도 됨
브라우저와 CDN이 알아서 최적의 프로토콜을 선택합니다. 우리는 그냥 쓰면 됩니다.
하지만 개발자라면 알아야 합니다:
- 왜 예전엔 파일을 합쳤는지 (HTTP/1.1의 한계)
- 왜 이젠 안 합쳐도 되는지 (HTTP/2의 Multiplexing)
- 왜 0-RTT가 위험할 수 있는지 (Replay Attack)
- 왜 모바일에서 HTTP/3가 특히 좋은지 (Connection Migration)
처음엔 "HTTP면 다 똑같은 거 아닌가?" 싶었지만, 지금은 웹 성능 최적화할 때 HTTP/2와 HTTP/3 지원 여부를 필수로 확인합니다. 결국 이거였다: 프로토콜의 진화는 단순히 "빠른 것"만이 아니라, 웹 개발 방식 자체를 바꿨습니다. 더 이상 파일을 억지로 합치거나 도메인을 쪼갤 필요가 없습니다. 프로토콜이 똑똑해지니까 개발이 단순해졌습니다.