TCP는 얼마나 빨리 보낼까 — 흐름 제어 vs 혼잡 제어, 그리고 톱니파의 비밀
흐름 제어(rwnd)와 혼잡 제어(cwnd)의 차이, 슬로스타트·혼잡 회피의 AIMD 톱니파, 손실 감지(타임아웃·3 dup ACK), Reno·CUBIC·BBR, 버퍼블로트와 BDP를 그림으로 설명합니다.
두 개의 “멈춰”는 서로 다른 사람이 외친다
TCP가 데이터를 보낼 때, “지금 얼마나 보내도 되나”를 정하는 두 개의 브레이크가 있습니다. 자주 한 덩어리로 뭉뚱그리지만 완전히 다른 문제입니다.
- 흐름 제어(flow control): 수신자가 감당 못 할까 봐 — “내 버퍼 넘치니 천천히.” 수신 버퍼가 결정.
- 혼잡 제어(congestion control): 네트워크가 막힐까 봐 — “중간 라우터 큐가 터지니 천천히.” 패킷 손실/지연이 결정.
실제 송신량은 둘 중 작은 쪽입니다: 전송 가능 = min(rwnd, cwnd). 이 글은 이 두 윈도우가 어떻게 움직이는지, 왜 혼잡 제어 그래프가 톱니파를 그리는지, 그리고 BBR이 왜 그 톱니를 버렸는지까지 봅니다. 전제는 TCP 핸드셰이크/신뢰성입니다.
흐름 제어: 슬라이딩 윈도우와 rwnd
수신자는 매 ACK의 윈도우 크기 필드(rwnd) 로 “내 수신 버퍼에 이만큼 여유 있다”를 알립니다. 송신자는 ACK 안 된 데이터를 그 한도 내로만 유지합니다. 윈도우가 ACK를 따라 오른쪽으로 미끄러지는 모습:
rwnd = 0이면 송신자는 멈추고, 주기적으로 작은 윈도우 프로브를 보내 “이제 여유 생겼나” 확인합니다. 수신 앱이 데이터를 안 읽으면 윈도우가 0으로 닫혀 송신이 막히는 게 정상 동작입니다(누수 아님).
혼잡 제어: 네트워크는 자기 상태를 안 알려준다
흐름 제어와 달리, 라우터는 “나 막혔어”를 친절히 알려주지 않습니다(best-effort). TCP는 간접 신호로 혼잡을 추측합니다 — 주로 패킷 손실, 또는 지연 증가(BBR). 핵심 변수가 송신자만 아는 혼잡 윈도우(cwnd) 입니다.
슬로 스타트 → 혼잡 회피 → 손실 → 반복
고전 알고리즘(Reno 계열)은 이렇게 움직입니다.
- 슬로 스타트:
cwnd를 1 MSS에서 시작, ACK마다 지수적(매 RTT 2배) 증가. “이름과 달리 빠르게” 가용 대역을 탐색. cwnd ≥ ssthresh가 되면 혼잡 회피: 매 RTT 선형(+1 MSS) 증가. 조심스럽게.- 손실 감지:
- 3 dup ACK(같은 ack 3번) → 가벼운 혼잡 →
cwnd절반, fast retransmit/recovery(슬로 스타트로 안 돌아감). - 타임아웃(RTO) → 심각 →
cwnd를 1로,ssthresh를 절반으로, 슬로 스타트부터 다시.
- 3 dup ACK(같은 ack 3번) → 가벼운 혼잡 →
이 “선형 증가 + 곱셈 감소”가 AIMD(Additive Increase, Multiplicative Decrease)이고, 그래서 cwnd는 톱니파를 그립니다. 직접 보세요 — 초록 곡선이 올라가다 손실에서 반토막 납니다.
왜 곱셈 감소인가? 혼잡은 빠르게 악화됩니다(모두가 큐를 채우면 붕괴). 그래서 줄일 땐 과감하게 절반으로 물러나 빠르게 안정시키고, 늘릴 땐 +1씩 조심합니다. 여러 흐름이 같은 병목을 공유할 때 AIMD가 공정성(각자 비슷한 몫)으로 수렴한다는 게 수학적으로 증명돼 있습니다.
Reno → CUBIC → BBR: 무엇이 바뀌었나
| 알고리즘 | 혼잡 신호 | 특징 | 어디서 |
|---|---|---|---|
| Reno/NewReno | 손실 | 고전 AIMD 톱니 | 교과서 |
| CUBIC | 손실 | 시간의 3차 함수로 증가 → 고대역·고지연(BDP 큰) 링크에서 빠른 회복 | 리눅스 기본 |
| BBR | 대역폭·RTT 모델 | 손실을 기다리지 않고 병목 대역폭·최소 RTT를 추정해 그 지점에서 운전 | 구글, YouTube·여러 CDN |
BBR이 중요한 이유: 손실 기반 알고리즘은 큐가 가득 찰 때까지 보내다 손실을 봐야 물러납니다. 그래서 라우터 버퍼가 과도하게 크면(요즘 흔함) 큐에 패킷이 잔뜩 쌓여 지연이 치솟습니다 — 이게 버퍼블로트(bufferbloat) 입니다. BBR은 손실이 아니라 “대역폭은 안 느는데 RTT만 늘기 시작하는 지점”을 병목으로 보고 거기서 멈춰, 큐를 거의 안 채우고도 대역을 다 씁니다.
BDP: 윈도우가 얼마나 커야 하나
링크를 가득 채우려면 “날아가는 중인 데이터”가 파이프 부피만큼 있어야 합니다.
1
2
BDP(대역폭-지연 곱) = 대역폭 × RTT
예) 1 Gbps × 80 ms = 10⁹ × 0.08 / 8 ≈ 10 MB
윈도우(min(rwnd, cwnd))가 BDP보다 작으면, 데이터를 다 보내고 ACK를 기다리느라 링크가 놀아 처리량이 떨어집니다. 그래서 고대역·고지연(LFN, “롱 팻 네트워크”)에선 윈도우 스케일링 옵션(16비트 윈도우 한계 64 KB를 1 GB까지 확장)이 필수입니다. 이 관계는 성능 글에서 더 깊게 다룹니다.
디버깅: cwnd와 RTT를 직접 본다
1
2
3
4
5
6
7
8
9
10
11
# 연결별 혼잡 상태 (cwnd, rtt, retrans, 알고리즘)
ss -ti dst 10.0.0.5
# cubic wscale:7,7 rtt:42.3/5.1 mss:1448 cwnd:120 ssthresh:80
# bytes_retrans:... retrans:0/3 ...
# 현재 기본 알고리즘과 사용 가능 목록
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
# BBR 켜기 (커널 4.9+)
sysctl -w net.ipv4.tcp_congestion_control=bbr
ss -ti의 cwnd가 작고 retrans가 계속 오르면 경로 어딘가에서 손실이 나고 있다는 뜻입니다. rtt가 평소의 몇 배로 부풀면 버퍼블로트를 의심합니다.
프로덕션 함정
- 큰 버퍼 ≠ 빠름: 송수신 버퍼나 라우터 큐를 무작정 키우면 버퍼블로트로 지연이 악화됩니다. 손실 기반 알고리즘은 큐를 채워야 멈추기 때문.
- rwnd만 보고 cwnd를 잊음: “수신 버퍼 키웠는데 안 빨라요” — 병목이 네트워크면 cwnd가 한계라 소용없습니다.
ss -ti로 어느 쪽이 작은지 확인하세요. - 짧은 연결은 슬로 스타트에 갇힘: 작은 요청은 cwnd가 커지기도 전에 끝나, 대역폭이 아니라 RTT가 지배합니다 → keep-alive/연결 재사용, HTTP/2·3의 멀티플렉싱이 중요한 이유.
면접/리뷰 단골 질문
- Q. 흐름 제어와 혼잡 제어의 차이는? → 흐름=수신자 보호(rwnd), 혼잡=네트워크 보호(cwnd). 실제 송신은
min(rwnd, cwnd). - Q. cwnd가 왜 톱니파인가? → AIMD: 선형 증가(탐색) + 손실 시 곱셈 감소(빠른 후퇴). 공정성·안정성에 유리.
- Q. 3 dup ACK와 RTO 타임아웃의 처리가 왜 다른가? → dup ACK는 후속 패킷이 도착 중이란 신호(가벼운 혼잡)라 절반만 줄이고 fast recovery. 타임아웃은 흐름이 끊긴 심각 상황이라 cwnd를 1로.
- Q. BBR이 CUBIC보다 나은 점은? → 손실을 기다리지 않고 대역폭·RTT를 모델링해, 버퍼블로트 환경에서 낮은 지연으로 높은 처리량을 낸다.
정리
- 송신량 = min(rwnd, cwnd) — 흐름 제어(수신자)와 혼잡 제어(네트워크)는 다른 브레이크다.
- 고전 혼잡 제어는 AIMD → cwnd 톱니파. 슬로 스타트(지수)→혼잡 회피(선형)→손실 시 반감.
- CUBIC(리눅스 기본)은 BDP 큰 링크에 최적화, BBR은 손실 대신 대역폭·RTT를 모델링해 버퍼블로트를 피한다.
- 처리량은 윈도우 ≥ BDP여야 보장된다 → 윈도우 스케일링.
- 진단은
ss -ti로 cwnd·rtt·retrans를 직접 본다.
다음: TCP의 한계(연결당 핸드셰이크, HOL blocking)를 정면으로 다루는 UDP와 QUIC.
