본문 바로가기

흐름 제어와 혼잡 제어

TCP가 한 번에 얼마나 보낼지를 정하는 두 장치를 비교합니다. 수신 측을 지키는 수신 윈도우와 네트워크를 지키는 혼잡 윈도우로 전송 한도를 계산하고, 슬로 스타트와 혼잡 회피, 손실 뒤의 반응을 RTT 단위의 모형으로 따라간 뒤, CUBIC과 BBR 같은 현대 알고리즘과 Linux에서 확인하는 방법을 정리합니다.

TCP의 신뢰성은 빠진 데이터를 다시 보내는 것만으로 이루어지지 않습니다. 받는 쪽이 감당하지 못할 만큼 쏟아부으면 수신 버퍼가 넘치고, 네트워크가 감당하지 못할 만큼 보내면 라우터의 큐가 넘쳐 패킷이 사라집니다. 어느 쪽이든 재전송이 늘어 오히려 느려집니다. 그래서 TCP는 두 가지 장치로 한 번에 보낼 양을 조절합니다. 받는 쪽을 지키는 흐름 제어(flow control)와 네트워크를 지키는 혼잡 제어(congestion control)입니다.

흐름 제어는 수신 측을 지킨다

받는 쪽 운영체제에는 도착한 데이터를 애플리케이션이 읽어 갈 때까지 담아 두는 수신 버퍼가 있습니다. 애플리케이션이 읽는 속도보다 데이터가 빨리 들어오면 버퍼의 여유가 줄어듭니다. 받는 쪽은 이 여유를 세그먼트 헤더의 Window 필드에 적어 보내는데, 이 값을 수신 윈도우(receive window, rwnd)라고 합니다. 보내는 쪽은 아직 ACK를 받지 못한 데이터가 rwnd를 넘지 않도록 보냅니다.

ACK가 올 때마다 확인된 만큼 보낼 수 있는 범위가 앞으로 밀려 나가므로, 이 방식을 슬라이딩 윈도우(sliding window)라고 부릅니다. 애플리케이션이 버퍼를 비우면 rwnd가 다시 커지고, 처리가 밀리면 줄어듭니다. rwnd가 0이 되면 보내는 쪽은 새 데이터를 멈추고, 윈도우가 다시 열렸는지 주기적으로 작은 probe를 보내 확인합니다(RFC 9293 §3.8.6.1). 윈도우가 열렸다는 ACK가 사라져도 연결이 영원히 멈추지 않게 하는 장치입니다.

Window 필드는 16비트라 원래는 65535바이트까지만 적을 수 있습니다. 1장 3편에서 계산한 것처럼 윈도우가 작으면 RTT마다 보낼 수 있는 양이 묶여, 회선이 아무리 빨라도 처리량이 윈도우 ÷ RTT를 넘지 못합니다. 그래서 SYN에서 Window Scale 옵션(RFC 7323)을 협상해 Window 값에 2의 거듭제곱을 곱해 읽습니다. 배수의 지수는 최대 14이므로 윈도우를 약 1GiB까지 키울 수 있습니다.

혼잡 제어는 네트워크를 지킨다

rwnd는 받는 쪽의 사정만 알려 줄 뿐, 그 사이 네트워크가 얼마나 붐비는지는 알려 주지 않습니다. 그래서 보내는 쪽은 스스로 네트워크에 얼마나 실어도 되는지를 짐작한 값을 따로 관리하는데, 이것이 혼잡 윈도우(congestion window, cwnd)입니다. 혼잡 제어는 패킷 손실이나 ECN 표시, 늘어나는 지연 같은 신호로 혼잡을 짐작하고 cwnd를 키우거나 줄입니다.

보내는 쪽은 두 윈도우를 모두 지켜야 합니다. RFC 5681의 용어로, 보냈지만 아직 ACK를 받지 못한 양을 FlightSize라고 하면, 새로 보낼 수 있는 양은 min(rwnd, cwnd)에서 FlightSize를 뺀 값이고 음수라면 0입니다.

TCP · 전송 한도

새 데이터 한도는 작은 창에서 미확인 바이트를 뺀 값이다

수신 측 rwnd와 송신 측 cwnd 중 작은 값이 제한합니다. 그중 이미 전송했지만 확인되지 않은 양을 빼고, 결과가 음수면 새 데이터를 추가하지 않습니다.

단위가 바이트인 가정값과 추가 전송 한도
상황rwnd (B)cwnd (B)미확인 (B)추가 한도 (B)
수신 측 제한4,00010,0003,0001,000
혼잡 제어 제한12,00010,0006,0004,000
창이 줄어든 뒤2,0008,0003,0000
수신 측 제한
rwnd 4,000 B · cwnd 10,000 B
미확인 3,000 B → 추가 한도 1,000 B
혼잡 제어 제한
rwnd 12,000 B · cwnd 10,000 B
미확인 6,000 B → 추가 한도 4,000 B
창이 줄어든 뒤
rwnd 2,000 B · cwnd 8,000 B
미확인 3,000 B → 추가 한도 0 B

가정값에 max(0, min(rwnd, cwnd) − flight size)를 적용한 새 데이터 한도입니다. 수신 측 광고가 줄어도 이미 보낸 바이트를 되돌릴 수는 없습니다. 재전송·윈도우 탐색과 pacing 등은 이 단순 계산에서 제외합니다.

첫 행은 rwnd가 4000바이트로 더 작아 받는 쪽이 한도를 정하고, 이미 3000바이트가 날아가 있으므로 1000바이트만 더 보낼 수 있습니다. 둘째 행은 cwnd가 더 작아 네트워크 쪽 사정이 한도를 정합니다. 셋째 행은 rwnd가 2000바이트로 줄었는데 이미 3000바이트를 보내 둔 경우로, 계산하면 음수가 되므로 새 데이터를 보내지 않고 ACK를 기다립니다. 이미 보낸 바이트를 되돌리는 것은 아닙니다. 실제로는 송신 버퍼에 데이터가 있는지, 패킷을 한꺼번에 몰아 보내지 않고 사이에 간격을 두어 내보내는 페이싱(pacing)이 적용되는지 같은 조건도 함께 작용합니다.

슬로 스타트로 경로를 더듬는다

연결이 막 맺어졌을 때 보내는 쪽은 경로가 얼마나 감당할 수 있는지 모릅니다. 그래서 작은 cwnd에서 시작해, 새 데이터를 확인하는 ACK를 받을 때마다 cwnd를 한 세그먼트씩 늘립니다. 한 RTT 동안 보낸 세그먼트가 모두 ACK되면 cwnd는 두 배가 되므로, 이름과 달리 빠르게 커집니다. 이것이 슬로 스타트(slow start)입니다. 처음 cwnd를 초기 윈도우(initial window)라고 하며, RFC 5681은 MSS에 따라 2~4 세그먼트를 정했고 RFC 6928은 이를 10 세그먼트로 늘리는 실험을 제안했습니다. Linux를 비롯한 많은 구현이 지금은 10 세그먼트로 시작합니다.

cwnd가 슬로 스타트 임계값(slow start threshold, ssthresh)에 이르면 혼잡 회피(congestion avoidance)로 넘어갑니다. 혼잡 회피에서는 ACK마다 cwnd를 조금씩 늘려, 한 RTT에 대략 한 세그먼트씩 커지게 합니다. 경로의 한계에 가까워졌을 테니 조심스럽게 늘리는 것입니다.

손실을 알아채는 방법에 따라 반응도 다릅니다. 재전송 타이머가 끝나도록 ACK가 오지 않는 타임아웃은 심한 혼잡의 신호로 봅니다. 이때는 ssthresh를 보낸 양의 절반으로 낮추고 cwnd를 한 세그먼트로 줄여 슬로 스타트부터 다시 합니다. 같은 ACK가 세 번 더 오는 중복 ACK는 세그먼트 하나가 빠졌지만 그 뒤의 세그먼트들은 도착하고 있다는 뜻이므로, 빠진 세그먼트를 타이머를 기다리지 않고 바로 다시 보내는 빠른 재전송(fast retransmit)을 하고, cwnd를 절반 정도로만 줄여 혼잡 회피를 이어 가는 빠른 회복(fast recovery)으로 넘어갑니다.

다음 코드는 Reno 방식의 이 규칙을 RTT 단위로 단순화해 cwnd의 변화를 따라갑니다. 단위는 세그먼트 수이고, 6번째 RTT에 중복 ACK로, 10번째 RTT에 타임아웃으로 손실을 알아챘다고 가정합니다.

def reno_rounds(rounds, events, cwnd=10, ssthresh=40):
    for rtt in range(1, rounds + 1):
        event = events.get(rtt)
        line = f"{rtt:>2}  cwnd={cwnd:<3} ssthresh={ssthresh}"
        print(f"{line}  {event}" if event else line)
        if event == "중복 ACK 3개":
            ssthresh = max(cwnd // 2, 2)        # 보낸 양의 절반
            cwnd = ssthresh                     # 빠른 회복 뒤 절반에서 혼잡 회피를 이어 간다
        elif event == "타임아웃":
            ssthresh = max(cwnd // 2, 2)
            cwnd = 1                            # 한 세그먼트부터 슬로 스타트를 다시 한다
        elif cwnd < ssthresh:
            cwnd = min(cwnd * 2, ssthresh)      # 슬로 스타트: RTT마다 두 배
        else:
            cwnd += 1                           # 혼잡 회피: RTT마다 하나씩

reno_rounds(12, {6: "중복 ACK 3개", 10: "타임아웃"})
 1  cwnd=10  ssthresh=40
 2  cwnd=20  ssthresh=40
 3  cwnd=40  ssthresh=40
 4  cwnd=41  ssthresh=40
 5  cwnd=42  ssthresh=40
 6  cwnd=43  ssthresh=40  중복 ACK 3개
 7  cwnd=21  ssthresh=21
 8  cwnd=22  ssthresh=21
 9  cwnd=23  ssthresh=21
10  cwnd=24  ssthresh=21  타임아웃
11  cwnd=1   ssthresh=12
12  cwnd=2   ssthresh=12

처음 두 RTT에는 cwnd가 두 배씩 커져 세 번째 RTT에 ssthresh 40에 이르고, 그 뒤로는 하나씩 늘어납니다. 중복 ACK를 받은 뒤에는 43의 절반인 21에서 다시 하나씩 늘어나므로 처리량이 절반으로 줄었다가 회복합니다. 타임아웃 뒤에는 cwnd가 1로 떨어져 처음부터 다시 커져야 하므로 훨씬 큰 손해를 봅니다. cwnd가 천천히 커지다 손실에서 절반으로 꺾이기를 되풀이하는 이 톱니 모양을 AIMD(additive increase, multiplicative decrease)라고 부릅니다. 실제 구현은 바이트 단위로 ACK마다 cwnd를 계산하고, 지연 ACK와 페이싱, 수신 윈도우의 영향도 받으므로 이 표는 규칙의 모양을 보여 주는 모형입니다.

빠진 구간을 정확히 알려 주는 장치들

중복 ACK는 "이 번호부터 아직 못 받았다"는 것만 알려 줍니다. 한 윈도우 안에서 세그먼트 여러 개가 빠지면 보내는 쪽은 하나를 다시 보낸 뒤에야 다음 빈 곳을 알게 되어 회복이 느립니다. 선택적 확인 응답(selective acknowledgment, SACK, RFC 2018)은 받는 쪽이 이미 받은 구간들을 옵션에 적어 보내므로, 보내는 쪽이 빠진 구간만 골라 다시 보낼 수 있습니다. 최근의 Linux는 여기에 시간 정보로 손실을 판단하는 RACK-TLP(RFC 8985)를 더해, 중복 ACK가 세 개 모이지 않는 상황에서도 손실을 빨리 알아챕니다.

이런 장치들은 "무엇이 빠졌는지"를 알아내는 손실 복구 기능입니다. 손실을 알아챈 뒤 "얼마나 보낼지"를 정하는 혼잡 제어 알고리즘과는 서로 다른 일을 하므로, 둘을 구분해 두면 커널 설정을 읽을 때 헷갈리지 않습니다.

현대의 혼잡 제어 알고리즘

Reno 이후의 알고리즘들은 무엇으로 혼잡을 판단하느냐에서 갈립니다.

알고리즘혼잡을 판단하는 신호특징
Reno, NewReno손실앞에서 본 슬로 스타트, 혼잡 회피, 빠른 회복의 기본형
CUBIC손실마지막 손실 때의 윈도우를 기준으로 3차 함수 모양으로 키워, 대역폭이 크고 RTT가 긴 경로에서 빨리 회복한다. Linux와 Windows의 기본 알고리즘
BBR대역폭과 RTT의 추정병목 대역폭과 최소 RTT를 재서 보내는 속도를 정한다. 손실이 있어도 바로 줄이지 않는다
Vegas지연RTT가 늘면 큐가 쌓인다고 보고 미리 줄인다
DCTCPECN 표시의 비율표시된 비율만큼 줄여 큐를 얕게 유지한다. ECN을 갖춘 데이터센터용

CUBIC은 RFC 9438로, DCTCP는 RFC 8257로 정리되어 있습니다. 손실 기반 알고리즘은 큐가 넘칠 때까지 윈도우를 키우므로 라우터의 버퍼가 크면 지연이 함께 커지는데, BBR은 이를 피하려고 경로를 모형으로 추정합니다. 다만 BBR이 언제나 낫지는 않습니다. 손실 기반 알고리즘과 같은 병목을 나눌 때의 공평성이나, 손실이 많은 경로에서의 동작은 버전과 환경에 따라 다르므로, 바꾸기 전후를 같은 트래픽으로 재 보고 정해야 합니다.

Linux에서 확인하기

Linux에서는 기본 알고리즘과 연결마다의 윈도우를 다음처럼 확인합니다. sysctl로 읽는 세 줄은 커널이 새 연결에 적용할 시스템 전체의 설정이고, 셋째 줄의 ss -ti만 지금 열려 있는 연결 하나하나의 상태를 보여 줍니다. dst 203.0.113.5는 목적지가 그 주소인 연결만 고르는 필터이며, 문서용 주소이므로 실제로 확인할 서버의 주소로 바꾸어 실행합니다.

sysctl net.ipv4.tcp_congestion_control              # 새 연결이 쓸 기본 알고리즘
sysctl net.ipv4.tcp_available_congestion_control    # 지금 쓸 수 있는 알고리즘
ss -ti dst 203.0.113.5                              # 연결마다 cwnd, ssthresh, RTT
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem          # 수신과 송신 버퍼의 최소, 기본, 최대

ss -ti는 연결마다 다음과 같은 정보를 함께 보여 줍니다. 아래는 출력의 일부를 옮긴 예입니다.

cubic wscale:7,7 rto:204 rtt:3.512/0.5 mss:1448 cwnd:10 ssthresh:20 bytes_acked:52133

cubic은 이 연결의 알고리즘, wscale:7,7은 양쪽이 협상한 Window Scale 지수, rtt:3.512/0.5는 평균 RTT와 그 변동(밀리초), cwnd:10은 세그먼트 단위의 혼잡 윈도우입니다. 전송이 기대보다 느릴 때 cwnd가 계속 작다면 손실이나 혼잡을, cwnd는 큰데 처리량이 낮다면 수신 윈도우나 애플리케이션의 송신 속도를 의심해 볼 수 있습니다.

tcp_congestion_control은 새로 만드는 연결의 기본값일 뿐입니다. 애플리케이션이 소켓마다 TCP_CONGESTION 옵션으로 다른 알고리즘을 고를 수 있고, 서버가 받아들인 연결은 리스닝 소켓의 설정을 이어받으므로, 지금 열린 연결의 알고리즘은 ss -ti로 직접 확인합니다. 버퍼 크기는 커널이 연결의 상황에 맞춰 자동으로 조절하며, 대역폭과 RTT의 곱이 큰 경로에서는 tcp_rmem과 tcp_wmem의 최댓값이 그 곱보다 커야 윈도우를 충분히 키울 수 있습니다.

더 깊이: 재전송 타이머는 얼마로 정하나

타임아웃의 기준이 되는 재전송 타이머(retransmission timeout, RTO)는 정해진 값이 아니라 연결마다 잰 RTT로 계산합니다. RFC 6298에 따르면 보내는 쪽은 RTT의 평활 평균(SRTT)과 변동(RTTVAR)을 새 측정값이 올 때마다 각각 1/8과 1/4의 가중치로 고치고, RTO를 SRTT에 RTTVAR의 네 배를 더한 값으로 정합니다. 평균만 쓰지 않고 변동을 더하는 것은, RTT가 들쭉날쭉한 경로에서 멀쩡한 세그먼트를 너무 일찍 다시 보내지 않기 위해서입니다.

측정에도 규칙이 있습니다. 재전송한 세그먼트의 ACK는 처음 보낸 것과 다시 보낸 것 가운데 어느 쪽에 대한 응답인지 알 수 없으므로, Timestamps 옵션을 쓰지 않는다면 그 ACK로는 RTT를 재지 않습니다. 이를 Karn의 알고리즘이라고 합니다. 타이머가 끝날 때마다 RTO는 두 배로 늘어나고, RTT를 아직 재지 못한 연결의 첫 RTO는 1초입니다. 이 값은 RFC 6298이 대체한 RFC 2988에서는 3초였습니다.

RFC는 RTO가 1초보다 작으면 1초로 올리라고 권하지만(SHOULD), Linux는 이보다 훨씬 작은 200ms를 하한으로 씁니다. 커널 문서의 tcp_rto_min_us 기본값이 200000마이크로초이며, 앞의 ss -ti 출력에서 RTT가 3.5ms인데 RTO가 200ms를 조금 넘는 rto:204로 나온 것도 이 하한 때문입니다. 커널 문서에 따르면 이 하한은 경로 옵션 rto_min이나 소켓 옵션으로 따로 정할 수도 있습니다.

정리

  • 흐름 제어는 수신 윈도우(rwnd)로 받는 쪽 버퍼가 넘치지 않게 하고, 혼잡 제어는 혼잡 윈도우(cwnd)로 네트워크가 넘치지 않게 합니다.
  • 새로 보낼 수 있는 양은 min(rwnd, cwnd)에서 아직 확인받지 못한 양을 뺀 값이며, 음수면 ACK를 기다립니다.
  • Window 필드는 16비트라 Window Scale 옵션 없이는 65535바이트가 한계이고, 대역폭과 RTT의 곱이 큰 경로에서는 이 한계가 처리량을 묶습니다.
  • 슬로 스타트는 RTT마다 cwnd를 두 배로, 혼잡 회피는 하나씩 키우며, 중복 ACK에는 절반으로, 타임아웃에는 한 세그먼트로 줄입니다.
  • SACK과 RACK-TLP는 빠진 구간을 찾는 손실 복구 기능이고, CUBIC과 BBR 같은 혼잡 제어 알고리즘은 보낼 양을 정하는 방식입니다.
  • Linux에서는 ss -ti로 연결마다의 알고리즘과 cwnd, RTT를 확인합니다.

연습 문제

1. ACK가 온 뒤의 전송 한도

rwnd가 12000바이트, cwnd가 8000바이트, 아직 확인받지 못한 양이 6500바이트일 때 새로 보낼 수 있는 양은 얼마인가요? 그 뒤 새 데이터는 보내지 않은 채 3000바이트가 ACK되었고, 같은 ACK에 실린 rwnd가 6500바이트로 줄었다면 다시 얼마인가요? cwnd는 그대로라고 합니다.

해설 보기

처음에는 min(12000, 8000) − 6500 = 1500바이트입니다. ACK 뒤에는 확인받지 못한 양이 6500 − 3000 = 3500바이트로 줄고, 한도는 min(6500, 8000) = 6500바이트가 되므로 6500 − 3500 = 3000바이트를 보낼 수 있습니다. 처음 한도 1500에 ACK된 3000을 더한 4500바이트로 계산하면, 줄어든 rwnd를 빠뜨린 것입니다.

2. 타임아웃만 일어나는 경우

본문의 reno_rounds를 reno_rounds(6, {4: "타임아웃"})으로 부르면 각 RTT의 cwnd와 ssthresh는 어떻게 찍히나요?

해설 보기

1번째 RTT는 cwnd 10, 2번째는 20, 3번째는 ssthresh와 같은 40이고, 4번째는 혼잡 회피로 하나 늘어난 41에서 타임아웃이 찍힙니다. 이때 ssthresh는 41 // 2 = 20이 되고 cwnd는 1로 떨어지므로, 5번째 RTT는 cwnd 1, ssthresh 20이고 6번째는 슬로 스타트로 두 배가 된 cwnd 2, ssthresh 20입니다. ssthresh가 40에서 20으로 낮아졌으므로, 이번에는 20에 이르면 혼잡 회피로 넘어갑니다.

3. 먼 거리의 빠른 회선

대역폭이 1Gbps이고 RTT가 50ms인 경로가 있습니다. 이 회선을 가득 채우려면 윈도우가 몇 바이트여야 하나요? Window Scale 없이 65535바이트로만 보낸다면 처리량은 최대 얼마인가요? 필요한 윈도우를 광고하려면 Window Scale 지수가 최소 얼마여야 하나요?

해설 보기

1Gbps는 초당 125,000,000바이트이므로 대역폭과 RTT의 곱은 125,000,000 × 0.05 = 6,250,000바이트입니다. 윈도우가 65535바이트라면 RTT마다 그만큼만 보낼 수 있으므로 처리량은 65535 ÷ 0.05 = 1,310,700바이트/초, 약 10.5Mbps로 회선의 1%를 겨우 넘습니다. 6,250,000 ÷ 65535 ≈ 95.4배가 필요하고, 2⁶ = 64는 모자라고 2⁷ = 128이면 충분하므로 지수는 최소 7입니다. 실제로는 수신 버퍼의 최댓값도 이만큼 커야 합니다.

다음 글 TCP 심화와 실무에서는 Nagle 알고리즘과 지연 ACK, Keep-Alive처럼 TCP의 동작을 바꾸는 옵션과, 연결 고갈과 SYN Flood 같은 실무 문제를 살펴봅니다.