QUIC · HTTP/3
FD.io VPP의 QUIC 프로토콜 구현과 HTTP/3 실전 운용을 심화 정리한 문서입니다. quicly 통합, QUIC engine API, ALPN 협상, 0-RTT 재개, VPP 25.10 제약사항까지를 한 곳에 모았으며, 본 문서는 호스트 스택 개요에서 QUIC/HTTP3 부분만을 별도로 분리한 심화 페이지(Page)입니다.
이 페이지에서 다루는 주요 h2 섹션은 다음과 같습니다. 첫째 VPP QUIC 프로토콜은 quicly 라이브러리 기반 통합 구조, 25.06 이후 도입된 QUIC engine API, 스트림·패킷(Packet) 처리 흐름, 운영 디버깅(Debugging) 팁을 다룹니다. 둘째 HTTP/3 실전 — ALPN 협상, 0-RTT, VPP 25.10 제약은 h2/h3 ALPN 협상 전략, 0-RTT 재개와 재전송 방어, 현재 VPP HTTP/3 어댑터의 실전 제약과 운영 고려사항을 정리합니다.
VPP QUIC 프로토콜
VPP는 quicly 라이브러리(H2O 프로젝트)를 기반으로 QUIC 전송 프로토콜을 네이티브 지원합니다. QUIC은 UDP 위에 TLS 1.3을 내장한 다중화(Multiplexing) 전송 프로토콜로, VPP의 유저스페이스 스택과 결합하면 커널 경유 없이 고성능 암호화(Encryption) 통신이 가능합니다.
- 25.06 — QUIC engine API 공식 출시. 전에는 quicly 직결 구조였지만 플러그형 엔진 인터페이스가 생기면서 다른 QUIC 구현도 끼울 수 있게 됐습니다.
- 25.10 — HTTP/2 쪽에서 extended CONNECT와 UDP 터널링 over HTTP/2가 들어왔습니다. HTTP/3 중계 경로가 정비되기 시작한 시점입니다.
- 26.02 — HTTP/3 framing layer + core skeleton + H3 client side와 QPACK 인코딩/디코딩이 릴리스 노트에 공식 항목으로 등장했습니다. 본 절의 기존 "HTTP/3 over VPP QUIC" 내용은 이 시점에 "실험 → 사용 가능한 파일럿" 상태로 올라왔습니다.
QUIC 전송 아키텍처
VPP의 QUIC 구현(src/plugins/quic/)은 세션 레이어의 전송 프로토콜로 등록되며, 단일 QUIC 연결 위에 여러 스트림을 다중화합니다:
/* QUIC 전송 프로토콜 등록 */
transport_register_protocol (TRANSPORT_PROTO_QUIC,
&quic_proto, FIB_PROTOCOL_IP4, ~0);
/* QUIC 세션 계층 구조 */
/*
* Application Stream ──→ QUIC Stream (session)
* │
* 여러 스트림 ──────→ QUIC Connection (session)
* │
* ──→ UDP Transport (session)
*/
typedef struct quic_ctx_
{
union {
transport_connection_t connection;
};
quicly_conn_t *conn; /* quicly 연결 객체 */
u32 listener_ctx_id;
u32 udp_session_handle; /* 하부 UDP 세션 */
u8 conn_state; /* 연결 상태 머신 */
u8 udp_is_ip4;
/* TLS 1.3은 quicly 내부에서 처리 */
} quic_ctx_t;
QUIC 내부 패킷 처리 흐름
VPP의 QUIC 플러그인은 quicly 라이브러리를 핵심 엔진으로 사용합니다. quicly는 QUIC 프로토콜의 패킷 파싱, 암호화(TLS 1.3 via picotls), 흐름 제어(Flow Control), 혼잡 제어(Congestion Control)를 모두 처리하는 C 라이브러리입니다. VPP는 quicly를 그래프 노드 기반 파이프라인(Pipeline)에 통합하여 고성능 QUIC 처리를 구현합니다.
QUIC 패킷 수신 경로
QUIC 패킷이 네트워크에서 도착하여 애플리케이션 데이터로 전달되기까지의 경로는 다음과 같습니다:
/* QUIC 수신 경로: UDP input → quicly → 애플리케이션 */
/* 1. VPP UDP 입력 노드가 QUIC 포트(443) 패킷을 QUIC 앱에 전달 */
/* udp-input → session-queue → quic_app_rx_callback() 호출 */
static int
quic_app_rx_callback (session_t *udp_session)
{
quic_ctx_t *ctx;
svm_fifo_t *rx_fifo;
u8 *data;
u32 data_len;
int rv;
/* UDP RX FIFO에서 QUIC 패킷 읽기 */
rx_fifo = udp_session->rx_fifo;
data_len = svm_fifo_max_dequeue (rx_fifo);
vec_validate (data, data_len - 1);
svm_fifo_dequeue (rx_fifo, data_len, data);
/* Connection ID로 QUIC 컨텍스트 검색 */
ctx = quic_find_ctx_by_conn_id (data);
/* 2. quicly_receive()로 패킷 처리 (복호화 + 프레임 파싱) */
rv = quicly_receive (ctx->conn, NULL,
&udp_session->transport.rmt_ip,
data, data_len);
if (rv != 0)
{
/* 패킷 처리 오류: 잘못된 패킷 또는 복호화 실패 */
quic_proto_error (ctx, rv);
return -1;
}
/* 3. 스트림 데이터가 있으면 앱 세션의 RX FIFO에 인큐 */
quic_check_streams_for_data (ctx);
/* 4. ACK 등 응답 패킷이 필요하면 송신 스케줄링 */
quic_send_packets (ctx);
return 0;
}
코드 분석: QUIC 패킷 수신 경로
quic_app_rx_callback()은 VPP 세션 레이어가 UDP 패킷을 수신할 때 호출되는 콜백(Callback)입니다. 핵심 동작은 quicly_receive() 호출입니다. quicly는 패킷 헤더를 파싱하여 Connection ID를 추출하고, AEAD(Authenticated Encryption with Associated Data)로 페이로드(Payload)를 복호화(Decryption)한 후, 내부 프레임(STREAM, ACK, MAX_DATA 등)을 처리합니다. STREAM 프레임에 포함된 애플리케이션 데이터는 해당 스트림의 수신 버퍼(Buffer)에 저장되며, 이후 quic_check_streams_for_data()가 이를 앱 세션의 RX FIFO에 복사합니다.
QUIC 패킷 송신 경로
애플리케이션 데이터가 QUIC 패킷으로 변환되어 네트워크로 전송되는 경로입니다:
/* QUIC 송신 경로: 앱 데이터 → quicly → UDP output */
static int
quic_send_packets (quic_ctx_t *ctx)
{
quicly_address_t dest, src;
struct iovec packets[QUIC_MAX_PACKET_BATCH];
size_t num_packets = QUIC_MAX_PACKET_BATCH;
int rv;
/* 1. quicly_send()로 전송할 패킷 생성 (암호화 포함) */
rv = quicly_send (ctx->conn, &dest, &src,
packets, &num_packets,
ctx->send_buf, sizeof(ctx->send_buf));
if (rv != 0 || num_packets == 0)
return 0;
/* 2. 생성된 패킷을 UDP TX FIFO에 인큐 */
for (size_t i = 0; i < num_packets; i++)
{
svm_fifo_enqueue (ctx->udp_session->tx_fifo,
packets[i].iov_len,
packets[i].iov_base);
}
/* 3. UDP 세션에 TX 이벤트 발생 → VPP가 패킷 전송 */
session_send_io_evt_to_thread (ctx->udp_session->tx_fifo,
SESSION_IO_EVT_TX);
return num_packets;
}
코드 분석: QUIC 패킷 송신 경로
quic_send_packets()는 quicly_send()를 호출하여 전송 대기 중인 모든 데이터(스트림 데이터, ACK, 흐름 제어 프레임 등)를 QUIC 패킷으로 만듭니다. quicly는 내부적으로 혼잡 윈도우를 확인하고, 패킷 번호를 부여하며, AEAD로 암호화합니다. 생성된 패킷은 struct iovec 배열로 반환되며, VPP는 이를 UDP TX FIFO에 일괄 인큐(Enqueue)합니다. 배치 처리(QUIC_MAX_PACKET_BATCH)를 통해 시스템 콜(System Call) 오버헤드를 최소화합니다.
QUIC 세션 API
QUIC은 연결(Connection)과 스트림(Stream)의 2계층 세션 모델을 사용합니다. VCL에서의 QUIC 사용:
/* QUIC 서버 — 연결 수락 + 스트림 수락 */
/* 1. QUIC 리스너 생성 */
int listener = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_bind (listener, &addr);
vppcom_session_listen (listener, 10);
/* 2. QUIC 연결 수락 (connection-level) */
int quic_conn = vppcom_session_accept (listener, &client_ep, 0);
/* 3. 스트림 수락 (stream-level, 실제 데이터 교환) */
int stream = vppcom_session_accept (quic_conn, &stream_ep, 0);
/* 4. 스트림에서 데이터 송수신 */
vppcom_session_read (stream, buf, sizeof(buf));
vppcom_session_write (stream, response, resp_len);
/* QUIC 클라이언트 — 연결 + 스트림 생성 */
int quic_conn = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_connect (quic_conn, &server_ep);
/* 동일 연결에 여러 스트림 생성 */
int stream1 = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_stream_connect (stream1, quic_conn);
QUIC 스트림 생명주기 소스 분석
QUIC 스트림은 QUIC 연결 내부에서 독립적인 데이터 채널 역할을 합니다. VPP에서 스트림의 생성부터 종료까지의 전체 생명주기를 소스 코드 수준에서 분석합니다.
스트림 생성 (quic_connect_stream)
클라이언트가 새 스트림을 열 때 quic_connect_stream()이 호출됩니다. 이 함수는 quicly 연결 위에 새 스트림 객체를 생성하고 VPP 세션과 연결합니다:
/* QUIC 스트림 연결 (클라이언트 → 서버) */
static int
quic_connect_stream (session_t *quic_session,
session_endpoint_cfg_t *sep)
{
quic_ctx_t *qctx, *sctx;
quicly_stream_t *stream;
session_t *stream_session;
int rv;
/* 상위 QUIC 연결 컨텍스트 조회 */
qctx = quic_ctx_get (quic_session->connection_index);
/* 1. quicly에 양방향 스트림 생성 요청 */
rv = quicly_open_stream (qctx->conn, &stream, 0 /* bidi */);
if (rv)
{
/* MAX_STREAMS 한도 초과 시 실패 */
return SESSION_E_REFUSED;
}
/* 2. VPP 스트림 컨텍스트 할당 */
sctx = quic_ctx_alloc (qctx->c_thread_index);
sctx->parent_ctx_id = qctx->ctx_id;
sctx->quicly_stream = stream;
sctx->conn_state = QUIC_CONN_STATE_STREAM_OPEN;
/* 3. VPP 세션 생성 및 FIFO 할당 */
stream_session = session_alloc (qctx->c_thread_index);
stream_session->session_type = SESSION_TYPE_QUIC_STREAM;
svm_fifo_alloc_pair (&stream_session->rx_fifo,
&stream_session->tx_fifo,
QUIC_FIFO_SIZE);
/* 4. quicly 스트림 콜백 등록 (데이터 수신 시 호출) */
stream->callbacks = &quic_stream_callbacks;
stream->data = sctx; /* VPP 컨텍스트 연결 */
/* 5. 애플리케이션에 연결 완료 알림 */
app_worker_connect_notify (stream_session);
return 0;
}
코드 분석: 스트림 생성 흐름
quic_connect_stream()의 핵심은 quicly 레벨 스트림과 VPP 세션 레벨 스트림을 양방향으로 연결하는 것입니다. quicly_open_stream()은 QUIC 프로토콜의 스트림 ID를 할당하고(클라이언트 시작 양방향 = 0, 4, 8, ...), MAX_STREAMS 한도를 확인합니다. 이후 VPP 세션을 할당하고 RX/TX FIFO 쌍을 만들어 애플리케이션과의 데이터 교환 통로를 설정합니다. stream->callbacks에 등록된 콜백은 quicly가 해당 스트림에 데이터를 수신할 때 자동으로 호출됩니다.
스트림 데이터 송수신
/* 스트림 수신: quicly → 앱 RX FIFO */
static int
quic_stream_rx (quicly_stream_t *stream, size_t off,
const void *src, size_t len)
{
quic_ctx_t *sctx = (quic_ctx_t *) stream->data;
session_t *stream_session;
stream_session = session_get (sctx->session_index);
/* quicly가 순서 재조립 완료한 데이터를 앱 FIFO에 인큐 */
svm_fifo_enqueue_with_offset (stream_session->rx_fifo,
off, len, src);
/* 앱에 RX 이벤트 알림 */
session_send_io_evt_to_thread (stream_session->rx_fifo,
SESSION_IO_EVT_RX);
return 0;
}
/* 스트림 송신: 앱 TX FIFO → quicly */
static int
quic_stream_tx (session_t *stream_session)
{
quic_ctx_t *sctx;
svm_fifo_t *tx_fifo = stream_session->tx_fifo;
u32 deq_len;
u8 *data;
sctx = quic_ctx_get (stream_session->connection_index);
deq_len = svm_fifo_max_dequeue (tx_fifo);
if (deq_len == 0)
return 0;
/* TX FIFO에서 데이터 읽기 */
vec_validate (data, deq_len - 1);
svm_fifo_dequeue (tx_fifo, deq_len, data);
/* quicly 스트림 버퍼에 데이터 추가 */
quicly_streambuf_egress_write (sctx->quicly_stream, data, deq_len);
/* 패킷 송신 스케줄링 */
quic_send_packets (sctx->parent_ctx);
return 0;
}
코드 분석: 스트림 데이터 경로
수신 방향에서 quic_stream_rx()는 quicly의 스트림 콜백으로 등록된 함수입니다. quicly가 STREAM 프레임을 복호화하고 순서를 재조립한 후 이 콜백을 호출합니다. off 파라미터는 스트림 내 바이트 오프셋(Offset)으로, 비순차 도착 패킷도 올바른 위치에 저장됩니다. 송신 방향에서 quic_stream_tx()는 앱이 TX FIFO에 쓴 데이터를 quicly_streambuf_egress_write()로 quicly 송신 버퍼에 전달하고, quic_send_packets()로 실제 UDP 패킷 생성을 트리거합니다.
스트림 상태 전이
QUIC 스트림은 RFC 9000에 정의된 상태 머신을 따릅니다. VPP에서 각 상태 전이는 conn_state 필드로 추적됩니다:
스트림 종료 및 오류 처리
/* 스트림 정상 종료 */
static void
quic_stream_close (quic_ctx_t *sctx)
{
/* 송신 방향 FIN 전송 */
quicly_streambuf_egress_shutdown (sctx->quicly_stream);
sctx->conn_state = QUIC_CONN_STATE_STREAM_HALF_CLOSED;
/* 수신 FIN도 받았으면 완전 종료 */
if (quicly_recvstate_transfer_complete (&sctx->quicly_stream->recvstate))
{
sctx->conn_state = QUIC_CONN_STATE_STREAM_CLOSED;
quic_stream_cleanup (sctx);
}
}
/* 스트림 리셋 (비정상 종료) */
static void
quic_stream_reset (quic_ctx_t *sctx, u64 error_code)
{
/* RESET_STREAM 프레임 전송 (즉시 종료) */
quicly_reset_stream (sctx->quicly_stream, error_code);
/* 세션 정리: FIFO 해제, 컨텍스트 반환 */
session_transport_reset_notify (sctx->session_index);
quic_stream_cleanup (sctx);
}
코드 분석: 스트림 종료
QUIC 스트림의 정상 종료는 양방향 FIN 교환으로 이루어집니다. quicly_streambuf_egress_shutdown()은 송신 방향을 닫아 STREAM 프레임에 FIN 비트를 설정합니다. 수신 방향도 FIN을 받으면 quicly_recvstate_transfer_complete()가 true를 반환하여 스트림이 완전히 종료됩니다. 비정상 종료 시 quicly_reset_stream()은 RESET_STREAM 프레임을 전송하여 상대방에게 오류 코드와 함께 스트림 즉시 종료를 통보합니다. 오류 코드는 H3_NO_ERROR(0), H3_REQUEST_CANCELLED(0x10c) 등 애플리케이션 프로토콜에 따라 달라집니다.
QUIC 연결 마이그레이션
QUIC의 가장 혁신적인 기능 중 하나가 연결 마이그레이션(Connection Migration)입니다. TCP는 (src_ip, src_port, dst_ip, dst_port) 4-tuple로 연결을 식별하므로 IP가 변경되면 연결이 끊어지지만, QUIC은 Connection ID로 연결을 식별하여 IP/포트 변경 시에도 연결을 유지합니다:
QUIC 흐름 제어와 혼잡 제어(Congestion Control)
QUIC은 TCP와 달리 2계층 흐름 제어를 제공합니다:
| 흐름 제어 레벨 | 프레임 | 설명 |
|---|---|---|
| 스트림 레벨 | MAX_STREAM_DATA | 개별 스트림의 수신 버퍼 한도 |
| 연결 레벨 | MAX_DATA | 전체 연결의 총 수신 데이터 한도 |
| 스트림 수 | MAX_STREAMS | 동시 활성 스트림 수 제한 |
VPP QUIC 흐름 제어 파라미터
quicly 내부에서 관리하는 주요 파라미터입니다:
initial_max_data- 연결 초기 수신 윈도우 (기본: 1MB)
initial_max_stream_data_bidi- 양방향 스트림 윈도우 (기본: 256KB)
initial_max_stream_data_uni- 단방향 스트림 윈도우
initial_max_streams_bidi- 최대 양방향 스트림 수 (기본: 100)
혼잡 제어는 quicly 기본 Reno + ECN을 지원하며, VPP 24.x부터 BBRv2 혼잡 제어 옵션이 추가되었습니다.
QUIC의 손실 감지(Loss Detection)는 TCP의 재전송(Retransmission) 메커니즘보다 정교합니다. 각 패킷에 고유한 번호가 부여되어(모호성 없음) ACK 기반의 정확한 RTT 측정과 빠른 손실 감지가 가능합니다. 또한 스트림별 독립 복구로 하나의 스트림 손실이 다른 스트림을 차단하지 않습니다(Head-of-Line blocking 해결).
quicly 혼잡 제어 알고리즘 상세
quicly는 기본적으로 Reno 혼잡 제어를 사용하며, VPP 24.x부터 BBRv2 옵션이 추가되었습니다. 두 알고리즘의 동작 방식과 적합한 사용 시나리오가 다릅니다:
| 항목 | Reno (기본) | BBRv2 |
|---|---|---|
| 동작 원리 | 패킷 손실 기반 (AIMD) | 대역폭(Bandwidth)·RTT 측정 기반 모델 |
| 혼잡 감지 | 패킷 손실 발생 시 | 대역폭 포화·큐잉 지연(Latency) 감지 |
| 슬로우 스타트 | 지수 증가 → 손실 시 절반 감소 | 대역폭 측정 후 모델 기반 전환 |
| 버퍼블로트(Bufferbloat) 대응 | 약함 (큐 가득 차야 감지) | 강함 (큐잉 지연 직접 측정) |
| 고손실 네트워크 | 성능 급감 | 안정적 처리량(Throughput) 유지 |
| CPU 오버헤드(Overhead) | 낮음 | 중간 (RTT 샘플링·모델 갱신) |
| 적합 시나리오 | LAN, 저지연 데이터센터 | WAN, 위성 링크, 모바일 네트워크 |
# startup.conf — 혼잡 제어 알고리즘 선택
quic {
# 기본 Reno 혼잡 제어 (명시적 지정 불필요)
cc-algorithm reno
# BBRv2 혼잡 제어 활성화 (VPP 24.x+)
# cc-algorithm bbr
}
# CLI에서 런타임 확인
vpp# show quic connections verbose
# 출력에 cc_algorithm, cwnd, ssthresh, bytes_in_flight 표시
패킷 손실 감지 메커니즘
quicly의 손실 감지는 RFC 9002에 기반하여 두 가지 메커니즘을 병행합니다:
- ACK 기반 손실 감지
- 수신측이 보낸 ACK 프레임을 분석하여, ACK된 가장 큰 패킷 번호보다
kPacketThreshold(기본 3) 이상 작은 번호의 미확인 패킷을 손실로 판정합니다. QUIC은 패킷 번호가 절대 재사용되지 않으므로 TCP의 재전송 모호성(Retransmission Ambiguity) 문제가 없습니다. - PTO(Probe Timeout) 기반 감지
- 일정 시간 동안 ACK를 받지 못하면 프로브(Probe) 패킷을 전송하여 경로 상태를 확인합니다. PTO는
smoothed_rtt + max(4 * rttvar, 1ms) + max_ack_delay로 계산됩니다. TCP의 RTO(Retransmission Timeout)보다 정밀한 RTT 측정이 가능합니다.
/* quicly 손실 감지 핵심 로직 (의사코드) */
static void
quicly_loss_on_ack_received (quicly_loss_t *loss,
u64 largest_acked,
int64_t now)
{
quicly_sent_packet_t *sent;
/* ACK 기반 손실 판정 */
vec_foreach (sent, loss->sent_packets)
{
if (sent->acked)
continue;
/* 패킷 번호 임계값 초과 확인 */
if (largest_acked - sent->pn >= QUICLY_LOSS_PACKET_THRESHOLD)
{
quicly_loss_mark_lost (loss, sent);
continue;
}
/* 시간 임계값 초과 확인 */
int64_t time_threshold = loss->smoothed_rtt * 9 / 8;
if (now - sent->sent_at > time_threshold)
quicly_loss_mark_lost (loss, sent);
}
/* 혼잡 제어 업데이트 */
loss->cc->on_loss_detected (loss->cc, loss->num_lost);
}
코드 분석: 손실 감지
QUIC의 손실 감지는 패킷 번호 임계값과 시간 임계값을 모두 사용합니다. 패킷 번호 기반 판정은 ACK된 가장 큰 번호와 3 이상 차이나는 미확인 패킷을 즉시 손실로 처리합니다. 시간 기반 판정은 smoothed_rtt * 9/8을 초과하여 미확인 상태인 패킷을 손실로 처리합니다. 손실이 감지되면 혼잡 제어의 on_loss_detected 콜백이 호출되어 Reno의 경우 cwnd를 절반으로 줄이고, BBR의 경우 대역폭 모델을 업데이트합니다.
HTTP/3 over VPP QUIC
HTTP/3는 QUIC 위에서 동작하는 HTTP의 차세대 버전으로, VPP의 QUIC 플러그인 위에 구현할 수 있습니다:
HTTP/3 프로토콜 스택 (VPP 환경)
┌────────────────────────────────┐
│ HTTP/3 (QPACK 헤더 압축) │ ← HTTP 시맨틱
├────────────────────────────────┤
│ QUIC Streams (다중화) │ ← 스트림별 독립 전송
├────────────────────────────────┤
│ QUIC Connection (quicly) │ ← TLS 1.3 내장, 혼잡 제어
├────────────────────────────────┤
│ UDP (TRANSPORT_PROTO_UDP) │ ← 포트 443 (IANA 표준)
├────────────────────────────────┤
│ VPP ip4/ip6 → DPDK/AF_XDP │ ← 유저스페이스 네트워크
└────────────────────────────────┘
주요 차이: HTTP/2 vs HTTP/3
• HTTP/2: TCP 위 TLS → HoL blocking 존재
• HTTP/3: QUIC 위 → 스트림별 독립 → HoL blocking 해결
• 서버 푸시, 우선순위, 헤더 압축은 동일 시맨틱
QUIC 설정과 활성화
# startup.conf — QUIC 플러그인 활성화
plugins {
plugin quic_plugin.so { enable }
}
quic {
# 최대 동시 연결 수
max-connections 100000
# 연결당 최대 스트림 수
max-streams-per-connection 100
# 0-RTT 활성화
enable-0rtt
# 유휴 타임아웃 (초)
idle-timeout 60
}
QUIC vs TCP+TLS 성능 비교
VPP 환경에서 QUIC과 TCP+TLS의 주요 차이점:
| 항목 | TCP + TLS 1.3 | QUIC |
|---|---|---|
| 연결 수립 | 2-RTT (TCP + TLS) | 1-RTT (통합 핸드셰이크) |
| 재연결 | 2-RTT | 0-RTT (PSK) |
| HoL Blocking | 있음 (TCP 순서 보장(Ordering)) | 없음 (스트림별 독립) |
| 다중화 | 불가 (HTTP/2로 해결) | 네이티브 스트림 다중화 |
| 연결 마이그레이션 | 불가 | Connection ID 기반 가능 |
| 패킷 손실 복구 | TCP 재전송 (느림) | 스트림별 독립 복구 (빠름) |
| VPP 처리량 | ~40 Gbps (단일 워커) | ~25 Gbps (단일 워커) |
| CPU 오버헤드 | 낮음 | 중간 (UDP+QUIC 레이어) |
| HW 오프로드 | QAT, NIC Crypto | 제한적 (UDP checksum만) |
QUIC 0-RTT / 1-RTT 타이밍 상세 분해
"QUIC은 0-RTT다"라는 말은 절반만 맞습니다. 실제로는 연결 상태에 따라 세 가지 모드가 존재하며, 각각의 유효 지연 시간이 다릅니다. VPP QUIC 플러그인이 이 세 모드를 어떻게 선택하는지와, TCP+TLS 1.3과의 정확한 타이밍 차이는 아래 표로 정리됩니다(RTT=20ms 기준).
| 시나리오 | TCP+TLS 1.3 지연 | QUIC 지연 | QUIC 모드 | 선결 조건 |
|---|---|---|---|---|
| 신규 연결 | 2×RTT + TLS = 40 ms | 1×RTT = 20 ms | Initial + Handshake | — |
| 재연결 (PSK 보유) | 2×RTT = 40 ms | 1×RTT = 20 ms | 1-RTT resumption | session ticket 캐시(Cache) |
| 재연결 + 멱등 요청 | 2×RTT = 40 ms | 0×RTT + early = 0 ms* | 0-RTT | GET/HEAD 등 멱등 |
| 재연결 + POST | 2×RTT = 40 ms | 1×RTT = 20 ms | 1-RTT 강제 | POST는 0-RTT 거부 |
| 네트워크 변경 (NAT rebind) | 연결 리셋 + 2×RTT = 40+α ms | 0 ms | Connection Migration | Connection ID 유지 |
* 0-RTT의 "0 ms"는 첫 요청 바이트가 즉시 송신된다는 의미이며, 응답이 돌아오는 시간(1×RTT)은 별도입니다.
/* VPP QUIC 플러그인 — 0-RTT 허용 판정 (개념 코드) */
static int
quic_allow_early_data (quic_ctx_t *ctx, http_request_t *req)
{
if (!ctx->session_ticket || ctx->session_ticket_expired)
return 0; /* PSK 없음 → 1-RTT로 강제 */
if (!ctx->early_data_allowed)
return 0; /* 서버가 max_early_data_size=0 전달 */
/* 멱등 메서드만 0-RTT 허용 — replay 공격 방어 */
if (req->method != HTTP_GET &&
req->method != HTTP_HEAD &&
req->method != HTTP_OPTIONS)
return 0;
/* anti-replay 윈도우 내 중복 여부 확인 */
if (anti_replay_cache_check (ctx->ticket_nonce))
return 0; /* 이미 본 nonce → 거부 */
return 1;
}
실전 결정: 모바일 클라이언트 위주 서비스에서 0-RTT의 실효 절감은 평균 8~15%에 불과합니다(캐시 hit율, 멱등 요청 비율이 낮기 때문). 반면 Connection Migration은 모바일 이동 시 끊김 없는 전환을 제공해 체감 품질 개선이 훨씬 큽니다. VPP QUIC 튜닝 우선순위(Priority)는 ① Connection ID·migration 지원 → ② 1-RTT resumption → ③ 0-RTT 순서로 두는 것이 합리적입니다.
QUIC 디버깅 및 트러블슈팅
VPP QUIC 환경에서 문제를 진단하고 해결하기 위한 CLI 명령, 이벤트 로그, 일반적인 문제 패턴을 정리합니다.
QUIC 상태 확인 CLI 명령
# QUIC 연결 목록 조회
vpp# show quic connections
# 출력: conn_id, state, streams, rx/tx bytes, rtt
# QUIC 연결 상세 정보
vpp# show quic connections verbose
# 출력: 혼잡 윈도우, ssthresh, bytes_in_flight, 스트림 상세
# 활성 스트림 목록
vpp# show quic streams
# 출력: stream_id, state, tx/rx offset, flow_control_limit
# QUIC 플러그인 통계
vpp# show errors
# quic-input, quic-output 노드의 오류 카운터 확인
# QUIC 패킷 트레이싱
vpp# trace add session-queue 100
vpp# show trace
# QUIC 패킷의 수신/처리/송신 경로 확인
# UDP 레벨 패킷 트레이싱 (QUIC 하부 전송)
vpp# trace add udp-input 100
vpp# trace add udp-output 100
quicly 이벤트 로그 활용
quicly는 내부적으로 상세한 이벤트 로그를 생성할 수 있습니다. VPP에서 이를 활성화하면 QUIC 프로토콜 수준의 문제를 세밀하게 분석할 수 있습니다:
# startup.conf — quicly 이벤트 로깅 활성화
quic {
# 이벤트 로그 파일 경로
event-log /tmp/quicly-events.json
# 로그 레벨: packet, cc(혼잡 제어), loss(손실), stream
event-log-level packet
}
# quicly 이벤트 로그 분석 (JSON 형식)
# 연결 수립 이벤트
cat /tmp/quicly-events.json | jq '.[] | select(.type == "connect")'
# 패킷 손실 이벤트 필터링
cat /tmp/quicly-events.json | jq '.[] | select(.type == "packet-lost")'
# 혼잡 윈도우 변화 추적
cat /tmp/quicly-events.json | jq '.[] | select(.type == "cc-cwnd-update")'
# qvis (QUIC 시각화 도구)로 분석
# https://qvis.quictools.info/ 에서 JSON 파일 업로드
일반적인 QUIC 문제 및 해결
| 문제 | 증상 | 진단 방법 | 해결책 |
|---|---|---|---|
| 핸드셰이크 실패 | 연결 수립 불가, 타임아웃 | show errors에 quic-handshake-fail | 인증서 경로·유효기간 확인, TLS 1.3 지원 여부 점검 |
| 0-RTT 거부 | 0-RTT 데이터가 무시됨 | 이벤트 로그에 early-data-rejected | 서버의 PSK 캐시 유효기간 확인, ALPN 일치 여부 점검 |
| 스트림 생성 실패 | MAX_STREAMS 초과 오류 | show quic connections verbose에서 스트림 수 확인 | max-streams-per-connection 설정 증가 |
| 처리량 저하 | 예상보다 낮은 대역폭 | show quic connections verbose에서 cwnd 확인 | 혼잡 제어 알고리즘 변경(BBR), 초기 윈도우 크기 조정 |
| 연결 마이그레이션 실패 | IP 변경 시 연결 끊김 | 이벤트 로그에 path-validation 실패 | 방화벽(Firewall)에서 새 경로의 UDP 트래픽 허용 |
| 과도한 패킷 손실 | 높은 재전송률 | 이벤트 로그에 packet-lost 빈도 확인 | 네트워크 경로 점검, MTU 설정 확인(PMTUD) |
| 메모리 증가 | QUIC 세션 수 대비 과다 메모리 | show memory verbose로 quic 힙 확인 | idle-timeout 줄이기, 비활성 연결 정리 |
| UDP 블로킹 | QUIC 패킷이 도달하지 않음 | show interface에서 UDP 드롭 확인 | 방화벽·NAT에서 UDP 443 포트 허용 |
QUIC 성능 진단 절차
# 1. 기본 상태 확인
vpp# show version
vpp# show quic connections
vpp# show quic streams
# 2. 혼잡 제어 상태 확인
vpp# show quic connections verbose
# cwnd, ssthresh, bytes_in_flight 값 확인
# cwnd가 비정상적으로 작으면 손실 과다 의심
# 3. 세션 FIFO 상태 확인
vpp# show session verbose
# RX/TX FIFO 사용률이 100%에 가까우면 병목
# 4. 오류 카운터 확인
vpp# show errors
# quic-input, quic-output 노드별 오류 확인
# 5. 패킷 경로 트레이싱
vpp# trace add session-queue 50
vpp# show trace
# 패킷이 어느 노드에서 드롭되는지 확인
# 6. 워커 스레드별 부하 확인
vpp# show runtime
# quic 관련 노드의 vectors/call, clocks/vector 확인
ping과 간단한 UDP 에코 테스트로 UDP 연결성을 확인하는 것이 좋습니다. 또한 quicly의 이벤트 로그를 qvis 도구(https://qvis.quictools.info/)에 업로드하면 패킷 타임라인, 혼잡 윈도우 변화, 스트림별 데이터 흐름을 시각적으로 분석할 수 있습니다.
HTTP/3 실전 — ALPN 협상, 0-RTT, VPP 25.10 제약
HTTP/3는 HTTP/2의 의미를 거의 그대로 유지한 채 전송 계층만 TCP에서 QUIC로 바꾼 것입니다. 그러나 이 한 가지 변화가 HOL 블로킹·연결 마이그레이션·0-RTT 재개 같은 굵직한 개선을 가져옵니다. FD.io VPP 25.10은 quicly 라이브러리 위에 HTTP/3 어댑터를 얹은 실험적 지원을 제공하며, 이 절은 실전 배포 관점에서 마주치는 ALPN 협상, 0-RTT, 현재 제약을 정리합니다.
ALPN 협상 — h2 vs h3 vs 둘 다
같은 호스트가 HTTP/2와 HTTP/3를 모두 지원할 때, 클라이언트는 어느 쪽을 쓸지 어떻게 결정할까요. 두 단계가 함께 작동합니다.
- Alt-Svc 헤더 또는 HTTPS DNS 레코드로 클라이언트는 "이 서버가 HTTP/3를 지원한다"는 사실을 학습합니다.
- 다음 요청부터 클라이언트는 UDP/443으로 QUIC Initial 패킷을 보내면서 TLS 1.3 ClientHello의 ALPN 확장에
h3를 넣습니다. - 서버가 ALPN으로
h3를 선택하면 HTTP/3로 협상이 끝납니다. UDP 경로가 막혀 있으면 클라이언트는 즉시 TCP/443로 폴백하고 ALPN으로h2를 받습니다.
0-RTT 재개 — 빠르지만 위험한 지름길
QUIC의 0-RTT는 TLS 1.3의 PSK(pre-shared key)를 활용해, 핸드셰이크 완료를 기다리지 않고 첫 패킷에 HTTP 요청을 함께 실어 보내는 기능입니다. 한 번이라도 같은 서버와 정상 핸드셰이크를 한 적이 있어야 합니다. 효과는 RTT 1회 절감이며, 모바일 환경에서 체감 지연이 크게 줄어듭니다.
| 0-RTT 적용 | 안전한 메서드 | 금지 메서드 |
|---|---|---|
| ✅ 권장 | GET (멱등, 캐시 가능) | — |
| ⚠️ 주의 | HEAD, OPTIONS | — |
| ❌ 금지 | — | POST, PUT, DELETE 등 부수효과 있는 메서드 |
금지 이유는 리플레이(replay) 공격입니다. 0-RTT 데이터는 같은 PSK로 중간자가 그대로 재전송할 수 있어, "결제 실행" 같은 비멱등 요청이 두 번 일어날 수 있습니다. RFC 9001은 0-RTT에 대해 애플리케이션이 명시적 anti-replay 방어(서버측 nonce 캐시)를 두지 않는 한 비멱등 메서드를 허용해서는 안 된다고 못 박습니다.
/* 0-RTT 허용 정책: GET만 통과 */
static int
h3_validate_0rtt_request (http_msg_t *msg)
{
if (msg->method != HTTP_METHOD_GET && msg->method != HTTP_METHOD_HEAD)
return -1; /* 425 Too Early로 응답 */
/* 헤더에 If-None-Match 있으면 캐시 검증으로 안전 */
return 0;
}
/* quicly 콜백: 0-RTT 데이터 도착 시 호출 */
static int
h3_on_early_data (quicly_conn_t *qc, ptls_iovec_t data)
{
http_msg_t msg;
if (http3_parse_headers (data, &msg) < 0) return -1;
if (h3_validate_0rtt_request (&msg) < 0)
return h3_send_status (qc, 425, "Too Early");
return h3_dispatch_request (qc, &msg);
}
VPP 25.10 HTTP/3 현재 제약
| 기능 | 상태 | 비고 |
|---|---|---|
| QUIC 전송 (quicly) | ✅ 안정 | 클라이언트·서버 모두 |
| HTTP/3 어댑터 | 🟡 실험적 | http3_plugin (out-of-tree) |
| QPACK | 🟡 부분 | 정적 테이블 위주, 동적 테이블 제한적 |
| 0-RTT | 🟡 실험적 | anti-replay 정책 수동 구성 필요 |
| 연결 마이그레이션 | 🟡 실험적 | NAT rebinding 한정 검증 |
| Datagram 확장 (RFC 9221) | ❌ 미지원 | WebTransport 의존 기능 불가 |
| Multipath QUIC | ❌ 미지원 | 표준화 진행 중 |
CLI 예시 — HTTP/3 활성화와 관찰
# 1) QUIC 플러그인 로드
vppctl show plugins | grep -E '(quic|http3)'
# 2) HTTP/3 서버 활성화 (가상 CLI)
vppctl http3 server enable uri quic://0.0.0.0/443 \
cert /etc/vpp/cert.pem key /etc/vpp/key.pem \
enable-0rtt allowed-methods GET,HEAD
# 3) Alt-Svc 광고를 위한 HTTP/2 서버도 같이 실행
vppctl http server enable uri tls://0.0.0.0/443 \
alt-svc 'h3=":443"; ma=86400'
# 4) curl로 HTTP/3 강제 요청
curl --http3 -k https://192.0.2.10/
# 5) QUIC 통계
vppctl show quic sessions
vppctl show quic stats
vppctl show http3 streams
Common Pitfalls
- UDP/443 차단 — 많은 기업 방화벽이 UDP/443을 막습니다. 클라이언트가 폴백할 수 있도록 반드시 TCP/443 HTTP/2를 함께 운영하고, Alt-Svc의
ma(max-age)를 짧게 잡아 잘못된 경로 학습을 빨리 만료시켜야 합니다. - 0-RTT를 모든 경로에 적용 — 결제·생성·삭제 API에 0-RTT를 허용하면 리플레이 사고가 납니다. 서버 측에서 메서드 화이트리스트와 nonce 캐시를 함께 두어야 합니다.
- QPACK 동적 테이블 미스매치 — 인코더와 디코더 스트림이 별도로 흐르기 때문에 순서가 어긋나면 헤더 블록이 잠깁니다. 단순한 구현이라면 정적 테이블만 쓰는 편이 안전합니다.
- 방화벽 NAT 타임아웃 — UDP는 TCP보다 NAT 매핑(Mapping) 수명이 훨씬 짧습니다(보통 30초). 클라이언트가 PING을 자주 보내거나, 서버가 연결 마이그레이션을 허용해 새 5튜플로 살아남을 수 있어야 합니다.
- 실험적 HTTP/3로 SLO 약속 — VPP 25.10의 HTTP/3는 아직 실험 단계입니다. 가용성 99.99% SLO를 약속하기 전에 quicly 업스트림 이슈와 VPP 통합 패치(Patch) 상태를 분기마다 점검해야 합니다.
Connection Migration 운영
QUIC의 대표 기능인 Connection Migration은 클라이언트의 IP·포트가 바뀌어도 Connection ID(CID)로 같은 연결을 이어갑니다. TCP와 달리 5튜플 변경에 무관하게 세션이 유지되므로 모바일 셀 전환, Wi-Fi↔LTE 핸드오버, NAT 재바인딩에 유리합니다. 그러나 실제 운영에서는 CID 관리, 경로 검증(Path Validation), DoS 완화의 세 가지 축을 모두 튜닝해야 합니다.
PATH_CHALLENGE/PATH_RESPONSE 프레임을 교환해 경로 도달성을 검증한 뒤, amplification limit(3배 제한)을 두고 트래픽을 옮겨갑니다. 서버는 active_connection_id_limit 만큼 예비 CID를 미리 발급해 두어야 하며, 이 값이 작으면 마이그레이션 시 CID 고갈로 연결이 끊어집니다.
시나리오 1: NAT 재바인딩
UDP NAT 매핑은 흔히 30~60초 만에 만료됩니다. 클라이언트가 PING 프레임을 보내지 않거나 마지막 트래픽 이후 시간이 지나면, NAT 게이트웨이는 새 외부 포트를 재할당합니다. 서버에서 관찰되는 증상은 기존 CID가 다른 5튜플에서 도착하는 것입니다.
- 감지:
show quic sessions verbose에서migration_count > 0,active_path의 5튜플이 초기값과 다름. - 튜닝: 클라이언트 측
max_idle_timeout을 NAT 타임아웃보다 작게(권장 20초), 서버 측preferred_address를 사용해 최초 선호 경로를 명시. - 주의: 다수 클라이언트가 동시에 NAT 재바인딩을 겪으면 서버 CPU에 spike가 발생합니다. path validation은 경로당 1회이지만 해시 테이블 lookup과 amplification 카운터 재설정 비용이 누적됩니다.
시나리오 2: 모바일 셀 · Wi-Fi↔LTE 핸드오버
스마트폰이 Wi-Fi에서 LTE로 전환되는 순간 커널 소켓은 EHOSTUNREACH 또는 ENETUNREACH를 받습니다. QUIC 스택은 새 인터페이스에서 같은 CID로 QUIC 패킷을 재전송하고, 서버는 마이그레이션을 감지해 active path를 교체합니다. 관찰 가능한 지표:
| 지표 | 정상 범위 | 이상 신호 |
|---|---|---|
| Migration 완료 시간 | < 200ms (1-RTT + path validation) | > 1s — 패킷 손실 또는 스로틀링 |
| Path validation 실패율 | < 1% | > 5% — 새 경로의 MTU/방화벽 이슈 |
| CID 고갈 이벤트 | 0 | > 0 — active_connection_id_limit 증가 필요 |
Amplification 공격 방어
Connection Migration은 DoS 증폭 공격의 벡터가 될 수 있습니다. 공격자가 피해자의 주소로 위조된 마이그레이션 시도를 보내면, 서버는 그 경로로 응답을 쏟아냅니다. QUIC 표준(RFC 9000 §8)은 이를 막기 위해 서버가 검증되지 않은 경로로는 클라이언트가 보낸 바이트의 3배 이상을 보낼 수 없다고 규정합니다.
path_challenge_bytes카운터가 3배 한도에 자주 부딪힌다면 정상 마이그레이션이 느려지고 있다는 신호입니다.- VPP quicly 통합에서는
vpp-quic-amp-limit-hit이벤트로 노출되며, stats segment의/quic/path_validation/amp_limited카운터로 확인할 수 있습니다. - 방어: 높은 신뢰성이 요구되는 서비스에서는
disable_active_migrationtransport parameter로 능동 마이그레이션을 전면 차단하는 것이 안전합니다(모바일 앱은 권장하지 않음).
0-RTT 재개와 재전송 방어
0-RTT는 이전 세션에서 서버가 발급한 session ticket을 이용해 첫 패킷과 함께 응용 데이터를 전송하는 기능입니다. 지연이 줄어드는 대가로 재전송 공격(replay attack)에 노출되는데, 공격자가 0-RTT 데이터를 캡처해 재생하면 서버가 같은 요청을 두 번 처리할 수 있습니다. TLS 1.3과 QUIC은 공통적으로 "0-RTT는 idempotent 요청에만 쓸 것"을 권고합니다.
Replay Window 설계
VPP quicly는 기본적으로 anti-replay를 활성화합니다. 세 가지 보호 계층이 있습니다.
- Single-use ticket: 각 session ticket은 한 번만 사용 가능. quicly의
ticket_file에 사용 기록이 저장되고, 중복 사용 시 full handshake로 폴백합니다. - Time-bound: Ticket lifetime은 기본 7200초. 이 창(window) 밖의 티켓은 거절합니다.
- Request class filter: HTTP 어댑터 레벨에서
GET·HEAD·OPTIONS와 일부PUT(ETag/If-Match 조건부)만 0-RTT 수락.POST는 1-RTT로 강제 연기.
vpp-quic-ticket-store를 Redis 같은 공유 저장소로 연결하거나, 세션 스티키니스를 보장해야 합니다.
애플리케이션 계약
VPP HTTP 어댑터는 0-RTT에서 도착한 요청의 X-VPP-Early-Data: 1 헤더를 설정합니다. 애플리케이션은 이 헤더가 있는 요청이 재전송될 수 있음을 가정하고 다음 규칙을 따라야 합니다.
- 멱등성 보장: 같은 요청이 2회 실행되어도 결과가 같아야 함.
- 부작용 금지: 결제, 이메일 발송, 재고 차감 같은 부작용은 0-RTT에서 금지.
425 Too Early로 거절하거나 1-RTT에서 재시도시키기. - Idempotency key:
Idempotency-Key헤더가 있으면 재전송을 안전하게 감지 — VPP HTTP 어댑터가 이 키를 세션 상태로 캐시.
0-RTT 모니터링 지표
운영에서 추적해야 할 카운터:
| 카운터 | 의미 | 권장 임계 |
|---|---|---|
/quic/0rtt/accepted | 0-RTT 데이터를 수락한 수 | 전체 연결의 20~60% |
/quic/0rtt/rejected_replay | anti-replay로 거절한 수 | 가급적 0, 1% 초과 시 알람 |
/quic/0rtt/rejected_expired | ticket 만료로 거절 | 정상 — 튜닝 대상 아님 |
/http/early_data/425_response | 애플리케이션이 0-RTT 거절한 수 | 설계 의도에 따름 |
VPP 25.10 → 26.02 변경 요약 (QUIC · HTTP/3)
본 문서의 기준 버전은 VPP v26.02(2026-02-25 릴리스)입니다. 앞 절에서 언급한 "VPP 25.10 제약" 일부는 26.02에서 개선되었으므로 운영 판단이 달라질 수 있습니다. 아래 표는 공식 26.02 릴리스 노트에 명시된 HTTP/3·QUIC 관련 변경을 추려 운영 관점에서 재정리한 것입니다.
| 영역 | VPP 25.10 | VPP 26.02 | 운영 영향 |
|---|---|---|---|
| HTTP/3 코어 | quicly 기반 얇은 h3 어댑터(실험) | H3 core skeleton과 H3 framing layer 도입 — HTTP/3 프레이밍을 VPP http 플러그인 내에서 처리하는 1급 경로가 생겼습니다. | h3 요청 파싱·상태 기계가 VPP 측에 있으므로, 호스트 스택 세션 훅(rx_callback, 통계, ACL)을 h3 트래픽에도 그대로 적용할 수 있습니다. |
| HTTP/3 클라이언트 | 사실상 없음(테스트 경로만) | H3 client side 공식 지원 | VPP 자체를 HTTP/3 클라이언트로 사용하는 프로브·헬스체크·proxy origin 호출이 가능해졌습니다. |
| QPACK | 미지원 | QPACK encoder/decoder 도입(요청·응답 모두). 단, static table 전용으로 dynamic table은 아직 없습니다. | 상호운용성은 확보되지만 동적 테이블 기반 고압축은 불가합니다. 대역폭 감축 기대치는 h2 HPACK 대비 소폭입니다. |
| HTTP CONNECT 프록시 | 없음(HTTP/2 extended CONNECT 서버 측만) | 호스트 스택에 HTTP CONNECT proxy client 추가 | VPP가 upstream proxy를 CONNECT로 경유하는 outbound 경로(기업망·사이드카)를 직접 구성할 수 있습니다. |
| HTTP 클라이언트 redirect | 수동 처리 필요 | HTTP 클라이언트에 basic redirect 지원 | 3xx 응답을 따라가는 프로브·파일 다운로드 시 애플리케이션 단순화가 가능합니다. |
| DPDK/RDMA 토대 | DPDK 25.07 + rdma-core 58.0 | DPDK 25.11 + rdma-core 60.0 | QUIC 가속 오프로드(UDP GSO/GRO)와 관련한 PMD 옵션 정비. 업그레이드 시 DPDK 25.11 deprecated 옵션을 점검해야 합니다. |
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하시기 바랍니다.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| VPP (Vector Packet Processing) | FD.io contributors | Apache License 2.0 | github.com/FDio/vpp |
| DPDK (Data Plane Development Kit) | DPDK contributors | BSD 3-Clause License | github.com/DPDK/dpdk |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.