---
title: "Cloudflare가 잡아낸 QUIC CUBIC 버그, ‘idle’ 한 줄 오판이 다운로드를 죽였다"
published: 2026-05-12T23:46:28.000Z
canonical: https://jeff.news/article/2610
---
# Cloudflare가 잡아낸 QUIC CUBIC 버그, ‘idle’ 한 줄 오판이 다운로드를 죽였다

Cloudflare의 QUIC 구현체 quiche에서 CUBIC 혼잡 제어가 최소 윈도우에 갇혀 회복하지 못하는 버그가 발견됐다. Linux 커널의 idle 최적화를 QUIC에 옮기는 과정에서 TCP와 QUIC의 이벤트 타이밍 차이를 놓쳤고, 결국 ACK 시점을 기준으로 idle 시간을 재도록 고쳐 100% 테스트 통과를 회복했다.

## 60% 확률로 실패하는 이상한 HTTP/3 테스트

- Cloudflare의 QUIC 구현체 quiche에서 CUBIC 혼잡 제어가 최소 전송량에 갇히는 버그가 나옴
  - CUBIC은 Linux 기본 혼잡 제어 알고리즘이고, TCP뿐 아니라 Cloudflare의 QUIC 구현에서도 기본값으로 쓰임
  - 문제 상황은 10MB 파일을 HTTP/3로 다운로드하는 통합 테스트에서 발견됨
  - 테스트 조건은 localhost, RTT 10ms, 시작 후 2초 동안 30% 랜덤 패킷 손실, 이후 손실 0%

- 정상이라면 초반 손실 때문에 cwnd가 줄었다가, 손실이 멈춘 뒤 다시 커져서 4~5초 안에 다운로드가 끝나야 함
  - 그런데 100회 반복 테스트에서 약 60%가 10초 타임아웃을 넘김
  - 손실은 2초 이후 완전히 사라졌는데도, 전송량이 회복되지 않음
  - Reno로 같은 테스트를 돌리면 100% 통과해서 CUBIC 쪽 문제로 좁혀짐

> [!IMPORTANT]
> 핵심 수치는 꽤 세다. 손실이 끝난 뒤에도 cwnd가 2700바이트, 즉 풀사이즈 패킷 2개 수준에 고정됐고, 상태 전환은 6.7초 동안 999번 발생함.

## ‘idle’이라고 생각했는데 사실은 congestion limited였음

- qlog를 까보니 CUBIC 상태가 recovery와 congestion avoidance 사이를 RTT마다 왔다 갔다 함
  - 전환 주기는 약 14ms였고, 설정된 RTT 10ms와 매우 가까웠음
  - 다운로드 방향은 서버에서 클라이언트라, 클라이언트 ACK가 서버에 도착할 때마다 서버 쪽 CUBIC 상태 머신이 움직임
  - cwnd가 2패킷까지 줄어든 상태에서는 ACK가 도착하면 bytes_in_flight가 0이 되고, 서버가 곧바로 다음 2패킷을 쏘는 패턴이 반복됨

- 문제의 출발점은 Linux 커널 CUBIC의 idle 최적화였음
  - CUBIC은 epoch_start를 기준으로 성장 곡선을 계산함
  - 앱이 한동안 idle이었다가 다시 보내면 now - epoch_start가 너무 커져서 cwnd가 비정상적으로 커질 수 있음
  - Linux에서는 idle 시간만큼 epoch를 앞으로 밀어 성장 곡선 모양은 유지하되, 갑자기 폭증하지 않게 처리함

- quiche는 이 아이디어를 QUIC에 옮기면서 on_packet_sent() 안에서 bytes_in_flight == 0이면 idle로 판단했음
  - TCP 커널에는 CA_EVENT_TX_START 같은 이벤트가 있지만, QUIC user space 구현에는 같은 방식의 콜백이 없음
  - 그래서 “보내기 직전에 비행 중인 바이트가 0이면 idle에서 재개한 것”이라는 근사 로직을 쓴 셈
  - 이 근사가 cwnd 최소값 근처에서는 완전히 다른 의미가 됨

```mermaid
sequenceDiagram
    participant 서버 as QUIC 서버
    participant CUBIC as CUBIC 상태머신
    participant 클라이언트 as QUIC 클라이언트
    participant 네트워크 as 손실 없는 네트워크
    서버->>클라이언트: 2패킷 전송
    클라이언트->>서버: ACK 도착
    서버->>CUBIC: bytes_in_flight = 0
    CUBIC->>CUBIC: idle로 오판하고 recovery 기준 시간을 미래로 이동
    서버->>클라이언트: 다음 2패킷 전송
    CUBIC->>서버: 아직 recovery라고 판단해 cwnd 성장 스킵
```

## death spiral이 도는 방식

- 최소 cwnd에서는 매 RTT마다 파이프가 완전히 비는 것처럼 보임
  - 서버가 2패킷을 보내고, 한 RTT 뒤 ACK를 받으면 bytes_in_flight가 0이 됨
  - 바로 다음 패킷을 보내는 순간 on_packet_sent()는 이걸 “idle 후 재개”로 해석함
  - 하지만 실제로는 앱이 쉰 게 아니라 cwnd가 너무 작아서 congestion limited 상태였던 것

- 기존 코드는 idle 시간을 now - last_sent_time으로 계산함
  - cwnd가 2패킷이면 last_sent_time은 이전 RTT 사이클의 시작 시각에 가까움
  - 그래서 실제 idle은 거의 0에 가까운데, 계산된 idle delta는 RTT에 가까운 14ms가 됨
  - 이 부풀려진 delta가 recovery start time을 계속 미래로 밀어버림

- recovery start time이 미래로 가면 CUBIC은 계속 “아직 recovery 중”이라고 착각함
  - recovery 중인 패킷에 대해서는 cwnd 성장을 건너뜀
  - cwnd가 안 커지니 다음 ACK 때도 bytes_in_flight가 다시 0이 됨
  - 그 결과 같은 루프가 수천 번 반복되고, 다운로드는 타임아웃까지 질질 끌림

> [!NOTE]
> 이 버그가 연결 시작부터 터지지 않은 이유도 중요함. slow start 단계에서는 CUBIC 곡선과 recovery 기준 시간이 아직 본격적으로 작동하지 않아서, 실제 손실로 recovery boundary가 잡힌 뒤 congestion avoidance에 들어가야 함정이 완성됨.

## 수정은 작았지만 원인 찾기가 빡셌음

- 해결책은 idle 시간을 마지막 송신 시각만 보지 말고, 마지막 ACK 시각까지 고려하는 것임
  - quiche는 CUBIC 상태에 last_ack_time을 추가함
  - 패킷을 보낼 때 idle_start를 last_ack_time과 last_sent_time 중 더 나중 시각으로 잡음
  - 이렇게 하면 cwnd가 작아 ACK 직후 다시 보내는 상황에서 RTT 전체를 idle로 착각하지 않음

- 진짜 idle 연결에 대한 기존 최적화는 유지됨
  - 앱이 실제로 오래 쉬었다면 last_ack_time도 과거에 머물러 있으니 idle duration을 정상적으로 반영함
  - 반대로 ACK를 막 받고 바로 보내는 경우에는 idle gap이 거의 0으로 계산됨
  - Cloudflare는 이 수정 뒤 quiche 테스트 스위트가 다시 100% 통과했다고 밝힘

- 교훈은 꽤 현실적임
  - “bytes_in_flight == 0”은 항상 idle이 아님
  - 작은 cwnd에서는 정상적인 ACK clock 흐름도 idle처럼 보일 수 있음
  - 커널 TCP에서 맞던 최적화를 user space QUIC에 포팅할 때는 이벤트가 발생하는 레이어와 시점을 다시 따져야 함

---
## 기술 맥락

- CUBIC의 선택은 “네트워크가 멀쩡하면 더 보내고, 손실이 나면 줄인다”는 loss-based 혼잡 제어의 대표 구현이라서 중요해요. Linux 기본값이다 보니 Cloudflare 같은 대규모 엣지 사업자가 QUIC에서도 같은 알고리즘을 쓰면 운영 경험과 튜닝 지식을 이어갈 수 있거든요.

- 이번 버그의 진짜 포인트는 TCP와 QUIC의 구현 위치가 다르다는 점이에요. TCP CUBIC은 커널 내부 이벤트를 기준으로 움직이지만, quiche는 user space에서 패킷 송수신과 ACK 처리를 직접 다뤄요. 그래서 “송신 직전 bytes_in_flight가 0”이라는 신호가 커널 TCP에서 기대한 의미와 달라질 수 있어요.

- Cloudflare가 qlog로 상태 전환을 시각화한 것도 좋은 선택이에요. 처리량 그래프만 보면 그냥 느린 테스트처럼 보이지만, recovery와 congestion avoidance가 999번 오간다는 이벤트 레벨 증거를 보면 상태 머신이 자기 발목을 잡고 있다는 게 드러나거든요.

- 수정이 작은 이유는 알고리즘 전체가 틀린 게 아니라 시간 기준 하나가 틀렸기 때문이에요. 마지막 패킷을 보낸 시각이 아니라 마지막 ACK를 받은 시각까지 고려하면, 실제 idle과 작은 cwnd에서 생기는 일시적 drain을 구분할 수 있어요.

## 핵심 포인트

- 초기 2초 동안 30% 패킷 손실을 넣은 HTTP/3 다운로드 테스트가 약 60% 확률로 10초 타임아웃에 걸림
- 손실이 끝난 뒤에도 CUBIC의 cwnd가 2패킷, 2700바이트 바닥에 고정됨
- 원인은 bytes_in_flight == 0을 진짜 idle로 오해하면서 recovery start time을 RTT만큼 계속 미래로 밀어버린 것
- 수정은 마지막 송신 시각이 아니라 마지막 ACK 시각까지 고려해 실제 idle 구간을 재는 방식으로 이뤄짐

## 인사이트

이 글은 ‘최적화 코드 하나’가 프로토콜 구현체를 옮겨 탈 때 얼마나 다른 의미가 되는지 보여주는 좋은 사례임. TCP 커널 콜백 기준으로 맞던 가정이 user space QUIC에서는 death spiral이 됐다는 점이 포인트.
