QUIC / HTTP/3 (RFC 9000, 9114, 9204)

QUIC(Quick UDP Internet Connections) 전송 프로토콜(Transport Protocol, RFC 9000)과 TLS 1.3 통합(RFC 9001), 손실 감지·혼잡 제어(Loss Detection and Congestion Control, RFC 9002), HTTP/3(RFC 9114), QPACK 헤더 압축(Header Compression, RFC 9204), HTTP 우선순위(Extensible Priorities, RFC 9218), WebSocket over HTTP/3(RFC 9220), QUIC v2(RFC 9369)를 기초 개념부터 프로토콜 바이트 레벨, 리눅스 커널 최적화, 구현체 생태계, 보안, 디버깅(Debugging)까지 최신 표준 문서를 기준으로 종합 정리합니다.

전제 조건: 네트워크 스택(Network Stack), UDP, TCP 문서를 먼저 읽으세요. QUIC은 UDP 위에서 동작하고 TLS 1.3의 키 교환(Key Exchange)을 그대로 사용하므로, UDP의 데이터그램(Datagram) 모델과 TLS 핸드셰이크(Handshake) 흐름을 알고 있으면 이해가 훨씬 빠릅니다. TCP와의 차이를 비교하는 것도 큰 도움이 됩니다.
일상 비유: TCP + TLS + HTTP/2 조합은 한 대의 리프트(엘리베이터)로 여러 층을 오가는 건물과 같습니다. 한 승객이 지연되면(패킷 손실) 뒤의 모든 승객이 같은 리프트에서 막힙니다(Head-of-Line Blocking). QUIC은 각 층마다 독립된 작은 리프트(스트림)를 두는 방식입니다. 하나가 막혀도 나머지는 계속 움직이고, 건물(연결)은 한 번만 지어 놓으면(핸드셰이크) 재사용할 수 있습니다.

핵심 요약

  • QUIC — UDP 위에서 신뢰성(Reliability)과 암호화(Encryption)를 직접 구현한 전송 계층 프로토콜입니다. TCP의 연결 설정 지연(Latency)과 HoL 블로킹(Head-of-Line Blocking) 문제를 해결합니다.
  • HTTP/3 — QUIC 위에서 동작하는 HTTP 버전입니다. 스트림(Stream) 기반 멀티플렉싱(Multiplexing)으로 여러 요청을 서로 간섭 없이 병렬 전송합니다.
  • QPACK — HTTP/3의 헤더 압축(Header Compression) 방식입니다. HTTP/2의 HPACK과 달리 스트림 간 차단(Blocking) 없이 동적 테이블(Dynamic Table)을 동기화합니다.
  • 0-RTT — TLS 1.3 세션 재개(Resumption)를 활용해 첫 패킷에 데이터를 실어 보내는 기능입니다. 왕복 시간(Round-Trip Time)을 한 번도 소비하지 않고 요청을 시작할 수 있습니다.
  • 연결 ID(Connection ID) — IP 주소와 포트가 아닌 Connection ID로 연결을 식별하여, 네트워크가 바뀌어도(와이파이→LTE) 연결이 유지되는 연결 마이그레이션(Connection Migration)을 지원합니다.

단계별 이해

  1. 왜 QUIC인가
    HTTP/1.1과 HTTP/2의 구조적 한계(연결당 요청 직렬화(Serialization), TCP HoL 블로킹, TLS 왕복)가 왜 QUIC를 만들었는지 배경을 이해합니다.
  2. 패킷과 프레임
    Long/Short 헤더(Packet Header)와 프레임(Frame)의 바이트 구조를 익힙니다. 모든 동작의 기초입니다.
  3. 연결 수명주기
    핸드셰이크 → 데이터 전송(스트림/흐름 제어) → 연결 종료까지의 상태 전이를 따라갑니다.
  4. HTTP/3와 QPACK
    QUIC 위에 HTTP 의미론(Semantics)이 어떻게 얹히는지, 헤더 압축이 어떻게 스트림 차단을 피하는지 확인합니다.
  5. 커널·운영·보안
    리눅스 커널의 UDP GSO/GRO, io_uring 등 고속 경로와, 구현체 선택, 디버깅, 보안 위협을 정리합니다.

등장 배경: 왜 QUIC가 필요한가

QUIC의 탄생 배경은 HTTP의 진화 과정과 TCP+TLS 조합의 구조적 한계에서 출발합니다. 먼저 HTTP/1.1과 HTTP/2가 어떤 문제를 남겼는지, 그리고 왜 전송 계층(Transport Layer) 자체를 새로 만들어야 했는지를 살펴봅니다.

HTTP/1.1의 한계: 연결당 요청 직렬화

HTTP/1.1은 기본적으로 하나의 TCP 연결에서 요청을 하나씩 순차 처리합니다. 파이프라이닝(Pipelining)이 표준에 정의되어 있지만 구현상의 문제로 실제로는 거의 사용되지 않았으며, 브라우저는 대신 도메인당 6개 안팎의 병렬 TCP 연결을 여는 방식으로 성능을 보완했습니다. 이 방식은 다음과 같은 근본 문제를 가집니다.

문제설명
요청 직렬화한 연결에서 요청은 응답을 기다린 후에야 다음 요청을 보낼 수 있습니다. RTT(Round-Trip Time)마다 요청 1개가 처리됩니다.
연결 증설 비용병렬 연결마다 TCP 핸드셰이크(1 RTT) + TLS 핸드셰이크(1~2 RTT)가 반복됩니다. 연결 수가 많아질수록 왕복 지연이 누적됩니다.
헤더 중복매 요청마다 User-Agent, Cookie, Accept 등 수백 바이트의 헤더가 반복 전송되어 대역폭(Bandwidth)을 낭비합니다.
우선순위 표현 부족리소스 간 중요도(HTML보다 CSS/JS를 먼저)를 표현할 표준 수단이 없어 브라우저가 페이지(Page) 로딩을 최적화하기 어렵습니다.

HTTP/2의 등장과 TCP HoL 블로킹

HTTP/2(RFC 7540, 2015)는 SPDY 실험을 계승하여 하나의 TCP 연결에 여러 요청/응답 스트림(Stream)을 멀티플렉싱(Multiplexing)하고, HPACK(RFC 7541)으로 헤더를 압축하며, 서버 푸시(Push)와 스트림 우선순위(Priority)를 도입했습니다. 이로써 애플리케이션(Application) 계층의 HoL 블로킹(Head-of-Line Blocking)은 사라졌습니다.

하지만 HTTP/2는 TCP 위에 얹혀 있기 때문에 전송 계층의 HoL 블로킹은 해결하지 못했습니다. TCP는 바이트 스트림(Byte Stream)을 순서대로 인도하며, 패킷이 하나라도 손실되면 그 시퀀스 이후의 모든 데이터가 재전송(Retransmission)을 기다려야 합니다. HTTP/2에서 스트림 B가 이미 도착했더라도, 손실된 스트림 A의 패킷이 복구될 때까지 커널의 수신 버퍼(Buffer)가 막히므로 B의 데이터를 애플리케이션에 전달하지 못합니다. 패킷 손실률이 높은 모바일 네트워크에서 이 문제는 체감 지연을 크게 만듭니다.

여기에 더해 TCP와 TLS의 중첩된 왕복(RTT)도 문제였습니다. TCP 핸드셰이크(1 RTT) 후 TLS 1.2 핸드셰이크(2 RTT)로 첫 요청 전까지 최소 3 RTT가 소요되었습니다. TLS 1.3(RFC 8446, 2018)은 핸드셰이크를 1 RTT로 줄였지만, TCP와 TLS가 별도 계층으로 동작하는 구조 자체는 그대로였습니다.

gQUIC에서 IETF QUIC로: 표준화 과정

구글(Google)은 2012년부터 gQUIC(Google QUIC)를 개발하여 Chrome 브라우저와 자사 서비스에 배포했습니다. gQUIC은 UDP 위에 암호화, 신뢰성, 멀티플렉싱을 모두 구현한 프로토콜로, 대규모 실전 검증을 마쳤습니다. 2016년부터 IETF QUIC 작업 그룹(Working Group)에서 gQUIC의 경험을 바탕으로 표준화가 진행되었고, 2021년 5월 QUIC v1 3종 세트가 RFC로 발행되었습니다.

RFC제목역할발행
RFC 8999Version-Independent Properties of QUIC모든 QUIC 버전이 공유하는 헤더 필드 정의2021-05
RFC 9000QUIC: A UDP-Based Multiplexed and Secure TransportQUIC 전송 프로토콜 본체(패킷, 프레임, 스트림, 흐름 제어, 마이그레이션)2021-05
RFC 9001Using TLS to Secure QUICTLS 1.3과 QUIC의 통합(패킷 보호, 키 스케줄)2021-05
RFC 9002QUIC Loss Detection and Congestion Control손실 감지(Probe Time Out)와 혼잡 제어(NewReno/CUBIC 기반)2021-05
RFC 9114HTTP/3QUIC 위의 HTTP 의미론 + 프레임 계층2022-06
RFC 9204QPACK: Field Compression for HTTP/3HTTP/3 헤더 압축(차단 없는 동적 테이블)2022-06
RFC 9218Extensible Prioritization Scheme for HTTPHTTP/2·HTTP/3 공용 우선순위(urgency/incremental)2022-06
RFC 9220Bootstrapping WebSockets with HTTP/3HTTP/3 위 WebSocket(extended CONNECT)2022-06
RFC 9369QUIC Version 2버전 고정 공격(ossification) 대비한 두 번째 버전2023-05
RFC 9221An Unreliable Datagram Extension to QUIC신뢰성 없는 데이터그램 확장(QUIC Datagrams)2023-03

HTTP 버전별 멀티플렉싱 구조 비교

아래 다이어그램은 HTTP/1.1, HTTP/2, HTTP/3가 여러 리소스 요청을 어떻게 병렬 처리하는지 보여줍니다. HTTP/1.1은 연결을 여러 개 열어야 하고, HTTP/2는 한 연결에서 스트림을 다중화(Multiplexing)하지만 TCP 바이트 스트림에 종속되며, HTTP/3는 UDP 데이터그램 위에서 각 스트림이 독립적으로 손실 복구됩니다.

HTTP 버전별 멀티플렉싱 구조 비교 HTTP/1.1 — 다중 TCP 연결 병렬 처리를 위해 TCP 연결을 여러 개 사용 — 연결 안에서는 요청이 직렬로 처리 클라이언트 서버 TCP 연결 1 TCP 연결 2 TCP 연결 3 1 2 3 1 2 3 1 2 3 연결마다 요청 1개씩 직렬 처리 → 병렬 처리에는 연결 추가가 필요 HTTP/2 — 스트림 멀티플렉싱 하나의 TCP 연결에서 여러 스트림을 다중화 — TCP 바이트 스트림의 순서 보장에 종속 클라이언트 서버 하나의 TCP 연결 — 바이트 스트림 스트림 1 스트림 2 스트림 3 재전송 대기 중 TCP 세그먼트 손실 → 바이트 스트림 순서 복원까지 스트림 1·3도 함께 대기 (전송 계층 HoL 블로킹) HTTP/3 — QUIC 독립 스트림 하나의 QUIC 연결에서 여러 스트림을 다중화 — 각 스트림이 독립적으로 손실 복구 클라이언트 서버 QUIC 연결 — 스트림 다중화 스트림 1 스트림 2 스트림 3 재전송 UDP 데이터그램으로 전송 (IP 캡슐화) 스트림 2 손실 → 스트림 2만 재전송, 스트림 1·3은 계속 전송 (전송 계층 HoL 블로킹 없음)
HTTP 버전별 멀티플렉싱 구조 비교: HTTP/3는 UDP 데이터그램 위에서 스트림 단위로 독립적인 손실 복구를 수행하므로 전송 계층 HoL 블로킹이 없습니다.
HoL 블로킹의 두 종류: 애플리케이션 계층 HoL(HTTP/1.1의 요청 직렬화)은 HTTP/2의 스트림 멀티플렉싱으로 해결되었지만, 전송 계층 HoL(TCP 바이트 스트림의 순서 보장(Ordering))은 QUIC이 스트림마다 독립적인 신뢰성(Reliability)을 제공하면서 해결했습니다. 이 두 개념을 혼동하는 경우가 많으므로 주의가 필요합니다.

QUIC 아키텍처 개요 (RFC 9000)

QUIC은 UDP를 캡슐화(Encapsulation) 계층으로 사용하는 전송 프로토콜입니다. 커널의 UDP 소켓(Socket)을 그대로 사용하므로 새 L4 프로토콜 번호(IP protocol number)도, 새로운 운영체제 커널 커스터마이징도 필요하지 않습니다. 대신 신뢰성, 다중화, 암호화(Confidentiality/Integrity), 흐름 제어(Flow Control), 혼잡 제어(Congestion Control)를 모두 사용자 공간(User Space) 구현으로 제공합니다.

QUIC 설계 목표

QUIC 계층 구조

아래 다이어그램은 QUIC이 TLS 1.3(암호화)과 UDP(전송)를 어떻게 결합하는지 보여줍니다. 전통적인 TCP+TLS 구조에서는 TLS 레코드(Record) 계층이 TCP 바이트 스트림 위에 있었지만, QUIC에서는 TLS 핸드셰이크 메시지가 QUIC CRYPTO 프레임(Frame)에 직접 실리고, 데이터 보호(Record Protection)는 QUIC 패킷 보호(Packet Protection)가 담당합니다.

TCP+TLS와 QUIC의 계층 구조 비교 전통적 구조 (TCP + TLS) QUIC 구조 HTTP/2 TLS 레코드 레코드 보호 TCP (커널) 신뢰성 · 순서 보장 IP (커널) 흡수 흡수 HTTP/3 TLS 1.3 패킷 보호 핸드셰이크 → CRYPTO 프레임 레코드 보호를 흡수 전송 기능 스트림 · 손실 · 혼잡 제어 연결 마이그레이션 (CID) UDP (커널) 데이터그램만 제공 IP (커널) 커널 / 사용자 공간 경계 QUIC의 핵심 설계 결정 • TLS 핸드셰이크 → CRYPTO 프레임에 탑재 • TLS 레코드 보호 → QUIC 패킷 보호로 대체 • 신뢰성 → 스트림 단위 독립 재전송 • 혼잡 제어 → 송신측 알고리즘 선택 자유 • UDP 사용 → 커널 수정 없이 배포 가능 • 버전 필드 → 새 버전 도입 자유로움 • Connection ID → 4-tuple 비의존 라우팅 • 0-RTT → 세션 티켓 기반 즉시 데이터 • 확장 프레임 → 새 기능을 프레임으로 추가 • 미들박스 의존 제거 → IP 재기록 불가 • 스핀 비트(Spin Bit) → 수동적 RTT 측정
TCP+TLS와 QUIC의 계층 구조 비교: QUIC은 TLS 레코드 계층을 제거하고 TLS 핸드셰이크를 CRYPTO 프레임으로, 데이터 보호를 패킷 보호로 흡수했습니다.

리눅스 커널의 역할

리눅스 커널은 QUIC 자체를 구현하지 않지만, QUIC이 그 위에서 고속으로 동작하도록 하는 세 가지 기반을 제공합니다. 이 항목들은 뒤의 리눅스 커널과 QUIC 섹션에서 자세히 다룹니다.

UDP 포트 협상: QUIC은 기본적으로 UDP 443 포트를 사용합니다(https URL의 기본값). HTTP/3 서버는 Alt-Svc 헤더나 HTTPS DNS 레코드(HTTPS RR, RFC 9460)로 QUIC 지원을 알립니다. QUIC이 UDP를 사용한다고 해서 일반 UDP 에코 서버처럼 동작하는 것은 아니며, 포트 443에서 HTTP/2 또는 QUIC이 ALPN(Application-Layer Protocol Negotiation, RFC 7301)으로 협상됩니다.

패킷 형식: Long 헤더와 Short 헤더

QUIC 패킷(Packet)은 첫 바이트의 최상위 비트(MSB)로 두 가지 헤더 폼(Header Form)을 구분합니다. Long 헤더는 첫 바이트가 1이며 버전(Version) 필드를 포함하여 연결 설정(핸드셰이크) 단계에서 사용됩니다. Short 헤더는 첫 바이트가 0이며 연결이 확립된 후 데이터 전송 단계에서 사용됩니다. 모든 QUIC 버전이 공유하는 이 구조는 RFC 8999(Version-Independent Properties of QUIC)에 정의되어 있습니다.

패킷 종류와 Long 헤더 레이아웃

Long 헤더의 첫 바이트는 Header Form(1비트) | Fixed(1비트) | Long Packet Type(2비트) | Type-Specific Bits(4비트)로 구성됩니다. 패킷 종류는 4가지이며, 각 종류마다 뒤따르는 필드가 다릅니다.

종류타입 필드의미전송 방향보호 키
Initial0x0핸드셰이크 시작, CRYPTO 프레임으로 ClientHello/ServerHello 전송양방향Initial 키(공개 유도 가능)
0-RTT0x1세션 재개 시 즉시 애플리케이션 데이터 전송클라이언트 → 서버0-RTT 키(early data 키)
Handshake0x2핸드셰이크 완료 메시지(Certificate, Finished 등) 전송양방향Handshake 키
Retry0x3서버가 클라이언트 주소 검증용 토큰(Token)을 되돌려줌서버 → 클라이언트Retry Integrity Tag(AEAD)
QUIC Long 헤더: 첫 바이트와 공통 필드 (Initial / 0-RTT / Handshake 공통) ① 첫 바이트(Byte 0) — 모든 Long 헤더 공통 Header Form 1비트 (=1) Fixed 1비트 (=1) Long Packet Type 2비트 (0x0~0x3) Type-Specific Bits 4비트 — 패킷 종류별 상이 bit 7 · 0x80 bit 6 · 0x40 bit 5~4 · 0x30 bit 3~0 · 0x0f Long Packet Type 값: Initial = 0x0 · 0-RTT = 0x1 · Handshake = 0x2 · Retry = 0x3 ② 공통 필드 — Initial / 0-RTT / Handshake 공통 Version 32비트 v1 = 0x00000001 DCID Length 8비트 0~20바이트 Destination CID 0~160비트 (0~20바이트) 수신자 식별 · 라우팅 SCID Length 8비트 0~20바이트 Source CID 0~160비트 (0~20바이트) 송신자 식별 DCID = 라우팅 키 SCID = 응답 목적지 네트워크 바이트 순서로 전송 ③ 패킷 종류별 추가 필드 (Type-Specific Payload) Initial 0x0 Token Length 8비트 Token 가변 (주소 검증) Length 가변 Packet Number 1~4바이트 Payload 암호화됨 0-RTT 0x1 Length 가변 Packet Number 1~4바이트 Payload 0-RTT 키로 보호 0-RTT: 핸드셰이크 완료 전 초기 데이터 전송 (서버 허용 시) Handshake 0x2 Length 가변 Packet Number 1~4바이트 Payload 핸드셰이크 키로 보호 Handshake: Certificate · Finished 등 핸드셰이크 메시지 전송 Retry 0x3 Token Length 8비트 Token 가변 (주소 검증) Retry Integrity Tag 16바이트 (AEAD) Retry: 주소 검증 — 서버 → 클라이언트 전용 (패킷 번호 없음) Packet Number와 Payload의 크기가 Length에 포함되며, Packet Number는 헤더 보호(Header Protection)로 보호됩니다. 결합(Coalescing) 순서: Initial → 0-RTT → Handshake → 1-RTT(Short 헤더)
QUIC Long 헤더의 바이트 레이아웃: 첫 바이트의 상위 4비트가 헤더 폼·고정 비트·패킷 종류를 결정하고, Connection ID 쌍이 뒤따릅니다.

Short 헤더 레이아웃

연결이 확립된 뒤에는 버전과 Source Connection ID를 생략한 Short 헤더를 사용합니다. Short 헤더의 첫 바이트는 Header Form(0) | Fixed(1) | Spin(1) | Reserved(2) | Key Phase(1) | Packet Number Length(2)입니다. 패킷 번호 길이(00=1바이트, 01=2바이트, 10=3바이트, 11=4바이트)는 패킷마다 다를 수 있습니다.

필드비트설명
Header Form10 = Short 헤더
Fixed1항상 1. QUIC이 아닌 UDP 프로토콜과의 구분용(프로토콜 오탐 방지)
Spin Bit1활성화되면 왕복 시간(RTT)을 패시브하게 측정할 수 있는 비트. 기본적으로 0으로 전송
Reserved2초기에는 0, 버전 고정을 막기 위해 회전(rotate)됨
Key Phase1키 업데이트(Key Update, RFC 9001 §6) 시 1로 전환. 수신자가 새 키를 시도하게 함
Packet Number Length2패킷 번호 필드의 길이 (1~4바이트)
Destination Connection ID가변연결 라우팅에 사용되는 목적지 Connection ID (0~20바이트)
Packet Number8/16/24/32비트암호화(헤더 보호)로 마스킹된 패킷 번호
Reserved 비트와 버전 고정(ossification): Short 헤더의 Reserved 비트(0x18)는 중간 장비가 QUIC 패킷 구조에 의존하지 않도록 하기 위한 것입니다. 끝점(Endpoint)은 수신한 Reserved 비트를 무시하고 반드시 0으로 재전송합니다. 버전 고정 공격(Version Ossification)이란 중간 박스(미들박스, Middlebox)가 특정 버전의 필드 값을 "학습"하여 이후 버전의 패킷을 오분류하는 현상으로, QUIC은 버전 독립 필드(중립 필드)를 고의로 회전시켜 이 문제를 완화합니다.

가변 길이 정수(QUIC Varint) 인코딩

QUIC의 많은 필드(프레임 타입, 길이, 스트림 ID, 오프셋 등)는 가변 길이 정수(Variable-Length Integer, 이하 varint)로 인코딩됩니다. varint는 앞의 2비트로 길이를 나타내며, 값은 네트워크 바이트 순서(Byte Order)인 빅엔디안(Big Endian)으로 배치됩니다. 이는 QUIC 전용 인코딩으로 TCP의 것과 다르므로 주의가 필요합니다.

앞 2비트길이표현 가능 범위
001바이트0 ~ 26-1 (0~63)
012바이트0 ~ 214-1 (0~16383)
104바이트0 ~ 230-1
118바이트0 ~ 262-1
/* QUIC varint 인코딩/디코딩 예제 (RFC 9000 §16) */
/* 인코딩: 값의 비트 수에 따라 1/2/4/8바이트 선택 */
static size_t quic_varint_encode(uint8_t *out, uint64_t value)
{
    if (value < 64) {
        out[0] = (uint8_t)value;          /* 00 xxxxxx */
        return 1;
    } else if (value < 16384) {
        out[0] = (uint8_t)(0x40 | (value >> 8));
        out[1] = (uint8_t)value;
        return 2;                     /* 01 xxxxxxxx xxxxxxxx */
    } else if (value < (1 << 30)) {
        out[0] = (uint8_t)(0x80 | (value >> 24));
        out[1] = (uint8_t)(value >> 16);
        out[2] = (uint8_t)(value >> 8);
        out[3] = (uint8_t)value;
        return 4;                     /* 10 ... */
    } else {
        out[0] = (uint8_t)(0xc0 | (value >> 56));
        out[1] = (uint8_t)(value >> 48);
        out[2] = (uint8_t)(value >> 40);
        out[3] = (uint8_t)(value >> 32);
        out[4] = (uint8_t)(value >> 24);
        out[5] = (uint8_t)(value >> 16);
        out[6] = (uint8_t)(value >> 8);
        out[7] = (uint8_t)value;
        return 8;                     /* 11 ... 최대 62비트 */
    }
}

/* 디코딩: 처음 2비트로 길이 판별, 빅엔디안 조립 */
static uint64_t quic_varint_decode(const uint8_t *buf, size_t *len)
{
    uint8_t prefix = buf[0] >> 6;
    size_t n = 1 << prefix;   /* 0->1바이트, 1->2바이트, 2->4바이트, 3->8바이트 */
    uint64_t v = buf[0] & 0x3f;
    for (size_t i = 1; i < n; i++)
        v = (v << 8) | buf[i];   /* 빅엔디안 조립 (상위 바이트부터 시프트) */
    *len = n;
    return v;
}
varint 예시: 값 0은 1바이트 0x00, 값 63은 0x3f, 값 64는 2바이트 0x4040로 인코딩됩니다. 스트림 ID, 오프셋, 길이 등에 사용되므로 패킷 파서를 직접 만들 때 반드시 구현해야 하는 기본 유틸리티입니다.

패킷 번호 공간(Packet Number Space)

QUIC은 암호화 수준(Encryption Level)에 따라 세 개의 독립된 패킷 번호 공간을 가집니다: Initial, Handshake, Application Data(0-RTT와 1-RTT가 공유). 각 공간의 패킷 번호는 따로 시작하고 따로 증가하므로, 패킷 번호가 "어느 공간의 번호인가"를 알아야 비교가 가능합니다. 패킷 번호의 길이(1~4바이트)는 헤더 보호로 마스킹되어 전송됩니다.

QUIC의 세 패킷 번호 공간 (RFC 9000 §12.3) 암호화 수준(Encryption Level)별로 패킷 번호가 0부터 독립적으로 증가합니다 — 공간이 다르면 번호를 비교할 수 없습니다 Initial 공간 Initial 패킷 (Type 0x0) Initial 키 · 공개 유도 패킷 번호 — 공간마다 0부터 독립 증가 0 1 2 3 … 이후: 키 폐기 핸드셰이크 시작 후 Initial 키 폐기 폐기 후 수신한 Initial 패킷 무시 실리는 프레임: CRYPTO (ClientHello / ServerHello) Handshake 공간 Handshake 패킷 (Type 0x2) Handshake 키 패킷 번호 — 공간마다 0부터 독립 증가 0 1 2 3 … 이후: 키 폐기 완료 시 서버 HANDSHAKE_DONE 전송 이후 Handshake 키 폐기 · 수신 무시 실리는 프레임: CRYPTO (Certificate / Finished) Application Data 공간 1-RTT · 0-RTT 패킷 1-RTT / 0-RTT 키 패킷 번호 — 공간마다 0부터 독립 증가 0 1 2 3 … 수명: 연결 종료까지 0-RTT와 1-RTT가 한 공간 공유 0-RTT는 재개 시 핸드셰이크 전 전송 실리는 프레임: STREAM · ACK · MAX_DATA 등 ACK 프레임은 같은 공간의 패킷 번호만 확인합니다 — 공간이 다르면 수신 확인(ACK)도 독립적입니다. 손실 감지와 PTO 타이머는 공간별로 동작하지만, 혼잡 창(Congestion Window)은 연결 전체가 공유합니다.
세 개의 패킷 번호 공간: 암호화 수준이 다르면 패킷 번호도 독립적으로 매겨지며, 이를 모르고 패킷 번호를 비교하면 오류가 발생합니다.

Initial 패킷의 최소 크기와 패딩(Padding)

클라이언트가 보내는 첫 UDP 데이터그램(Initial 패킷 1개 이상 포함)은 반드시 1200바이트 이상이어야 합니다(RFC 9000 §14.1). 이 요구사항은 증폭 공격(Amplification Attack) 방지를 위한 것입니다. 서버 주소 검증(Address Validation) 전에는 서버가 클라이언트에게 보낼 수 있는 데이터가 수신한 데이터의 3배(amplification limit)로 제한되는데(RFC 9000 §8.1), 1200바이트 최소 크기 규칙이 있으면 서버의 최초 응답으로 최소한 3600바이트까지는 안전하게 보낼 수 있습니다.

PADDING 남용 주의: 1200바이트 요구를 채우기 위해 PADDING 프레임(0x00)을 사용하지만, 핸드셰이크가 완료된 뒤에는 큰 패킷을 만들기 위해 PADDING을 계속 붙이는 것은 대역폭 낭비입니다. 반대로 초기 패킷에서 PADDING을 빼먹으면 구현에 따라 서버가 패킷을 버리거나 연결이 진행되지 않을 수 있습니다. 실제 트래픽의 대부분은 1-RTT(Short 헤더) 데이터이며, 초기 패킷의 패딩은 손실 감지·PMTU 탐색과도 관련이 있습니다.

프레임(Frame)의 종류와 구조

QUIC 패킷의 페이로드에는 하나 이상의 프레임(Frame)이 실립니다. 프레임 타입은 varint로 표현되며, 각 프레임은 연결의 신뢰성 있는 데이터 전달, 흐름 제어, 연결 관리 등의 역할을 담당합니다. 프레임 타입 0x1f~0x2f 영역은 확장(Extension)용으로 예약되어 있고, IANA에 등록 절차가 정의되어 있습니다.

프레임 타입 전체 목록 (RFC 9000 §19)

타입프레임핵심 필드용도
0x00PADDING—패딩. 데이터그램 크기 확장·트래픽 분석 방해
0x01PING—연결 유지(keep-alive), PTO 탐색용 ACK 유발
0x02ACKLargest Acked, Ack Delay, Ranges패킷 수신 확인 (발신자 포함 안 됨)
0x03ACK (ECN)+ ECT0/ECT1/CE 카운트ECN 정보를 포함한 ACK
0x04RESET_STREAMStream ID, App Error Code, Final Size스트림을 오류로 종료(중단)
0x05STOP_SENDINGStream ID, App Error Code수신자가 더 이상 데이터를 원하지 않음을 통지
0x06CRYPTOOffset, Length, DataTLS 핸드셰이크 메시지 전송(스트림 번호 없음)
0x07NEW_TOKENToken Length, Token향후 0-RTT 주소 검증용 토큰 제공(서버→클라이언트)
0x08~0x0fSTREAM+Stream ID, [Offset], [Length], Data스트림 데이터 전송 (하위 3비트가 옵션 플래그)
0x10MAX_DATAMaximum Data연결 전체 수신 허용량(크레딧) 증가
0x11MAX_STREAM_DATAStream ID, Maximum Stream Data개별 스트림 수신 허용량 증가
0x12MAX_STREAMS (Bidi)Maximum Streams양방향 스트림 개수 제한 증가
0x13MAX_STREAMS (Uni)Maximum Streams단방향 스트림 개수 제한 증가
0x14DATA_BLOCKEDMaximum Data연결 레벨 흐름 제어에 막혔음을 통지
0x15STREAM_DATA_BLOCKEDStream ID, Maximum Stream Data스트림 레벨 흐름 제어에 막혔음을 통지
0x16STREAMS_BLOCKED (Bidi)Maximum Streams양방향 스트림 개수 제한에 막혔음을 통지
0x17STREAMS_BLOCKED (Uni)Maximum Streams단방향 스트림 개수 제한에 막혔음을 통지
0x18NEW_CONNECTION_IDSeq, Retire Prior To, CID, Stateless Reset Token새 Connection ID 발급 (마이그레이션·프라이버시)
0x19RETIRE_CONNECTION_IDSequence Number사용하지 않게 된 Connection ID 폐기 통지
0x1aPATH_CHALLENGE64비트 난수경로(주소) 검증용 도전 값 전송
0x1bPATH_RESPONSE64비트 난수(에코)PATH_CHALLENGE에 대한 응답
0x1cCONNECTION_CLOSE (전송 오류)Error Code, Frame Type, Reason Phrase전송 계층 오류로 연결 종료
0x1dCONNECTION_CLOSE (앱 오류)Error Code, Reason Phrase앱 계층 오류(HTTP/3 등)로 연결 종료
0x1eHANDSHAKE_DONE—핸드셰이크 완료 후 서버가 보내 확인(0-RTT 수락 통지 겸용)
0x1f~0x2f예약(확장용)—향후 표준 확장 프레임을 위한 예약 영역
0x30~0x31DATAGRAM (RFC 9221)[Length], Data신뢰성 없는 데이터그램 전송 (QUIC Datagrams)

STREAM 프레임 상세

STREAM 프레임(0x08~0x0f)은 실제 애플리케이션 데이터를 나르는 프레임입니다. 타입 값의 하위 3비트(타입 - 0x08)가 옵션 플래그로 동작합니다: 0x04 = OFF(오프셋 필드 존재), 0x02 = LEN(길이 필드 존재), 0x01 = FIN(스트림 종료 표시). 모든 플래그가 꺼진 0x08은 오프셋 0부터 시작하고 길이 필드가 없어 스트림 데이터가 패킷의 끝까지 이어지는 프레임입니다. 스트림의 끝은 FIN 비트(0x01)가 켜진 프레임이 표시하며, 그 프레임의 (오프셋 + 길이)가 스트림의 최종 크기(Final Size)가 됩니다.

/* STREAM 프레임 (0x0f: OFF + LEN + FIN 모두 포함) 예시 */
/*
 * 바이트 0        : 0x0f (타입)
 *     1..2(가변)  : Stream ID  (varint, 예: 0x00 = 클라이언트 양방향 스트림 0)
 *     3..(가변)   : Offset     (varint, 예: 0)
 *     (가변)      : Length     (varint, 예: 11)
 *     이후        : 데이터 11바이트 "hello world"
 */

/* 파싱 의사 코드 */
uint8_t type = *p++;
if ((type & 0xf8) == 0x08) {        /* STREAM 프레임 */
    int has_off = (type & 0x04) != 0;
    int has_len = (type & 0x02) != 0;
    int fin     = (type & 0x01) != 0;
    uint64_t stream_id = varint_decode(&p);
    uint64_t offset    = has_off ? varint_decode(&p) : 0;
    size_t length = has_len ? varint_decode(&p)
                                  : (size_t)(패킷 끝 - p);
    /* offset + length 가 스트림의 최종 크기(Final Size) 검사에 사용됨 */
}

스트림 데이터는 애플리케이션 관점에서는 바이트 스트림이지만, QUIC 전송 계층에서는 오프셋(Offset) 기반의 "정렬된 데이터"(Ordered Bytes)로 관리됩니다. 즉 수신측은 스트림별로 빈틈 없는 연속 구간만 애플리케이션에 전달하고, 나머지는 내부 버퍼에 보관합니다. 이 때문에 스트림 0의 1바이트 손실이 스트림 1에 아무 영향도 주지 않는 것입니다.

ACK 프레임과 ECN

ACK 프레임(0x02/0x03)은 Largest Acknowledged(받은 가장 큰 패킷 번호), ACK Delay(수신 시각과 ACK 전송 시각의 차이), ACK Range Count, First ACK Range(최근 연속 수신 개수), 그리고 반복되는 Gap + ACK Range Length 쌍으로 구성됩니다. 0x03 타입은 ECN(Explicit Congestion Notification) 카운트(ECT0, ECT1, CE)를 추가로 포함하며, 수신 경로의 혼잡 신호(CE 마킹)를 송신측 혼잡 제어에 반영합니다.

ACK 프레임은 발신자(Sender)가 없습니다: ACK 프레임은 "수신 확인"을 위한 프레임으로, 흐름 제어(크레딧)와 무관하며 ACK themselves는 재전송되거나 ACK되거나 혼잡 창에 포함되지 않습니다. 즉 ACK를 보낸다고 해서 상대방이 다시 ACK를 보내지 않습니다. 이를 모르고 TCP처럼 ACK-of-ACK을 기대하면 안 됩니다.

전송 오류 코드(Transport Error Codes)

CONNECTION_CLOSE(0x1c) 프레임에 실리는 전송 오류 코드는 RFC 9000 §20.1에 정의되어 있습니다. 앱 오류 코드(0x1d)는 HTTP/3 등 상위 프로토콜이 자체적으로 정의합니다.

값이름의미
0x00NO_ERROR오류 없이 정상 종료
0x01INTERNAL_ERROR내부 구현 오류
0x02CONNECTION_REFUSED서버가 연결을 거부 (용량, 정책 등)
0x03FLOW_CONTROL_ERROR흐름 제어 한도 초과 데이터 수신
0x04STREAM_LIMIT_ERROR스트림 개수 한도 초과
0x05STREAM_STATE_ERROR잘못된 스트림 상태 전이
0x06FINAL_SIZE_ERROR최종 크기(Final Size) 불일치 (이미 닫힌 스트림 데이터 등)
0x07FRAME_ENCODING_ERROR프레임 인코딩 오류
0x08TRANSPORT_PARAMETER_ERROR전송 파라미터 오류
0x09CONNECTION_ID_LIMIT_ERRORConnection ID 개수 한도 초과
0x0aPROTOCOL_VIOLATION프로토콜 규칙 위반 (일반)
0x0bINVALID_TOKEN토큰(Retry/NEW_TOKEN)이 유효하지 않음
0x0cAPPLICATION_ERROR애플리케이션 정의 오류
0x0dCRYPTO_BUFFER_EXCEEDEDCRYPTO 버퍼 한도 초과
0x0eKEY_UPDATE_ERROR키 업데이트 규칙 위반
0x0fAEAD_LIMIT_REACHEDAEAD 사용 한도 도달 (키 재교체 필요)
0x10NO_VIABLE_PATH전송 가능한 경로가 없음 (PMTU 등)
0x0100~0x01ffCRYPTO_ERRORTLS 경고(alert) 값. 0x0100 | alert 형태

스트림(Stream): QUIC의 데이터 전달 단위

스트림은 QUIC 연결 안에서 데이터가 전달되는 논리적 단위입니다. 하나의 연결에 동시에 여러 스트림이 존재할 수 있으며, 각 스트림은 독립적인 신뢰성(순서 보장, 재전송)과 흐름 제어를 가집니다. HTTP/3에서는 요청/응답 하나가 스트림 하나에 매핑(Mapping)됩니다.

스트림 ID 규칙

스트림 ID는 62비트 정수(varint로 인코딩)이며, 하위 2비트가 방향과 타입을 결정합니다.

Stream ID 하위 2비트타입개시자예시
00양방향(Bidirectional)클라이언트0, 4, 8, …
01양방향서버1, 5, 9, …
10단방향(Unidirectional)클라이언트2, 6, 10, …
11단방향서버3, 7, 11, …

스트림 ID의 최하위 비트(bit 0)는 개시자(0=클라이언트, 1=서버), 두 번째 비트(bit 1)는 타입(0=양방향, 1=단방향)을 나타냅니다. 각 타입의 스트림 번호는 0부터 시작해 4씩 증가합니다. 발신자는 같은 타입의 스트림을 반드시 ID 오름차순으로 열어야 하며(RFC 9000 §3.1), 작은 ID가 열리지 않았는데 큰 ID가 도착하면 STREAM_LIMIT_ERROR 처리됩니다. 이 규칙 때문에 구현체는 "다음에 열 스트림 ID" 하나만 기억하면 됩니다.

스트림 타입과 용도

스트림 상태 머신

각 끝점은 스트림을 송신(sending) 관점과 수신(receiving) 관점으로 나누어 상태를 관리합니다. 양방향 스트림은 두 상태 머신을 모두 유지하고, 단방향 스트림은 개시자에게 송신 상태, 수신자에게 수신 상태만 존재합니다. 아래 다이어그램은 RFC 9000 §3.1의 상태 머신을 단순화한 것입니다.

송신·수신 스트림 상태 머신 (RFC 9000 §3.1/§3.2) 송신 측 상태 머신 (RFC 9000 §3.1) 시작: 앱이 스트림 생성 · 피어의 양방향 스트림 생성 → Ready Ready 데이터 버퍼링 Send STREAM 전송 · 재전송 Data Sent FIN 전송 완료 Data Recvd 모든 ACK (종료 상태) 첫 STREAM 전송 FIN 전송 모든 ACK RESET_STREAM 전송 (앱 포기 · 피어 STOP_SENDING 수신 · 첫 프레임일 수도) Reset Sent RESET_STREAM 전송 Reset Recvd RESET ACK (종료 상태) RESET ACK 수신 수신 측 상태 머신 (RFC 9000 §3.2) 시작: 첫 STREAM / STREAM_DATA_BLOCKED / RESET_STREAM 수신 · MAX_STREAM_DATA / STOP_SENDING 수신 · 상위 번호 스트림 생성 Recv 데이터 버퍼링 Size Known FIN 수신 · 최종 크기 확정 Data Recvd 모든 데이터 수신 Data Read 앱이 전부 읽음 (종료 상태) FIN 수신 전부 수신 앱이 읽음 RESET_STREAM 수신 (Recv · Size Known · Data Recvd — Data Recvd에서는 선택 경로) Reset Recvd RESET_STREAM 수신 Reset Read 앱이 리셋 읽음 (종료 상태) 리셋 신호 전달 closed 양쪽 종료 → closed (Stream ID 재사용 안 함) 한 이벤트가 여러 상태를 연속 전이할 수 있음 (예: STREAM+FIN → Ready→Send→Data Sent) · 상태 머신은 구현 참고용(informative)
QUIC 스트림 상태 머신 (RFC 9000 §3.1/§3.2): 송신 측과 수신 측 상태를 분리하여 관리하며, 수신 측 상태는 첫 STREAM/RESET_STREAM 수신 또는 양방향 스트림 개시 시 생성됩니다. 송신·수신이 모두 종료 상태(data recvd/reset recvd · data read/reset read)가 되어야 closed가 됩니다. 종료 상태 박스에는 "종료 상태" 표기를 붙였으며, 상태 머신은 구현 참고용(informative)입니다.

아래 표는 위 그림의 상태 전이를 이벤트 기준으로 정리한 것입니다. STREAM_DATA_BLOCKED(흐름 제어로 막혀도 Send로 진입)나 STOP_SENDING(피어의 수신 포기로 인한 리셋)처럼 그림에 표시하기 어려운 트리거까지 포함합니다. 첫 프레임에 FIN이 함께 실리면 Ready → Send와 Send → Data Sent의 두 전이가 한 이벤트로 연속 발생합니다(RFC 9000 §3 메모).

현재 상태이벤트 / 트리거다음 상태
Ready (송신)첫 STREAM / STREAM_DATA_BLOCKED 전송Send
Ready (송신)RESET_STREAM 전송 (첫 프레임일 수도 있음)Reset Sent
Send (송신)STREAM 연속 전송·재전송Send 유지
Send (송신)FIN 포함 STREAM 전송Data Sent
Send (송신)RESET_STREAM 전송 (앱이 포기하거나 피어 STOP_SENDING 수신)Reset Sent
Data Sent (송신)모든 데이터 ACK 수신Data Recvd (종료)
Data Sent (송신)RESET_STREAM 전송Reset Sent
Reset Sent (송신)RESET_STREAM의 ACK 수신Reset Recvd (종료)
(수신 진입)첫 STREAM / STREAM_DATA_BLOCKED / RESET_STREAM 수신, MAX_STREAM_DATA / STOP_SENDING 수신, 상위 번호 스트림 생성Recv
Recv (수신)FIN 포함 STREAM 수신 (최종 크기 확정)Size Known
Recv (수신)RESET_STREAM 수신Reset Recvd
Size Known (수신)나머지 데이터 전부 수신Data Recvd
Size Known (수신)RESET_STREAM 수신Reset Recvd
Data Recvd (수신)앱이 데이터 전부 읽음Data Read (종료)
Data Recvd (수신)RESET_STREAM 수신 (선택 경로)Reset Recvd
Reset Recvd (수신)앱이 리셋 신호 읽음Reset Read (종료)
양쪽 종료송신 종료(Data Recvd/Reset Recvd) + 수신 종료(Data Read/Reset Read)Closed (Stream ID 재사용 안 함)
실무 관점: 대부분의 QUIC 구현체(quiche, ngtcp2, msquic 등)는 이 상태 머신을 내부적으로 유지하며, 애플리케이션에는 "스트림 열림(open) / 데이터 도착(writable/readable) / FIN / 리셋" 이벤트만 노출합니다. 상태 머신을 직접 구현할 때는 FINAL_SIZE_ERROR(이미 종료된 스트림에 데이터 도착)와 STREAM_STATE_ERROR(불법 전이)를 정확히 구분해 오류 코드를 매겨야 합니다.

실전 구현: 스트림 이벤트 처리 예제

실제 QUIC 구현체(ngtcp2, quiche, msquic 등)는 스트림 상태 머신을 내부에서 관리하고, 애플리케이션에는 데이터 도착, FIN, 리셋(RESET_STREAM), 읽기 중단(STOP_SENDING), 크레딧 갱신(MAX_STREAM_DATA) 이벤트만 콜백(Callback)으로 노출합니다. 아래는 ngtcp2 + nghttp3 기반 서버가 HTTP/3 요청 스트림을 처리하면서 취소(Cancel)와 리셋(Reset) 시나리오를 함께 고려한 이벤트 처리 골격입니다. API 시그니처는 구현체 버전에 따라 다를 수 있으며, 오류·버퍼 관리는 설명을 위해 단순화했습니다.

/* 스트림 상태 이벤트 처리 골격 (ngtcp2 + nghttp3 기반, 개념 예시) */
/* API 시그니처는 구현체 버전에 따라 다를 수 있으며, 오류·버퍼 관리는 단순화했습니다 */

struct http_stream {
    int64_t stream_id;
    int     state;    /* APP_HEADERS / APP_BODY / APP_DONE */
    int     cancelled; /* 피어가 STOP_SENDING으로 읽기 포기 */
};

/* ① 피어 데이터 도착 — 수신 상태 Recv */
static int on_recv_stream_data(ngtcp2_conn *conn, uint32_t flags,
                               int64_t stream_id, uint32_t offset,
                               const uint8_t *data, size_t datalen,
                               void *user_data, void *stream_user_data)
{
    int rv = nghttp3_conn_read_stream(h3conn, stream_id,
                                      data, datalen,
                                      (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0,
                                      NULL);
    if (rv == NHTTP3_ERR_WOULDBLOCK || rv == NHTTP3_ERR_STREAM_IN_USE)
        return 0;  /* HEADERS가 아직 완성되지 않음 — 데이터를 버퍼에 유지 */
    if (rv < 0)
        return NGTCP2_ERR_CALLBACK_FAILURE;  /* HTTP/3 오류 → 연결 오류로 승격 */

    /* ② 소비한 바이트만큼 크레딧 회복 — 생략하면 수신 윈도우가 고갈되어 데드락 */
    ngtcp2_conn_extend_max_stream_offset(conn, stream_id, datalen);
    ngtcp2_conn_extend_max_offset(conn, datalen);
    return 0;
}

/* ③ 피어가 STOP_SENDING — 응답을 더 이상 원하지 않음 */
static int on_stop_sending(ngtcp2_conn *conn, int64_t stream_id,
                           uint64_t app_error_code,
                           void *user_data, void *stream_user_data)
{
    struct http_stream *st = stream_user_data;
    st->cancelled = 1;
    /* RFC 9000 §3.5: Ready/Send 상태면 RESET_STREAM으로 응답해야 함 */
    /* HTTP/3 애플리케이션 오류 코드는 H3_REQUEST_CANCELLED (0x010c) */
    ngtcp2_conn_shutdown_stream(conn, NGTCP2_SHUTDOWN_STREAM_SEND,
                                stream_id, H3_REQUEST_CANCELLED);
    return 0;
}

/* ④ 피어가 RESET_STREAM — 보내던 데이터를 중간에 포기 */
static int on_stream_reset(ngtcp2_conn *conn, int64_t stream_id,
                           uint64_t final_size, uint64_t app_error_code,
                           void *user_data, void *stream_user_data)
{
    /* final_size를 기준으로 흐름 제어를 정산한 뒤 스트림 상태를 폐기 */
    /* (종료된 스트림의 후속 데이터는 전송 계층이 FINAL_SIZE_ERROR로 거부) */
    destroy_http_stream(stream_id);
    return 0;
}

/* ⑤ FIN 수신 — Stream Close 이벤트: 상태 정리 */
static int on_stream_close(ngtcp2_conn *conn, uint32_t flags,
                           int64_t stream_id, uint64_t app_error_code,
                           void *user_data, void *stream_user_data)
{
    if (app_error_code != H3_NO_ERROR)
        log_request_abort(stream_id, app_error_code);
    destroy_http_stream(stream_id);
    return 0;
}

/* ⑥ 송신 측: 전송할 데이터는 있으나 크레딧이 없으면 writev가 차단을 보고 */
/*    MAX_STREAM_DATA(extend_max_stream_data 콜백) 도착 시 nghttp3_conn_resume_stream()으로 재개 */

위 예제가 다루는 시나리오별 동작을 정리하면 다음과 같습니다.

구현 시 흔한 버그: ① extend_max_stream_offset/extend_max_offset 호출 누락 — 수신 데이터를 소비해도 크레딧을 회복하지 않으면 피어의 송신 윈도우가 0으로 고정되어 데드락에 빠집니다. ② STOP_SENDING을 받고도 계속 데이터를 쓰는 경우 — RFC 9000 §3.5에 따라 RESET_STREAM으로 즉시 응답해야 하며, 무시하고 쓰면 피어가 데이터를 버리면서 크레딧만 낭비합니다. ③ 리셋 이벤트에서 상태를 정리하지 않아 종료된 스트림의 잔여 데이터로 오동작하는 경우 — 리셋·종료된 스트림의 데이터는 폐기해야 합니다.

흐름 제어(Flow Control)

QUIC의 흐름 제어는 TCP와 달리 연결 전체(Connection)와 개별 스트림(Stream)의 두 레벨로 동작하며, 여기에 스트림 개수 제한이 더해집니다. 모두 "크레딧(Credit)" 방식입니다. 수신자가 허용한 최대량(Maximum)을 송신자가 넘지 않도록, 수신자가 새 MAX_* 프레임으로 허용량을 늘려주는 방식입니다.

세 가지 흐름 제어 레벨

레벨제어 프레임초기값 파라미터제한 대상
연결 데이터량MAX_DATA(0x10)initial_max_data(0x04)연결 전체에서 보낸 스트림 데이터 총량
스트림 데이터량MAX_STREAM_DATA(0x11)initial_max_stream_data_bidi_local/remote(0x05/0x06), initial_max_stream_data_uni(0x07)개별 스트림의 바이트 오프셋(크기)
스트림 개수MAX_STREAMS(0x12/0x13)initial_max_streams_bidi(0x08), initial_max_streams_uni(0x09)동시에 열 수 있는 스트림 수

송신측이 막혔을 때는 DATA_BLOCKED(0x14), STREAM_DATA_BLOCKED(0x15), STREAMS_BLOCKED(0x16/0x17) 프레임을 보내 수신측이 크레딧을 늘려주도록 요청합니다. 이 "차단 통지"(Blocked Notification)는 디버깅에 유용합니다—수신측은 보내지 않은 MAX 프레임이 병목(Bottleneck)인지 알 수 있습니다.

연결 레벨 흐름 제어: MAX_DATA 크레딧 송신자 (클라이언트) 누적 전송량 ≤ MAX_DATA 모든 스트림의 바이트 오프셋 합산 전송 시 크레딧 소모 (ACK 전) 소진 시 DATA_BLOCKED 전송 수신자 (서버) 소비한 바이트만큼 MAX_DATA ↑ 수신 버퍼에서 앱이 읽은 분량 단조 증가만 허용 (감소 = 오류) 소비 시 MAX_DATA 갱신 전송 STREAM DATA MAX_DATA 크레딧 변화 시나리오 (시간순) ① 크레딧 사용 중 — 누적 600KB / MAX_DATA = 1MB 600KB 400KB 남음 → STREAM DATA ② 크레딧 소진 — 누적 1MB = MAX_DATA → BLOCKED 1MB = MAX_DATA · 남음 0KB → DATA_BLOCKED ③ 크레딧 충전 — 누적 1MB / MAX_DATA = 1.5MB (500KB 소비 후 갱신) 1MB 500KB 남음 (신규) ← MAX_DATA 흐름 제어는 수신 버퍼 오버플로 방지가 목적이며 혼잡 제어와는 별개입니다. 스트림 레벨 크레딧은 바이트 오프셋 기준이라 동일 데이터 재전송 시 오프셋이 증가하지 않아 크레딧을 다시 소모하지 않습니다.
연결 레벨 흐름 제어: 송신자는 누적 전송량이 크레딧(MAX_DATA)을 넘지 못하며, 수신자는 애플리케이션이 데이터를 소비한 후 MAX_DATA를 단조 증가로 갱신합니다. 크레딧이 소진되면 DATA_BLOCKED를 보내 대기하다가 수신자가 갱신하면 다시 전송할 수 있습니다.

흐름 제어 관련 흔한 오해

실전 구현: 자동 튜닝과 BLOCKED 처리

수신자는 애플리케이션의 소비 속도와 RTT에 맞춰 크레딧을 자동 조절(Autotuning)해야 하며, 송신자는 흐름 제어에 막혔을 때 DATA_BLOCKED/STREAM_DATA_BLOCKED를 보내 수신자의 갱신을 유도합니다. 아래는 두 역할을 모두 고려한 개념 예시입니다(RFC 9000 §4.1~4.3).

/* 수신 측: 소비량 기반 크레딧 자동 튜닝 (RFC 9000 §4.2) */
struct flow_controller {
    uint64_t consumed;     /* 애플리케이션이 소비한 누적 바이트 */
    uint64_t window;       /* 현재 광고할 윈도우 크기 */
    uint64_t limit;        /* 마지막으로 광고한 한도 = consumed + window */
    uint64_t max_window;   /* 연결 메모리 예산으로 제한된 상한 */
};

/* 애플리케이션이 데이터를 소비할 때마다 호출 */
void fc_consume(struct flow_controller *fc, uint64_t amount)
{
    fc->consumed += amount;
    /* 윈도우 절반 이상을 새로 소비했다면 윈도우를 2배로 확장(상한까지) */
    /* → RFC 9000 §4.2: 소액 빈번 갱신(오버헤드)과 대량 갱신(메모리 커밋)의 균형 */
    if (fc->consumed + fc->window / 2 >= fc->limit && fc->window < fc->max_window) {
        fc->window = min(fc->window * 2, fc->max_window);
        fc->limit = fc->consumed + fc->window;
        /* ngtcp2: ngtcp2_conn_extend_max_offset()로 반영 → 다음 writev에서 MAX_DATA 전송 */
    }
}
/* 송신 측: 크레딧 부족과 유휴 타임아웃 방지 (RFC 9000 §4.1) */
/*  - writev가 흐름 제어 차단(예: NGTCP2_ERR_STREAM_DATA_BLOCKED)을 반환하면  */
/*    전송 중인 ACK 유발 패킷이 없을 때 STREAM_DATA_BLOCKED/DATA_BLOCKED를      */
/*    주기적으로 보냅니다. 이 프레임은 수신자가 MAX_* 갱신을 보내도록 유도하며,  */
/*    오랫동안 아무것도 보내지 않으면 수신자가 유휴 타임아웃으로 연결을 닫습니다. */

/* 수신 측: DATA_BLOCKED/STREAM_DATA_BLOCKED 수신 시 즉시 크레딧을 확장합니다.   */
/* 단, RFC 9000 §4.2에 따라 수신자는 BLOCKED 프레임을 기다린 뒤에 갱신해도     */
/* 안 됩니다(최소 1 RTT 손실). 미리 충분한 크레딧을 보내는 것이 정상 동작입니다. */

/* 0-RTT 처리 (RFC 9000 §7.4.1) */
/*  - 서버가 0-RTT를 수락하면 클라이언트는 티켓에 저장된 이전 연결의             */
/*    초기 흐름 제어 파라미터(initial_max_data, initial_max_stream_data_*)로   */
/*    데이터를 보냅니다. 서버는 그 값으로 크레딧을 복원해야 하며,                */
/*    저장된 값이 없거나 손상되었으면 0-RTT 수락을 거부해야 합니다.              */

흐름 제어 구현에서 놓치기 쉬운 시나리오를 정리합니다.

연결 설정: 1-RTT 핸드셰이크와 0-RTT

QUIC은 TLS 1.3(암호화 프로토콜 버전) 핸드셰이크를 자체 패킷 구조에 통합하여, 최초 연결 1-RTT(클라이언트 요청 전 왕복 1회), 재접속 시 0-RTT(왕복 없이 첫 패킷부터 데이터 전송)로 첫 요청을 시작합니다. 이는 TCP(1 RTT) + TLS 1.3(1 RTT) = 2 RTT짜리 기존 조합보다 설정 지연이 작습니다.

1-RTT 핸드셰이크 흐름

1-RTT 핸드셰이크 흐름 (RFC 9000 §4.1) 클라이언트 서버 ① Initial CRYPTO: ClientHello + 전송 파라미터 (≥1200B 패딩, Initial 키로 보호) ← 1 RTT → ② Initial+Handshake CRYPTO: ServerHello → Handshake + 1-RTT 키 유도 + EncryptedExtensions · Certificate · CertificateVerify · Finished ③ Handshake CRYPTO: Finished → 서버 인증 확인, 1-RTT 데이터 전송 가능 ④ 1-RTT HTTP/3 요청 (Short 패킷, 1-RTT 키로 보호) ⑤ 1-RTT HANDSHAKE_DONE + HTTP/3 응답 (Short 패킷) Handshake+1-RTT 키 서버 인증 확인 핸드셰이크 완료 첫 애플리케이션 데이터까지 총 1 RTT (TCP + TLS 1.3 조합은 최소 2 RTT)
1-RTT 핸드셰이크: ClientHello(Initial) → ServerHello+인증서(Initial/Handshake) → Finished(Handshake) 후 1-RTT 데이터가 오갑니다. ServerHello 수신 시 1-RTT 키가 유도되어 클라이언트가 바로 애플리케이션 데이터를 전송할 수 있습니다.

0-RTT 연결 재개

0-RTT는 이전 연결에서 받은 NewSessionTicket(TLS 1.3 세션 티켓)을 이용해, 서버와의 첫 패킷 왕복 없이 이미 0-RTT 키를 도출한 상태로 애플리케이션 데이터를 보내는 기능입니다.

0-RTT 연결 재개 (RFC 9001 §4.6) 전제: 이전 연결에서 NewSessionTicket 수신 → PSK(세션 티켓) 저장 재연결 시 PSK로 0-RTT 키를 미리 유도 → 첫 패킷에 애플리케이션 데이터 탑재 가능 클라이언트 서버 같은 UDP 데이터그램 ① Initial CRYPTO: ClientHello + 세션 티켓(PSK), Initial 키로 보호 ② 0-RTT HTTP/3 요청 (early data, 0-RTT 키로 보호) 서버: PSK 검증 → 0-RTT 수락/거부 판정 ③ ServerHello CRYPTO: ServerHello (Initial 패킷) — 수락/거부 결과 포함 ④ Handshake CRYPTO: EncryptedExtensions · Certificate · CertificateVerify · Finished ⑤ 1-RTT ⑥ Handshake CRYPTO: Finished — 핸드셰이크 완료 거부 시: 정상 1-RTT 폴백, 클라이언트가 요청 재전송 0-RTT 키로 처리 핸드셰이크 완료 서버 응답(1 RTT) 전에 요청이 이미 전송됨 — 왕복 0회로 첫 요청 시작 재생 위험(Replay Attack): 0-RTT 데이터는 캡처·재전송될 수 있음 — 멱등(Idempotent) 요청(GET 등)에만 사용 (RFC 9001 §9.2) 서버는 단일 사용 티켓(Single-Use Ticket)과 재생 캐시(Replay Cache)로 위험을 완화
0-RTT 연결 재개: 이전 연결의 NewSessionTicket(PSK)으로 0-RTT 키를 미리 유도해, 첫 데이터그램에 Initial(ClientHello)과 0-RTT 패킷(HTTP/3 요청)을 결합 전송합니다. 서버는 PSK 검증 후 0-RTT를 수락하면 즉시 응답하고, 거부하면 1-RTT 핸드셰이크로 폴백합니다.
0-RTT의 재전송 위험(Replay Attack): 0-RTT 데이터는 공격자가 캡처해 서버에 재전송(Replay)할 수 있습니다. RFC 9001 §9.2는 서버가 0-RTT 데이터를 수락할 때 재생 위험을 반드시 평가하도록 요구합니다. 따라서 0-RTT는 멱등(Idempotent)한 요청(GET, 상태 조회 등)에만 사용해야 하며, 주문 생성·결제 같은 부작용이 있는 요청에 0-RTT를 쓰면 이중 처리 사고가 날 수 있습니다. 서버 구현은 단일 사용 티켓(Single-Use Ticket)과 재생 캐시(Replay Cache)로 이 위험을 줄입니다.

TLS 1.3 통합 (RFC 9001)

QUIC은 TLS 1.3을 유일한 암호화 계층으로 사용합니다. 가장 큰 설계 변화는 TLS 레코드(Record) 계층을 제거하고, TLS 핸드셰이크 메시지를 QUIC의 CRYPTO 프레임에 직접 싣는 것입니다. TLS 1.3의 트래픽 키(Traffic Key)들은 그대로 QUIC 패킷 보호(Packet Protection)에 사용됩니다.

TLS 통합의 핵심 요점

초기 키(Initial Keys) 유도

첫 Initial 패킷은 상대방의 공개 정보만으로도 복호화(Decryption)할 수 있어야 하므로, 키가 고정된 salt와 패킷에 보이는 DCID로부터 유도됩니다. 이 때문에 Initial 패킷은 기밀성(Confidentiality)이 아니라 경로에 있는지(On-Path) 확인과 무결성 보호 목적이라고 설명됩니다.

# QUIC v1(RFC 9001 §5.2)의 Initial 키 유도 과정 요약
# 1. 초기 비밀
initial_salt    = 38762cf7f55934b34d179ae6a4c80cadccbb7f0a
client_initial_secret = HKDF-Extract(initial_salt, client_dcid)

# 2. 트래픽 비밀과 키 (HKDF-Expand-Label 사용)
client_initial_traffic_secret  = HKDF-Expand-Label(client_initial_secret, "client in", ...)
server_initial_traffic_secret  = HKDF-Expand-Label(client_initial_secret, "server in", ...)

# 3. 패킷 보호 키/IV/헤더 보호 키 (라벨: "quic key", "quic iv", "quic hp")
client_initial_key  = HKDF-Expand-Label(client_initial_traffic_secret, "quic key", ...)
client_initial_iv   = HKDF-Expand-Label(client_initial_traffic_secret, "quic iv", ...)
client_initial_hp   = HKDF-Expand-Label(client_initial_traffic_secret, "quic hp", ...)
QUIC v2의 salt: QUIC v2(RFC 9369)는 버전 고정 방지를 위해 Initial salt를 다르게 사용합니다. 같은 패킷이라도 v1과 v2는 서로 다른 키를 만들기 때문에, 버전 번호와 salt가 짝을 이뤄야 정상 복호화됩니다. 이처럼 v2는 "버전이 다르면 동작이 달라진다"는 점을 중간 장비가 학습하지 못하게 하는 것이 목적입니다.

패킷 보호(Packet Protection)와 AEAD 한도

QUIC 패킷 페이로드는 TLS 1.3 협상된 AEAD 암호 스위트(예: AES-128-GCM, ChaCha20-Poly1305)로 보호됩니다. AEAD의 추가 인증 데이터(AAD)에는 헤더와 패킷 번호가 포함되고, nonce는 IV XOR 패킷 번호로 만듭니다. 이 구조는 TLS 레코드와 달리 패킷 단위로 암호화되므로, 하나의 패킷이 손상되어도 다른 패킷에 영향을 주지 않습니다.

AEAD는 사용량 한도(Usage Limit)가 있습니다. RFC 9001 §6.6은 한도에 도달하기 전에 키 업데이트를 요구하며, 한도를 넘기면 AEAD_LIMIT_REACHED(0x0f) 오류가 발생할 수 있습니다. 운영 관점에서: 암호화 라이브러리(OpenSSL 3.x 등)의 QUIC 지원은 이러한 quic key/quic iv/quic hp 라벨 기반 키 스케줄과 패킷 보호 API를 함께 제공해야 올바르게 동작합니다.

Initial 패킷은 "전송 계층 보호"가 아닙니다: Initial 키는 DCID와 공개 salt로 누구나 유도할 수 있습니다. Initial 패킷의 보호는 무결성(Integrity)과 경로 검증 목적이며, 기밀성(Confidentiality)을 제공하지 않습니다. 따라서 Initial 패킷의 메타데이터(버전, DCID 등)를 기밀로 취급하면 안 됩니다. 실제 기밀성은 1-RTT(Handshake 완료 후)부터 시작됩니다.

전송 파라미터(Transport Parameters)

전송 파라미터(Transport Parameter)는 QUIC 연결의 동작을 협상하는 매개변수 모음입니다. TLS 확장 quic_transport_parameters(0x0039)에 실려 ClientHello(클라이언트)와 EncryptedExtensions(서버)에서 각각 전송되며, 핸드셰이크 중에만 교환되고 암호화됩니다. 이후 변경이 불가능하므로 0-RTT와 1-RTT 양쪽에서 보낼 값(0-RTT 절 참조)을 처음부터 신중히 정해야 합니다.

ID파라미터기본값설명
0x00original_destination_connection_id—클라이언트 첫 Initial의 DCID (서버만 전송, Retry 검증용)
0x01max_idle_timeout0(비활성)유휴 타임아웃(ms). 양측 값 중 작은 값이 적용됩니다
0x02stateless_reset_token—무상태 리셋 검증용 128비트 토큰 (서버만 전송)
0x03max_udp_payload_size65527수신 가능한 최대 UDP 페이로드. 1200 미만은 위반
0x04initial_max_data0연결 전체 흐름 제어 초기 크레딧
0x05initial_max_stream_data_bidi_local0로컬 개시 양방향 스트림의 초기 크레딧
0x06initial_max_stream_data_bidi_remote0피어 개시 양방향 스트림의 초기 크레딧
0x07initial_max_stream_data_uni0유니 스트림의 초기 크레딧
0x08initial_max_streams_bidi0열 수 있는 양방향 스트림 최대 개수
0x09initial_max_streams_uni0열 수 있는 유니 스트림 최대 개수
0x0aack_delay_exponent3ACK 지연 필드의 지수 스케일(2n ms 단위)
0x0bmax_ack_delay25ms수신자가 ACK을 지연시킬 수 있는 상한 (PTO 계산에 사용)
0x0cdisable_active_migration0(허용)1이면 클라이언트의 능동 마이그레이션 금지
0x0dpreferred_address—서버의 대체 주소(IPv4/IPv6). 클라이언트가 최적 경로 선택에 사용
0x0eactive_connection_id_limit2동시에 유지 가능한 CID 개수 상한
0x0finitial_source_connection_id—송신자가 보낸 최초 SCID 값(무결성 검증용)
0x10retry_source_connection_id—Retry 패킷의 SCID (서버만 전송)
0x20max_datagram_frame_size—QUIC Datagram(RFC 9221) 최대 크기 협상 (RFC 9221 §5.1)
흐름 제어 초기값의 중요성: HTTP/3 사용 시 서버는 initial_max_stream_data_bidi_remote(0x06)와 initial_max_streams_bidi(0x08)를 충분히 크게 설정해야 클라이언트가 핸드셰이크 직후 여러 요청을 크레딧 부족 없이 보낼 수 있습니다. 값이 0이면 MAX_STREAM_DATA/MAX_STREAMS 프레임으로 증가시키기 전까지 데이터를 보낼 수 없어 왕복 지연이 늘어납니다. 0-RTT 데이터는 "서버가 이전 연결에서 보낸 파라미터"를 따르므로, 파라미터를 줄여서 허용하면 안 됩니다(서버는 0-RTT에 사용된 값보다 보수적으로 처리).

손실 감지와 복구 (RFC 9002)

QUIC은 패킷 번호(Packet Number)와 ACK 프레임으로 신뢰성을 구현합니다. TCP와 달리 패킷 번호가 절대 재사용되지 않으므로, "어떤 패킷이 ACK되었는지, 어떤 패킷이 손실되었는지"를 모호함 없이 판별할 수 있습니다. 손실 감지(Loss Detection)는 ACK 기반(재정렬 임계값)과 타이머(Timer) 기반(Probe Timeout)의 두 축으로 동작합니다.

손실 감지의 핵심 규칙

메커니즘규칙값
패킷 재정렬 임계값ACK 범위에서 3개 이상의 후속 패킷이 확인되면 손실 선언(kPacketThreshold)3
시간 임계값보낸 지점부터 최대 왕복 추정치(max(rtt × 9/8, kGranularity))가 지나면 손실 선언(kTimeThreshold)9/8 × RTT
기본 RTTRTT 샘플 확보 전에 사용하는 초기 RTT(kInitialRtt)333ms
시간 세분화타이머 최소 단위(kGranularity)1ms
PTO 계산smoothed_rtt + max(4 × rttvar, kGranularity) + max_ack_delay패킷 공간별
ACK 지연ACK를 지연시킬 수 있는 상한(transport parameter max_ack_delay, 기본 25ms)25ms

타이머 기반 감지는 PTO(Probe Timeout)라 불립니다. ACK를 기다리지 못하면 패킷 공간별 PTO 타이머가 만료되고, 송신자는 탐색 패킷(Probe)을 보내 상대방의 ACK을 다시 유발합니다. 이때 중요한 규칙은 다음과 같습니다.

지속적 혼잡(Persistent Congestion)

PTO 주기 기준으로 kPersistentCongestionThreshold(3)개 이상의 PTO 구간 동안 어떤 ACK도 받지 못하고 재전송도 손실된 것으로 판단되면 지속적 혼잡으로 간주합니다. 이 경우 혼잡 창(Congestion Window)을 최소 창(kMinimumWindow) 수준으로 줄여 네트워크가 회복될 때까지 보수적으로 전송합니다.

TCP와의 차이: TCP는 중복 ACK 3회(DUPACK)를 손실 신호로 쓰지만, QUIC은 재정렬 허용치(3)와 시간 임계값(9/8×RTT)을 조합하여 잘못된 손실 판정(스퓨리어스 손실, Spurious Loss)을 줄입니다. 또한 TCP는 RTO(Retransmission Timeout) 후 같은 시퀀스를 재전송하지만, QUIC의 PTO는 새 번호의 탐색 패킷을 보냅니다.

혼잡 제어(Congestion Control)

RFC 9002는 손실 감지와 함께 혼잡 제어의 기본 요구사항을 정의합니다. 중요한 점: QUIC은 혼잡 제어 알고리즘을 특정하지 않습니다. NewReno, CUBIC, BBR, Copa 등 구현체가 선택한 알고리즘을 사용할 수 있으며, RFC는 모든 알고리즘이 반드시 지켜야 할 기본값과 조건(특히 송신량 한도)만 규정합니다.

상수값의미
kInitialWindow10 × max_datagram_size초기 혼잡 창. UDT(큰 데이터그램) 기준 약 10개 패킷분량
kMinimumWindow2 × max_datagram_size최소 혼잡 창. 손실 시 이 수준까지 감소 가능
kLossReductionFactor0.5손실 감지 시 혼잡 창 감소 계수(절반으로)
kPersistentCongestionThreshold3지속적 혼잡 판정 기준(PTO 주기 수)

혼잡 창은 연결 전체(In-Flight 바이트 총량)를 제한합니다. 스트림별 제한은 흐름 제어가 담당하므로, "혼잡 창은 연결 단위, 흐름 제어 크레딧은 연결+스트림 단위"로 기억하면 혼란이 줄어듭니다. 전송량은 혼잡 창, 흐름 제어 크레딧, 송신 버퍼 세 가지 제약을 동시에 만족해야 합니다.

혼잡 창(Congestion Window) 변화 개념도 (NewReno 계열, RFC 9002) 혼잡 창 경과 시간 (RTT) 0 2 4 6 8 10 12 14 16 0 1 2 3 4 5 6 7 8 9 10 kInitialWindow = 10 kMinimumWindow = 2 느린 시작 (지수 성장) 손실 감지 16 혼잡 창 ×0.5 혼잡 회피 (가산 성장) 손실 감지 12 혼잡 창 ×0.5 6 지속적 혼잡 → kMinimumWindow(2) 손실 감지 시 창을 ×0.5(kLossReductionFactor)로 감소, PTO가 반복되는 지속적 혼잡(Persistent Congestion)이면 kMinimumWindow(2)까지 급감 느린 시작은 매 RTT마다 2배(지수), 혼잡 회피는 매 RTT마다 +1(가산) — 이후 감소·재성장 사이클이 반복됩니다
혼잡 창 변화: kInitialWindow(10)에서 느린 시작(지수, 매 RTT 2배)으로 성장하다가 손실 감지 시 ×0.5(kLossReductionFactor)로 감소하고, 혼잡 회피(가산, 매 RTT +1)로 재성장합니다. PTO가 반복되는 지속적 혼잡(Persistent Congestion)이면 창이 kMinimumWindow(2)까지 급감합니다. y축 단위는 max_datagram_size입니다.

ECN(Explicit Congestion Notification)

QUIC은 ECN을 지원합니다. 수신측은 IP 헤더의 ECN 마킹(ECT0/ECT1/CE)을 확인해 ACK(0x03) 프레임의 카운터로 보고하고, 송신측은 CE(혼잡 경험, Congestion Experienced) 카운터 증가를 손실 없이 혼잡 신호로 사용해 창을 줄일 수 있습니다. ECN 경로가 중간 장비에 의해 제거될 수 있으므로, 구현체는 "ECN이 실제로 동작하는지"를 검증(피드백 확인)한 뒤에만 사용해야 합니다.

연결 마이그레이션(Connection Migration)

TCP는 연결을 4-tuple(출발지/목적지 IP·포트)로 식별하므로 IP가 바뀌면(와이파이 → LTE) 연결이 끊깁니다. QUIC은 Connection ID로 연결을 식별하기 때문에, 주소가 바뀌어도 같은 연결을 계속 사용할 수 있습니다. 스마트폰이 이동 중 네트워크를 바꿔도 스트리밍·게임 세션이 유지되는 핵심 기능입니다.

와이파이 → LTE 전환 시에도 연결 유지 (연결 마이그레이션) 클라이언트 연결 식별자: CID (IP와 무관) 와이파이: 10.0.0.5 LTE: 100.64.8.9 IP가 바뀌어도 연결 유지 서버 CID 기반 연결 라우팅 주소: 203.0.113.10 연결 상태 · 세션 키 유지 CID로 연결 식별 경로 A: 와이파이 (10.0.0.5 ↔ 203.0.113.10) 네트워크 전환: 와이파이 → LTE (연결 ID 유지) ① LTE 전환 — PATH_CHALLENGE로 주소 검증 요청 (검증 전 최소 패킷만) ② 서버가 PATH_RESPONSE 회신 — 주소 소유권 확인 ③ 검증 완료 — 새 경로로 데이터 송수신, 기존 와이파이 경로 폐기 CID가 유지되므로 핸드셰이크 재수행 없이 세션 키·흐름 제어 상태가 그대로 이어집니다 NAT 리바인딩(IP 동일·포트만 변경)은 마이그레이션으로 취급하지 않으며, 서버는 disable_active_migration으로 능동 마이그레이션을 금지할 수 있습니다
연결 마이그레이션: 클라이언트의 IP가 바뀌어도(와이파이 → LTE) Connection ID로 연결이 유지되며, 새 경로는 PATH_CHALLENGE/PATH_RESPONSE 주소 검증을 거친 뒤 실제 데이터를 실어 나릅니다. 검증 전에는 최소한의 탐색 패킷만 전송됩니다.

마이그레이션 규칙

마이그레이션 오해: QUIC의 마이그레이션은 클라이언트 주소 변경을 위한 것이며, 서버 측 IP 변경은 해당되지 않습니다. 또한 UDP이므로 IP 단편화(Fragmentation) 없이 패킷 크기가 제한됩니다. 새 경로의 MTU가 작으면 패킷이 드롭될 수 있어, 구현체는 경로 변경 시 PMTU(경로 MTU)를 다시 측정해야 합니다. NAT 리바인딩으로 인해 공격자가 경로를 탈취하려는 시도도 가능하므로, 주소 검증이 필수입니다.

주소 검증(Address Validation)과 증폭 방지

UDP 기반 프로토콜의 대표적 공격은 증폭 공격(Amplification Attack)입니다. 공격자가 작은 요청의 출발지 IP를 피해자로 위조(Spoofing)하면, 서버가 큰 응답을 피해자에게 쏟아부어 트래픽을 증폭시킵니다. QUIC은 두 가지 메커니즘으로 이를 막습니다.

증폭 한도(Amplification Limit)

주소가 검증되지 않은 상태에서 서버가 클라이언트에게 보낼 수 있는 총 데이터는 수신한 데이터의 3배로 제한됩니다(RFC 9000 §8.1). 초기 패킷을 1200바이트 이상으로 강제하는 이유도, 이 3배 한도(최소 3600바이트) 안에서 서버가 핸드셰이크 응답(인증서 포함)을 전부 보낼 수 있게 하기 위함입니다.

Retry 패킷과 토큰

서버는 클라이언트 주소를 즉시 검증하고 싶을 때 Retry 패킷을 보냅니다. Retry 패킷에는 서버가 생성한 토큰(Token)이 실리며, 클라이언트는 이 토큰을 새 Initial 패킷에 포함해 다시 보냅니다. 토큰은 서버가 무상태(Stateless)로 검증할 수 있도록 설계할 수 있고(예: 출발지 IP + 타임스탬프를 키로 MAC 계산), Retry Integrity Tag로 변조를 방지합니다.

이미 검증된 주소에는 NEW_TOKEN 프레임으로 토큰을 미리 발급할 수 있습니다. 클라이언트는 이후 연결에서 이 토큰을 Initial 패킷에 포함해 Retry 왕복을 생략합니다(0-RTT와 결합 시 효과가 큽니다).

Retry 패킷과 토큰 — 무상태 주소 검증 (RFC 9000 §8.1) 클라이언트 서버 ① Initial — 토큰 없음 (최초 요청) DCID = 임의 값 · ClientHello 포함 (1200바이트 이상) ② Retry — 토큰 지급 + 새 SCID 무상태(Stateless) 검증용 토큰 · Retry Integrity Tag로 변조 방지 ③ Initial — 토큰 포함 재전송 Retry가 준 SCID를 DCID로 사용 · ClientHello 다시 전송 ④ 토큰 검증 MAC 기반 무상태 검증 성공 → 증폭 한도 해제 토큰은 출발지 IP·타임스탬프를 키로 한 MAC 계산으로 무상태 검증하며, 서버가 연결 상태를 저장하지 않아도 됩니다. 검증된 주소에는 NEW_TOKEN 프레임으로 토큰을 선발급 — 다음 연결에서 Initial에 포함하면 Retry 왕복을 생략합니다 (0-RTT와 결합 시 효과 큼). 참고: 토큰은 주소 검증용이지 인증(Authentication)이 아니며, 연결 보안은 TLS가 담당합니다.
Retry 기반 주소 검증: 서버는 ClientHello에 포함된 토큰으로 주소 소유권을 확인한 뒤에만 증폭 한도를 해제하고 핸드셰이크를 진행합니다. 검증된 주소에는 NEW_TOKEN으로 토큰을 선발급하여 다음 연결에서 Retry 왕복을 생략할 수 있습니다.
Retry의 트레이드오프: Retry는 왕복 1회를 추가로 소모하므로 항상 사용하면 지연이 늘어납니다. 서버는 일반적으로 첫 연결에서 Retry를 쓰지 않고, 부하가 높거나 의심스러운 요청(예: 동일 IP에서 대량 연결)에만 적용합니다. NEW_TOKEN으로 발급된 토큰이 있으면 정상 클라이언트는 Retry 없이 통과합니다. 또 오해하지 말아야 할 점: 토큰은 주소 검증용이지 인증(Authentication)이 아니며, 공격자가 토큰을 탈취해도 연결 자체는 TLS 인증서로 보호됩니다.

연결 종료와 오류 처리

QUIC 연결은 세 가지 방식으로 종료됩니다: 명시적 종료(CONNECTION_CLOSE), 유휴 타임아웃(Idle Timeout), 무상태 리셋(Stateless Reset).

세 가지 종료 방식

방식트리거특징
CONNECTION_CLOSE(0x1c/0x1d)오류 또는 정상 종료오류 코드와 사유(Reason Phrase)를 전달. 전송 오류(0x1c)는 범위 오류 프레임 타입 포함
유휴 타임아웃지정 시간 동안 패킷 없음max_idle_timeout 파라미터(기본 0 = 비활성화). 양측 값 중 작은 값이 적용됩니다
무상태 리셋연결 상태를 잃었거나 유지 불가Stateless Reset Token(128비트)이 든 UDP 데이터그램 전송. 수신자는 연결이 종료되었음을 알게 됨

무상태 리셋의 토큰은 NEW_CONNECTION_ID 프레임을 통해 상대방에게 미리 전달됩니다. 연결 상태 없이 리셋을 검증할 수 있어, 서버가 재시작(Reboot)해 연결 상태를 잃은 경우에도 클라이언트가 즉시 오류를 인지합니다.

정상 종료 절차: HTTP/3에서는 먼저 GOAWAY 프레임으로 "더 이상 새 요청을 받지 않는다"를 알린 뒤, 진행 중인 요청이 모두 끝나면 QUIC 연결을 CONNECTION_CLOSE(0x1d, 애플리케이션 오류 코드)로 닫는 것이 표준 패턴입니다. 갑자기 연결을 끊으면 재시도 요청이 쏟아질 수 있으므로, 로드밸런서 교체·배포 시 GOAWAY를 활용한 우아한 종료(Graceful Shutdown)가 필요합니다.

버전 협상과 QUIC v2 (RFC 9369)

QUIC 패킷의 Long 헤더에는 32비트 버전(Version) 필드가 있습니다. QUIC v1은 0x00000001, QUIC v2는 0x6b3343cf이며, 버전 협상(Version Negotiation) 패킷은 버전 0x00000000을 사용합니다.

버전 협상(Version Negotiation)

클라이언트가 서버가 지원하지 않는 버전으로 Initial 패킷을 보내면, 서버는 버전 협상(VN) 패킷으로 자신이 지원하는 버전 목록을 되돌려줍니다. VN 패킷은 암호화되지 않으므로(RFC 8999), 중간 공격자가 다운그레이드를 유도할 수 없도록 클라이언트는 VN 응답이 "자신이 보낸 패킷에 대한 응답"인지 검사해야 합니다.

버전값구분
버전 협상0x00000000VN 패킷 전용
QUIC v10x00000001RFC 9000~9002 (2021)
QUIC v20x6b3343cfRFC 9369 (2023)

QUIC v2와 v1의 차이

RFC 9369는 의도적으로 v1과 거의 동일하게 설계되었습니다. 차이는 다음 세 가지뿐입니다.

왜 v2인가: QUIC v1은 UDP 443 트래픽으로 널리 퍼졌고, 일부 중간 장비가 "QUIC = 특정 바이트 패턴"으로 식별하기 시작했습니다(버전 고정/Ossification). v2는 버전 전환으로 이 학습을 무력화하고, 앞으로도 버전을 주기적으로 바꿀 수 있다는 점을 생태계에 보여주는 표준적인 대응입니다. 운영 관점에서 서버는 v1과 v2를 동시에 지원하는 것이 일반적이며, 구현체(quiche, ngtcp2 등)는 별도 설정으로 v2를 켭니다.

HTTP/3 개요 (RFC 9114)

HTTP/3은 QUIC 위에서 동작하는 HTTP 버전입니다. ALPN 식별자는 h3입니다. HTTP 의미론(Semantics)은 HTTP/2와 동일하게 유지되지만, 전송 계층이 QUIC으로 바뀌면서 요청/응답이 스트림에 1:1 매핑되고, 헤더 압축은 QPACK(RFC 9204)으로 대체되며, 프레임 계층(Frame Layer)도 QUIC에 맞게 재정의되었습니다.

HTTP/3의 설계 원칙

HTTP/3 스트림 매핑

HTTP/3 스트림 매핑 — QUIC 스트림 유형 · ID · 메시지 흐름 (RFC 9114 §6.2) 요청 (양방향) — ID 0 · 4 · 8 · … 응답 전송 (같은 스트림에서) 요청/응답 — 하나의 양방향 스트림에서 HEADERS · DATA 클라이언트 제어 (유니) — 0x00 · ID 2 서버 제어 (유니) — 0x01 · ID 3 SETTINGS · GOAWAY · MAX_PUSH_ID SETTINGS · GOAWAY 클라이언트 인코더 (유니) — 0x02 · ID 6 서버 인코더 (유니) — 0x02 · ID 7 인코더 명령 — 필드 라인 갱신 인코더 명령 클라이언트 디코더 (유니) — 0x03 · ID 10 서버 디코더 (유니) — 0x03 · ID 11 디코더 명령 — 승인 · 스트림 취소 디코더 명령 푸시 수신 (클라이언트) 푸시 (유니) — 0x04 · ID 15 · 19 · … PUSH_PROMISE로 예고된 응답 스트림 ID 하위 2비트 — 00: 클라이언트 양방향(요청 0 · 4 · 8) · 10: 클라이언트 단방향(제어 2 · 인코더 6 · 디코더 10) · 11: 서버 단방향(제어 3 · 인코더 7 · 디코더 11 · 푸시 15 · 19 · …) · 01: 서버 양방향은 HTTP/3에서 개시 금지 제어 스트림: SETTINGS가 첫 프레임이어야 하며, 제어 스트림이 닫히면 H3_CLOSED_CRITICAL_STREAM(0x0104) 연결 오류 QPACK 인코더/디코더 스트림은 헤더 압축 상태를 동기화해, 한 스트림의 헤더 누락이 다른 스트림을 막지 않게 합니다 요청/응답은 같은 양방향 스트림에서만 주고받으며, 서버 푸시는 PUSH_PROMISE로 예고한 뒤 푸시 스트림(서버 단방향, 타입 0x04)으로 보냅니다 실무에서 브라우저들은 HTTP/2·3 푸시를 사실상 사용하지 않으므로, HTTP 캐싱 · 103 Early Hints를 우선 권장합니다
HTTP/3 스트림 매핑: 요청/응답은 클라이언트 개시 양방향 스트림, 제어·QPACK 상태·푸시는 단방향 전용 스트림으로 분리됩니다. 푸시 스트림 유형은 0x01이 아닌 0x04입니다(RFC 9114 §6.2).

HTTP/2와의 차이점 정리

항목HTTP/2HTTP/3
전송TCP (전송 HoL 존재)QUIC (스트림별 독립 복구)
헤더 압축HPACK (동적 테이블이 스트림과 결합)QPACK (인코더/디코더 스트림으로 분리)
우선순위Priority 프레임 + 종속 트리Priority 필드 (urgency/incremental, RFC 9218)
흐름 제어TCP 윈도우 + HTTP/2 윈도우QUIC 연결/스트림 크레딧 (HTTP 계층 없음)
연결 마이그레이션불가가능 (Connection ID)
0-RTT불가가능 (세션 티켓)
스트림 ID 규칙별도 인코딩QUIC 스트림 ID (0부터 4씩 증가)

HTTP/3 프레임 계층

HTTP/3 프레임은 프레임 타입(Type, varint) + 길이(Length, varint) + 페이로드 구조를 가집니다. QUIC STREAM 프레임과 이름이 겹치므로 "HTTP/3 프레임"과 "QUIC 프레임"을 구분해서 이해해야 합니다 — QUIC 프레임은 패킷 페이로드 안에 있고, HTTP/3 프레임은 QUIC 스트림의 바이트 스트림 안에 있습니다.

타입프레임전송 스트림용도
0x00DATA요청/응답메시지 본문(바디) 데이터
0x01HEADERS요청/응답QPACK으로 압축된 헤더 섹션
0x02예약—사용 금지 (오류로 처리)
0x03CANCEL_PUSH클라→서버 제어수신을 원하지 않는 푸시 예고 취소
0x04SETTINGS양방향 제어(연결 시작 시 1회)QPACK 용량, CONNECT 활성화 등 파라미터 교환
0x05PUSH_PROMISE서버(요청 스트림)푸시 자원의 링크드 헤더 예고
0x06예약—사용 금지
0x07GOAWAY서버→클라 제어새 요청 거부 시작, 마지막 수락 스트림 ID 포함
0x08, 0x09예약—사용 금지
0x0dMAX_PUSH_ID클라→서버 제어서버가 열 수 있는 푸시 스트림 최대 Push ID 설정
0x10~0x1f예약(확장 프레임)—미래 확장용

SETTINGS 파라미터

식별자이름기본값의미
0x01SETTINGS_QPACK_MAX_TABLE_CAPACITY0QPACK 동적 테이블 최대 용량(바이트). 0 = 동적 테이블 비활성
0x06SETTINGS_MAX_FIELD_SECTION_SIZE무제한수신할 수 있는 최대 헤더 섹션 크기
0x07SETTINGS_QPACK_BLOCKED_STREAMS0수신자가 허용하는 "차단된(동적 테이블 갱신 대기) 스트림"의 상한
0x08SETTINGS_ENABLE_CONNECT_PROTOCOL0확장 CONNECT(RFC 9220 WebSocket) 활성화
SETTINGS는 연결 시작 시 제어 스트림에서 1회만: HTTP/2와 달리 HTTP/3의 SETTINGS 프레임은 반드시 연결 시작 시 제어 스트림에서 첫 프레임으로 전송되어야 하며, 이후에 또 SETTINGS가 도착하면 H3_FRAME_UNEXPECTED입니다. 또한 SETTINGS 프레임은 요청/응답 스트림이나 다른 스트림에서 오면 오류입니다.

HTTP/3 오류 코드

HTTP/3 오류는 CONNECTION_CLOSE(0x1d) 또는 RESET_STREAM/STOP_SENDING에 실리는 62비트 애플리케이션 오류 코드입니다.

코드이름의미
0x0100H3_NO_ERROR오류 없음
0x0101H3_GENERAL_PROTOCOL_ERROR일반 프로토콜 위반
0x0102H3_INTERNAL_ERROR구현 내부 오류
0x0103H3_STREAM_CREATION_ERROR스트림 생성/사용 오류
0x0104H3_CLOSED_CRITICAL_STREAM제어/QPACK 스트림이 조기 종료됨
0x0105H3_FRAME_UNEXPECTED이 스트림에서 허용되지 않는 프레임
0x0106H3_FRAME_ERROR프레임 형식 오류 (길이 불일치 등)
0x0107H3_EXCESSIVE_LOAD과도한 부하 유발 시도
0x0108H3_ID_ERROR스트림/Push ID 오류
0x0109H3_SETTINGS_ERRORSETTINGS 값/처리 오류
0x010aH3_MISSING_SETTINGSSETTINGS 누락 또는 위치 오류
0x010bH3_REQUEST_REJECTED서버가 요청을 즉시 거부 (재시도 가능)
0x010cH3_REQUEST_CANCELLED요청이 취소됨
0x010dH3_REQUEST_INCOMPLETE스트림이 완전한 메시지 없이 종료됨
0x010eH3_MESSAGE_ERROR메시지(요청/응답) 형식 오류
0x010fH3_CONNECT_ERRORCONNECT 프록시 처리 오류
0x0110H3_VERSION_FALLBACKHTTP/2로 폴백해야 함을 알림

실전 구현: 프레임 판독과 디스패치(Dispatch)

HTTP/3 프레임은 타입(Type, varint) + 길이(Length, varint) + 페이로드로 구성되며, 어느 스트림에서 왔는지에 따라 허용되는 프레임이 달라집니다. 아래는 제어 스트림(Control Stream)·요청/응답 스트림·푸시 스트림(Push Stream)을 구분해 프레임을 판독·디스패치하고 오류 코드를 매기는 개념 예시입니다(RFC 9114 §7).

/* HTTP/3 프레임 디스패치 (RFC 9114 §7.2, 개념 예시) */

/* ① 스트림 종류 (RFC 9114 §6) */
enum h3_stream_kind {
    H3_STREAM_CONTROL,   /* 유니 스트림 타입 0x00 */
    H3_STREAM_PUSH,      /* 유니 스트림 타입 0x01 */
    H3_STREAM_REQUEST,   /* 양방향 스트림 */
    /* QPACK 인코더(0x02)·디코더(0x03) 스트림은 QPACK 계층이 전담 */
};

static int h3_dispatch_frame(struct h3_stream *s, uint64_t type,
                             const uint8_t *payload, uint64_t len)
{
    /* ② HTTP/2 유래 예약 타입: 송신 금지, 수신 시 H3_FRAME_UNEXPECTED (RFC 9114 §7.2.8) */
    /*    0x02(PRIORITY) 0x06(PING) 0x08(WINDOW_UPDATE) 0x09(CONTINUATION) */
    if (type == 0x02 || type == 0x06 || type == 0x08 || type == 0x09)
        return h3_conn_error(H3_FRAME_UNEXPECTED);

    /* ③ 0x1f * N + 0x21 형태(0x21, 0x40, ...): 의미 없음 — 무시 (패딩 용도) */
    if (type >= 0x21 && (type - 0x21) % 0x1f == 0)
        return 0;

    /* ④ 알 수 없는 타입: 확장 허용을 위해 무시·폐기 (RFC 9114 §9) */
    /*    단, SETTINGS가 필요한 위치(제어 스트림 첫 프레임)는 예외 — 미지 타입은 */
    /*    그 요구를 충족하지 못하므로 오류로 처리해야 합니다 (§9) */

    switch (s->kind) {
    case H3_STREAM_CONTROL:
        /* SETTINGS는 제어 스트림의 첫 프레임이어야 함 (RFC 9114 §7.2.4) */
        if (!s->seen_settings && type != H3_FRAME_SETTINGS)
            return h3_conn_error(H3_MISSING_SETTINGS);   /* 0x010a */
        if (type == H3_FRAME_SETTINGS && s->seen_settings)
            return h3_conn_error(H3_FRAME_UNEXPECTED);   /* 1회 제한 */
        s->seen_settings = 1;
        if (type == H3_FRAME_GOAWAY)      /* 0x07: 마지막 수락 스트림 ID 이후 거부 */
            return h3_process_goaway(payload, len);
        if (type == H3_FRAME_MAX_PUSH_ID) /* 0x0d: 이전 값보다 작으면 H3_ID_ERROR */
            return h3_process_max_push_id(payload, len);
        if (type == H3_FRAME_CANCEL_PUSH) /* 0x03 */
            return h3_process_cancel_push(payload, len);
        return h3_conn_error(H3_FRAME_UNEXPECTED);  /* 제어 스트림의 DATA/HEADERS 금지 */

    case H3_STREAM_REQUEST:
        /* 요청/응답: HEADERS로 시작, 이후 HEADERS(트레일러)·DATA 허용 */
        if (type == H3_FRAME_HEADERS)
            return h3_process_headers(s, payload, len);
        if (type == H3_FRAME_DATA && s->headers_seen)
            return h3_process_data(s, payload, len);
        return h3_stream_error(s, H3_FRAME_UNEXPECTED);  /* 스트림 오류로 종료 */

    case H3_STREAM_PUSH:
        if (type == H3_FRAME_HEADERS || type == H3_FRAME_DATA)
            return h3_process_push(s, type, payload, len);
        return h3_conn_error(H3_FRAME_UNEXPECTED);
    }
    return 0;
}

시나리오별로 매겨야 하는 오류 코드를 요약하면 다음과 같습니다.

HTTP/2와 다른 두 가지: HTTP/3에는 CONTINUATION 프레임이 없으므로 HEADERS는 한 프레임 안에 완결되어야 하며, 큰 헤더 섹션 제한은 SETTINGS_MAX_FIELD_SECTION_SIZE로 협상합니다. 또한 모든 HTTP/3 프레임 헤더(Frame Header)와 페이로드가 QUIC 흐름 제어의 적용을 받으므로(RFC 9114 부록 A.2.3), 프레임 크기를 너무 크게 허용하면 수신 버퍼 부담이 커집니다 — 프레임 길이 상한과 H3_EXCESSIVE_LOAD(0x0107) 대응을 설계에 포함해야 합니다.

HTTP 우선순위: Extensible Priorities (RFC 9218)

HTTP/2의 복잡한 우선순위 트리(종속 트리 + 가중치)는 구현마다 해석이 달라 실제로는 거의 사용되지 않았습니다. RFC 9218은 HTTP/2와 HTTP/3에서 공통으로 쓸 수 있는 단순하고 확장 가능한 우선순위 체계를 정의합니다. HTTP/3에서는 우선순위가 헤더 필드(Priority)로 전달됩니다.

파라미터기본값범위의미
u (urgency)30~7긴급도. 값이 작을수록 우선 (0 = 가장 긴급)
i (incremental)00/11이면 응답을 순차가 아닌 청크 단위로 교차 전송 가능

예: Priority: u=0, i 는 "가장 긴급하고 점진적으로 렌더링 가능한 리소스"(예: CSS)라는 뜻입니다. 브라우저는 HTML 파싱 단계에 따라 리소스 우선순위를 매기고, 서버는 이 신호로 전송 순서를 정합니다.

운영 관점: QUIC은 스트림을 우선순위에 따라 스케줄링하기 때문에, u=0(긴급) 리소스의 스트림에 먼저 대역폭을 할당하고 i=1 스트림은 큰 응답을 여러 스트림에 나눠 인터리빙(Interleaving)할 수 있습니다. RFC 9218은 우선순위 신호를 "힌트"로 규정하며, 수신자가 반드시 따라야 하는 것은 아닙니다.

QPACK: HTTP/3 헤더 압축 (RFC 9204)

QPACK(Header Compression for HTTP/3)은 HPACK(HTTP/2)의 후속 헤더 압축 방식입니다. 목표는 동일합니다(정적 테이블 + 동적 테이블 + Huffman 코딩으로 헤더 중복 제거). 하지만 HTTP/2의 근본 결함 — 동적 테이블 갱신이 스트림 데이터에 섞여 있어서, 한 스트림의 헤더 손실이 다른 스트림을 막는 HoL 블로킹 — 을 해결하기 위해 설계가 바뀌었습니다.

HPACK의 문제와 QPACK의 해결

HTTP/2에서 HPACK 상태 갱신(동적 테이블 삽입)은 헤더 블록 자체(HEADERS 프레임 페이로드)에 포함됩니다. TCP에서는 스트림 간 순서 보장이 되므로 괜찮았지만, QUIC은 스트림별 독립 전달이므로 "스트림 A의 헤더 블록 안에 있는 테이블 삽입"을 스트림 B가 기다려야 하는 상황이 생깁니다. QPACK은 이 상태 갱신을 인코더 스트림(Encoder Stream)이라는 전용 유니 스트림으로 분리하고, 디코더 스트림(Decoder Stream)으로 확인(ACK)을 돌려보냅니다. 헤더 섹션(필드 라인)은 상대 인덱스와 Base를 써서 "테이블의 어느 시점 기준인지"를 명시적으로 표현하므로, 손실된 갱신을 기다리지 않고도 다른 스트림을 진행할 수 있습니다.

QPACK: 인코더·디코더 스트림으로 분리된 헤더 압축 (RFC 9204) 인코더 (송신측) 요청 헤더 필드를 압축: Huffman 코딩 · 정적 테이블(99개 엔트리) · 동적 테이블(용량 협상) — 재사용할 필드 라인은 Insert로 동적 테이블에 추가 압축 결과는 테이블 갱신 지시어(① 인코더 스트림)와 필드 섹션(② HEADERS)의 두 경로로 분리 전송됩니다 ① 인코더 스트림 (유니) Insert · Duplicate · Capacity — 필드 라인 갱신 지시어 HEADERS보다 먼저 전송되어, 디코더의 동적 테이블 상태를 최신으로 유지 ② 요청/응답 스트림 HEADERS 프레임 — 필드 섹션 = Required Insert Count + Base Base로 '테이블의 어느 시점'을 지정 — 최신 갱신이 없어도 이전 상태로 즉시 디코딩 ③ 디코더 스트림 (유니) Section Acknowledgment · Stream Cancellation · Insert Count Increment 디코딩된 섹션 승인과 불필요한 필드 라인 취소 — 테이블 라인 해제 시점을 결정 디코더 (수신측) 동적 테이블 미러 복제 후 인코더 스트림 갱신을 즉시 적용 → 수신 HEADERS(필드 섹션)를 디코딩 필수 갱신이 아직 도착하지 않은 필드 섹션은 Blocked로 대기 — 허용 수는 SETTINGS_QPACK_BLOCKED_STREAMS로 협상 핵심: 테이블 갱신이 별도 인코더 스트림으로 '선행 전송'되므로, 한 요청 스트림의 HEADERS 손실·지연이 다른 요청의 압축 해제를 막지 않습니다 클라이언트·서버는 모두 인코더와 디코더를 각자 가집니다(스트림 매핑 다이어그램 참조) — 이 그림은 한 방향(송신→수신)의 흐름을 나타냅니다 SETTINGS_QPACK_MAX_TABLE_CAPACITY(0x01)로 동적 테이블 용량, SETTINGS_QPACK_BLOCKED_STREAMS(0x07)로 허용 Blocked 스트림 수(기본 0)를 양방향 협상합니다
QPACK 구조: 테이블 갱신(인코더 스트림)은 HEADERS(요청/응답 스트림)와 분리 전송되고, 디코더 스트림으로 승인·취소가 돌아와 스트림 간 차단(Blocking)이 제거됩니다. 클라이언트·서버는 각자 인코더와 디코더를 가집니다.

QPACK 지시어와 필드 라인

종류프리픽스내용
인코더: 테이블 용량 설정001 (3비트)동적 테이블 용량 변경
인코더: 이름 참조 삽입1 (1비트) + T기존 이름(정적/동적)을 참조해 새 엔트리 삽입
인코더: 이름 리터럴 삽입01 (2비트)이름과 값을 리터럴로 삽입
인코더: 중복(Duplicate)000 (3비트)기존 엔트리를 테이블 앞에 복제 (인덱스 압축)
디코더: Section Acknowledgment1 (1비트)스트림의 필드 섹션 디코딩 완료 확인
디코더: Stream Cancellation01 (2비트)취소된 스트림의 미적용 엔트리 폐기
디코더: Insert Count Increment00 (2비트)동적 테이블 적용 개수 증가 통지
필드 라인: Indexed1 (1비트)정적/동적 테이블 인덱스 참조 (인코딩 최소)
필드 라인: 이름 참조 리터럴01 (2비트)이름은 테이블, 값은 리터럴
필드 라인: 이름 리터럴001 (3비트)이름·값 모두 리터럴 (최초 등장 등)
필드 라인: Post-Base 참조0001/0000Base 이후 위치의 엔트리를 상대 인덱스로 참조
QPACK 인코딩의 정확성 부담: QPACK은 디코더가 "보장된 최신 상태(Known Received Count)"까지만 안전하게 참조할 수 있습니다. 인코더가 확인(ACK)되지 않은 엔트리를 참조하면 디코더가 필드 섹션을 차단(Blocked)하거나 오류가 납니다. 따라서 인코더는 in-flight(미확인) 엔트리 수를 신중히 관리해야 하며, HTTP/2의 HPACK 구현을 그대로 가져다 쓰면 안 됩니다. 이 지점이 QPACK 구현 시 가장 버그가 많이 나는 부분입니다.
정적 테이블(Static Table): QPACK 정적 테이블은 HTTP/2 HPACK과 완전히 동일한 61개 엔트리에 추가 항목을 더해 99개 엔트리로 구성됩니다(:method: GET, :status: 200, accept-encoding: gzip, deflate, br 등). 자주 쓰는 헤더는 인덱스 1바이트로 표현되므로, 동적 테이블이 꺼져 있어도(용량 0) 상당한 압축 효과가 있습니다.

실전 구현: 차단(Blocked)과 퇴거(Eviction) 처리

QPACK 구현의 난점은 두 가지 제약을 동시에 만족하는 데 있습니다. 첫째, 디코더가 아직 받지 못한 동적 테이블 엔트리를 참조하면 해당 스트림이 차단(Blocked)됩니다. 둘째, 아직 참조 중인 엔트리를 퇴거(Evict)하면 QPACK_DECOMPRESSION_FAILED가 됩니다. 아래는 인코더의 참조 추적(Reference Tracking)과 디코더의 차단 스트림 관리(Blocked Stream Management)를 함께 보여주는 개념 예시입니다(RFC 9204 §2.1~2.2).

/* QPACK 인코더: 참조 추적과 퇴거 제약 (RFC 9204 §2.1.1, 개념 예시) */
struct qpack_entry {
    uint64_t abs_index;   /* 절대 인덱스: 삽입 순서대로 0부터 증가 */
    size_t   size;        /* 이름 길이 + 값 길이 + 32 (RFC 9204 §3.2.1) */
    uint64_t refs;        /* 미확인 필드 섹션이 참조 중인 횟수 */
    int      acked;       /* 디코더가 수신 승인 (Insert Count Increment 등) */
};

struct qpack_encoder {
    struct qpack_entry *table;  /* FIFO: 새 엔트리는 앞, 오래된 것은 뒤 */
    size_t capacity;            /* SETTINGS_QPACK_MAX_TABLE_CAPACITY */
    size_t used;                /* 현재 테이블 점유 크기 */
    uint64_t insert_count;      /* 삽입·복제 총 횟수 */
    uint64_t known_received;    /* Known Received Count (RFC 9204 §2.1.4) */
};

/* 참조해도 안전한가: 디코더가 이미 보유한 엔트리만 (그래야 차단 위험이 없음) */
int qpack_may_reference(const struct qpack_encoder *enc,
                        const struct qpack_entry *e)
{
    /* 실제 구현에서는 디코더 측 퇴거(용량·새 삽입에 의한 밀어냄) 추적도 필요 */
    return e->abs_index < enc->known_received;
}

/* 엔트리를 퇴거할 수 있는가: 승인 + 미확인 참조 0 */
int qpack_evictable(const struct qpack_entry *e)
{
    return e->acked && e->refs == 0;
}

/* 새 엔트리 삽입: 퇴거 불가능한 엔트리를 건드려야 하면 삽입을 포기 */
int qpack_try_insert(struct qpack_encoder *enc, size_t new_size)
{
    if (new_size > enc->capacity)
        return 0;  /* 단일 엔트리가 용량 초과 → 리터럴 인코딩으로 대체 */
    while (enc->used + new_size > enc->capacity) {
        struct qpack_entry *victim = tail(enc->table);
        if (!qpack_evictable(victim))
            return 0;  /* 참조 중인 엔트리를 퇴거할 수 없음 → 삽입 중단 (§2.1.1) */
        evict(enc, victim);
    }
    insert_front(enc, new_size);
    return 1;
}
/* QPACK 디코더: 차단 스트림 관리 (RFC 9204 §2.2.1, 개념 예시) */
struct qpack_blocked {
    int64_t  stream_id;
    uint64_t required_insert_count;  /* 이 값까지 insert_count가 도달하면 해제 */
};

struct qpack_decoder {
    uint64_t insert_count;       /* 인코더 스트림 지시어를 적용한 삽입 수 */
    size_t   max_blocked;        /* SETTINGS_QPACK_BLOCKED_STREAMS */
    struct qpack_blocked *blocked;
};

/* HEADERS(필드 섹션) 수신 */
int qpack_decode_headers(struct qpack_decoder *dc, int64_t stream_id,
                         const uint8_t *encoded, size_t len)
{
    uint64_t required = read_required_insert_count(encoded);
    if (required == 0)
        return decode_now(dc, encoded, len);  /* 정적 테이블·리터럴만 사용 → 즉시 */

    if (required > dc->insert_count) {
        /* 아직 필요한 갱신이 도착하지 않음 → Blocked로 등록 */
        if (blocked_count(dc) >= dc->max_blocked)
            return QPACK_DECOMPRESSION_FAILED;  /* 약속한 상한 초과 (§2.1.2) */
        enqueue_blocked(dc, stream_id, required);
        /* 차단된 필드 섹션 데이터는 흐름 제어 윈도우 안에 보존 (§2.2.1) */
        return QPACK_BLOCKED;
    }
    return decode_now(dc, encoded, len);
}

/* 인코더 스트림 지시어(Insert/Duplicate) 적용 시마다 호출 */
void qpack_on_table_update(struct qpack_decoder *dc)
{
    dc->insert_count++;
    for (size_t i = 0; i < blocked_count(dc); ) {
        if (dc->blocked[i].required_insert_count <= dc->insert_count) {
            requeue_for_decode(dc, dc->blocked[i].stream_id);  /* 해제 */
            remove_blocked(dc, i);   /* i 증가 없음 */
        } else {
            i++;
        }
    }
    /* 해제된 수만큼 Insert Count Increment 지시어를 디코더 스트림으로 전송 — */
    /* 여러 갱신을 모아 한 번에 보내는 것도 가능 (RFC 9204 §2.2.2.3, §4.4.3) */
}

구현 시 확인해야 할 시나리오를 정리합니다.

실무 권장: nghttp3·quiche 등 성숙한 라이브러리는 위 동적 테이블 관리(참조 추적, Blocked 스트림 관리, Section Acknowledgment 처리)를 내장하고 있습니다. QPACK을 직접 구현하려면 반드시 다른 구현체와의 상호 검증(크로스 테스트)을 수행하고, RFC 9204 부록 B·C의 인코딩·디코딩 예제로 단위 테스트를 구성하세요.

HTTP/3 확장: WebSocket, Datagram, WebTransport, MASQUE

WebSocket over HTTP/3 (RFC 9220)

WebSocket은 TCP의 양방향 바이트 스트림 위에서 동작했지만, QUIC 위에서는 스트림을 직접 사용하는 편이 자연스럽습니다. RFC 9220은 확장 CONNECT(Extended CONNECT) 메커니즘으로 HTTP/3에서 WebSocket을 부트스트랩합니다.

QUIC Datagrams (RFC 9221)와 HTTP Datagrams (RFC 9297)

QUIC은 기본적으로 신뢰성 있는 스트림 전송이지만, 신뢰성 없는(Unreliable) 데이터그램 확장도 표준화되었습니다. DATAGRAM 프레임(0x30/0x31)은 스트림 번호 없이 패킷에 직접 실리며, 손실 시 재전송되지 않습니다.

WebTransport

WebTransport는 브라우저에서 QUIC(또는 TCP/TLS) 기반의 양방향 통신을 제공하는 W3C API로, HTTP/3 기반 하위집합(WebTransport over HTTP/3)이 RFC 9297의 HTTP Datagrams와 양방향 스트림을 사용합니다. 게임, 원격 데스크톱, 실시간 협업 도구의 기반 기술입니다.

MASQUE: QUIC 위의 터널(Tunnel)링

MASQUE(Multiplexed Application Substrate over QUIC Encryption)는 HTTP/3 확장 CONNECT를 이용해 UDP 프록시(RFC 9298), IP 터널(RFC 9484)을 제공하는 프레임워크입니다. 기존 IPsec/OpenVPN을 대체할 수 있는 사용자 공간 터널링으로, CONNECT-UDP 메서드가 대표적입니다.

리눅스 커널과 QUIC: 고속 경로의 이해

QUIC은 사용자 공간(User Space)에서 동작하지만, 그 성능은 커널의 UDP 경로가 얼마나 효율적인지에 크게 좌우됩니다. QUIC 스택(nginx, Cloudflare, LiteSpeed 등)이 대량의 작은 UDP 데이터그램을 주고받는 데서 오는 시스템콜(System Call) 오버헤드(Overhead)와 인터럽트 오버헤드를 줄이는 것이 핵심이며, 이를 위해 리눅스 커널이 제공하는 기능이 UDP GSO와 UDP GRO입니다.

UDP GSO: 전송 배치(Transmit Batching)

QUIC은 한 연결에 여러 스트림을 멀티플렉싱하므로, 송신 순간에 전송할 패킷이 여러 개 모이는 경우가 많습니다. 패킷마다 sendmsg()(시스템콜)를 호출하면 CPU 비용이 커집니다. UDP GSO(Generic Segmentation Offload)는 여러 QUIC 패킷을 하나의 큰 UDP 데이터그램으로 묶어서 한 번의 시스템콜로 커널에 전달하고, 커널(또는 NIC)이 경로 MTU(PMTU)에 맞게 분할해서 내보냅니다.

UDP GSO: QUIC 패킷 여러 개를 한 번의 시스템콜로 커널에 전달 ① 사용자 공간 QUIC 스택 QUIC 패킷 1 — 1280B QUIC 패킷 2 — 1280B QUIC 패킷 3 — 1280B 3840B 단일 버퍼 UDP_SEGMENT(1280) cmsg로 세그먼트 크기 지정 한 번의 sendmsg()/sendmmsg()로 전달 ② 커널 UDP 경로 3840B 버퍼 수신 → GSO 세그먼트(1280B 단위) 분할 예약 NIC가 UDP GSO를 지원하면 하드웨어 분할, 미지원이면 커널이 소프트웨어 분할 세그먼트마다 독립 UDP 헤더·체크섬을 생성 → 선상에서는 개별 데이터그램으로 취급 ③ 네트워크 출력 (선상) UDP 데이터그램 1 1280B UDP 데이터그램 2 1280B UDP 데이터그램 3 1280B 각 세그먼트는 독립적인 UDP 데이터그램으로 전송 — GSO 묶음은 QUIC 재전송 단위와 다릅니다 이점: 시스템콜 3→1회 감소, 인터럽트·드라이버 소프트웨어 처리 감소, 전송 처리를 배치(Batch) 단위로 일괄 수행 주의: 세그먼트 경계는 QUIC 패킷 경계와 일치해야 하며, 손실 시 QUIC은 개별 패킷 단위로 재전송합니다 (묶음 단위가 아님)
UDP GSO 배치 전송: 3개 QUIC 패킷(3840B)을 한 번의 sendmsg()와 UDP_SEGMENT(1280) cmsg로 커널에 넘기고, 커널/NIC가 1280B 단위로 분할해 독립 UDP 데이터그램으로 출력합니다. 시스템콜과 인터럽트 수를 줄여 고속 QUIC 전송의 CPU 비용을 낮춥니다.

UDP GRO: 수신 병합(Receive Coalescing)

수신 측에서는 반대로, 연속해서 도착한 UDP 데이터그램 여러 개를 하나의 큰 skb(socket buffer)로 병합하여 커널이 처리하는 패킷 수를 줄입니다. UDP GRO(Generic Receive Offload)를 활성화하면 수신 인터럽트/softirq 수가 줄고 처리량이 올라갑니다.

확인 명령:
# NIC의 오프로드 기능 확인 (GSO/GRO 활성 여부)
ethtool -k eth0 | grep -E "generic-segmentation-offload|generic-receive-offload|udp-fragmentation-offload"

# UDP 소켓 버퍼 기본값 (적정치로 올리면 대량 수신 시 유리)
sysctl net.core.rmem_default net.core.wmem_default net.core.rmem_max net.core.wmem_max

# 커널 UDP GSO/GRO 지원 확인
grep -E "UDP_SEGMENT|UDP_GRO" /usr/include/linux/udp.h   # sockopt 번호: UDP_SEGMENT=103, UDP_GRO=104

io_uring과 제로 카피(Zero-Copy)

QUIC 스택은 패킷을 암호화(TLS)한 뒤 전송하므로 CPU 비용이 큽니다. io_uring은 비동기 I/O와 제로 카피 전송을 제공하여 이 병목을 줄이는 데 사용됩니다. 주요 관련 기능은 다음과 같습니다.

zerocopy 사용 주의: MSG_ZEROCOPY는 전송 완료를 비동기로 알리므로(완료 큐 필요), QUIC처럼 재전송을 위해 버퍼를 보관해야 하는 경우 "완료 통지를 받기 전에 버퍼를 재사용하면 안 되는" 규칙을 지켜야 합니다. 또한 일부 NIC/가상화(Virtualization) 환경에서는 제로 카피 지원이 없어 오히려 느릴 수 있으므로 벤치마크로 검증해야 합니다.

커널 튜닝 체크리스트

QUIC 구현체 생태계

IETF 표준화 이후 QUIC 구현체가 다양해졌습니다. 전송 라이브러리(Transport), HTTP/3 서버/클라이언트, 도구로 나누어 정리합니다. 선택 기준은 RFC 준수 수준, TLS 백엔드(OpenSSL/BoringSSL/Rustls), 성숙도, 라이선스, 활발한 유지보수입니다.

전송 라이브러리

구현체언어TLS 백엔드특징
quicheRustBoringSSL/quiche-tlsCloudflare 개발. HTTP/3(QPACK 포함) 내장, GSO/GRO 지원, qlog 지원
ngtcp2COpenSSL/GnuTLS/BoringSSLnghttp2 저자가 개발. 별도 crypto 계층, nghttp3가 HTTP/3/QPACK 담당
quic-goGocrypto/tls (Go 내장)Go 표준 crypto/tls로 단일 바이너리. HTTP/3, MASQUE, WebTransport 지원
msquicCSchannel/OpenSSLMicrosoft 개발. Windows/Linux/macOS, 커널 모드도 지원
aioquicPythoncryptography/pyOpenSSL연구·교육용으로 인기, qlog 등 디버깅 지원 우수
picoquicCOpenSSL/사용자 정의프랑스 국립연구소(INRIA) Christian Huitema, 실험 기능 풍부
lsquicCOpenSSL/BoringSSLLiteSpeed 개발. LiteSpeed 웹서버에 내장
s2n-quic / neqoRusts2n-tls(Rust), rustlsAWS(s2n-quic), Mozilla(neqo — Firefox) 개발

HTTP/3 서버와 프록시

제품기반비고
nginx자체 QUIC(+OpenSSL 3.x QUIC API)1.25+에서 HTTP/3 실험 지원. listen ... quic 지시어 사용
Cloudflarequiche전 세계 CDN에서 HTTP/3 기본 제공, 0-RTT 활성화
LiteSpeed / OpenLiteSpeedlsquicQUIC 초기 도입자. HTTP/3 성능 강점
h2o자체/ngtcp2Fastly 계열. 고성능 HTTP/3 서버
Caddyquic-go자동 HTTPS와 함께 HTTP/3 기본 제공
HAProxyquic2.4+ QUIC(HTTP/3) 로드밸런서 지원 — 프런트엔드 UDP 443
Envoy자체(Google QUIC 라이브러리)gRPC/HTTP/3 게이트웨이용
프록시/로드밸런서 구성 주의: QUIC은 사용자 공간에서 동작하므로 TCP 프록시(nginx stream, 기존 L4 LB)가 UDP 443을 그대로 라우팅하려면 UDP 프록시 모드가 필요합니다. 또 Connection ID 기반 라우팅을 하면 연결 마이그레이션 시에도 세션을 유지할 수 있어, disable_active_migration보다 "CID 해시(Hash) 라우팅"이 운영상 권장됩니다. 서버가 여럿일 때 모든 서버가 같은 TLS 키(세션 티켓 키)를 공유해야 0-RTT 재개가 서버 간에 동작합니다.

클라이언트와 도구

QUIC/HTTP/3 MITM 프록시: 구현 접근 방식

TCP+TLS 시대의 중간자 공격(MITM, Man-in-the-Middle) 검사 장비는 커널의 연결 테이블(Connection Table)과 TLS 종단을 활용해 비교적 쉽게 구현되었습니다. QUIC은 TLS를 전송 계층과 결합하고 모든 상태를 사용자 공간으로 옮겼기 때문에, 같은 목적의 MITM 프록시(Man-in-the-Middle Proxy)를 만들려면 설계 관점을 새로 세워야 합니다. 이 섹션에서는 MITM 프록시 구현을 위한 접근 방식의 분류와 트레이드오프(Trade-off), 그리고 실제 구현에 필요한 상태 매핑(State Mapping)을 다양한 시각에서 정리합니다.

왜 QUIC MITM이 어려운가

기존 TCP/TLS 기반 인터셉션(Interception) 장비가 QUIC 앞에서 무력해지는 이유는 다음 특성 때문입니다.

MITM 구현 접근 방식 비교

프록시를 구현하는 방식은 "언제, 어디서 평문(Plaintext)에 접근하는가"에 따라 아래처럼 분류할 수 있습니다. 명백한 정답이 없는 분야이므로, 목적(관측·차단·변조·전달)에 따라 선택이 달라집니다.

방식동작 원리장점단점적합 사례
키 로그 기반 수동 복호화클라이언트·서버가 남긴 키 로그(Key Log)로 패킷 캡처를 복호화프록시 개입 없음, 성능 영향 없음, 구현 비용이 낮음실시간 차단·변조 불가, 키 로그를 남기는 종단이 필요포렌식(Forensics), 프로토콜 분석, 성능 진단
TLS 종단 능동 프록시프록시가 수신·발신 두 QUIC 연결을 각자 TLS 종단하며 평문 HTTP 의미론(Semantics)을 검사실시간 검사·변조·차단이 가능, 기존 WAF 정책 재사용양쪽 종단의 오버헤드(Overhead), 인증서 신뢰 설정 필요, 상태 매핑이 복잡웹 방화벽, 모니터링, 디버그 프록시
커널 L4 유도와 결합TPROXY/XDP/NFQUEUE로 UDP 데이터그램을 프록시로 유도한 뒤 프록시가 QUIC 종단투명 유도(Transparent Redirection), CID 해시 기반 세션 유지 가능데이터그램은 암호화된 채 도착 — QUIC 종단은 결국 프록시 몫기존 중계 장비의 트래픽 유도 + 능동 검사 결합
CONNECT-UDP 터널 전달HTTP/3의 CONNECT-UDP(RFC 9298)로 QUIC 패킷을 캡슐화(Encapsulation)해 다른 게이트웨이(Gateway)로 전달QUIC 종단이 불필요, 표준 확장, 데이터그램 보존페이로드 검사·변조 불가(엔드투엔드 암호화 유지)전송 경로 조정, 회사망 게이트웨이, 오버헤드 회피
서버 키 위임형서버의 인증서·개인키를 프록시가 공유하고 TLS 종단을 대신 수행별도 CA 구성이 불필요, 도메인 검증이 수월개인키 노출 범위 확대, 감사·규정 문제, ECH 구성 제약제한된 CDN 엣지(Edge) 시나리오

참고로 대표적인 오픈소스 MITM 프록시인 mitmproxy도 공식 문서 기준 HTTP/1·HTTP/2까지만 지원하며 HTTP/3는 지원하지 않습니다. HTTP/3 MITM이 자체 QUIC 스택 구현(또는 이식)을 필요로 한다는 것을 보여주는 사례입니다.

수동 복호화: 키 로그 기반 관측

가장 구현 비용이 낮은 방식은 키 로그(Key Log)를 활용한 수동 복호화(Passive Decryption)입니다. 클라이언트나 서버가 SSLKEYLOGFILE 환경 변수로 트래픽 시크릿(Traffic Secret)을 남기면, Wireshark/tshark가 NSS 키 로그 형식의 레이블(CLIENT_HANDSHAKE_TRAFFIC_SECRET 등)을 읽어 캡처된 QUIC 패킷을 복호화합니다. 실시간 차단·변조가 필요 없고 데이터 경로에 영향을 주지 않아 포렌식·프로토콜 분석·성능 진단에 적합합니다. 자세한 설정은 패킷 복호화(Wireshark) 항목을 참고하세요.

# 키 로그 수집: curl 클라이언트 (OpenSSL 계열 TLS 백엔드)
SSLKEYLOGFILE=/tmp/quic-client.keys curl --http3-only https://example.org/

# 캡처 파일 복호화 (tshark — 옵션 이름은 버전에 따라 다를 수 있습니다)
tshark -r quic-capture.pcapng -o tls.keylog_file:/tmp/quic-client.keys

# 서버 측 키 로그가 필요하면 서버 프로세스에도 같은 환경 변수를 주입합니다
SSLKEYLOGFILE=/tmp/quic-server.keys ./your-quic-server

능동 MITM 프록시: 구조와 상태 매핑

실시간 검사·변조·차단이 목표라면 능동 프록시(Active Proxy)가 필요합니다. 능동 프록시는 QUIC의 TLS 결합 구조 때문에 반드시 두 개의 QUIC 연결을 각자 종단(Terminate)해야 하며, 그 사이에서만 평문에 접근할 수 있습니다.

두 개의 QUIC 종단 구조

프록시는 클라이언트와의 연결 A에서 QUIC 서버 역할을, 오리진 서버(Origin Server)와의 연결 B에서 QUIC 클라이언트 역할을 합니다. 연결 A와 연결 B는 패킷 보호 키, 스트림 ID 공간, QPACK 상태, 흐름 제어·혼잡 제어 상태가 전부 독립된 별개의 QUIC 연결입니다.

능동 MITM 프록시: 두 개의 독립 QUIC 연결 클라이언트 앱 HTTP/3 스택 프록시 CA 신뢰 설치 ServerName = 검사 대상 도메인 MITM 프록시 수신 종단 — QUIC 서버 (연결 A) TLS 1.3 종단 · 프록시 CA 인증서 · 0-RTT 수락 검사 · 정책 · 변환 QPACK 헤더 재인코딩 (의미론 레벨 변경) 스트림 ID · 흐름 제어 크레딧 재매핑 0-RTT 리플레이 캐시 · GOAWAY 정책 발신 종단 — QUIC 클라이언트 (연결 B) TLS 1.3: 오리진 인증서 검증 · 1-RTT 시작 오리진 서버 QUIC 서버 연결 A · UDP 443 연결 B · UDP 443 연결 A와 연결 B는 패킷 보호 키 · 스트림 ID 공간 · QPACK 상태 · 혼잡 제어가 모두 독립된 별개의 QUIC 연결입니다
능동 MITM 프록시의 두 종단 구조: 프록시는 클라이언트(연결 A)와 오리진 서버(연결 B) 사이에서 두 개의 QUIC 연결을 각자 종단하며, 그 사이에서만 평문 HTTP 의미론에 접근할 수 있습니다.

중계 루프: 두 연결 사이의 재전송

QUIC 프레임은 수신 연결의 문맥(패킷 번호 공간, 스트림 ID, 크레딧)에 묶여 있으므로, 프록시는 프레임을 "전달"하지 않고 평문 데이터를 읽어 다른 연결에서 "재전송"합니다. 아래는 ngtcp2 + nghttp3 기반 구현의 개념 예시(Pseudocode)입니다.

/* MITM 프록시 중계 루프 개념 예시 (ngtcp2 + nghttp3 기반) */
/* 실제 API 시그니처는 사용 버전에 따라 다를 수 있으며, 오류 처리·버퍼 관리는 생략했습니다 */

struct proxy_conn {
    ngtcp2_conn *c2p;   /* 연결 A: 클라이언트 ↔ 프록시 — 프록시가 QUIC 서버 */
    ngtcp2_conn *p2o;   /* 연결 B: 프록시 ↔ 오리진 — 프록시가 QUIC 클라이언트 */
    nghttp3_conn *h3c;  /* 연결 A의 HTTP/3 계층 */
    nghttp3_conn *h3o;  /* 연결 B의 HTTP/3 계층 */
};

/* UDP 데이터그램 수신 → 발신지에 따라 해당 종단으로 전달 */
if (is_from_client(addr)) {
    ngtcp2_conn_read_pkt(c->c2p, &path, NULL, buf, nbytes);
} else {
    ngtcp2_conn_read_pkt(c->p2o, &path, NULL, buf, nbytes);
}

/* 연결 A에서 HTTP/3 요청 수신 → 검사·변조 후 연결 B의 새 스트림으로 재전송 */
nghttp3_conn_read_stream(c->h3c, stream_id, data, ndata, fin, NULL);
if (policy_allows(&req)) {
    update_forwarded_headers(&req);  /* 평문 의미론에서 Via, X-Forwarded-For 등 추가 */
    nghttp3_conn_submit_request(c->h3o, req_nva, req_nvlen, NULL, NULL);
}

/* 발신 종단이 생성한 패킷을 UDP로 전송 (응답도 같은 경로의 역방향) */
nv = ngtcp2_conn_writev_stream(c->p2o, &path, NULL,
                                 out, outlen, &ndatalen,
                                 0, new_stream_id, vec, 1, NULL);
send(udp_fd, out, nv, 0);

상태 매핑: 스트림·QPACK·흐름 제어

능동 프록시가 반드시 관리해야 할 상태와 그 매핑 방법은 다음과 같습니다.

상태연결 A (클라이언트 ↔ 프록시)연결 B (프록시 ↔ 오리진)프록시의 매핑 작업
스트림 ID클라이언트가 시작한 스트림(0x0, 0x4, …)프록시가 새로 시작한 스트림1:1 또는 1:N 재매핑 테이블 유지 — 종료(FIN/RESET) 전파
QPACK 상태독립 인코더·디코더 스트림독립 인코더·디코더 스트림압축된 헤더 블록은 그대로 전달 불가 — 평문 의미론으로 재구성 후 재인코딩
흐름 제어 크레딧MAX_DATA/MAX_STREAM_DATA가 연결 A 한정연결 B 한정크레딧은 단조 증가(Monotonic)만 허용 — 버퍼 상한과 연동한 역압(Backpressure) 설계
혼잡 제어cwnd·RTT 추정이 연결 A 한정연결 B 한정수신 연결이 막히면 발신 송신도 함께 멈추는 대기열(Queueing) 제어
패킷 번호 공간독립(헤더 보호 포함)독립프레임을 양쪽 연결 사이에서 그대로 옮길 수 없음 — 항상 재송신

특히 주의할 점은 두 연결의 흐름 제어 크레딧이 서로 무관하다는 것입니다. 연결 A에서 크레딧이 소진되면 연결 B의 송신도 중단되어야 하며, 반대로 연결 B가 막히면 연결 A의 크레딧 갱신을 늦추는 방식으로 역압을 걸어야 합니다. 스트림을 1:N으로 매핑할 때는 스트림별 버퍼 상한을 두지 않으면 메모리 폭발로 이어질 수 있으며, 오리진이 응답하지 않는 상황에 대비한 자체 타임아웃(Timeout)과 오류 응답 정책도 함께 설계해야 합니다.

0-RTT · 세션 티켓 · 인증서 운영

성능 관점: 커널 고속 경로 활용

능동 프록시는 데이터가 통과할 때마다 양쪽 종단에서 각각 AEAD 암호화·복호화를 수행하므로, 동일 트래픽에 대해 암호 연산이 두 번 발생하는 구조입니다(경로상의 상수 비용). 이를 보완하려면 리눅스 커널과 QUIC 섹션의 고속 경로를 적극 활용해야 합니다.

보안 한계와 운영 정책

디버깅과 관측

QUIC은 암호화되어 있고 사용자 공간에서 동작하므로, TCP처럼 커널의 ss·tcpdump만으로 상태를 볼 수 없습니다. 디버깅 전략은 "qlog 이벤트 로그"와 "패킷 복호화" 두 축으로 나뉩니다.

qlog: 표준 이벤트 로그

qlog은 QUIC 구현체의 내부 이벤트(패킷 송수신, 손실 감지, 혼잡 창 변화, 스트림 상태 전이)를 표준 JSON 스키마로 기록하는 포맷입니다. qlog를 출력하는 구현체(quiche, aioquic, ngtcp2 등)는 qvis(qvis.quictools.info) 웹 도구에 로그를 올려 시퀀스 다이어그램, 혼잡 창 그래프, 손실 분석을 시각화할 수 있습니다.

# qlog 파일 예시 (간략)
# 1단계: 클라이언트에서 qlog 활성화 (구현체별 옵션)
#  aioquic:  --qlog-dir=./qlogs
#  quiche:   QUICHE_QLOG_DIR 환경 변수

# 2단계: qvis에 업로드 또는 로컬에서 시각화
#  https://qvis.quictools.info/  (파일 3개: client.qlog, server.qlog, keys.txt)

Wireshark/tshark로 패킷 복호화

Wireshark 3.x 이상은 QUIC 디섹터(Dissector)를 내장하고 있습니다. TLS 키 로그(SSLKEYLOGFILE)를 주입하면 Initial/Handshake/1-RTT 패킷을 복호화하여 프레임 수준에서 분석할 수 있습니다.

# 1. 클라이언트에서 키 로그 생성 환경 변수 설정
export SSLKEYLOGFILE=$PWD/keys.log
curl --http3-only https://example.com/

# 2. tshark로 복호화 캡처 (UDP 443)
tshark -r quic.pcap -o tls.keylog_file:keys.log -Y "quic"

# 3. 필터 예시
tshark -r quic.pcap -Y "quic.frame_type == 0x06"   # CRYPTO 프레임만
tshark -r quic.pcap -Y "quic.packet_number && quic.header_form == 1"   # Long 헤더
복호화가 안 되는 초기 단계: Initial 패킷은 salt와 DCID로 복호화할 수 있지만, Handshake/1-RTT 이후는 TLS 키 로그가 있어야 합니다. 또 키 로그(SSLKEYLOGFILE)에는 초기 트래픽 비밀만 남기 때문에, 키 업데이트(Key Update) 이후의 패킷은 키 로그만으로 복호화되지 않습니다. 이런 경우 qlog 기반 분석이 더 실용적입니다.

빠른 동작 확인 체크리스트

보안: 목표와 공격 벡터

RFC 9000 §21은 QUIC의 보안 목표를 "기밀성(Confidentiality), 무결성(Integrity), 가용성(Availability)"으로 정의하며, TCP+TLS 조합보다 메타데이터 노출을 최소화하는 것을 추가 목표로 삼습니다. 패킷 번호, 스트림 ID, 프레임 타입이 모두 암호화되어 중간 장비는 패킷 크기와 타이밍만 관찰할 수 있습니다.

암호화된 메타데이터

항목TCP+TLSQUIC
시퀀스/패킷 번호평문(TCP 헤더)헤더 보호로 마스킹
프레임/스트림 구조애플리케이션 데이터는 TLS 레코드로 봉인프레임 타입·스트림 ID 암호화(패킷 보호)
레이턴시(Latency) 추정패킷 번호·ACK로 가능Spin Bit 미활성 시 불가 (활성화 시 부분 가능)
연결 추적(Connection Tracking)4-tuple 기반CID 교체로 회피 가능(프라이버시)

주요 공격 벡터와 방어

공격내용QUIC의 방어
증폭(Amplification)위조 출발지 IP로 작은 요청 → 큰 응답 유도주소 검증 전 응답 3배 한도(Amplification Limit), 최소 1200바이트 Initial
반사(Reflection)/증폭 체인서버를 증폭기로 악용Retry 토큰, NEW_TOKEN, 3배 한도로 파괴적 증폭 차단
핸드셰이크 DoSClientHello 폭주로 CPU/메모리 고갈Initial 패킷 검증(패킷 보호 무결성), Retry로 조기 주소 검증, 최소 패킷 크기 강제
연결 추적/프라이버시 침해CID로 사용자 식별NEW_CONNECTION_ID로 CID 주기 교체, 회전 기반 프라이버시
0-RTT 재생(Replay)캡처된 0-RTT 데이터 재전송서버 측 재생 캐시, 단일 사용 티켓, 멱등 요청만 0-RTT 권장
무상태 리셋 위조Stateless Reset 패킷으로 연결 강제 종료128비트 토큰 검증(공격자가 예측 불가), 리셋 패킷 최소 크기 규정
경로 탈취(마이그레이션 악용)가짜 경로로 연결 가로채기PATH_CHALLENGE/PATH_RESPONSE 주소 검증, CID 검증
다운그레이드버전 협상 응답 위조VN 패킷이 자신의 요청에 대한 응답인지 확인, v2+버전의 모델 충돌 방지
ECN 마킹 위조CE 마킹으로 혼잡 제어 오작동 유도ECN 검증(피드백 확인) 후 사용, 손상된 경로 시 ECN 비활성화
방화벽/DDoS 장비와의 비호환: QUIC의 암호화된 메타데이터는 기존 "심층 패킷 검사(DPI)" 기반 방화벽 규칙(예: "URL 패턴 차단")을 무력화합니다. 운영자는 UDP 443 정책을 재설계해야 합니다. 반면 규제·감사 환경에서 "연결 내 요청별 메타데이터"가 필요하다면 HTTP/3의 표준 기능으로는 부족할 수 있으므로, 프록시 계층에서 처리하는 설계가 필요합니다.

흔한 오해와 주의사항

QUIC/HTTP/3를 다룰 때 기술적으로 틀리기 쉬운 지점을 정리합니다. RFC 원문(Facts)과 비교해 보면 확실해집니다.

오해(Misconception)사실(Fact)
"QUIC은 TCP를 대체한다."아닙니다. QUIC은 UDP 위의 전송 프로토콜이며, HTTP/3 전용이 아닙니다. TCP가 사라지는 것이 아니라, 애플리케이션이 선택할 수 있는 전송이 하나 늘어난 것입니다.
"QUIC은 항상 TCP보다 빠르다."연결 설정(1-RTT/0-RTT)과 손실 시 HoL 회피에서는 유리하지만, 처리량(Throughput)은 혼잡 제어 알고리즘과 네트워크 특성에 달려 있습니다. 혼잡 제어는 QUIC이 '직접 구현'하므로 알고리즘 선택이 성능을 좌우합니다.
"Initial 패킷도 암호화되어 안전하다."Initial 패킷의 보호 키는 공개 salt와 DCID로 유도되므로 누구나 복호화 가능합니다. 기밀성은 Handshake 완료(1-RTT) 후에 시작됩니다. Initial의 보호는 무결성과 경로 검증 목적입니다.
"0-RTT는 보안 문제가 없는 재접속이다."0-RTT 데이터는 재생(Replay) 위험과 함께, 서버가 "0-RTT 키로 받아들인 요청"을 1-RTT 키로 처리한 응답과 섞을 수 있는 프로토콜 혼합 문제가 있습니다. 멱등 요청에 한정해서 사용해야 합니다.
"HTTP/3는 HTTP/2와 100% 호환된다."HTTP 의미론은 호환되지만, 프레임 계층·헤더 압축(QPACK vs HPACK)·우선순위(RFC 9218)·흐름 제어(QUIC 계층)가 모두 다릅니다. HTTP/2 구현을 그대로 옮길 수 없습니다.
"QPACK은 HPACK과 같다."다릅니다. 동적 테이블 갱신을 인코더/디코더 스트림으로 분리했고, 필드 섹션에 Required Insert Count와 Base가 추가되어 인덱스가 '상대적'입니다. 구현이 HPACK과 호환되지 않습니다.
"QUIC가 UDP라서 패킷이 순서 없이 도착한다."QUIC은 스트림 안에서 자체적으로 순서 보장을 합니다. UDP의 비순서 전달 위에 스트림별 순서화를 구현한 것입니다. 다만 스트림 간 순서는 보장하지 않습니다.
"QUIC은 포트 443에서만 동작한다."알려진 포트는 443이지만 규약상 어느 UDP 포트든 사용할 수 있습니다. HTTP/3는 Alt-Svc 또는 HTTPS RR로 광고합니다.
"패킷 번호가 재전송 시 재사용된다."절대 재사용되지 않습니다. 재전송은 새 패킷 번호를 부여하고 같은 스트림 데이터를 다시 실습니다. 이것이 QUIC 손실 감지를 단순하게 만드는 핵심 설계입니다.
"연결 마이그레이션은 서버도 한다."기본적으로 클라이언트가 주도하며, 서버는 disable_active_migration으로 금지할 수 있습니다. 서버 측 주소 변경은 별도 메커니즘(preferred_address 등)으로 처리됩니다.
"UDP GSO/GRO는 QUIC 전용 기능이다."아닙니다. 커널의 범용 UDP 오프로드 기능으로, QUIC뿐 아니라 고속 UDP 서비스에 공통으로 유용합니다.
"ss 명령으로 QUIC 연결 상태를 볼 수 있다."QUIC은 커널에 연결 상태가 없으므로 ss -u는 UDP 소켓만 보여줍니다. 세부 상태는 사용자 공간 스택(또는 sysadmin 반영된 구현체의 진단 API)에서 확인해야 합니다.

자주 묻는 질문(FAQ)

Q. QUIC과 HTTP/3의 관계는 무엇인가요?
QUIC은 전송 프로토콜이고 HTTP/3은 QUIC 위의 애플리케이션 프로토콜입니다. DNS-over-QUIC(DoQ), WebTransport, MASQUE 등 다른 애플리케이션도 QUIC을 사용할 수 있습니다.
Q. QUIC을 쓰려면 커널을 새로 컴파일해야 하나요?
아닙니다. UDP 소켓과 GSO/GRO가 있는 최신 커널이면 충분하며, QUIC 스택은 전부 사용자 공간입니다. 다만 최신 UDP 오프로드(zerocopy, UDP GRO) 기능은 커널 버전에 따라 지원 범위가 다릅니다.
Q. 서버에서 HTTP/3를 켰는데 방문자가 HTTP/2로만 옵니다. 왜일까요?
클라이언트가 QUIC을 지원하지 않거나, UDP 443이 차단되었거나, Alt-Svc 광고가 아직 만료되기 전일 수 있습니다. HTTPS RR을 추가하고, curl --http3-only로 직접 확인해 보세요.
Q. 0-RTT는 언제 써야 하나요?
같은 서버에 자주 재접속하면서, 실패해도 문제없는 멱등 요청(GET, 프리페치)에 사용합니다. 부작용이 있는 요청(POST 결제 등)에는 금지입니다.
Q. QUIC 스택을 직접 만들어야 하나요?
일반적으로 성숙한 오픈소스 구현체(quiche, ngtcp2, msquic, quic-go)를 사용합니다. 직접 만들 경우 RFC 9000/9001/9002와 TLS 백엔드 연동, 헤더 보호, QPACK까지 모두 구현해야 하므로 대규모 작업입니다.

참고 자료와 추가 학습 경로

표준 문서(RFC)

RFC제목필요 시점
RFC 8999Version-Independent Properties of QUIC헤더 폼·버전 필드 이해
RFC 9000QUIC: A UDP-Based Multiplexed and Secure Transport전송 프로토콜 전체 (필수)
RFC 9001Using TLS to Secure QUIC키 스케줄·패킷 보호·헤더 보호·키 업데이트
RFC 9002QUIC Loss Detection and Congestion ControlPTO·손실 감지·혼잡 제어 기본
RFC 9114HTTP/3HTTP/3 프레임·스트림·SETTINGS
RFC 9204QPACKHTTP/3 헤더 압축
RFC 9218Extensible Prioritization Scheme for HTTPHTTP/2·3 우선순위
RFC 9220Bootstrapping WebSockets with HTTP/3WebSocket over HTTP/3
RFC 9221An Unreliable Datagram Extension to QUICQUIC Datagrams
RFC 9369QUIC Version 2v2·버전 협상
RFC 9297HTTP Datagrams and the Capsule ProtocolHTTP Datagram·Capsule
RFC 9298Proxying UDP in HTTP (MASQUE)CONNECT-UDP 터널링
RFC 9460Service Binding and Parameter Specification (HTTPS RR)DNS 기반 HTTP/3 광고

구현체 저장소

도구와 문서

표준 문서 참고: QUIC/HTTP/3 관련 RFC 원문은 IETF rfc-editor.org에서 공식 문서를 확인할 수 있습니다. 본 문서는 RFC 9000(전송) · 9001(TLS) · 9002(손실·혼잡) · 9114(HTTP/3) · 9204(QPACK) · 9218(우선순위) · 9220(WebSocket over HTTP/3) · 9369(QUIC v2)를 기준으로 작성했습니다. QUIC Datagrams(RFC 9221) · HTTP Datagrams(RFC 9297) · MASQUE(RFC 9298) 원문을 함께 보면 확장 챕터를 더 정밀하게 이해할 수 있습니다.

한눈에 보는 QUIC/HTTP/3 (요약)

주제한 줄 정리
왜 QUIC인가TCP+TLS의 2 RTT 연결 설정과 전송 계층 HoL 블로킹을 해결하고, 연결 마이그레이션과 암호화 내장을 얻기 위함
패킷Long(핸드셰이크용, 버전 포함) / Short(데이터용) 두 폼, 패킷 번호는 3개 공간(Initial/Handshake/Application)에서 독립 증가
프레임STREAM(데이터), ACK, CRYPTO(핸드셰이크), CONNECTION_CLOSE 등 0x00~0x1e 전체(31개 타입 값) + DATAGRAM(0x30/0x31), varint 인코딩
신뢰성패킷 번호 재사용 없음 → 재전송 시 새 번호, 손실 감지는 ACK 기반 + PTO 타이머
흐름 제어연결 크레딧 + 스트림 크레딧(MAX_DATA/MAX_STREAM_DATA), 자동 튜닝
혼잡 제어전송량 = min(혼잡 창, 흐름 제어 크레딧, 송신 버퍼), 알고리즘은 구현체 선택(NewReno/CUBIC/BBR 등)
핸드셰이크TLS 1.3 통합: 1-RTT(최초), 0-RTT(재개), CRYPTO 프레임 사용
HTTP/3요청/응답 = 스트림 1:1, 제어 스트림 분리, QPACK 헤더 압축, RFC 9218 우선순위
커널UDP GSO/GRO로 시스템콜·인터럽트 오버헤드 감소, io_uring/zerocopy로 CPU 비용 절감
보안메타데이터 암호화, 증폭 방지(주소 검증·3배 한도), 재생 방지(0-RTT), 무상태 리셋 토큰
필수 관련 문서:
  • UDP — QUIC의 전송 기반인 UDP 데이터그램 모델과 커널 UDP 경로(버퍼, 소켓 옵션)를 다룹니다.
  • TCP — QUIC이 해결하려는 신뢰성·흐름 제어·혼잡 제어의 원형을 비교 대상으로 다룹니다.
  • 네트워크 스택(Network Stack) — QUIC이 얹히는 리눅스 네트워크 스택 전반의 위치를 파악합니다.
참고 문서:
  • io_uring 네트워킹 — 고속 QUIC 서버 구현 시 비동기 I/O 경로의 선택지입니다.
  • XDP/AF_XDP — 커널 네트워크 스택을 우회하는 초고속 데이터 경로로, QUIC 대량 서비스의 고성능 확장 옵션입니다.
  • kTLS — 커널 TLS 오프로드로, QUIC(사용자 공간 TLS)과의 대비를 이해하는 데 도움이 됩니다.
  • RX/TX 심화 & 오프로드 — UDP GSO/GRO를 포함한 리눅스 네트워크 오프로드의 원리를 정리합니다.
  • TPROXY(투명 프록시) — 커널 레벨에서 UDP 흐름을 프록시로 유도하는 L4 인터셉트 경로를 다룹니다.
  • NFQUEUE & DPI — 암호화된 QUIC 페이로드의 DPI(Deep Packet Inspection) 한계와 메타데이터 활용을 다룹니다.