HTTP/3는 TCP를 쓰지 않는다. UDP 위에 QUIC이라는 새 전송 계층을 얹고 그 위에서 돈다.
프로토콜 하나 고치자고 전송 계층을 통째로 갈아 끼운 것이다. 무엇이 그만큼 아팠는지, 그리고 바꿔서 실제로 무엇이 줄었는지가 이 글의 내용이다.
배경 — HTTP/2가 못 푼 것이 남아 있었다
HTTP/1.1의 문제는 연결 하나에 요청 하나였다. 응답이 올 때까지 그 연결로 다음 요청을 못 보낸다. 브라우저는 연결을 여러 개 열어 우회했지만 그것도 6개 남짓이 한계였다.
HTTP/2가 이걸 풀었다. 하나의 연결에 스트림을 여러 개 두고 요청을 동시에 실어 보낸다. 응답도 뒤섞여 온다. 연결을 여러 개 열 필요가 없어졌다.
그런데 한 겹 아래가 남아 있었다. TCP는 바이트 스트림 하나를 순서대로 배달하는 것이 일이다. 중간 패킷 하나가 유실되면 그 뒤에 도착한 것들이 다 멀쩡해도 커널이 애플리케이션에 넘기지 않는다. 빠진 조각이 재전송돼 채워질 때까지 기다린다.
그 결과가 이렇다.
graph TD subgraph HTTP/2 위에서 A["스트림 A: 패킷 유실"] --> W["TCP가 순서를 지키느라 대기"] B["스트림 B: 멀쩡히 도착"] --> W C["스트림 C: 멀쩡히 도착"] --> W W --> R["A가 채워질 때까지 B·C도 못 올라간다"] end
HTTP/2가 애플리케이션 계층에서 없앤 줄서기가 전송 계층에 그대로 남은 것이다. 이것을 head-of-line 블로킹이라고 부른다.
TCP를 고쳐서 풀 수는 없었다. TCP는 커널에 있고 전 세계 중간 장비가 그 동작을 전제로 만들어져 있다. 그래서 TCP를 고치는 대신 안 쓰기로 한 것이 HTTP/3다.
핸드셰이크 — 무엇을 줄이려 했나
전송 계층을 갈면서 같이 얻은 것이 있다. 핸드셰이크 왕복이다.
기존 방식은 계층이 층층이 쌓여 있고, 각 층의 왕복이 서로 겹쳐지지 않는다. TCP가 끝나야 TLS를 시작할 수 있고, TLS가 끝나야 HTTP를 보낼 수 있다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C,S: HTTP/1.1 · HTTP/2 (TCP + TLS 1.3) C->>S: SYN S->>C: SYN-ACK C->>S: ACK Note right of S: 1왕복 — TCP 수립 C->>S: ClientHello (ALPN 포함) S->>C: ServerHello, 인증서, Finished Note right of S: 2왕복 — 키 교환 C->>S: HTTP 요청 S->>C: HTTP 응답
실제로 그렇게 도는지 재 봤다. 이 사이트에 붙어 단계별 시각을 찍은 것이다.
$ curl -s -o /dev/null -w "..." https://createworld.net/
DNS 0.014224s
TCP완료 0.159300s ← +145ms
TLS완료 0.373693s ← +214ms
첫바이트 0.556113s ← +182msHTTP 바이트가 한 개도 움직이지 않은 채 0.37초가 지나간다. 계층 분리라는 설계가 지연을 직렬로 누적시킨 결과다.
QUIC이 한 일은 그 두 층을 하나로 합친 것이다. 전송 계층 자체가 암호를 품는다 — TLS 1.3 핸드셰이크 메시지가 QUIC 핸드셰이크 패킷 안에 실려 간다. 연결 수립과 키 교환이 같은 왕복에서 끝난다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C,S: HTTP/3 (QUIC) — 첫 연결 C->>S: Initial (ClientHello 동봉) S->>C: Initial + Handshake (ServerHello, 인증서) Note right of S: 1왕복 — 수립과 키 교환이 한 번에 C->>S: HTTP 요청 S->>C: HTTP 응답
한 번 더 줄인 것이 0-RTT다. 전에 이야기한 적 있는 서버면 파라미터가 캐시돼 있으니 요청을 첫 패킷에 같이 실어 보낸다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C,S: HTTP/3 — 재접속 (0-RTT) C->>S: Initial + HTTP 요청 (같은 패킷) S->>C: HTTP 응답 Note right of S: 왕복 0 — 요청이 첫 패킷에 탄다
정리하면 이렇다.
| HTTP 첫 바이트까지 | |
|---|---|
| HTTP/1.1 · HTTP/2 + TLS 1.3 | 2 왕복 |
| HTTP/3 첫 연결 | 1 왕복 |
| HTTP/3 재접속 | 0 왕복 |
그래서 이 이득은 “몇 퍼센트”로 말할 수 없다. 절약한 왕복 수에 RTT를 곱한 값이다. 같은 랜에서는 무시할 만하고, RTT 100ms인 모바일 회선에서는 100~200ms가 사라진다. 지연이 클수록 커진다.
위 세 그림은 프로토콜 구조를 그린 것이지 패킷을 잡아서 본 것이 아니다. 와이어 캡처는 관리자 권한이 필요해 하지 않았다.
0-RTT는 공짜가 아니다. 첫 패킷에 실린 요청은 재전송 공격에 노출된다 — 공격자가 그 패킷을 그대로 다시 보내면 서버가 또 처리한다. 그래서 멱등한 요청에만 써야 한다. 결제나 주문 생성에 0-RTT를 켜면 안 된다.
헤더 — 바이트는 어디서 갈리나
왕복과 별개로 요청마다 반복되는 비용이 있다. 헤더다.
HTTP/1.1은 헤더가 평문이고 매 요청 전부 다시 보낸다. 얼마나 되는지 재 봤다. 받은 요청의 요청줄과 헤더를 그대로 세는 서버를 띄우고 curl로 붙은 결과다.
protocol=HTTP/1.1
헤더총합=89바이트
28 (요청줄)
24 User-Agent
22 Host
13 Accept89바이트다. 다만 이건 curl이라 작다. 브라우저는 Accept-Language, Accept-Encoding, Sec-Fetch-*, User-Agent가 훨씬 길고 쿠키까지 붙어 수백 바이트가 된다.
그리고 그게 요청 수만큼 곱해진다. 이 사이트의 글 한 페이지를 브라우저로 열면 요청이 55건이었다. 헤더 대부분이 매번 똑같은데도 55번 다시 간다.
HTTP/3는 QPACK으로 이걸 줄인다. 흔한 헤더는 정적 테이블의 번호로 대신하고, 그 연결에서 이미 보낸 헤더는 동적 테이블의 인덱스로 가리킨다. 같은 헤더가 두 번째부터는 몇 바이트짜리 참조가 된다.
그런데 이 공은 대부분 HTTP/2 것이다
여기가 흔히 뭉뚱그려지는 지점이다.
헤더 압축을 가져온 것은 HTTP/2의 HPACK이다. HTTP/3의 QPACK은 압축률이 HPACK과 비슷하다. QPACK이 다른 점은 헤더 디코더에서 순서 의존을 없앤 것이지 더 작게 만드는 게 아니다. HPACK은 동적 테이블 갱신이 순서대로 도착해야 해서, QUIC처럼 스트림이 제각각 도착하는 환경에서는 그 자체가 새로운 줄서기를 만든다. QPACK은 그걸 피하려고 다시 설계한 것이다.
| 개선 | 누구의 공 |
|---|---|
| 요청 다중화 | HTTP/2 |
| 헤더 압축 | HTTP/2(HPACK) |
| 전송 계층 HOL 제거 | HTTP/3(QUIC) |
| 핸드셰이크 왕복 축소 | HTTP/3(QUIC) |
| 헤더 압축의 순서 의존 제거 | HTTP/3(QPACK) |
그래서 “HTTP/1.1 대비 HTTP/3가 몇 배”라는 질문은 두 개의 다른 개선을 뭉뚱그린다. 그 숫자의 상당 부분은 HTTP/2가 이미 가져간 몫이다. 비교하려면 h1→h2와 h2→h3를 갈라서 봐야 한다.
협상 — h3로 바로 시작할 수 없다
브라우저는 처음 보는 출처에 곧바로 h3로 붙지 못한다. UDP로 QUIC을 시도해도 서버가 받는지 알 수 없고, 중간 장비가 UDP를 막고 있을 수도 있다.
그래서 순서가 이렇다. 먼저 TCP로 붙고, 서버가 응답 헤더에 “나는 h3도 받는다”고 광고하면, 그다음부터 h3로 옮겨 간다. 그 광고가 Alt-Svc 헤더다.
이 사이트에서 실제로 확인한 것이다.
$ curl -s -D - -o /dev/null https://createworld.net/
HTTP/1.1 200 OK
Server: cloudflare
alt-svc: h3=":443"; ma=86400ma=86400은 이 정보를 하루 동안 기억하라는 뜻이다. 그래서 첫 방문은 TCP로 가고 두 번째 방문부터 h3가 된다.
curl로는 h3 통신 자체를 볼 수 없다. 이 글을 쓰며 확인한 두 환경 모두 HTTP/3를 지원하지 않았다 — 윈도우 8.8.0(Schannel), WSL 7.81.0. h3를 쓰려면 별도로 빌드된 curl이 필요하다. 도구가 아직 고르게 퍼지지 않았다는 뜻이기도 하다.
ASP.NET Core에서 켜기
Kestrel은 HTTP/3를 지원한다. 엔드포인트마다 프로토콜을 고른다.
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5401, listen =>
{
listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
listen.UseHttps(); // h3는 TLS가 필수다
});
});
var app = builder.Build();
app.Use(async (ctx, next) =>
{
ctx.Response.Headers.AltSvc = "h3=\":5401\"; ma=86400";
await next();
});
app.MapGet("/", (HttpContext c) => $"protocol={c.Request.Protocol}\n");
app.Run();Alt-Svc를 직접 넣어 준 것에 주의한다. 앞 절에서 본 대로 브라우저는 이 헤더를 보고서야 h3로 옮겨 간다. 프로토콜만 켜고 광고를 안 하면 h3 리스너는 떠 있는데 아무도 안 온다.
띄우고 나면 같은 포트 번호에 TCP와 UDP가 나란히 뜬다.
$ netstat -ano | grep 5401
TCP 0.0.0.0:5401 0.0.0.0:0 LISTENING 48192
UDP 0.0.0.0:5401 *:* 48192같은 프로세스(PID 48192)가 TCP로는 h1·h2를, UDP로는 h3를 받는다. HTTP/3가 UDP 위에 있다는 사실이 여기서 눈에 보인다.
응답 헤더도 확인된다.
$ curl -k -s -D - -o /dev/null --http1.1 https://localhost:5401/
HTTP/1.1 200 OK
Alt-Svc: h3=":5401"; ma=86400전제 조건이 둘 있다. 윈도우는 11 이상이어야 하고(QUIC 구현인 msquic이 그 이후 것이다), TLS 인증서가 필요하다. msquic 자체는 .NET 런타임에 동봉돼 따로 깔 것이 없다.
개발용 인증서가 신뢰 저장소에 없으면 브라우저가 경고 페이지를 띄우고 h3 협상까지 가지 못한다. 로컬에서 h3를 끝까지 확인하려면
dotnet dev-certs https --trust가 필요하다.
대가
- UDP가 막힌 환경이 있다. 일부 기업망과 공용 와이파이가 UDP를 제한한다. 그래서 h3는 항상 TCP 경로를 남겨 둔 채 부가 경로로 광고된다. h3만 켜고 h1·h2를 끄는 선택지는 사실상 없다.
- CPU를 더 쓴다. TCP는 커널이 처리하고 하드웨어 오프로드도 받지만, QUIC은 대부분 사용자 공간에서 돌고 패킷마다 암호화가 붙는다. 손실이 없는 빠른 회선에서는 h3가 오히려 느릴 수 있다.
- 도구가 덜 여물었다. 위에서 본 대로 흔한
curl이 h3를 못 쓴다. 패킷을 떠도 QUIC은 페이로드가 암호화돼 있어 TCP처럼 훑어보기 어렵다. - 이득이 조건에 달렸다. 왕복 축소는 RTT가 클수록, HOL 제거는 손실률이 높을수록 값이 커진다. 모바일과 원거리에서 크고, 사내망에서는 거의 없다.
참고
- HTTP/3는 왜 UDP를 선택한 것일까? — 이 글을 쓰게 된 출발점이다. QUIC이 TCP의 무엇을 피하려 했는지에 대한 문제 설정을 참고했다. 2019년 글이라 HTTP/3가 아직 표준화 전(RFC 9114는 2022년)이던 시점의 서술이라는 점은 감안해서 읽어야 한다.
- 이 글의 측정은 전부 직접 한 것이다. 핸드셰이크 단계별 시각과
Alt-Svc는 이 사이트에, 헤더 바이트와 Kestrel 설정은 로컬에 띄운 ASP.NET Core 앱(.NET 10)에 붙어 확인했다.