Skip to content

필사 모드: TCP 혼잡 제어를 눈으로 보기 — CUBIC과 BBRv3, 그리고 브라우저에서 도는 gVisor 네트스택

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 브라우저 탭에서 도는 두 개의 TCP 스택

2026년 7월 28일 해커뉴스에 Simulating TCP loss and congestion in browser using Go/WASM이라는 링크가 올라왔습니다. 링크를 열면 ccsim이라는 페이지가 나오는데, 여기서 도는 것이 데모용 애니메이션이 아닙니다. 소스 저장소의 설명이 정확합니다 — gVisor의 사용자 공간 TCP/IP 스택(gvisor.dev/gvisor/pkg/tcpip) 두 벌을 각각 송신자와 수신자로 세우고, 그 사이에 토큰 버킷 방식의 병목 링크 모델을 끼워 넣은 이벤트 구동 시뮬레이터를 통째로 WebAssembly로 빌드해 브라우저 워커에서 실행합니다.

만든 쪽의 기술 블로그에 동기가 적혀 있습니다. 이들은 터널링 인프라에서 패킷을 주입하는 데 gVisor 네트스택을 쓰는데, 공동 창업자의 불안정한 가정용 회선에서 프로브가 계속 죽었습니다. 손실이 있는 링크에서 CUBIC이 잘 못 한다는 심증은 있었고, 그것을 확인하려다 보니 BBRv3를 netstack 위에 직접 구현하고 검증 하네스까지 만들게 됐다는 흐름입니다. 시뮬레이터는 그 검증 하네스의 부산물입니다.

브라우저 데모라고 얕볼 물건이 아닌 이유는 저장소의 검증 문서에 있습니다. CUBIC 윈도 성장 곡선이 RFC 9438의 삼차 함수에 C=0.410, R²=0.9999로 맞고, 손실 응답 함수는 규격값의 0.68~0.95배 안에 들며, RED의 마킹 곡선은 정확한 램프 확률질량함수와 카이제곱 검정으로 비교합니다. 게다가 네이티브 빌드와 WASM 빌드의 출력 샘플 스트림이 바이트 단위로 동일합니다. 교보재이면서 회귀 테스트인 셈입니다.

이 글은 이 시뮬레이터를 후크로 삼되, 실제 가치는 그 아래 있는 알고리듬 쪽에 두겠습니다. 혼잡 제어는 30년 넘게 같은 문제를 풀어 왔고, 화면에서 본 톱니 모양이 실제 서버에서 어떤 증상으로 나타나는지가 결국 우리가 알아야 할 것이기 때문입니다.

골격 — slow start, congestion avoidance, fast retransmit

TCP 송신자는 두 개의 창을 동시에 봅니다. 하나는 수신자가 광고한 수신 윈도(rwnd)로 "받는 쪽이 감당할 수 있는 양"이고, 다른 하나가 혼잡 윈도(cwnd)로 "네트워크가 감당할 수 있다고 송신자가 추정한 양"입니다. 실제로 내보낼 수 있는 양은 둘 중 작은 쪽입니다. 혼잡 제어 알고리듬이란 결국 cwnd를 언제 얼마나 올리고 내릴지에 대한 규칙 모음입니다.

Slow start는 이름과 달리 지수적으로 빠릅니다. 연결이 시작되면 cwnd를 초기 윈도(요즘 리눅스는 RFC 6928에 따라 10 MSS)로 두고, ACK가 하나 올 때마다 cwnd를 1 MSS씩 늘립니다. ACK 하나가 새 세그먼트 두 개를 풀어 주므로 RTT마다 cwnd가 두 배가 됩니다. 경로 용량을 전혀 모르는 상태에서 로그 시간에 근접해 가기 위한 탐색입니다. 이 단계는 ssthresh에 도달하거나 손실이 감지될 때까지 계속됩니다.

Congestion avoidance는 그다음입니다. 이제 용량 근처에 있다고 보고, 증가 속도를 RTT당 대략 1 MSS로 낮춥니다. 손실을 만나면 cwnd를 곱셈으로 줄입니다. 이 AIMD(additive increase, multiplicative decrease) 구조가 인터넷을 붕괴에서 구한 핵심입니다 — 여러 흐름이 같은 병목을 공유할 때 AIMD는 공정성 쪽으로 수렴하는 반면, 곱셈 증가는 그렇지 않습니다.

Fast retransmit은 손실을 재전송 타임아웃(RTO)보다 훨씬 빨리 잡기 위한 장치입니다. 수신자는 순서에서 벗어난 세그먼트를 받으면 마지막으로 연속 수신한 지점을 반복해서 ACK합니다. 송신자가 같은 ACK를 세 번 중복으로 받으면 RTO를 기다리지 않고 그 세그먼트를 즉시 재전송하고, fast recovery로 들어가 cwnd를 절반 정도로 낮춘 뒤 계속 보냅니다. RTO를 기다렸다면 최소 수백 ms를 날렸을 자리입니다.

여기서 한 가지는 짚고 넘어가야 합니다. 위 문단은 교과서의 Reno 기준이고, 지금 리눅스가 실제로 하는 손실 감지는 RACK-TLP(RFC 8985)입니다. 중복 ACK 개수를 세는 대신 각 세그먼트의 전송 시각과 SACK로 확인된 도착 순서를 비교해, "이 세그먼트보다 나중에 보낸 것이 이미 도착했고 재정렬 윈도도 지났다"면 손실로 판정합니다. 중복 ACK 세 개라는 규칙이 재정렬에 약하고 꼬리 손실(마지막 세그먼트가 사라져 중복 ACK 자체가 안 생기는 경우)에 무력했기 때문입니다. ccsim도 RACK/TLP를 켜고 돌립니다.

CUBIC이 최적화하는 것

리눅스·윈도우·애플 스택의 기본값인 CUBIC은 RFC 9438으로 2023년 8월에 표준화됐습니다(RFC 8312를 폐기). 이름 그대로 윈도 성장을 삼차 함수로 정의합니다.

W_cubic(t) = C * (t - K)^3 + W_max

  t     : 마지막 혼잡 이벤트 이후 경과 시간(초)
  W_max : 혼잡 이벤트 직전의 윈도 크기
  C     : 스케일링 상수 (기본 0.4)
  K     : cbrt(W_max * (1 - beta) / C),   beta = 0.7

이 곡선의 모양이 설계 의도 전부입니다. 손실 직후에는 W_max 근처까지 오목하게 빠르게 회복하고, W_max에 다다르면 거의 평평해져 그 부근에 오래 머물며 탐색하고, 아무 일도 없으면 그 위로 볼록하게 가속해 새 용량을 찾아 나섭니다. 감소 계수는 Reno의 0.5가 아니라 0.7이라 손실 한 번에 잃는 양도 적습니다.

무엇을 최적화한 것이냐면, 두 가지입니다.

첫째, 고 BDP 링크의 이용률입니다. Reno의 RTT당 1 MSS 증가는 대역폭이 크고 지연이 긴 경로에서 절망적으로 느립니다. CUBIC의 증가는 경과 시간의 함수라서 RTT에 직접 묶여 있지 않습니다.

둘째, 바로 그 이유로 RTT 공정성입니다. Reno 계열에서는 RTT가 짧은 흐름이 같은 시간 동안 더 많이 증가해 대역폭을 더 가져갑니다. 시간의 함수인 CUBIC은 이 편향이 훨씬 작습니다.

대신 CUBIC이 포기한 것이 명확합니다. CUBIC의 혼잡 신호는 여전히 패킷 손실 하나뿐입니다. 큐가 얼마나 차 있는지, 지연이 얼마나 늘었는지는 보지 않습니다. 손실이 날 때까지 밀어 올리고, 손실이 나면 줄입니다. 이 한 문장에서 뒤에 나올 두 가지 증상이 모두 파생됩니다.

BBR이 다르게 보는 것 — 손실이 아니라 경로 모델

BBR은 질문을 바꿉니다. "언제 손실이 나는가" 대신 "이 경로의 병목 대역폭과 최소 왕복 지연은 얼마인가"를 계속 추정하고, 그 곱(BDP) 근처를 유지하려 합니다. ccsim의 화면 설명이 이 대비를 잘 압축합니다 — 1 BDP가 최적점이고, CUBIC은 손실 쪽으로 넘어가면서 그 지점을 찾고, BBR은 경로를 추정해 그 지점 근처에 머무릅니다.

이 최적점은 Kleinrock이 1979년에 정리한 것이기도 합니다. in-flight가 BDP보다 적으면 링크가 놀고, BDP보다 많으면 초과분은 전부 병목 큐에 쌓여 지연만 늘릴 뿐 처리량은 그대로입니다. 문제는 대역폭과 최소 지연을 동시에 측정할 수 없다는 데 있습니다 — 대역폭 최대치를 재려면 큐를 만들어야 하고, 최소 지연을 재려면 큐를 비워야 합니다. 그래서 BBR은 두 값을 번갈아 가며 잽니다.

ccsim이 구현한 BBRv3는 draft-ietf-ccwg-bbr의 상태 기계를 따릅니다(현재 06판, 상태는 Experimental). 저장소 문서에 실린 상수까지 옮기면 이렇습니다.

  • Startup — pacing 게인 710/256(약 2.77), cwnd 게인 2.0. 대역폭 추정치가 더 이상 늘지 않을 때까지 밀어 올립니다.
  • Drain — pacing 게인 88/256(약 0.34). Startup에서 만들어 놓은 큐를 비웁니다.
  • ProbeBW — DOWN(232/256) → CRUISE(1.0) → REFILL(1.0) → UP(1.25)의 순환. 대부분의 시간을 여기서 보내며, UP 구간에서만 잠깐 더 밀어 대역폭이 늘었는지 확인합니다.
  • ProbeRTT — cwnd 게인 0.5로 약 200ms. 최소 5초 간격으로 스케줄되며, 큐를 비워 min_rtt 필터를 갱신합니다. min_rtt 자체는 10초 창으로 유지됩니다.

여기에 BBRv3가 v1 대비 추가한 것이 손실과 ECN에 대한 명시적 응답 경계입니다. inflight_hi, inflight_lo, bw_lo 같은 상한을 두어, 경로가 실제로 손실이나 CE 마킹을 돌려주면 모델을 그대로 밀어붙이지 않고 물러섭니다. BBRv1이 CUBIC과 공존할 때 지나치게 공격적이라는 비판을 받았던 부분에 대한 답입니다.

그리고 BBR을 실제로 쓰려면 반드시 따라오는 것이 pacing입니다. cwnd만 열어 두고 버스트로 내보내면 병목 큐가 순간적으로 터지므로, 추정한 대역폭에 맞춰 패킷을 시간축에 고르게 뿌려야 합니다. 리눅스에서는 fq qdisc나 커널 내부 pacing이 이 일을 합니다. ccsim은 이것을 CC 안이 아니라 송신 통합 계층에 두고, min(pacing_rate * 1ms, 64KB) 크기의 양자 단위로 가상 클록 타이머를 통해 내보냅니다.

엔지니어가 실제로 보는 증상 세 가지

여기까지가 이론이고, 아래가 티켓으로 올라오는 모양입니다. ccsim 저장소의 시나리오 측정치를 그대로 옮기면 세 증상이 숫자로 보입니다.

시나리오조건결과
cubic-single100Mbps, 손실 없음96.5 Mbps, cwnd 감축 3회, RTO 0회
random-loss랜덤 손실 1%CUBIC 3.0 Mbps 대 BBRv3 57 Mbps
bufferbloat깊은 taildrop 버퍼, base RTT 30msCUBIC 정상 상태 srtt 1,070ms 대 BBRv3 32ms
ecn-codelCoDel + ECNCE 마킹 654회, 드롭·재전송 0회, srtt는 base의 2.1배 이하
rate-step대역폭 계단 변화즉시 24 Mbps 전달, max-bw 필터 2주기 후 새 BDP의 1.07배
fairness100Mbps 한 링크에 CUBIC과 BBR 공존링크 96% 사용, 분배는 약 33% 대 67%

증상 1 — 손실 링크에서의 처리량 붕괴. 1% 랜덤 손실에서 CUBIC이 3.0 Mbps로 주저앉습니다. 100Mbps 링크에서 3%입니다. 이건 버그가 아니라 손실 기반 설계의 직접적 귀결이고, Mathis 근사식이 그대로 예측합니다.

throughput  ~  MSS / (RTT * sqrt(p))  * C

  MSS 1448B, RTT 40ms, p = 0.01, C ~ 1.22
  -> 1.22 * 1448 * 8 / (0.04 * 0.1)  ~  3.5 Mbps

식에서 손실률이 제곱근으로 들어간다는 점이 중요합니다. 손실이 0.1%에서 1%로 열 배 늘면 처리량은 3분의 1이 아니라 약 3.16분의 1로 떨어집니다. 그리고 이 손실이 혼잡 때문인지 무선 간섭이나 회선 품질 때문인지 CUBIC은 구분하지 못합니다. 무선 구간이나 장거리 회선을 지나는 흐름이 이유 없이 느린 전형적인 원인이고, BBR이 57 Mbps를 내는 이유이기도 합니다 — BBR은 손실이 나도 전달률 추정치가 유지되면 모델을 크게 흔들지 않습니다.

증상 2 — bufferbloat. 깊은 버퍼가 달린 병목에서 CUBIC의 정상 상태 srtt가 1,070ms입니다. 경로의 실제 왕복 지연은 30ms입니다. 1초가 전부 큐 대기 시간입니다. 여기서 손실은 오히려 거의 나지 않습니다 — 버퍼가 깊으니 드롭 대신 지연으로 흡수되기 때문입니다. 그래서 CUBIC은 "아무 문제 없다"고 판단하고 그 상태로 안정화됩니다. 같은 조건에서 BBRv3는 32ms를 유지합니다. base RTT가 30ms이니 큐가 사실상 비어 있다는 뜻입니다.

증상 3 — 버퍼를 키우면 더 나빠진다. 위의 두 문단을 합치면 자동으로 나옵니다. 손실 기반 CC의 정상 상태 큐 점유량은 버퍼 크기가 결정합니다. 버퍼를 2배로 키우면 정상 상태 지연도 대략 2배가 됩니다. "패킷 드롭이 보이니 버퍼를 늘리자"는 직관은 드롭을 지연으로 바꿀 뿐이고, 대화형 트래픽 입장에서는 더 나쁜 거래입니다. 라우터 버퍼가 싸지면서 이 실수가 인터넷 전역에 퍼진 것이 bufferbloat 문제의 역사입니다.

처방은 버퍼 크기가 아니라 큐 규율입니다. 표의 ecn-codel 행이 그 그림입니다 — CoDel이 큐 지연을 보고 미리 CE를 마킹하니 드롭도 재전송도 0회인데 srtt는 base의 2.1배 이하로 눌립니다. 손실이라는 신호를 지연 신호로 대체한 것이고, 리눅스라면 fq_codel이나 cake가 이 일을 합니다. 소비자용 라우터라면 이쪽이 사실상 유일한 실용적 해법입니다.

마지막 행의 공정성 숫자도 실무적으로 의미가 있습니다. CUBIC과 BBR이 같은 링크를 공유할 때 33% 대 67%로 갈립니다. 링크는 96%까지 차 있으니 낭비는 없지만 균등하지도 않습니다. BBRv3가 v1보다 온순해졌다고는 해도, 서버 함대의 일부만 BBR로 바꾸면 나머지 CUBIC 흐름이 손해를 본다는 뜻입니다. 전환은 부분이 아니라 도메인 단위로 하는 편이 낫습니다.

실제 호스트에서 cwnd와 재전송을 보는 법

시뮬레이터가 예쁘게 그려 주는 값들은 리눅스 호스트에서도 다 볼 수 있습니다. 계측 코드는 필요 없습니다.

# 현재 기본 알고리듬과 사용 가능한 목록
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control

# BBR을 쓰려면 모듈과 pacing qdisc가 같이 필요하다
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
tc qdisc show dev eth0        # fq 또는 fq_codel 인지 확인

# 소켓별 상태: cwnd, ssthresh, rtt, 재전송, 전달률, pacing rate가 전부 여기 나온다
ss -tin state established
#   cubic wscale:7,7 rto:236 rtt:35.6/2.1 mss:1448 cwnd:73 ssthresh:52
#   bytes_sent:... retrans:0/14 delivery_rate:76.4Mbps busy:... pacing_rate:...

# 특정 목적지만
ss -tin dst 203.0.113.10

# 호스트 전체 재전송 카운터 (증가분만)
nstat -az | grep -Ei 'TcpRetransSegs|TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPFastRetrans'

ss -tin 출력에서 실무적으로 가장 중요한 세 값은 이렇습니다.

  • cwnd — 패킷 단위입니다. cwnd * mssrtt로 나누면 그 순간의 상한 처리량이 나옵니다. delivery_rate와 비교하면 애플리케이션이 못 채우고 있는지(app-limited) 네트워크가 막고 있는지 갈립니다.
  • retrans:X/Y — 앞이 현재 미해결 재전송, 뒤가 누적입니다. 뒤 숫자가 bytes_sent 대비 몇 퍼센트인지가 앞 절의 손실률 p입니다.
  • rtt:A/B — A가 평활화된 RTT, B가 변동(mdev)입니다. A가 경로의 최소 RTT보다 훨씬 크고 재전송은 거의 없다면 그것이 bufferbloat의 서명입니다.

주기적으로 찍어 cwnd 톱니를 눈으로 보고 싶다면 이 정도로 충분합니다.

# 200ms 간격으로 cwnd/rtt/retrans만 뽑아 시계열로 남긴다
while :; do
  date +%s.%N | tr -d '\n'
  ss -tin dst 203.0.113.10 | tr '\n' ' ' \
    | grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+' | tr '\n' ' '
  echo
  sleep 0.2
done

커널 이벤트 레벨로 내려가려면 tracepoint가 이미 준비돼 있습니다.

# 혼잡 상태 전이와 재전송을 이벤트로 본다
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb { @retrans[comm] = count(); }
tracepoint:tcp:tcp_cong_state_set { @state[args->cong_state] = count(); }'

# 재현 환경 만들기: 손실 1% + 지연 20ms + 얕은 큐
sudo tc qdisc replace dev eth0 root netem loss 1% delay 20ms limit 100
# bufferbloat 재현: 큐를 아주 깊게
sudo tc qdisc replace dev eth0 root netem delay 20ms limit 10000
# 처방 확인: AQM으로 교체
sudo tc qdisc replace dev eth0 root fq_codel

netem으로 손실을 만들어 놓고 같은 파일을 CUBIC과 BBR로 각각 전송해 보면, 앞 절 표의 3.0 Mbps 대 57 Mbps가 어떤 종류의 차이인지 30초 안에 체감됩니다. 리눅스 네트워크 스택 내부 구조 전반은 리눅스 네트워킹 내부 편에, TCP 자체의 동작은 TCP 심층 분석 편에 따로 정리해 두었습니다.

시뮬레이터가 보여 주지 못하는 것

교보재로서 ccsim의 강점은 명확합니다. 실제 프로토콜 구현을 돌리므로 SACK 스코어보드, RACK 재정렬 판정, ECN 에코 같은 것이 근사가 아니라 실물이고, 결정적(deterministic)이라 같은 시드로 같은 결과가 나옵니다. 슬라이더를 움직이면 시뮬레이션이 실행 중인 채로 파라미터가 바뀌므로, 대역폭이 갑자기 떨어질 때 두 알고리듬이 얼마나 다르게 반응하는지가 한 화면에서 보입니다.

동시에 시뮬레이션의 경계도 분명합니다.

  • 흐름이 하나거나 둘입니다. 실제 병목에는 수천 개의 흐름이 있고, 그 중 대부분은 짧아서 slow start를 벗어나지도 못합니다. 인터넷 트래픽에서 정상 상태 동작이 차지하는 비중은 생각보다 작습니다.
  • 병목이 하나이고 고정입니다. 실제 경로에서는 병목이 이동하고, 무선 구간의 용량은 초 단위로 요동칩니다.
  • 미들박스가 없습니다. ECN 비트를 지우는 장비, SACK을 깨뜨리는 방화벽, TCP를 대신 종단해 버리는 프록시는 실제 인터넷에서 흔합니다. BBR을 켰는데 기대한 효과가 안 나는 원인의 상당수가 여기 있습니다.
  • CPU와 인터럽트 처리가 없습니다. GRO, TSO, NIC 큐, 그리고 어느 코어가 소프트IRQ를 처리하는지가 실제로는 지연 분포를 크게 바꿉니다.

그래서 시뮬레이터에서 얻을 것은 "우리 환경에서 BBR이 몇 Mbps를 낸다"가 아니라 이 알고리듬은 어떤 신호에 반응하고 무엇에는 반응하지 않는가입니다. 그 감각이 있으면 ss -tin 출력 한 줄에서 읽어 낼 수 있는 것이 확실히 달라집니다.

마치며 — 혼잡 제어는 여전히 추정 문제다

정리하면 이렇습니다.

  • cwnd는 네트워크 용량에 대한 송신자의 추정치입니다. 알고리듬의 차이는 무엇을 증거로 삼아 그 추정을 갱신하느냐의 차이입니다.
  • CUBIC은 손실 하나만 증거로 씁니다. 그래서 무선 손실을 혼잡으로 오인하고, 깊은 버퍼 앞에서는 증거를 못 받아 큐를 가득 채운 채 안정화됩니다.
  • BBR은 전달률과 최소 RTT로 경로 모델을 세우고 BDP 근처를 노립니다. BBRv3는 여기에 손실·ECN 응답 경계를 더해 공존성을 개선했지만, CUBIC과 나눠 쓸 때의 분배는 여전히 균등하지 않습니다.
  • 버퍼를 키우는 것은 드롭을 지연으로 바꾸는 거래입니다. 답은 큐 크기가 아니라 fq_codel 같은 AQM입니다.
  • 이 전부는 ss -tin, nstat, tc netem 세 도구로 자기 호스트에서 재현하고 관측할 수 있습니다.

혼잡 제어를 오래 안 들여다본 채로 "느리다"는 티켓을 받으면 대개 대역폭을 의심하게 됩니다. 실제로는 cwnd가 손실률에 갇혀 있거나, RTT가 큐 때문에 부풀어 있는 경우가 더 많습니다. 두 경우 모두 대역폭을 늘려도 아무것도 나아지지 않습니다.

참고 자료

현재 단락 (1/102)

2026년 7월 28일 해커뉴스에 [Simulating TCP loss and congestion in browser using Go/WASM](https://news.ycombi...

작성 글자: 0원문 글자: 9,435작성 단락: 0/102