컴퓨터공학 300 주제 시리즈의 148번째 글이다. 전체 지도는 여기.

한 줄 요약

흐름 제어는 “받는 쪽이 감당할 만큼만”(수신 윈도, rwnd), 혼잡 제어는 “네트워크가 감당할 만큼만”(혼잡 윈도, cwnd) 보내게 하는 장치다. TCP 는 둘 중 작은 값만큼만 확인 응답 없이 보낼 수 있다.

왜 필요한가

회선은 1Gb/s 인데 파일 전송이 수십 Mb/s 에서 멈춘다. 같은 서버가 서울 클라이언트에는 빠르고 해외 클라이언트에는 느리다. 패킷 손실 0.1% 가 처리량을 몇 분의 일로 떨어뜨린다. 이런 현상은 대역폭이 아니라 윈도와 RTT, 그리고 혼잡 제어 알고리즘이 결정한다.

혼잡 제어는 인터넷 전체의 안정성 문제이기도 하다. 모두가 손실을 무시하고 최대 속도로 보내면 라우터 대기열이 넘쳐 아무도 데이터를 전달하지 못하는 혼잡 붕괴가 일어난다. TCP 혼잡 제어는 이를 막기 위해 들어갔다.

핵심 개념

슬라이딩 윈도

TCP 송신자는 ACK 를 받지 않은 데이터를 “윈도” 크기까지 내보낼 수 있다. ACK 가 오면 윈도가 앞으로 미끄러진다.

보냄+확인됨 | 보냄+미확인 (in flight) | 보낼 수 있음 | 아직 못 보냄
            |<------------- 윈도 = min(rwnd, cwnd) ------------>|

흐름 제어: 수신 윈도

수신자는 매 ACK 에 “내 수신 버퍼에 아직 이만큼 비어 있다”를 담아 보낸다. 이것이 rwnd 다. 수신 앱이 데이터를 읽지 않아 버퍼가 차면 rwnd 가 0 이 된다(zero window). 송신자는 멈추고 주기적으로 윈도 탐색(window probe)을 보낸다.

TCP 헤더의 윈도 필드는 16비트라 최대 65,535바이트다. 고속·장거리 망에는 턱없이 작아서, 윈도 스케일 옵션(RFC 7323)으로 왼쪽 시프트 값을 협상한다. 시프트는 최대 14 이고, 그러면 윈도를 2^30 바이트(1 GiB)까지 쓸 수 있다.

대역폭-지연 곱(BDP)

회선을 꽉 채우려면 한 RTT 동안 보낸 데이터가 회선 용량을 채워야 한다.

필요 윈도 = 대역폭 × RTT
예) 1 Gb/s × 100 ms = 100,000,000 bit = 12.5 MB

윈도가 64KB 로 묶이면 RTT 100ms 경로에서 처리량은 64KB / 0.1s ≈ 5.2 Mb/s 를 넘지 못한다. 회선이 아무리 빨라도 마찬가지다. “해외가 느린” 첫 번째 이유다.

혼잡 제어: 혼잡 윈도

송신자는 네트워크 상태를 직접 알 수 없다. 손실(그리고 지연 증가)을 혼잡 신호로 보고 cwnd 를 조절한다. 표준 알고리즘은 RFC 5681 에 있다.

  1. 느린 시작(slow start): cwnd 를 작게 시작해 ACK 하나마다 1 MSS 씩 늘린다. 결과적으로 RTT 마다 두 배가 된다. 초기 윈도는 RFC 6928 이 10 세그먼트 안팎(정확히는 min(10×MSS, max(2×MSS, 14600바이트)))으로 늘렸다.
  2. 혼잡 회피(congestion avoidance): cwnd 가 임계값 ssthresh 에 닿으면 RTT 마다 약 1 MSS 씩만 늘린다.
  3. 빠른 재전송·빠른 회복: 같은 ACK 가 세 번 더 오면(중복 ACK 3개) 손실로 보고 타이머를 기다리지 않고 재전송한다. ssthresh 를 전송 중인 양의 절반으로 낮추고 cwnd 도 그 근처로 줄인다.
  4. 재전송 타임아웃(RTO): ACK 가 아예 안 오면 타이머가 만료된다. 가장 심한 신호로 보고 cwnd 를 1 세그먼트 수준으로 떨어뜨린 뒤 느린 시작부터 다시 한다. RTO 계산은 RFC 6298 이 정한다. 초기 RTO 는 1초이고, 계산값이 1초보다 작으면 1초로 올리도록 권고한다.

혼잡 회피의 “더할 때는 천천히, 줄일 때는 절반” 원칙을 AIMD(Additive Increase, Multiplicative Decrease)라 부른다. 여러 흐름이 같은 병목을 나눌 때 공정한 몫으로 수렴하는 성질이 있다.

현대 알고리즘

  • CUBIC(RFC 9438): 리눅스 기본 알고리즘이다. 손실 뒤 cwnd 를 0.7 배로 줄이고(Reno 의 0.5 보다 덜 줄인다), 마지막 손실 지점 근처까지는 3차 함수 모양으로 빠르게 회복했다가 그 근처에서 조심스럽게 늘린다. RTT 와 무관하게 시간 기준으로 증가해 장거리 고속망에서 Reno 보다 낫다.
  • BBR: 손실 대신 병목 대역폭과 최소 RTT 를 측정해 보내는 속도를 정하는 모델 기반 방식이다(Cardwell 외, ACM Queue, 2016). 손실이 혼잡과 무관하게 생기는 무선·장거리 망에서 유리할 수 있다.
  • ECN(RFC 3168): 라우터가 패킷을 버리는 대신 IP 헤더에 혼잡 표시를 해 주고, 수신자가 이를 송신자에게 알린다. 손실 없이 혼잡 신호를 받는다.

직접 해 보기

Reno 방식의 cwnd 변화를 RTT 단위로 단순화해 시뮬레이션해 보자. 손실은 정해진 라운드에 일어난다고 가정한다.

def simulate(rounds, losses, timeouts, iw=10, ssthresh=64):
    cwnd, trace = iw, []
    for r in range(rounds):
        trace.append(cwnd)
        if r in timeouts:                     # RTO: 가장 강한 신호
            ssthresh, cwnd = max(cwnd // 2, 2), 1
        elif r in losses:                     # 중복 ACK 3개: 빠른 회복
            ssthresh = max(cwnd // 2, 2); cwnd = ssthresh
        elif cwnd < ssthresh:                 # 느린 시작: RTT 마다 두 배
            cwnd = min(cwnd * 2, ssthresh)
        else:                                 # 혼잡 회피: RTT 마다 +1
            cwnd += 1
    return trace

t = simulate(rounds=30, losses={12, 22}, timeouts={26})
for r, c in enumerate(t):
    print(f"{r:2d} {c:3d} " + "#" * c)

# 대역폭-지연 곱
def bdp_bytes(bps, rtt_ms): return bps * rtt_ms / 1000 / 8
print("1Gb/s x 100ms BDP = %.1f MB" % (bdp_bytes(1e9, 100) / 1e6))
print("64KB 윈도, RTT 100ms 상한 = %.2f Mb/s" % (65535 * 8 / 0.1 / 1e6))

출력의 앞부분과 끝부분은 다음과 같다(가운데 생략).

 0  10 ##########
 1  20 ####################
 2  40 ########################################
 3  64 ################################################################
 4  65 #################################################################
...
12  73 #########################################################################
13  36 ####################################
14  37 #####################################
...
22  45 #############################################
23  22 ######################
...
26  25 #########################
27   1 #
28   2 ##
29   4 ####
1Gb/s x 100ms BDP = 12.5 MB
64KB 윈도, RTT 100ms 상한 = 5.24 Mb/s

빠르게 두 배씩 오르다(느린 시작), 한 칸씩 오르다(혼잡 회피), 손실에서 절반으로 떨어지는 톱니 모양이 보인다. 26번 라운드의 타임아웃 뒤에는 1 부터 다시 시작한다. 중복 ACK 로 감지한 손실보다 타임아웃이 훨씬 비싸다는 걸 숫자로 볼 수 있다.

현업에서는

  • ss -ti 로 연결별 cwnd, rtt, 재전송 횟수, 사용 중인 혼잡 제어 알고리즘을 볼 수 있다. “느리다”는 신고가 오면 이 값부터 본다.
  • 리눅스는 net.ipv4.tcp_congestion_control 로 알고리즘을 고른다. 바꾸기 전에 실제 경로에서 측정해야 한다. 경로마다 결과가 다르다.
  • 장거리 대용량 전송(백업, 복제)이 느리면 소켓 버퍼 상한(tcp_rmem, tcp_wmem)이 BDP 보다 작은지 확인한다.
  • 수신 쪽 앱이 느려 zero window 가 반복되면 네트워크가 아니라 소비자 처리 속도 문제다. 패킷 캡처에서 “TCP ZeroWindow” 표시로 구분한다.
  • 홈랩처럼 RTT 가 1ms 미만인 LAN 에서는 윈도가 거의 문제가 되지 않는다. 같은 앱이 클라우드 원거리 리전에서 느려지는 건 그래서다.

확인 문제

  1. rwnd 와 cwnd 는 각각 누가 정하고, 무엇을 보호하는가?
  2. 대역폭 100Mb/s, RTT 40ms 인 경로의 BDP 는 몇 바이트인가?
  3. 중복 ACK 3개로 감지한 손실과 RTO 만료는 cwnd 에 어떻게 다르게 반영되는가? 왜 다르게 다루는가?
  4. 윈도 스케일 옵션이 없으면 RTT 200ms 경로의 최대 처리량은 대략 얼마인가?
  5. CUBIC 이 손실 후 cwnd 를 줄이는 비율은 Reno 와 어떻게 다른가?

풀이

  1. rwnd 는 수신자가 버퍼 여유를 알려 수신자를 보호하고, cwnd 는 송신자가 손실·지연 신호로 추정해 네트워크를 보호한다.
  2. 100×10^6 × 0.04 / 8 = 500,000바이트(500KB).
  3. 중복 ACK 는 뒤 패킷들은 도착하고 있다는 뜻이라 cwnd 를 절반 수준으로만 줄인다. RTO 는 아무것도 도착하지 않는다는 강한 신호라 cwnd 를 1 세그먼트 수준으로 줄이고 느린 시작부터 다시 한다.
  4. 65,535바이트 × 8 / 0.2초 ≈ 2.6 Mb/s.
  5. CUBIC 은 0.7 배로 줄이고 Reno 는 0.5 배로 줄인다.

더 읽을거리 (References)