TCP handshake와 teardown 상태 머신은 한 번 정리해 둔 적이 있다. 그런데 정작 "연결이 열린 뒤 송신 속도는 무엇이 결정하는가"는 늘 흐릿했다. 막연히 "TCP는 수신 윈도우(rwnd)만큼 보낸다"고 알고 있었기 때문이다.
결론부터 말하면 그건 절반만 맞는 이야기다. 실제 송신 상한은 min(cwnd, rwnd)이고, 대역폭이 넉넉한 환경에서 흐름을 옥죄는 건 거의 항상 혼잡 윈도우(cwnd) 쪽이다. 이 글은 cwnd가 ACK 피드백과 손실 신호에 따라 어떤 상태들을 오가며 조절되는지를 RFC 5681과 6582를 따라가며 정리한다.
RFC 5681이 정의하는 세 변수부터 분리하자(단위는 모두 바이트).
| 변수 | 의미 | 누가 정하나 |
|---|---|---|
cwnd | 네트워크에 띄울 수 있는 미확인 데이터 상한 | 송신자 로컬 (패킷에 안 실림) |
rwnd | 수신자 버퍼 여유 | 수신자가 광고 (TCP Window 필드) |
ssthresh | Slow Start ↔ Congestion Avoidance 경계 | 송신자 |
핵심은 흐름 제어(rwnd)와 혼잡 제어(cwnd)가 목적이 다른 별개의 메커니즘이라는 점이다. rwnd는 느린 수신자가 버퍼 오버플로로 죽지 않게 보호하고, cwnd는 네트워크 중간 라우터의 큐가 터지지 않게 보호한다. 실제 송신 가능량은 둘 중 작은 쪽, min(cwnd, rwnd)다.
"TCP 흐름은 rwnd가 정한다"는 통념이 어긋나는 지점이 여기다. LAN처럼 rwnd가 넉넉하면 병목은 거의 cwnd다.
연결 직후나 타임아웃 복구 직후 진입한다. 초기 cwnd(IW)는 RFC 5681 기준 2~4 MSS였고, RFC 6928이 약 10 MSS로 확대했다.
규칙은 단순하다. ACK 하나가 올 때마다 cwnd += SMSS. 한 RTT 동안 cwnd만큼 세그먼트가 나가고 그만큼 ACK가 돌아오므로, RTT마다 cwnd가 대략 두 배가 된다. 이름은 "slow"지만 증가율로 치면 가장 공격적인 구간이다 — 단지 시작점이 낮을 뿐이다.
RTT 0: cwnd=1 ┐ 각 ACK마다 +1
RTT 1: cwnd=2 │ → RTT당 약 2배 (지수)
RTT 2: cwnd=4 │
RTT 3: cwnd=8 ┘
... cwnd >= ssthresh 도달 시 Congestion Avoidance로 전환
cwnd >= ssthresh가 되면 진입한다. 더 키우다간 네트워크를 터뜨릴 수 있으니 RTT당 최대 1 SMSS만 늘린다. RFC 5681이 권장하는 근사식:
cwnd += SMSS * SMSS / cwnd (ACK 수신마다)
cwnd가 SMSS개의 세그먼트로 차 있을 때 한 RTT에 그만큼 ACK가 오고, 각 ACK가 SMSS/cwnd씩 키우므로 합이 ≈1 SMSS가 된다. 이것이 AIMD의 Additive Increase다.
cwnd를 줄이는 트리거는 "손실"인데, 감지 경로가 둘이고 심각도를 다르게 본다.
┌─ RTO 만료 (심각: ACK가 아예 안 옴)
손실 감지 ───┤ → ssthresh = max(in-flight/2, 2*SMSS)
│ cwnd = 1 MSS, Slow Start로 리셋
│
└─ 3 dup ACK (경미: 뒷 세그먼트는 도착 중)
→ Fast Retransmit + Fast Recovery
왜 하필 3개인가? 네트워크 재정렬(reordering)로 ACK가 한두 개 뒤바뀌는 건 흔하다. dup ACK 1~2개를 손실로 단정하면 멀쩡한 데이터를 불필요하게 재전송하게 된다. RFC 5681은 임계값을 3으로 잡아 재정렬과 진짜 손실을 가른다.
3 dup ACK를 받으면 RTO를 기다리지 않고 즉시 빠진 세그먼트를 재전송(Fast Retransmit)한 뒤 Fast Recovery로 들어간다.
ssthresh = max(FlightSize / 2, 2*SMSS) — 절반으로 깎음(Multiplicative Decrease).cwnd = ssthresh + 3*SMSS — window inflation. 3을 더하는 건 이미 네트워크를 빠져나가 수신자에 도착한 세그먼트 3개를 회계상 반영하는 것이다.cwnd += SMSS로 계속 부풀리고, 여유가 생기면 새 데이터를 보낸다.cwnd = ssthresh로 deflate하고 Congestion Avoidance로 복귀.NewReno(RFC 6582)는 한 윈도우에서 여러 세그먼트가 동시에 손실된 경우를 보강한다. Recovery 진입 시점의 최고 시퀀스를 recover로 기록해 두고, 새 ACK가 이를 다 덮지 못하는 부분 ACK(partial ACK) 면 Recovery를 벗어나지 않고 다음 빠진 세그먼트를 곧장 재전송한다. 덕분에 다중 손실에도 RTO로 추락하지 않고 한 RTT에 하나씩 메운다.
RFC 5681 §3.1~§3.2 규칙을 SMSS=1000B, IW=1 MSS, ssthresh=64 MSS로 따라가 본다.
RTT0 cwnd=1 < ssthresh → SlowStart, ACK → cwnd=2
RTT1 cwnd=2 각 ACK +1 → cwnd=4
RTT2 cwnd=4 → cwnd=8
... cwnd=64 >= ssthresh → Congestion Avoidance 진입
RTT k cwnd=64 ACK 64개, 각 +약 15.6B → RTT당 ≈+1 MSS → cwnd=65
... 여기서 3 dup ACK, FlightSize=80 MSS 가정
ssthresh = max(80/2, 2) = 40 MSS
cwnd = 40 + 3 = 43 MSS (Fast Retransmit)
재전송 ack하는 새 ACK → cwnd = 40 MSS, Congestion Avoidance 재개
cwnd가 80에서 40으로 반토막 났을 뿐 1로 추락하지 않은 점이 RTO 경로(cwnd=1)와의 결정적 차이다. AIMD — 천천히 올리고(+1/RTT) 손실엔 확 깎는(×1/2) — 가 만드는 톱니파(sawtooth) cwnd 그래프가 다수 흐름이 병목 대역폭을 공평하게 나눠 갖게 만드는 수렴 성질의 근거다.
리눅스라면 추상이 아니라 실제 값을 볼 수 있다.
# 활성 TCP 소켓의 cwnd, ssthresh, rtt를 직접 출력
ss -ti
TCP 송신자는 매 순간
min(cwnd, rwnd)만큼만 미확인 데이터를 띄우고, cwnd는 ACK라는 피드백 클럭과 손실이라는 혼잡 신호에 따라 Slow Start → Congestion Avoidance → Fast Recovery를 오가며 끊임없이 조절된다.
특히 기억할 세 가지: ① 병목은 rwnd가 아니라 보통 cwnd, ② Slow Start는 증가율로는 지수, ③ 손실이라고 항상 cwnd=1이 아니라 RTO일 때만 1로 리셋된다는 점이다.
더 파고들 거리: