TCP 흐름 제어를 tcpdump로 직접 들여다보기
최근 네트워크 지식이 부족하다고 느껴 TCP 수준부터 다시 공부해보려고 관련 서적을 읽고 있습니다. 그중 흐름 제어(flow control) 부분을 읽다가 잘 이해되지 않는 부분이 있었는데, 직접 테스트하고 패킷을 캡처해서 분석해보면 어떨까 싶어 느린 수신자와 빠른 송신자를 만들어 놓고 tcpdump로 잡아봤습니다. 그 과정에서 공부한 내용을 공유합니다.
흐름 제어란 무엇인가
흐름 제어는 한 줄로 줄이면 받는 쪽이 감당할 수 있는 만큼만 보내게 하는 것입니다. 송신자가 아무리 빨라도 수신자가 못 받으면 소용없으니까요.
대화로 그려보면 이렇습니다.
- 수신자: 저는 지금
100바이트까지만 받을 수 있어요. - 송신자: 저는
200까지 보낼 수 있지만, 당신이100까지라니 딱 그만큼만 보낼게요.
그럼 이 “받을 수 있는 양”을 어떻게 알릴까요? TCP는 이걸 윈도우 크기(window size)로 매 패킷에 실어 알립니다. 수신자가 자기 수신 버퍼의 여유 공간을 윈도우로 광고하고, 송신자는 그 윈도우를 넘지 않는 선에서만 보내는 식입니다.
윈도우 크기와 슬라이딩 윈도우
윈도우 크기는 TCP 헤더에서 16비트 필드라, 원래는 0 ~ 65535(바이트)까지밖에 표현하지 못합니다. 요즘은 핸드셰이크 때 window scale 옵션으로 이 범위를 키우는데, 잠시 뒤 캡처에서 실제로 협상되는 걸 보게 됩니다.
그리고 이 윈도우를 활용하는 방식이 슬라이딩 윈도우(sliding window) 입니다. 패킷을 하나 보내고 ACK를 받을 때까지 기다리는 stop-and-wait 대신, 윈도우 크기만큼은 ACK를 안 기다리고 연속으로 흘려보낸 뒤, ACK가 오는 만큼 윈도우를 앞으로 밀며 다음 데이터를 채웁니다.
stop-and-wait를 쓰면 문제가 분명합니다. 패킷 하나마다 ACK를 기다리니 대역폭이 남아돌아도 회선이 노는 시간이 많고(낮은 효율), 왕복 지연만큼 전송이 길어집니다(높은 대기시간). 슬라이딩 윈도우는 여러 패킷을 한 번에 띄워 이 빈 시간을 메우면서, 동시에 그 “한 번에”의 양을 수신자 윈도우로 제한해 흐름 제어까지 같이 하는 셈입니다.
슬라이딩 윈도우 실험
정말 윈도우가 줄어드는 게 보일까요? 말로만은 추상적이라 환경을 만들어 봤습니다. 로컬 루프백(127.0.0.1:9999)에 느린 수신자(수신 버퍼를 작게 잡고, 0.4초마다 512바이트씩만 읽음)와 빠른 송신자(연결되자마자 대량으로 밀어 넣음)를 두고, 그 사이를 tcpdump로 캡처했습니다.
sudo tcpdump -i lo0 -nn -S 'tcp port 9999'
핸드셰이크에서 윈도우 스케일을 협상한다
먼저 연결을 맺는 SYN / SYN-ACK입니다.
127.0.0.1.52989 > 127.0.0.1.9999: Flags [S], win 65535, options [mss 16344, wscale 6, sackOK, ...]
127.0.0.1.9999 > 127.0.0.1.52989: Flags [S.], win 65535, options [mss 16344, wscale 3, sackOK, ...]
양쪽이 wscale(window scale)을 주고받는 게 보입니다. tcpdump가 핸드셰이크에서 이 옵션을 봤기 때문에, 이후 패킷의 win 값은 스케일을 반영한 실제 광고 윈도우(바이트)로 찍히는 듯합니다. 즉 아래 숫자들이 수신자가 “지금 이만큼 받을 수 있다”고 알리는 양인 셈입니다.
윈도우가 65535에서 0까지 줄어든다
수신자가 데이터를 느리게 읽으니, 수신 버퍼가 차면서 광고하는 윈도우가 계속 줄어듭니다. 수신자(9999)가 보낸 ACK들의 win만 순서대로 추리면 이렇게 흘러갔습니다.
win 65535 → win 40830 → win 25482 → ... → win 907 → win 0
줄어들다 못해 win 0, 즉 수신자가 “지금은 한 바이트도 못 받는다”고 알리는 zero window까지 갔습니다. 이번 캡처에선 zero window 패킷이 25개 잡혔습니다.
127.0.0.1.9999 > 127.0.0.1.52989: Flags [.], ack ..., win 0
윈도우가 0이면 송신자는 멈춘다
흐름 제어가 실제로 송신을 멈추는지는 송신자 쪽 로그에서 더 분명히 드러났습니다. 송신자는 연결 직후 순식간에 약 377KB를 밀어 넣고는, 그대로 멈췄습니다.
send: total= 376832 t= 0.00s
send: total= 385024 t= 5.20s <- 약 5.2초 동안 멈췄다가 재개
t=0.00s에 버퍼와 수신 윈도우를 가득 채운 뒤, 약 5.2초 동안 send()가 블록됐습니다. 보낼 데이터가 있는데도 못 보낸 겁니다. 패킷 타임라인에서도 같은 구간에 수신자의 win 0가 찍혀 있었으니, 송신자가 멈춘 이유가 “내가 느려서”가 아니라 “수신자가 받을 자리가 없어서”였다는 게 맞물리는 듯합니다.
수신자가 비우면 윈도우가 다시 열린다
수신자가 0.4초마다 조금씩 읽어내며 버퍼에 자리가 생기자, 수신자는 윈도우가 다시 열렸다는 걸 알리는 패킷(window update)을 보냅니다.
127.0.0.1.9999 > 127.0.0.1.52989: Flags [.], ack ..., win 32723
... win 63346 ...
win이 다시 커지고 나서야 송신자의 전송이 재개됐습니다. 줄었다(흐름 제어), 0이 됐다(정지), 다시 열렸다(재개)가 한 흐름으로 패킷에 그대로 남아 있었습니다.
남은 궁금증 하나
실험하면서 든 의문이 있었습니다. 송신 측 버퍼에 윈도우 크기만큼 데이터가 아직 안 쌓였으면, 그만큼 모일 때까지 기다렸다 보낼까요? 찾아보니 그렇지는 않았습니다. TCP는 윈도우를 채우려고 기다리지 않고, 보낼 수 있는 만큼 그때그때 내보냅니다. 다만 아주 작은 조각이 잦으면 효율이 떨어지니, Nagle 알고리즘이 작은 세그먼트를 잠깐 모았다 보내는 식으로 다듬습니다. 즉 “윈도우만큼 모아 보낸다”기보다, “윈도우를 넘지 않는 선에서 있는 만큼 보내되, 너무 잘게는 안 보낸다”에 가까웠습니다.
정리
- 흐름 제어는 수신자가 받을 수 있는 양을 윈도우로 광고하고, 송신자가 그걸 넘지 않게 보내는 메커니즘이었음. 그 윈도우를 ACK에 맞춰 밀어가며 연속 전송하는 게 슬라이딩 윈도우.
- 직접 캡처해보니 수신자가 느릴 때 광고 윈도우가 65535에서 0까지 줄고,
win 0인 동안 송신자가 약 5.2초 멈췄다가, window update로 윈도우가 열리자 재개됐음. 책에서 글로만 보던 zero window와 window update를 패킷으로 확인한 셈. - 윈도우 16비트의 한계는 핸드셰이크의 window scale 옵션으로 넓힌다는 것도 캡처에서 같이 봤음.
댓글
GitHub(giscus) 댓글은 설정 완료 후 활성화됩니다.