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)까지 최신 표준 문서를 기준으로 종합 정리합니다.
핵심 요약
- 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)을 지원합니다.
단계별 이해
- 왜 QUIC인가
HTTP/1.1과 HTTP/2의 구조적 한계(연결당 요청 직렬화(Serialization), TCP HoL 블로킹, TLS 왕복)가 왜 QUIC를 만들었는지 배경을 이해합니다. - 패킷과 프레임
Long/Short 헤더(Packet Header)와 프레임(Frame)의 바이트 구조를 익힙니다. 모든 동작의 기초입니다. - 연결 수명주기
핸드셰이크 → 데이터 전송(스트림/흐름 제어) → 연결 종료까지의 상태 전이를 따라갑니다. - HTTP/3와 QPACK
QUIC 위에 HTTP 의미론(Semantics)이 어떻게 얹히는지, 헤더 압축이 어떻게 스트림 차단을 피하는지 확인합니다. - 커널·운영·보안
리눅스 커널의 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 8999 | Version-Independent Properties of QUIC | 모든 QUIC 버전이 공유하는 헤더 필드 정의 | 2021-05 |
| RFC 9000 | QUIC: A UDP-Based Multiplexed and Secure Transport | QUIC 전송 프로토콜 본체(패킷, 프레임, 스트림, 흐름 제어, 마이그레이션) | 2021-05 |
| RFC 9001 | Using TLS to Secure QUIC | TLS 1.3과 QUIC의 통합(패킷 보호, 키 스케줄) | 2021-05 |
| RFC 9002 | QUIC Loss Detection and Congestion Control | 손실 감지(Probe Time Out)와 혼잡 제어(NewReno/CUBIC 기반) | 2021-05 |
| RFC 9114 | HTTP/3 | QUIC 위의 HTTP 의미론 + 프레임 계층 | 2022-06 |
| RFC 9204 | QPACK: Field Compression for HTTP/3 | HTTP/3 헤더 압축(차단 없는 동적 테이블) | 2022-06 |
| RFC 9218 | Extensible Prioritization Scheme for HTTP | HTTP/2·HTTP/3 공용 우선순위(urgency/incremental) | 2022-06 |
| RFC 9220 | Bootstrapping WebSockets with HTTP/3 | HTTP/3 위 WebSocket(extended CONNECT) | 2022-06 |
| RFC 9369 | QUIC Version 2 | 버전 고정 공격(ossification) 대비한 두 번째 버전 | 2023-05 |
| RFC 9221 | An 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 데이터그램 위에서 각 스트림이 독립적으로 손실 복구됩니다.
QUIC 아키텍처 개요 (RFC 9000)
QUIC은 UDP를 캡슐화(Encapsulation) 계층으로 사용하는 전송 프로토콜입니다. 커널의 UDP 소켓(Socket)을 그대로 사용하므로 새 L4 프로토콜 번호(IP protocol number)도, 새로운 운영체제 커널 커스터마이징도 필요하지 않습니다. 대신 신뢰성, 다중화, 암호화(Confidentiality/Integrity), 흐름 제어(Flow Control), 혼잡 제어(Congestion Control)를 모두 사용자 공간(User Space) 구현으로 제공합니다.
QUIC 설계 목표
- 연결 설정 지연 최소화 — TLS 1.3 핸드셰이크를 QUIC 패킷에 통합하여 1-RTT, 재접속 시 0-RTT로 첫 데이터 전송을 시작합니다.
- 전송 계층 HoL 블로킹 제거 — 스트림(Stream)마다 독립된 번호 공간과 재전송으로 하나의 손실이 다른 스트림을 막지 않습니다.
- 연결 마이그레이션(Connection Migration) — Connection ID로 연결을 식별하여 IP/포트가 바뀌어도 연결을 유지합니다.
- 암호화 기본 내장 — 모든 QUIC 패킷(초기 Initial 패킷 제외의 페이로드(Payload))이 TLS 1.3 키로 보호되며, 메타데이터 노출도 최소화합니다.
- 진화 가능성(Evolvability) — 버전 협상(Version Negotiation)과 사용자 공간 구현으로 TCP의 "ossification"(중간 박스가 확장을 막는 현상)을 피합니다.
- IP 릴레이(anycast) 친화 — 서버가 Connection ID 기반으로 연결을 라우팅(Routing)하므로 로드밸런서 뒤의 전용 IP(Unicorn IP) 없이도 동작합니다.
QUIC 계층 구조
아래 다이어그램은 QUIC이 TLS 1.3(암호화)과 UDP(전송)를 어떻게 결합하는지 보여줍니다. 전통적인 TCP+TLS 구조에서는 TLS 레코드(Record) 계층이 TCP 바이트 스트림 위에 있었지만, QUIC에서는 TLS 핸드셰이크 메시지가 QUIC CRYPTO 프레임(Frame)에 직접 실리고, 데이터 보호(Record Protection)는 QUIC 패킷 보호(Packet Protection)가 담당합니다.
리눅스 커널의 역할
리눅스 커널은 QUIC 자체를 구현하지 않지만, QUIC이 그 위에서 고속으로 동작하도록 하는 세 가지 기반을 제공합니다. 이 항목들은 뒤의 리눅스 커널과 QUIC 섹션에서 자세히 다룹니다.
- UDP 소켓 — QUIC 패킷의 전송/수신 통로입니다.
AF_INET/AF_INET6UDP 소켓이 그대로 사용됩니다. - UDP GSO/GRO — 여러 QUIC 패킷을 하나의 큰 세그먼트로 묶어 시스템콜(System Call) 수와 인터럽트(Interrupt) 수를 줄입니다.
- io_uring / zerocopy — 비동기 I/O와 제로 카피(Zero-Copy) 전송으로 사용자 공간 QUIC 스택의 CPU 비용을 낮춥니다.
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가지이며, 각 종류마다 뒤따르는 필드가 다릅니다.
| 종류 | 타입 필드 | 의미 | 전송 방향 | 보호 키 |
|---|---|---|---|---|
| Initial | 0x0 | 핸드셰이크 시작, CRYPTO 프레임으로 ClientHello/ServerHello 전송 | 양방향 | Initial 키(공개 유도 가능) |
| 0-RTT | 0x1 | 세션 재개 시 즉시 애플리케이션 데이터 전송 | 클라이언트 → 서버 | 0-RTT 키(early data 키) |
| Handshake | 0x2 | 핸드셰이크 완료 메시지(Certificate, Finished 등) 전송 | 양방향 | Handshake 키 |
| Retry | 0x3 | 서버가 클라이언트 주소 검증용 토큰(Token)을 되돌려줌 | 서버 → 클라이언트 | Retry Integrity Tag(AEAD) |
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 Form | 1 | 0 = Short 헤더 |
| Fixed | 1 | 항상 1. QUIC이 아닌 UDP 프로토콜과의 구분용(프로토콜 오탐 방지) |
| Spin Bit | 1 | 활성화되면 왕복 시간(RTT)을 패시브하게 측정할 수 있는 비트. 기본적으로 0으로 전송 |
| Reserved | 2 | 초기에는 0, 버전 고정을 막기 위해 회전(rotate)됨 |
| Key Phase | 1 | 키 업데이트(Key Update, RFC 9001 §6) 시 1로 전환. 수신자가 새 키를 시도하게 함 |
| Packet Number Length | 2 | 패킷 번호 필드의 길이 (1~4바이트) |
| Destination Connection ID | 가변 | 연결 라우팅에 사용되는 목적지 Connection ID (0~20바이트) |
| Packet Number | 8/16/24/32비트 | 암호화(헤더 보호)로 마스킹된 패킷 번호 |
가변 길이 정수(QUIC Varint) 인코딩
QUIC의 많은 필드(프레임 타입, 길이, 스트림 ID, 오프셋 등)는 가변 길이 정수(Variable-Length Integer, 이하 varint)로 인코딩됩니다. varint는 앞의 2비트로 길이를 나타내며, 값은 네트워크 바이트 순서(Byte Order)인 빅엔디안(Big Endian)으로 배치됩니다. 이는 QUIC 전용 인코딩으로 TCP의 것과 다르므로 주의가 필요합니다.
| 앞 2비트 | 길이 | 표현 가능 범위 |
|---|---|---|
00 | 1바이트 | 0 ~ 26-1 (0~63) |
01 | 2바이트 | 0 ~ 214-1 (0~16383) |
10 | 4바이트 | 0 ~ 230-1 |
11 | 8바이트 | 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;
}
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바이트)는 헤더 보호로 마스킹되어 전송됩니다.
Initial 패킷의 최소 크기와 패딩(Padding)
클라이언트가 보내는 첫 UDP 데이터그램(Initial 패킷 1개 이상 포함)은 반드시 1200바이트 이상이어야 합니다(RFC 9000 §14.1). 이 요구사항은 증폭 공격(Amplification Attack) 방지를 위한 것입니다. 서버 주소 검증(Address Validation) 전에는 서버가 클라이언트에게 보낼 수 있는 데이터가 수신한 데이터의 3배(amplification limit)로 제한되는데(RFC 9000 §8.1), 1200바이트 최소 크기 규칙이 있으면 서버의 최초 응답으로 최소한 3600바이트까지는 안전하게 보낼 수 있습니다.
프레임(Frame)의 종류와 구조
QUIC 패킷의 페이로드에는 하나 이상의 프레임(Frame)이 실립니다. 프레임 타입은 varint로 표현되며, 각 프레임은 연결의 신뢰성 있는 데이터 전달, 흐름 제어, 연결 관리 등의 역할을 담당합니다. 프레임 타입 0x1f~0x2f 영역은 확장(Extension)용으로 예약되어 있고, IANA에 등록 절차가 정의되어 있습니다.
프레임 타입 전체 목록 (RFC 9000 §19)
| 타입 | 프레임 | 핵심 필드 | 용도 |
|---|---|---|---|
0x00 | PADDING | — | 패딩. 데이터그램 크기 확장·트래픽 분석 방해 |
0x01 | PING | — | 연결 유지(keep-alive), PTO 탐색용 ACK 유발 |
0x02 | ACK | Largest Acked, Ack Delay, Ranges | 패킷 수신 확인 (발신자 포함 안 됨) |
0x03 | ACK (ECN) | + ECT0/ECT1/CE 카운트 | ECN 정보를 포함한 ACK |
0x04 | RESET_STREAM | Stream ID, App Error Code, Final Size | 스트림을 오류로 종료(중단) |
0x05 | STOP_SENDING | Stream ID, App Error Code | 수신자가 더 이상 데이터를 원하지 않음을 통지 |
0x06 | CRYPTO | Offset, Length, Data | TLS 핸드셰이크 메시지 전송(스트림 번호 없음) |
0x07 | NEW_TOKEN | Token Length, Token | 향후 0-RTT 주소 검증용 토큰 제공(서버→클라이언트) |
0x08~0x0f | STREAM | +Stream ID, [Offset], [Length], Data | 스트림 데이터 전송 (하위 3비트가 옵션 플래그) |
0x10 | MAX_DATA | Maximum Data | 연결 전체 수신 허용량(크레딧) 증가 |
0x11 | MAX_STREAM_DATA | Stream ID, Maximum Stream Data | 개별 스트림 수신 허용량 증가 |
0x12 | MAX_STREAMS (Bidi) | Maximum Streams | 양방향 스트림 개수 제한 증가 |
0x13 | MAX_STREAMS (Uni) | Maximum Streams | 단방향 스트림 개수 제한 증가 |
0x14 | DATA_BLOCKED | Maximum Data | 연결 레벨 흐름 제어에 막혔음을 통지 |
0x15 | STREAM_DATA_BLOCKED | Stream ID, Maximum Stream Data | 스트림 레벨 흐름 제어에 막혔음을 통지 |
0x16 | STREAMS_BLOCKED (Bidi) | Maximum Streams | 양방향 스트림 개수 제한에 막혔음을 통지 |
0x17 | STREAMS_BLOCKED (Uni) | Maximum Streams | 단방향 스트림 개수 제한에 막혔음을 통지 |
0x18 | NEW_CONNECTION_ID | Seq, Retire Prior To, CID, Stateless Reset Token | 새 Connection ID 발급 (마이그레이션·프라이버시) |
0x19 | RETIRE_CONNECTION_ID | Sequence Number | 사용하지 않게 된 Connection ID 폐기 통지 |
0x1a | PATH_CHALLENGE | 64비트 난수 | 경로(주소) 검증용 도전 값 전송 |
0x1b | PATH_RESPONSE | 64비트 난수(에코) | PATH_CHALLENGE에 대한 응답 |
0x1c | CONNECTION_CLOSE (전송 오류) | Error Code, Frame Type, Reason Phrase | 전송 계층 오류로 연결 종료 |
0x1d | CONNECTION_CLOSE (앱 오류) | Error Code, Reason Phrase | 앱 계층 오류(HTTP/3 등)로 연결 종료 |
0x1e | HANDSHAKE_DONE | — | 핸드셰이크 완료 후 서버가 보내 확인(0-RTT 수락 통지 겸용) |
0x1f~0x2f | 예약(확장용) | — | 향후 표준 확장 프레임을 위한 예약 영역 |
0x30~0x31 | DATAGRAM (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 마킹)를 송신측 혼잡 제어에 반영합니다.
전송 오류 코드(Transport Error Codes)
CONNECTION_CLOSE(0x1c) 프레임에 실리는 전송 오류 코드는 RFC 9000 §20.1에 정의되어 있습니다. 앱 오류 코드(0x1d)는 HTTP/3 등 상위 프로토콜이 자체적으로 정의합니다.
| 값 | 이름 | 의미 |
|---|---|---|
| 0x00 | NO_ERROR | 오류 없이 정상 종료 |
| 0x01 | INTERNAL_ERROR | 내부 구현 오류 |
| 0x02 | CONNECTION_REFUSED | 서버가 연결을 거부 (용량, 정책 등) |
| 0x03 | FLOW_CONTROL_ERROR | 흐름 제어 한도 초과 데이터 수신 |
| 0x04 | STREAM_LIMIT_ERROR | 스트림 개수 한도 초과 |
| 0x05 | STREAM_STATE_ERROR | 잘못된 스트림 상태 전이 |
| 0x06 | FINAL_SIZE_ERROR | 최종 크기(Final Size) 불일치 (이미 닫힌 스트림 데이터 등) |
| 0x07 | FRAME_ENCODING_ERROR | 프레임 인코딩 오류 |
| 0x08 | TRANSPORT_PARAMETER_ERROR | 전송 파라미터 오류 |
| 0x09 | CONNECTION_ID_LIMIT_ERROR | Connection ID 개수 한도 초과 |
| 0x0a | PROTOCOL_VIOLATION | 프로토콜 규칙 위반 (일반) |
| 0x0b | INVALID_TOKEN | 토큰(Retry/NEW_TOKEN)이 유효하지 않음 |
| 0x0c | APPLICATION_ERROR | 애플리케이션 정의 오류 |
| 0x0d | CRYPTO_BUFFER_EXCEEDED | CRYPTO 버퍼 한도 초과 |
| 0x0e | KEY_UPDATE_ERROR | 키 업데이트 규칙 위반 |
| 0x0f | AEAD_LIMIT_REACHED | AEAD 사용 한도 도달 (키 재교체 필요) |
| 0x10 | NO_VIABLE_PATH | 전송 가능한 경로가 없음 (PMTU 등) |
| 0x0100~0x01ff | CRYPTO_ERROR | TLS 경고(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" 하나만 기억하면 됩니다.
스트림 타입과 용도
- 양방향 스트림(Bidirectional) — 양쪽이 데이터를 보낼 수 있습니다. HTTP/3의 요청/응답에 사용됩니다.
- 단방향 스트림(Unidirectional) — 개시자만 데이터를 보낼 수 있고, 상대방은 RESET_STREAM/STOP_SENDING으로만 반응합니다. HTTP/3의 제어 스트림, QPACK 인코더/디코더 스트림에 사용됩니다.
스트림 상태 머신
각 끝점은 스트림을 송신(sending) 관점과 수신(receiving) 관점으로 나누어 상태를 관리합니다. 양방향 스트림은 두 상태 머신을 모두 유지하고, 단방향 스트림은 개시자에게 송신 상태, 수신자에게 수신 상태만 존재합니다. 아래 다이어그램은 RFC 9000 §3.1의 상태 머신을 단순화한 것입니다.
아래 표는 위 그림의 상태 전이를 이벤트 기준으로 정리한 것입니다. 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 재사용 안 함) |
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()으로 재개 */
위 예제가 다루는 시나리오별 동작을 정리하면 다음과 같습니다.
- 정상 완료: STREAM(FIN) → on_stream_close(H3_NO_ERROR) → 요청 처리 결과를 그대로 기록합니다.
- 클라이언트 취소: STOP_SENDING 수신(③) → 서버는 남은 응답 데이터를 버리고 RESET_STREAM으로 회신합니다. HTTP/3 요청은 취소(Cancelled) 상태가 되며 이후 데이터는 전송하지 않습니다.
- 서버 취소: 응답 도중 더 이상 진행할 수 없으면 ngtcp2_conn_shutdown_stream(송신+수신 플래그 동시 지정)으로 양방향을 종료하고 H3_REQUEST_CANCELLED를 실어 보냅니다.
- 중간 손실: 패킷 손실로 스트림 데이터가 재전송되더라도 같은 오프셋이 중복 도착하면 전송 계층이 중복 제거(De-dup)를 수행하므로 애플리케이션은 신경 쓸 필요가 없습니다.
- 비정상 종료: FIN 이후 같은 스트림에 데이터가 도착하면 FINAL_SIZE_ERROR, 불법 전이는 STREAM_STATE_ERROR로 연결이 종료됩니다(앞선 상태 표 참조).
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)인지 알 수 있습니다.
흐름 제어 관련 흔한 오해
- "TCP처럼 수신 윈도우가 열렸다 닫히는가?" — 아닙니다. QUIC의 흐름 제어는 단조 증가(Monotonic)만 허용합니다. 다만 광고된 한도보다 작은 값을 다시 보내는 것은 오류가 아니라 "효과 없음"입니다(RFC 9000 §4.1). 송신자는 증가하지 않는 MAX_DATA/MAX_STREAM_DATA를 무시해야 하며,
FLOW_CONTROL_ERROR는 송신자가 허용 한도를 초과해 데이터를 보냈을 때 수신자가 내리는 오류입니다. - "재전송이 크레딧을 두 번 소모하는가?" — 아닙니다. 크레딧은 "스트림 데이터의 최대 바이트 오프셋" 기준이므로 같은 데이터를 재전송해도 오프셋이 증가하지 않습니다.
- "0-RTT 데이터도 크레딧에 포함되는가?" — 포함됩니다. 0-RTT는 서버가 아직 크레딧을 갱신하지 못한 상태에서 도착하므로, 서버는 0-RTT 수락 시 이전 연결의 전송 파라미터로 크레딧을 회복해야 합니다.
- "흐름 제어를 크게 설정하면 무조건 좋은가?" — 수신 버퍼 메모리와 공격 표면(수신측 메모리 고갈) 사이의 균형입니다. 특히 서버는
initial_max_streams_*를 과도하게 크게 설정하면 연결당 스트림 남용 위험이 있습니다.
실전 구현: 자동 튜닝과 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 수락을 거부해야 합니다. */
흐름 제어 구현에서 놓치기 쉬운 시나리오를 정리합니다.
- 단조 증가(Monotonic) 검사: 수신자가 받은 MAX_* 프레임 중 새 한도가 기존보다 작으면 무시해야 합니다(오류 아님). 반대로 송신자가 광고 한도를 초과해 스트림 데이터를 보내면 FLOW_CONTROL_ERROR로 연결을 닫아야 합니다.
- 데드락(Deadlock): 수신 버퍼가 가득 차 소비가 불가능하면 크레딧 갱신이 자연히 늦어집니다. 이것은 정상 역압(Backpressure)이며, 소비는 했지만 갱신을 잊은 경우만 버그입니다.
- 크레딧 남용 방지: 수신 버퍼 크기 예산을 넘는 윈도우를 광고하면 메모리 고갈 공격에 노출됩니다. 위 예시의 max_window가 바로 이 예산입니다.
- BLOCKED 프레임 남용: 송신자도 BLOCKED 프레임을 흐름 제어가 실제로 막힐 때만 보내야 하며, 크레딧이 충분한데 보내면 수신자에게 불필요한 부하를 줍니다.
연결 설정: 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 핸드셰이크 흐름
0-RTT 연결 재개
0-RTT는 이전 연결에서 받은 NewSessionTicket(TLS 1.3 세션 티켓)을 이용해, 서버와의 첫 패킷 왕복 없이 이미 0-RTT 키를 도출한 상태로 애플리케이션 데이터를 보내는 기능입니다.
TLS 1.3 통합 (RFC 9001)
QUIC은 TLS 1.3을 유일한 암호화 계층으로 사용합니다. 가장 큰 설계 변화는 TLS 레코드(Record) 계층을 제거하고, TLS 핸드셰이크 메시지를 QUIC의 CRYPTO 프레임에 직접 싣는 것입니다. TLS 1.3의 트래픽 키(Traffic Key)들은 그대로 QUIC 패킷 보호(Packet Protection)에 사용됩니다.
TLS 통합의 핵심 요점
- ALPN 필수: QUIC은 TLS ALPN(Application-Layer Protocol Negotiation)으로 애플리케이션 프로토콜(예:
h3)을 협상합니다. ALPN이 없으면 연결이 실패합니다. - 버전 제약: TLS 1.3만 허용됩니다. TLS 1.2 이하로의 폴백(Fallback)이 없습니다.
- 전송 파라미터 교환: QUIC 전송 파라미터(Transport Parameter)는 TLS 확장
quic_transport_parameters(확장 타입 0x0039)로 ClientHello와 EncryptedExtensions에 실립니다. - CRYPTO 프레임: 핸드셰이크 메시지는 암호화 수준(Initial/Handshake)별 CRYPTO 프레임에 오프셋 순서로 실립니다.
- 헤더 보호(Header Protection): 패킷 번호와 일부 헤더 필드가 별도 키로 마스킹되어, 중간 장비가 패킷 번호를 읽지 못합니다.
- 키 업데이트(Key Update): 연결 중 1-RTT 키를 바꿀 수 있으며, Short 헤더의 Key Phase 비트로 알립니다.
초기 키(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", ...)
패킷 보호(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를 함께 제공해야 올바르게 동작합니다.
전송 파라미터(Transport Parameters)
전송 파라미터(Transport Parameter)는 QUIC 연결의 동작을 협상하는 매개변수 모음입니다.
TLS 확장 quic_transport_parameters(0x0039)에 실려 ClientHello(클라이언트)와
EncryptedExtensions(서버)에서 각각 전송되며, 핸드셰이크 중에만 교환되고 암호화됩니다.
이후 변경이 불가능하므로 0-RTT와 1-RTT 양쪽에서 보낼 값(0-RTT 절 참조)을
처음부터 신중히 정해야 합니다.
| ID | 파라미터 | 기본값 | 설명 |
|---|---|---|---|
| 0x00 | original_destination_connection_id | — | 클라이언트 첫 Initial의 DCID (서버만 전송, Retry 검증용) |
| 0x01 | max_idle_timeout | 0(비활성) | 유휴 타임아웃(ms). 양측 값 중 작은 값이 적용됩니다 |
| 0x02 | stateless_reset_token | — | 무상태 리셋 검증용 128비트 토큰 (서버만 전송) |
| 0x03 | max_udp_payload_size | 65527 | 수신 가능한 최대 UDP 페이로드. 1200 미만은 위반 |
| 0x04 | initial_max_data | 0 | 연결 전체 흐름 제어 초기 크레딧 |
| 0x05 | initial_max_stream_data_bidi_local | 0 | 로컬 개시 양방향 스트림의 초기 크레딧 |
| 0x06 | initial_max_stream_data_bidi_remote | 0 | 피어 개시 양방향 스트림의 초기 크레딧 |
| 0x07 | initial_max_stream_data_uni | 0 | 유니 스트림의 초기 크레딧 |
| 0x08 | initial_max_streams_bidi | 0 | 열 수 있는 양방향 스트림 최대 개수 |
| 0x09 | initial_max_streams_uni | 0 | 열 수 있는 유니 스트림 최대 개수 |
| 0x0a | ack_delay_exponent | 3 | ACK 지연 필드의 지수 스케일(2n ms 단위) |
| 0x0b | max_ack_delay | 25ms | 수신자가 ACK을 지연시킬 수 있는 상한 (PTO 계산에 사용) |
| 0x0c | disable_active_migration | 0(허용) | 1이면 클라이언트의 능동 마이그레이션 금지 |
| 0x0d | preferred_address | — | 서버의 대체 주소(IPv4/IPv6). 클라이언트가 최적 경로 선택에 사용 |
| 0x0e | active_connection_id_limit | 2 | 동시에 유지 가능한 CID 개수 상한 |
| 0x0f | initial_source_connection_id | — | 송신자가 보낸 최초 SCID 값(무결성 검증용) |
| 0x10 | retry_source_connection_id | — | Retry 패킷의 SCID (서버만 전송) |
| 0x20 | max_datagram_frame_size | — | QUIC Datagram(RFC 9221) 최대 크기 협상 (RFC 9221 §5.1) |
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 |
| 기본 RTT | RTT 샘플 확보 전에 사용하는 초기 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을 다시 유발합니다. 이때 중요한 규칙은 다음과 같습니다.
- 탐색 패킷은 손실 선언 없이 보냅니다. PTO는 "손실로 확정"이 아니라 "확인이 필요함"을 뜻합니다.
- 패킷 공간(Initial/Handshake/Application)마다 PTO가 독립적으로 계산되며, 탐색이 실패하면 PTO가 지수적으로 증가합니다.
- 재전송 시 새 패킷 번호가 부여됩니다. 같은 패킷 번호를 다시 쓰는 재전송(Retransmission)은 없으며, 데이터만 새 패킷에 다시 실립니다. 손실된 패킷 번호는 재사용되지 않으므로 수신측이 중복을 판별하기 쉽습니다.
- 연결이 유휴(Idle) 상태로 진입하기 전에 통신해야 하는 경우, PING 프레임으로 ACK을 유발합니다.
지속적 혼잡(Persistent Congestion)
PTO 주기 기준으로 kPersistentCongestionThreshold(3)개 이상의 PTO 구간 동안
어떤 ACK도 받지 못하고 재전송도 손실된 것으로 판단되면 지속적 혼잡으로 간주합니다.
이 경우 혼잡 창(Congestion Window)을 최소 창(kMinimumWindow) 수준으로 줄여 네트워크가 회복될 때까지 보수적으로 전송합니다.
혼잡 제어(Congestion Control)
RFC 9002는 손실 감지와 함께 혼잡 제어의 기본 요구사항을 정의합니다. 중요한 점: QUIC은 혼잡 제어 알고리즘을 특정하지 않습니다. NewReno, CUBIC, BBR, Copa 등 구현체가 선택한 알고리즘을 사용할 수 있으며, RFC는 모든 알고리즘이 반드시 지켜야 할 기본값과 조건(특히 송신량 한도)만 규정합니다.
| 상수 | 값 | 의미 |
|---|---|---|
| kInitialWindow | 10 × max_datagram_size | 초기 혼잡 창. UDT(큰 데이터그램) 기준 약 10개 패킷분량 |
| kMinimumWindow | 2 × max_datagram_size | 최소 혼잡 창. 손실 시 이 수준까지 감소 가능 |
| kLossReductionFactor | 0.5 | 손실 감지 시 혼잡 창 감소 계수(절반으로) |
| kPersistentCongestionThreshold | 3 | 지속적 혼잡 판정 기준(PTO 주기 수) |
혼잡 창은 연결 전체(In-Flight 바이트 총량)를 제한합니다. 스트림별 제한은 흐름 제어가 담당하므로,
"혼잡 창은 연결 단위, 흐름 제어 크레딧은 연결+스트림 단위"로 기억하면 혼란이 줄어듭니다.
전송량은 혼잡 창, 흐름 제어 크레딧, 송신 버퍼 세 가지 제약을 동시에 만족해야 합니다.
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로 연결을 식별하기 때문에, 주소가 바뀌어도 같은 연결을 계속 사용할 수 있습니다. 스마트폰이 이동 중 네트워크를 바꿔도 스트리밍·게임 세션이 유지되는 핵심 기능입니다.
마이그레이션 규칙
- 마이그레이션은 클라이언트가 주도합니다. 서버는
disable_active_migration파라미터(0x0d)를 보내 클라이언트의 능동 마이그레이션을 금지할 수 있습니다. - 경로 검증 경쟁 조건(Race Condition): 새 경로로 데이터를 보내기 전에 PATH_CHALLENGE/PATH_RESPONSE로 주소 소유권을 확인해야 합니다.
- 프라이버시: 이동 후에는 이전 Connection ID를 쓰지 않고
NEW_CONNECTION_ID프레임으로 받은 새 CID를 사용합니다(추적 방지). - NAT 리바인딩(NAT Rebinding): 같은 IP의 포트만 바뀌는 경우는 활성 마이그레이션으로 보지 않습니다. 다만 경로의 특성이 바뀌었을 수 있으므로 검증을 권장합니다.
- preferred_address(0x0e): 서버가 대체 주소를 미리 알려주어, 클라이언트가 서버의 다중 주소 중 최적 경로를 선택할 수 있게 합니다.
주소 검증(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와 결합 시 효과가 큽니다).
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)해 연결 상태를 잃은 경우에도 클라이언트가 즉시 오류를 인지합니다.
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 응답이 "자신이 보낸 패킷에 대한 응답"인지 검사해야 합니다.
| 버전 | 값 | 구분 |
|---|---|---|
| 버전 협상 | 0x00000000 | VN 패킷 전용 |
| QUIC v1 | 0x00000001 | RFC 9000~9002 (2021) |
| QUIC v2 | 0x6b3343cf | RFC 9369 (2023) |
QUIC v2와 v1의 차이
RFC 9369는 의도적으로 v1과 거의 동일하게 설계되었습니다. 차이는 다음 세 가지뿐입니다.
- 버전 번호가 다릅니다 (0x6b3343cf).
- Initial salt가 다릅니다. 중간 장비가 "v1 salt로 복호화되는 패킷 = QUIC"이라고 고정 학습하는 것을 방지합니다.
- Retry 무결성 태그의 키/연산이 다릅니다.
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의 설계 원칙
- 스트림 = 트랜잭션(Transaction): HTTP 요청/응답 한 쌍이 클라이언트 개시 양방향 스트림 하나를 사용합니다. 스트림마다 독립된 손실 복구가 일어나므로 전송 계층 HoL 블로킹이 없습니다.
- 제어 채널 분리: SETTINGS, GOAWAY 등 연결 수준 제어 메시지는 별도의 제어 스트림(Control Stream)으로 보냅니다.
- 헤더 압축 차단 방지: QPACK은 압축 상태를 인코더/디코더 전용 스트림으로 동기화하여, 한 스트림의 헤더 누락이 다른 스트림을 막지 않습니다.
- HTTP/2와의 호환성: 요청 필드 규칙(의사 헤더 :method 등), 상태 코드, 캐시 의미론은 HTTP/2와 같습니다.
HTTP/3 스트림 매핑
HTTP/2와의 차이점 정리
| 항목 | HTTP/2 | HTTP/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 스트림의 바이트 스트림 안에 있습니다.
| 타입 | 프레임 | 전송 스트림 | 용도 |
|---|---|---|---|
| 0x00 | DATA | 요청/응답 | 메시지 본문(바디) 데이터 |
| 0x01 | HEADERS | 요청/응답 | QPACK으로 압축된 헤더 섹션 |
| 0x02 | 예약 | — | 사용 금지 (오류로 처리) |
| 0x03 | CANCEL_PUSH | 클라→서버 제어 | 수신을 원하지 않는 푸시 예고 취소 |
| 0x04 | SETTINGS | 양방향 제어(연결 시작 시 1회) | QPACK 용량, CONNECT 활성화 등 파라미터 교환 |
| 0x05 | PUSH_PROMISE | 서버(요청 스트림) | 푸시 자원의 링크드 헤더 예고 |
| 0x06 | 예약 | — | 사용 금지 |
| 0x07 | GOAWAY | 서버→클라 제어 | 새 요청 거부 시작, 마지막 수락 스트림 ID 포함 |
| 0x08, 0x09 | 예약 | — | 사용 금지 |
| 0x0d | MAX_PUSH_ID | 클라→서버 제어 | 서버가 열 수 있는 푸시 스트림 최대 Push ID 설정 |
| 0x10~0x1f | 예약(확장 프레임) | — | 미래 확장용 |
SETTINGS 파라미터
| 식별자 | 이름 | 기본값 | 의미 |
|---|---|---|---|
| 0x01 | SETTINGS_QPACK_MAX_TABLE_CAPACITY | 0 | QPACK 동적 테이블 최대 용량(바이트). 0 = 동적 테이블 비활성 |
| 0x06 | SETTINGS_MAX_FIELD_SECTION_SIZE | 무제한 | 수신할 수 있는 최대 헤더 섹션 크기 |
| 0x07 | SETTINGS_QPACK_BLOCKED_STREAMS | 0 | 수신자가 허용하는 "차단된(동적 테이블 갱신 대기) 스트림"의 상한 |
| 0x08 | SETTINGS_ENABLE_CONNECT_PROTOCOL | 0 | 확장 CONNECT(RFC 9220 WebSocket) 활성화 |
H3_FRAME_UNEXPECTED입니다.
또한 SETTINGS 프레임은 요청/응답 스트림이나 다른 스트림에서 오면 오류입니다.
HTTP/3 오류 코드
HTTP/3 오류는 CONNECTION_CLOSE(0x1d) 또는 RESET_STREAM/STOP_SENDING에 실리는 62비트 애플리케이션 오류 코드입니다.
| 코드 | 이름 | 의미 |
|---|---|---|
| 0x0100 | H3_NO_ERROR | 오류 없음 |
| 0x0101 | H3_GENERAL_PROTOCOL_ERROR | 일반 프로토콜 위반 |
| 0x0102 | H3_INTERNAL_ERROR | 구현 내부 오류 |
| 0x0103 | H3_STREAM_CREATION_ERROR | 스트림 생성/사용 오류 |
| 0x0104 | H3_CLOSED_CRITICAL_STREAM | 제어/QPACK 스트림이 조기 종료됨 |
| 0x0105 | H3_FRAME_UNEXPECTED | 이 스트림에서 허용되지 않는 프레임 |
| 0x0106 | H3_FRAME_ERROR | 프레임 형식 오류 (길이 불일치 등) |
| 0x0107 | H3_EXCESSIVE_LOAD | 과도한 부하 유발 시도 |
| 0x0108 | H3_ID_ERROR | 스트림/Push ID 오류 |
| 0x0109 | H3_SETTINGS_ERROR | SETTINGS 값/처리 오류 |
| 0x010a | H3_MISSING_SETTINGS | SETTINGS 누락 또는 위치 오류 |
| 0x010b | H3_REQUEST_REJECTED | 서버가 요청을 즉시 거부 (재시도 가능) |
| 0x010c | H3_REQUEST_CANCELLED | 요청이 취소됨 |
| 0x010d | H3_REQUEST_INCOMPLETE | 스트림이 완전한 메시지 없이 종료됨 |
| 0x010e | H3_MESSAGE_ERROR | 메시지(요청/응답) 형식 오류 |
| 0x010f | H3_CONNECT_ERROR | CONNECT 프록시 처리 오류 |
| 0x0110 | H3_VERSION_FALLBACK | HTTP/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;
}
시나리오별로 매겨야 하는 오류 코드를 요약하면 다음과 같습니다.
- 예약 타입 수신 (0x02/0x06/0x08/0x09) → 연결 오류
H3_FRAME_UNEXPECTED(0x0105) - SETTINGS 누락·중복·위치 위반 →
H3_MISSING_SETTINGS(0x010a) 또는H3_FRAME_UNEXPECTED - MAX_PUSH_ID 감소 →
H3_ID_ERROR(0x0108) - varint 파싱 실패·길이 불일치 →
H3_FRAME_ERROR(0x0106) - 제어/QPACK 스트림 조기 종료 →
H3_CLOSED_CRITICAL_STREAM(0x0104) - 알 수 없는 스트림 타입 → 무시하거나 수신 중단 (RFC 9114 §6.2) — 오류 아님
- 알 수 없는 오류 코드 수신 →
H3_NO_ERROR와 동일하게 취급 (RFC 9114 §8)
HTTP 우선순위: Extensible Priorities (RFC 9218)
HTTP/2의 복잡한 우선순위 트리(종속 트리 + 가중치)는 구현마다 해석이 달라 실제로는 거의 사용되지 않았습니다.
RFC 9218은 HTTP/2와 HTTP/3에서 공통으로 쓸 수 있는 단순하고 확장 가능한 우선순위 체계를 정의합니다.
HTTP/3에서는 우선순위가 헤더 필드(Priority)로 전달됩니다.
| 파라미터 | 기본값 | 범위 | 의미 |
|---|---|---|---|
u (urgency) | 3 | 0~7 | 긴급도. 값이 작을수록 우선 (0 = 가장 긴급) |
i (incremental) | 0 | 0/1 | 1이면 응답을 순차가 아닌 청크 단위로 교차 전송 가능 |
예: Priority: u=0, i 는 "가장 긴급하고 점진적으로 렌더링 가능한 리소스"(예: CSS)라는 뜻입니다.
브라우저는 HTML 파싱 단계에 따라 리소스 우선순위를 매기고, 서버는 이 신호로 전송 순서를 정합니다.
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 지시어와 필드 라인
| 종류 | 프리픽스 | 내용 |
|---|---|---|
| 인코더: 테이블 용량 설정 | 001 (3비트) | 동적 테이블 용량 변경 |
| 인코더: 이름 참조 삽입 | 1 (1비트) + T | 기존 이름(정적/동적)을 참조해 새 엔트리 삽입 |
| 인코더: 이름 리터럴 삽입 | 01 (2비트) | 이름과 값을 리터럴로 삽입 |
| 인코더: 중복(Duplicate) | 000 (3비트) | 기존 엔트리를 테이블 앞에 복제 (인덱스 압축) |
| 디코더: Section Acknowledgment | 1 (1비트) | 스트림의 필드 섹션 디코딩 완료 확인 |
| 디코더: Stream Cancellation | 01 (2비트) | 취소된 스트림의 미적용 엔트리 폐기 |
| 디코더: Insert Count Increment | 00 (2비트) | 동적 테이블 적용 개수 증가 통지 |
| 필드 라인: Indexed | 1 (1비트) | 정적/동적 테이블 인덱스 참조 (인코딩 최소) |
| 필드 라인: 이름 참조 리터럴 | 01 (2비트) | 이름은 테이블, 값은 리터럴 |
| 필드 라인: 이름 리터럴 | 001 (3비트) | 이름·값 모두 리터럴 (최초 등장 등) |
| 필드 라인: Post-Base 참조 | 0001/0000 | Base 이후 위치의 엔트리를 상대 인덱스로 참조 |
: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) */
}
구현 시 확인해야 할 시나리오를 정리합니다.
- 차단 초과: Blocked 스트림 수가 SETTINGS_QPACK_BLOCKED_STREAMS 값을 넘으면 QPACK_DECOMPRESSION_FAILED로 연결 오류입니다 (RFC 9204 §2.1.2). 기본값이 0이므로, 송신 측이 승인되지 않은 동적 테이블 참조를 만들면 즉시 위반할 수 있습니다.
- 잘못된 참조: 이미 퇴거된 엔트리나 Required Insert Count 이상의 절대 인덱스를 참조하면 QPACK_DECOMPRESSION_FAILED, 인코더 스트림 지시어의 잘못된 참조는 QPACK_ENCODER_STREAM_ERROR, 디코더 스트림 지시어 오류는 QPACK_DECODER_STREAM_ERROR입니다 (RFC 9204 §2.2.3, §4.4).
- 차단 중 메모리: Blocked 스트림의 필드 섹션 데이터는 디코딩 확정 전까지 흐름 제어 윈도우 안에 유지해야 합니다 — 흐름 제어를 미리 풀면 메모리 고갈 공격에 노출됩니다 (RFC 9204 §2.2.1).
- 0-RTT 승인: 클라이언트가 0-RTT로 보내는 필드 섹션은 이전 연결에서 기억한 용량 제한으로 인코딩됩니다. 서버가 SETTINGS_QPACK_MAX_TABLE_CAPACITY를 기억된 값과 다르게 보내면 참조가 깨질 수 있으므로, 기억된 값이 0이 아니면 같은 값을 보내야 합니다 (RFC 9204 §3.2.3).
- 교착(Deadlock) 회피: 인코더는 큰 지시어(Insert 등)를 보낼 때 스트림·연결 흐름 제어 크레딧이 지시어 전체에 충분한지 확인해야 합니다 — 부족하면 디코더가 필드 섹션을 기다리는 동안 크레딧을 주지 않아 교착이 생길 수 있습니다 (RFC 9204 §2.1.3).
HTTP/3 확장: WebSocket, Datagram, WebTransport, MASQUE
WebSocket over HTTP/3 (RFC 9220)
WebSocket은 TCP의 양방향 바이트 스트림 위에서 동작했지만, QUIC 위에서는 스트림을 직접 사용하는 편이 자연스럽습니다. RFC 9220은 확장 CONNECT(Extended CONNECT) 메커니즘으로 HTTP/3에서 WebSocket을 부트스트랩합니다.
- 클라이언트는 요청에
:protocol = websocket의사 헤더를 포함한 CONNECT 메서드를 보냅니다. - 서버는
SETTINGS_ENABLE_CONNECT_PROTOCOL(0x08)로 이 기능을 지원함을 미리 알립니다. - 200 응답 후에는 해당 스트림이 WebSocket 데이터 프레임(HTTP/3 DATA 프레임 안)을 주고받는 채널이 됩니다.
- WebSocket 메시지가 QUIC 스트림에 1:1 매핑되므로, TCP에서의 HoL 블로킹과 연결당 메시지 직렬화 문제가 사라집니다.
QUIC Datagrams (RFC 9221)와 HTTP Datagrams (RFC 9297)
QUIC은 기본적으로 신뢰성 있는 스트림 전송이지만, 신뢰성 없는(Unreliable) 데이터그램 확장도 표준화되었습니다.
DATAGRAM 프레임(0x30/0x31)은 스트림 번호 없이 패킷에 직접 실리며, 손실 시 재전송되지 않습니다.
- 사용하려면 전송 파라미터
max_datagram_frame_size(0x20, RFC 9221 §5.1)로 최대 크기를 협상해야 합니다. - HTTP/3에서 DATAGRAM을 메시지 경계로 쓰는 규칙이 HTTP Datagrams(RFC 9297)에 정의되어 있으며, 내부에 Capsule(0x00) 프레임을 사용합니다.
- 실시간(Real-time) 게임, 화상 통화, 미디어 전송처럼 "늦은 재전송보다 최신 데이터"가 중요한 애플리케이션에 적합합니다.
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_SEGMENT(cmsg타입UDP_SEGMENT)로 각 세그먼트 크기를 지정합니다. - 애플리케이션은 최대 전송 단위(MTU) 크기 미만의 여러 패킷을 이어 붙인 큰 버퍼를
sendmmsg()또는write()로 전달합니다. - NIC가 UDP 분할 오프로드(
NETIF_F_GSO_UDP_L4)를 지원하면 하드웨어가 분할하고, 아니면 커널 소프트웨어가 분할합니다. - 결과: 시스템콜 수 감소, 패킷당 CPU 비용 감소, 처리량 증가. QUIC 구현체(quiche, ngtcp2, msquic 등) 대부분이 지원합니다.
UDP GRO: 수신 병합(Receive Coalescing)
수신 측에서는 반대로, 연속해서 도착한 UDP 데이터그램 여러 개를 하나의 큰 skb(socket buffer)로 병합하여 커널이 처리하는 패킷 수를 줄입니다. UDP GRO(Generic Receive Offload)를 활성화하면 수신 인터럽트/softirq 수가 줄고 처리량이 올라갑니다.
- 유니캐스트 UDP GRO는 소켓 옵션
UDP_GRO로 활성화할 수 있으며, unconnected 소켓에 적용됩니다. - 병합 조건: 같은 4-tuple, 같은 세그먼트 크기(동일 구성), 연속된 데이터. 조건이 맞지 않으면 병합하지 않습니다.
- GRO는 순수 TCP와 달리 손실이 있으면 병합을 중단하고 나머지를 개별 전달하므로, UDP 애플리케이션은 recvmmsg()로 여러 메시지를 한 번에 꺼내야 효율이 극대화됩니다.
- 커널 컴파일 옵션
CONFIG_INET_UDP_DIAG등은 진단용이고, GRO/GSO 자체는 기본 네트워크 스택 기능(CONFIG_NET_IPGRE등과 무관)입니다.
# 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와 제로 카피 전송을 제공하여 이 병목을 줄이는 데 사용됩니다. 주요 관련 기능은 다음과 같습니다.
- sendmsg_zc / zerocopy TX: 소켓 버퍼로 데이터를 복사하지 않고 사용자 버퍼를 직접 전송(MSG_ZEROCOPY). UDP에서는 커널 4.18 이후 UDP 제로 카피 TX가 가능해졌고, 이후 버전에서 안정화되었습니다.
- io_uring send/recv: 시스템콜 없이 커널에 작업을 제출합니다. QUIC 스택(예: quiche 기반 glommio, nginx의 일부 경로)에서 채택 사례가 늘고 있습니다.
- AF_XDP: 사용자 공간이 NIC RX/TX 큐를 직접 소유해 커널 네트워크 스택을 완전히 우회하는 고성능 경로입니다. 커널 스택을 거치지 않으므로 GRO/GSO나 방화벽(Firewall) 등과 동작 방식이 다릅니다 — XDP/AF_XDP 문서 참조.
커널 튜닝 체크리스트
- 대량 UDP 수신:
net.core.rmem_max를 크게(예: 16MB 이상) 설정 — QUIC 연결당 소켓 버퍼가 부족하면 패킷 드롭이 발생합니다. - GRO/GSO: NIC 드라이버/ethtool에서 GRO, GSO 유지(기본 켜짐). UDP_GRO는 애플리케이션 소켓에서 opt-in.
- SO_REUSEPORT: 다중 프로세스(Process)/스레드(Thread) 서버에서 소켓을 여러 개 열어 커널 수신 큐를 분산(수신측 스케일링 보조).
- busy poll:
SO_BUSY_POLL로 수신 인터럽트 지연을 줄이는 대신 CPU를 소모 (지연 민감 서비스에 선택 적용). - epoll/io_uring: 동시 연결 수가 많으면 이벤트 루프(Event Loop)를 epoll 또는 io_uring 기반으로 구성합니다.
- UDP GRO 병합의 함정: GRO로 병합된 데이터그램은 head padding이 커질 수 있고, recvmmsg 없이 read하면 한 번에 한 메시지만 얻게 되어 이점을 잃습니다.
QUIC 구현체 생태계
IETF 표준화 이후 QUIC 구현체가 다양해졌습니다. 전송 라이브러리(Transport), HTTP/3 서버/클라이언트, 도구로 나누어 정리합니다. 선택 기준은 RFC 준수 수준, TLS 백엔드(OpenSSL/BoringSSL/Rustls), 성숙도, 라이선스, 활발한 유지보수입니다.
전송 라이브러리
| 구현체 | 언어 | TLS 백엔드 | 특징 |
|---|---|---|---|
| quiche | Rust | BoringSSL/quiche-tls | Cloudflare 개발. HTTP/3(QPACK 포함) 내장, GSO/GRO 지원, qlog 지원 |
| ngtcp2 | C | OpenSSL/GnuTLS/BoringSSL | nghttp2 저자가 개발. 별도 crypto 계층, nghttp3가 HTTP/3/QPACK 담당 |
| quic-go | Go | crypto/tls (Go 내장) | Go 표준 crypto/tls로 단일 바이너리. HTTP/3, MASQUE, WebTransport 지원 |
| msquic | C | Schannel/OpenSSL | Microsoft 개발. Windows/Linux/macOS, 커널 모드도 지원 |
| aioquic | Python | cryptography/pyOpenSSL | 연구·교육용으로 인기, qlog 등 디버깅 지원 우수 |
| picoquic | C | OpenSSL/사용자 정의 | 프랑스 국립연구소(INRIA) Christian Huitema, 실험 기능 풍부 |
| lsquic | C | OpenSSL/BoringSSL | LiteSpeed 개발. LiteSpeed 웹서버에 내장 |
| s2n-quic / neqo | Rust | s2n-tls(Rust), rustls | AWS(s2n-quic), Mozilla(neqo — Firefox) 개발 |
HTTP/3 서버와 프록시
| 제품 | 기반 | 비고 |
|---|---|---|
| nginx | 자체 QUIC(+OpenSSL 3.x QUIC API) | 1.25+에서 HTTP/3 실험 지원. listen ... quic 지시어 사용 |
| Cloudflare | quiche | 전 세계 CDN에서 HTTP/3 기본 제공, 0-RTT 활성화 |
| LiteSpeed / OpenLiteSpeed | lsquic | QUIC 초기 도입자. HTTP/3 성능 강점 |
| h2o | 자체/ngtcp2 | Fastly 계열. 고성능 HTTP/3 서버 |
| Caddy | quic-go | 자동 HTTPS와 함께 HTTP/3 기본 제공 |
| HAProxy | quic | 2.4+ QUIC(HTTP/3) 로드밸런서 지원 — 프런트엔드 UDP 443 |
| Envoy | 자체(Google QUIC 라이브러리) | gRPC/HTTP/3 게이트웨이용 |
disable_active_migration보다 "CID 해시(Hash) 라우팅"이 운영상 권장됩니다.
서버가 여럿일 때 모든 서버가 같은 TLS 키(세션 티켓 키)를 공유해야 0-RTT 재개가 서버 간에 동작합니다.
클라이언트와 도구
- 브라우저: Chrome(구글 gQUIC → IETF QUIC v1), Firefox(neqo), Safari, Edge 모두 HTTP/3 지원.
- curl:
curl --http3-only https://example.com— QUIC 전송 확인. 빌드 시 ngtcp2+nghttp3 또는 quiche 필요. - nghttp3: ngtcp2 기반 HTTP/3 클라이언트/서버 테스트 도구(
nghttp3-client,nghttp3-server). - aioquic:
python3 examples/http3_client.py— 최소한의 HTTP/3 클라이언트 실습용. - GnuTLS/OpenSSL: OpenSSL은 3.2부터 클라이언트용 QUIC API(
SSL_set_quic_method등)를 제공했고, 2025년 4월 발표된 3.5(LTS)부터 서버측 QUIC API가 추가되어 자체 서버 구현의 TLS 백엔드로 사용할 수 있습니다.
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 앞에서 무력해지는 이유는 다음 특성 때문입니다.
- TLS 종단의 사용자 공간 이동 — 커널의
ss·conntrack·netfilter는 암호화된 QUIC 페이로드(Payload)만 볼 수 있어, TCP 세그먼트(Segment) 기반으로 동작하던 기존 검사 로직을 재사용할 수 없습니다. - 프레임·스트림 정보의 암호화 — 패킷 번호(Packet Number)는 헤더 보호(Header Protection)로 난독화되고, 프레임 종류와 스트림 ID는 모두 보호된 페이로드 안에 있어 온라인(Online) 재조립이 불가능합니다.
- 스트림 멀티플렉싱(Multiplexing) — 대화 흐름이 패킷 경계와 무관하고 재전송으로 패킷 번호가 불연속이므로, "세션 재조립"을 응용 계층에서 직접 수행해야 합니다.
- 0-RTT 리플레이(Replay) 위험 — 프록시가 early data를 그대로 오리진으로 전달하면 리플레이 공격(Replay Attack)의 지점이 됩니다. TLS 1.3이 요구하는 리플레이 캐시(Replay Cache)를 프록시가 직접 구현해야 합니다.
- 연결 마이그레이션(Connection Migration) — 4-tuple 기반 세션 추적이 무력화되므로, 프록시도 Connection ID 기반의 세션 테이블을 유지해야 합니다.
- 증폭 방지(Amplification Limit, 3배 상한) — 서버 역할을 하는 수신 종단도 주소 검증(Address Validation) 토큰을 발급·검증해야 하며, 무상태(Stateless) Retry 처리까지 고려해야 합니다.
- ECH(Encrypted ClientHello) — SNI가 암호화되면 도메인 기반 라우팅·차단 정책이 제한됩니다. ECH는 IETF에서 draft-ietf-tls-esni로 표준화가 진행 중이며 이 글 작성 시점까지 RFC로 발행되지 않았습니다(발행 상황은 수시 확인 필요). 다만 클라이언트가 프록시로 직접 연결되는 능동 MITM 구조에서는 TLS 종단이 프록시 자신이므로 ECH와 무관하게 검사할 수 있습니다.
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 연결입니다.
중계 루프: 두 연결 사이의 재전송
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 · 세션 티켓 · 인증서 운영
- 클라이언트 방향 0-RTT: 프록시가 자체 세션 티켓(Session Ticket) 키를 발급하면 클라이언트는 다음 연결에서 0-RTT early data를 보냅니다. 프록시는 TLS 1.3이 요구하는 리플레이 보호를 위해 수신 early data를 캐싱·검증해야 합니다.
- 오리진 방향 0-RTT: 세션 티켓은 각 연결의 TLS 종단이 발급하므로 프록시는 오리진의 티켓을 얻을 수 없습니다. 따라서 클라이언트의 early data를 1-RTT 요청으로 재작성하는 것이 일반적인 설계이며, early data를 그대로 전달하면 리플레이 위험이 있습니다.
- 인증서: 프록시 CA를 만들어 검사 대상 도메인별 인증서를 동적으로 발급합니다(mitmproxy 방식). 인증서 피닝(Certificate Pinning)을 사용하는 클라이언트와 ECH를 사용하는 오리진은 능동 MITM을 어렵게 만드는 대표적인 사례입니다.
- 점검·교체: 점검 중에는 GOAWAY 프레임으로 새 스트림 생성을 거부하고 기존 스트림을 모두 완료한 뒤 종료합니다(연결 종료 섹션의 정상 종료 절차 참고).
성능 관점: 커널 고속 경로 활용
능동 프록시는 데이터가 통과할 때마다 양쪽 종단에서 각각 AEAD 암호화·복호화를 수행하므로, 동일 트래픽에 대해 암호 연산이 두 번 발생하는 구조입니다(경로상의 상수 비용). 이를 보완하려면 리눅스 커널과 QUIC 섹션의 고속 경로를 적극 활용해야 합니다.
- UDP GSO/GRO — 여러 QUIC 패킷을 묶어 시스템콜(System Call) 수와 인터럽트 수를 줄입니다. 프록시는 두 연결을 중계하므로 데이터 이동량이 많아 개선 효과가 큽니다.
- recvmmsg/sendmmsg 배치 처리 — 수신·송신 데이터그램을 묶어 처리하면 이벤트 루프(Event Loop)당 처리량이 크게 개선됩니다.
- SO_REUSEPORT + CID 라우팅 — 프록시 인스턴스를 여러 개 띄워 연결을 고르게 분산하되, 연결 마이그레이션을 지원하려면 동일 인스턴스로 CID가 라우팅되도록 해시를 관리해야 합니다.
- io_uring — 이벤트 루프의 입출력(I/O) 시스템콜을 비동기로 넘겨 CPU 사용률을 낮추는 선택지입니다.
보안 한계와 운영 정책
- 신뢰 경계: 클라이언트가 프록시 CA를 신뢰해야만 동작합니다. 인증서 피닝, ECH, TLS 1.3 세션 재개 등은 설계상 프록시를 우회하거나 제한합니다.
- 개인키 보호: 중계 대상 도메인의 세션 티켓 키와 CA 개인키가 유출되면 전체 트래픽 복호화로 이어지므로, TPM/HSM과 같은 하드웨어 보호를 검토해야 합니다.
- 감사(Audit) 기록: 검사 정책의 적용 결과(허용·차단·변조)를 로그로 남기고, 중간자 동작이 관련 법규(개인정보보호, 통신비밀보호)와 충돌하지 않는지 법률 검토가 필요합니다.
- 오리진 방향 최소 권한: 프록시가 오리진으로 보내는 요청은 원본을 충실히 복원해야 하며, 0-RTT 데이터를 그대로 전달하지 않는 기본 운영 정책을 권장합니다.
디버깅과 관측
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 헤더
빠른 동작 확인 체크리스트
curl -I --http3-only https://site— HTTP/3 응답 헤더(alt-svc, server) 확인.nslookup -type=HTTPS site— HTTPS DNS 레코드(RFC 9460)로 QUIC(CID: h3) 광고 확인.ss -u -a -p— UDP 443 소켓 수신 확인(연결 상태는 커널에 없음에 주의).tcpdump -i any udp port 443— 첫 바이트 헤더 폼(0x80=Long, 0x40이하=Short)으로 QUIC 여부 1차 판별.- CGNAT/방화벽: UDP 443 차단 여부 확인 — HTTP/2로 폴백이 자동으로 일어나는지도 점검(alt-svc 유효기간 관리).
보안: 목표와 공격 벡터
RFC 9000 §21은 QUIC의 보안 목표를 "기밀성(Confidentiality), 무결성(Integrity), 가용성(Availability)"으로 정의하며, TCP+TLS 조합보다 메타데이터 노출을 최소화하는 것을 추가 목표로 삼습니다. 패킷 번호, 스트림 ID, 프레임 타입이 모두 암호화되어 중간 장비는 패킷 크기와 타이밍만 관찰할 수 있습니다.
암호화된 메타데이터
| 항목 | TCP+TLS | QUIC |
|---|---|---|
| 시퀀스/패킷 번호 | 평문(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배 한도로 파괴적 증폭 차단 |
| 핸드셰이크 DoS | ClientHello 폭주로 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 비활성화 |
흔한 오해와 주의사항
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)
참고 자료와 추가 학습 경로
표준 문서(RFC)
| RFC | 제목 | 필요 시점 |
|---|---|---|
| RFC 8999 | Version-Independent Properties of QUIC | 헤더 폼·버전 필드 이해 |
| RFC 9000 | QUIC: A UDP-Based Multiplexed and Secure Transport | 전송 프로토콜 전체 (필수) |
| RFC 9001 | Using TLS to Secure QUIC | 키 스케줄·패킷 보호·헤더 보호·키 업데이트 |
| RFC 9002 | QUIC Loss Detection and Congestion Control | PTO·손실 감지·혼잡 제어 기본 |
| RFC 9114 | HTTP/3 | HTTP/3 프레임·스트림·SETTINGS |
| RFC 9204 | QPACK | HTTP/3 헤더 압축 |
| RFC 9218 | Extensible Prioritization Scheme for HTTP | HTTP/2·3 우선순위 |
| RFC 9220 | Bootstrapping WebSockets with HTTP/3 | WebSocket over HTTP/3 |
| RFC 9221 | An Unreliable Datagram Extension to QUIC | QUIC Datagrams |
| RFC 9369 | QUIC Version 2 | v2·버전 협상 |
| RFC 9297 | HTTP Datagrams and the Capsule Protocol | HTTP Datagram·Capsule |
| RFC 9298 | Proxying UDP in HTTP (MASQUE) | CONNECT-UDP 터널링 |
| RFC 9460 | Service Binding and Parameter Specification (HTTPS RR) | DNS 기반 HTTP/3 광고 |
구현체 저장소
- quiche — https://github.com/cloudflare/quiche
- ngtcp2/nghttp3 — https://github.com/ngtcp2/ngtcp2 , https://github.com/ngtcp2/nghttp3
- quic-go — https://github.com/quic-go/quic-go
- msquic — https://github.com/microsoft/msquic
- aioquic — https://github.com/aiortc/aioquic
- lsquic — https://github.com/litespeedtech/lsquic
도구와 문서
- qvis (qlog 시각화) — https://qvis.quictools.info/
- Wireshark QUIC 디섹터 — https://www.wireshark.org/docs/wsdg_html_chunked/ (QUIC/TLS 키 로그)
- Linux 커널 문서 — Documentation/networking/segmentation-offloads.txt (UDP GSO/GRO)
- curl HTTP/3 빌드 가이드 — https://curl.se/docs/http3.html
- nghttp3 문서 — https://nghttp2.org/documentation/
한눈에 보는 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) 한계와 메타데이터 활용을 다룹니다.