컴퓨터공학 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 협상 줄이 어떤 버전이 선택됐는지 알려 준다.

확인 문제

  1. HTTP/2 에서 여러 요청이 한 연결에 섞여 흘러도 응답을 올바로 조립할 수 있는 이유는?
  2. HTTP/2 가 손실이 많은 망에서 HTTP/1.1(연결 여러 개)보다 불리할 수 있는 이유는?
  3. QUIC 이 TCP 위가 아니라 UDP 위에 만들어진 이유를 두 가지 들라.
  4. 0-RTT 데이터를 멱등 요청에만 써야 하는 이유는?
  5. 클라이언트가 어떤 서버가 HTTP/3 를 지원한다는 걸 처음 알게 되는 대표적 방법은?

풀이

  1. 모든 프레임이 스트림 ID 를 갖고 있어, 받는 쪽이 스트림별로 프레임을 모을 수 있기 때문이다.
  2. 모든 스트림이 TCP 바이트 스트림 하나를 공유하므로, 패킷 하나의 손실이 재전송될 때까지 모든 스트림을 멈추기 때문이다.
  3. 새 전송 프로토콜은 중간 장비(NAT, 방화벽)를 통과하기 어렵지만 UDP 는 이미 통과하고, 운영체제 커널을 바꾸지 않고 사용자 공간에서 구현·개선할 수 있기 때문이다.
  4. 0-RTT 데이터는 공격자가 가로채 다시 보낼 수 있어(재전송 공격), 같은 요청이 두 번 처리돼도 문제없는 요청에만 써야 한다.
  5. HTTP/1.1 이나 HTTP/2 응답에 담긴 Alt-Svc 헤더다.

더 읽을거리 (References)