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     ← +182ms

HTTP 바이트가 한 개도 움직이지 않은 채 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.32 왕복
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  Accept

89바이트다. 다만 이건 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=86400

ma=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)에 붙어 확인했다.