TCP(Transmission Control Protocol)는 네트워크 통신의 근간을 이루는 중요한 프로토콜이다. TCP는 단순히 데이터를 보내고 받는 것 이상의 기능을 제공하는데, 주된 세 가지 기능은 흐름 제어, 오류 제어, 그리고 혼잡 제어가 있다.
이 기능들 덕분에 네트워크에서 발생할 수 있는 문제를 TCP가 알아서 처리해주기 때문에 상위 레이어, 즉 애플리케이션 개발자는 복잡한 네트워크 상황을 신경 쓸 필요가 없다.
흐름 제어는 송신 측과 수신 측의 데이터 처리 속도를 맞추기 위한 장치이다. 송신 측이 데이터를 너무 빨리 보내면 수신 측의 버퍼가 꽉 차서 데이터를 제대로 처리하지 못할 수 있다. 이때 발생하는 문제가 오버플로우(Overflow)다. 이를 막기 위해 TCP는 윈도우 크기(Window Size)를 통해 송신 측이 보낼 수 있는 데이터 양을 조절한다.
윈도우 크기는 수신 측이 자신의 버퍼에서 처리할 수 있는 데이터의 양을 송신 측에 전달하는 정보다. 그래서 송신 측은 이 정보를 바탕으로 한 번에 얼마나 데이터를 보낼지 결정한다.
가장 단순한 흐름 제어 방식은 Stop-and-Wait 방식이다. 송신 측이 데이터를 보낸 후, 수신 측의 응답(ACK)를 기다렸다가 다음 데이터를 전송하는 방식이다.
기본적인 ARQ(Automatic Repeat Request)를 구현한다고 생각해 보면, 수신 측의 윈도우 크기를 1 byte로 설정하고 처리 가능 = 1, 처리 불가능 = 0과 같은 식으로 대충 구현해도 돌아가기는 한다.

이 방식은 매우 간단한 만큼 비효율적이다. 송신 측이 자신의 데이터를 직접 보내봐야 이 데이터를 수신 측이 처리할 수 있는지 알 수 있기 때문이다.
이때문에 Stop and Wait 방식을 사용해 흐름 제어를 할 경우에는, 이런 비효율성을 커버하기 위해 이런 단순한 구현이 아닌 여러 가지 오류 제어 방식을 함께 도입해 사용한다.
그렇기에 더 효율적인 방식인 Sliding Window가 등장한다. Sliding Window는 수신 측이 자신이 한 번에 받을 수 있는 데이터 양(윈도우 크기)을 송신 측에 알려주고, 송신 측은 그 범위 안에서 여러 개의 데이터를 한 번에 보낼 수 있다.
e.g. 수신 측이 한 번에 5개의 패킷을 처리할 수 있다면 송신 측은 응답을 기다리지 않고 5개의 패킷을 연달아 보낸다.

윈도우 안에 들어있는 프레임은 수신 측의 응답이 없어도 연속으로 보낼 수 있다.
송신 측의 윈도우 크기는 맨 처음 TCP의 연결을 생성하는 과정인 3-Way Handshake 때 결정된다. 이때 송신 측과 수신 측은 자신의 현재 버퍼 크기를 서로에게 알려주게 되고, 송신 측은 수신 측이 보내준 버퍼 크기를 사용해 자신의 윈도우 크기를 정하게 된다.
localhost.initiator > localhost.receiver: Flags [S], seq 1487079775, win 65535
localhost.receiver > localhost.initiator: Flags [S.], seq 3886578796, ack 1487079776, win 65535
localhost.initiator > localhost.receiver: Flags [.], ack 1, win 6379
tcpdump를 통해 3-Way Handshake를 관찰해 보자. 처음 SYN과 SYN+ACK 패킷에는 각자 자신의 버퍼를 알려준 후 마지막 ACK 패킷 때 송신 측이 자신이 정한 윈도우 사이즈를 수신 측에 통보한다.
이때 송신, 수신 측 모두 자신의 버퍼 크기를 65535라고 했지만 최종적으로 송신 측이 정한 윈도우 크기는 6379이다. 왜 송신 측은 수신 측 버퍼 크기의 10분의 1로 자신의 윈도우 크기를 정한 것일까?
송신 측의 윈도우 크기는 수신 측의 버퍼 크기로만 정하는 것이 아니라 다른 여러 가지 요인들을 함께 고려해 결정되기 때문이다. 네트워크 상태에 따라, 예를 들어 패킷 왕복 시간(Round Trop Time, RTT)이 길어지거나 네트워크가 혼잡하다고 판단되면, 송신 측은 윈도우 크기를 줄여서 네트워크에 부담을 덜 주도록 조절할 수 있다.
이때 정해진 윈도우 크기는 통신을 하는 과정 중간에도 계속해서 네트워크의 혼잡 환경과 수신 측이 보내주는 윈도우 크기를 통해 동적으로 변경될 수 있다.
🪟 이름이 왜 Sliding Window인가?
먼저, 송신 측이 0~6번의 시퀀스 번호를 가진 데이터를 상대방에게 전송하고 싶어하는 상황을 생각해 보자. 이때 송신 측의 버퍼에는 전송해야 할 데이터들이 아래와 같이 담겨져 있을 것이다.
이때 송신 측은 수신 측에게 받은 윈도우 크기와 현재 네트워크 상황을 고려하여 윈도우 크기를 3으로 잡았고, 윈도우 안에 있는 데이터를 순차적으로 전송한다.
송신 측이 데이터를 전송했을 때, 그 데이터가 수신 측에 도착했는지, 혹은 수신 측이 잘 처리했는지 여부는 아직 모르는 상태이다. 이 상황에서 송신 측의 윈도우에는 이미 전송한 데이터들이 남아있지만, 응답(ACK)을 받기 전까지는 여전히 확인되지 않은 상태인 것이다.
이처럼 윈도우 안에 있는 데이터들은 모두 전송이 끝났지만, 아직 수신 측이 응답을 보내지 않았기 때문에 “불확실한” 상태로 남아있다. 이 점이 슬라이딩 윈도우의 중요한 포인트 중 하나다. 즉, 송신 측은 항상 여러 개의 데이터를 보내면서도 그 데이터들이 실제로 전송이 완료되었는지 여부는 나중에 확인해야 한다.
이제 수신 측이 데이터를 처리한 후, 응답(ACK)을 보내면서 “나 윈도우 크기 1만큼 남았어”라는 메세지를 보낸다고 가정해 보자. 이때 송신 측은 그 메세지를 받고 나서, 윈도우를 한 칸 오른쪽으로 밀어서 새롭게 들어온 데이터를 보낼 준비를 한다.
이 과정이 슬라이딩 윈도우에서 중요한 “슬라이딩” 동작이다. 윈도우가 한 칸씩 옆으로 밀리면서 새로운 데이터를 전송한다. 예를 들어, 송신 측은 이제 3번 데이터를 윈도우에 넣고 전송할 수 있게 된다.
단, 이 경우 송신 측의 윈도우 크기가 3이기 때문에 수신 측이 4를 보냈다고 해서 4칸을 밀지는 않고, 자신의 윈도우 크기인 3만큼만 밀 수 있다. 그러나 이 경우에는 송신 측이 수신 측의 퍼포먼스가 더 좋아졌다는 것을 알았으니 자신의 윈도우 크기를 늘리는 방법으로 대처할 수 있을 것이다.
즉, 슬라이딩 윈도우 방식은
보내고→응답 받고→윈도우 밀고를 반복하면서, 현재 자신이 보낼 수 있는 데이터를 최대한 연속적으로 보내는 방법이라고 할 수 있다.
TCP는 데이터가 손상되거나 유실되는 것을 허용하지 않는다. 네트워크 통신에서 오류가 발생하면, 이를 감지하고 다시 데이터를 전송하는 방식으로 오류를 처리한다. 이를 재전송 기반 오류제어, ARQ(Automatic Repeat Request)라고 부른다.

패킷 기반 전송을 하는 TCP의 특성 상 각 패킷의 도착 순서가 무조건 보장되는 것이 아니기에, 중복된 ACK를 보통은 3회 정도 받았을 때 에러라고 판별한다.
즉, 데이터를 제대로 받지 못했으면 그 데이터를 다시 보내달라고 요청하는 방식이다. 이 방식이 TCP가 신뢰성을 제공하는 핵심 이유이다.
하지만 이 재전송이라는 작업 자체가 했던 일을 다시 해야하는 비효율적인 작업이기 때문에, 이 재전송 과정을 최대한 줄일 수 있는 여러 가지 방법을 사용하게 된다.
🚨 오류가 발생했다는 걸 확인하는 방법
TCP를 사용하는 송수신 측이 오류를 파악하는 방법은 크게 두 가지로 나누어진다.
수신 측이 송신 측에게 명시적으로
NACK(부정응답)을 보내는 방법, 그리고 송신 측에게 ACK(긍정응답)가 오지 않거나, 중복된ACK가 계속해서 오면 오류가 발생했다고 추정하는 방법이다.간단히 생각해 보면 NACK을 사용하는 게 훨씬 명확하고 간단할 것 같지만, NACK을 사용하게 되면 수신 측이 상대방에게 ACK을 보낼지 NACK을 보낼지 선택해야 하는 로직이 추가적으로 필요하기 때문에, 일반적으로는 ACK만을 사용해 오류를 추정한다.
가장 기본적인 오류 제어 방식이다. 송신 측은 데이터를 하나 보낸 뒤, ACK를 기다린다. 만약 일정 시간 안에 ACK가 오지 않으면, 송신 측은 해당 데이터를 다시 보낸다.

흐름 제어의 Stop and Wait 때 한 번 살펴본 방식인데, 이 방식만으로 흐름 제어와 오류 제어가 동시에 가능하다. 그러나 위에서 살펴본 슬라이딩 윈도우를 사용하여 흐름 제어를 하는 경우에는 윈도우 안에 있는 데이터를 연속적으로 보내야 하기 때문에, 오류 제어에 Stop and Wait를 사용해 버리면 슬라이딩 윈도우를 사용하는 이점을 잃어버린다.
그런 이유로 일반적으로 이런 단순한 방법보다 조금 더 효율적이도 똑똑한 ARQ를 사용한다.
더 발전된 방식이 Go Back N ARQ이다. 송신 측은 여러 데이터를 연속적으로 보낼 수 있는데, 만약 중간에 하나의 패킷이 손실되면 그 이후에 보낸 모든 데이터를 다시 보내는 방식이다.
e.g. 1, 2, 3, 4번 데이터를 보냈는데, 4번 데이터가 손실되었다면, 4번부터 그 이후 모든 데이터를 재전송한다.

이미 성공적으로 전송된 데이터까지 다시 보내기 때문에 조금 비효율적이긴 하다.

그래서 나온 방식이 Selective Repeat ARQ이다. Go Back N과 달리 손실된 데이터만 다시 보내는 방식이다.
e.g. 1, 2, 3, 4번 중 3번 데이터만 손실되었다면, 3번 데이터만 다시 보내면 된다.
하지만 이 방식은 수신 측에서 데이터를 정렬할 별도의 버퍼가 필요하고, 데이터가 순차적으로 오지 않을 수 있기 때문에 약간의 복잡성이 추가된다.
혼잡 제어는 네트워크에서 혼잡 상태를 파악하고 이를 해결하기 위해 데이터 전송을 조절하는 기법이다. 네트워크는 워낙 광대하고 블랙박스 같은 특성을 갖고 있어서, 어디에서 어떤 이유로 전송이 느려지는지 정확하게 파악하기 어렵다.
하지만 데이터 전송이 느려지는 현상 자체는 송신 측에서 쉽게 감지할 수 있다. 예를 들어, 데이터를 보냈는데 상대방으로부터 응답이 늦게 오거나 아예 안 오면 문제가 발생했다는 걸 알 수 있듯 말이다.
이때 흐름 제어나 오류 제어 기법만 사용하면 재전송이 계속 반복될 수밖에 없다. 한두 번의 재전송은 큰 문제가 아니겠지만, 네트워크가 여러 사람들이 함께 사용하는 공간이기 때문에, 이런 재전송이 여러 곳에서 발생하면 네트워크가 심각하게 혼잡해질 수 있다. 이것을 혼잡 붕괴라고 한다.
따라서 네트워크의 혼잡이 감지되면 최악의 상황을 피하기 위해 송신 측은 혼잡 윈도우(CWND)의 크기를 줄여 데이터를 덜 보내는 방식으로 대응한다. 이 과정이 바로 혼잡 제어다.

🪟 혼잡 윈도우(Congestion Window, CWND)
혼잡 제어에서 중요한 개념인 혼잡 윈도우는 송신 측이 네트워크의 혼잡 상태를 고려해 정하는 윈도우 크기를 의미한다. 송신 측은 데이터를 보낼 때 수신 측에서 보내준 수신 윈도우(RWND)와 자신이 결정한 혼잡 윈도우 중 더 작은 값을 사용한다. 즉, 송신 윈도우 크기가 이 두 값에 의해 결정된다.
혼잡 윈도우는 네트워크 상황을 반영해 계속 변화하는데, 여기서 조정하는 건 송신 윈도우가 아니라 송신 측의 혼잡 윈도우다.
TCP 혼잡 제어 정책들은 혼잡 상태를 감지하는 방법과 혼잡 윈도우 크기를 조절하는 방식을 점차 발전시켜 왔다. 그 기본은 AIMD와 Slow Start라는 두 가지 기법을 상황에 맞게 조합해 사용하는 방식이다.
AIMD는 패킷을 처음 보낼 때 하나씩 보내고, 문제가 발생하지 않으면 전송 속도를 서서히 증가시키는 방식이다. 전송 속도는 1씩 추가적으로 늘어나고(Additive Increase), 만약 패킷 전송에 실패하면 전송 속도를 절반으로 줄인다.(Multiplicative Decrease) 이렇게 하면 전송 속도가 네트워크 상태에 따라 서서히 안정되는 균형점에 도달한다.

이 방식은 아주 간단해 보이지만, 네트워크 자원을 공평하게 분배하는 데 효과적이다.
예를 들어, 여러 사용자가 이미 네트워크를 이용하고 있는 상황에서 새로 들어온 사용자가 있다면, 기존 사용자의 윈도우 크기가 더 클 것이다. 하지만 혼잡이 발생하면 윈도우 크기가 큰 사용자부터 데이터를 더 많이 보내게 되므로 손실 확률도 높아진다.
이런 상황이라면 네트워크에 일찍 참여해 이미 혼잡 윈도우가 큰 사람이 자신의 윈도우 크기를 줄여서 혼잡 상황을 해결하려 할 것이고, 이때 남은 대역폭을 활용하여 나중에 들어온 사람들이 자신의 혼잡 윈도우 크기를 키울 수 있는 것이다. 그런 이유로 시간이 흐르면 네트워크에 참여한 순서와 관계 없이 모든 호스트들의 윈도우 크기가 평행 상태로 수렴하게 된다.
그러나 AIMD의 문제점은 네트워크 대역이 펑펑 남아도는 상황에도 윈도우 크기를 너무 조금씩 늘리면서 접근한다는 것이다. 때문에 AIMD 방식은 네트워크의 모든 대역을 활용하여 제대로 된 속도로 통신하기까지 시간이 걸린다.
Slow Start는 AIMD의 단점을 보완한 방식이다. AIMD는 전송 속도를 1씩 천천히 올리는 반면, Slow Start는 처음에 패킷을 하나 보내고, ACK를 받을 때마다 윈도우 크기를 지수적으로(2배씩) 늘려나간다. 혼잡이 감지되면 윈도우 크기를 1로 다시 줄이고, 그 후에는 혼잡이 발생할 때까지 빠르게 늘리다가 일정 부분에서부터는 천천히 1씩 증가시킨다.
이 방식 덕분에 네트워크 용량에 대한 정보를 빠르게 파악할 수 있고, 어느 정도 네트워크 수용량을 예측한 후 혼잡이 발생하기 전까지는 전송 속도를 계속해서 빠르게 늘릴 수 있다.
TCP에서 혼잡 제어는 네트워크의 혼잡 상태를 감지하고 그에 맞춰 데이터 전송을 조절하는 방법이다. 주요 기법으로는 Tahoe와 Reno가 있으며, 이 두 가지 기법은 기본적으로 네트워크가 혼잡하다고 느껴졌을 때 윈도우 크기를 줄이거나, 증가를 멈추고 혼잡을 회피하는 방식으로 동작한다.
🚨 혼잡 감지 기준
- Timeout: 송신 측이 데이터를 보내고 나서 응답을 받지 못할 때 혼잡 상태라고 간주한다.
- 3
ACKDuplicated: 수신 측이 정상적으로 데이터를 받지 못해 같은 승인 번호(ACK)를 세 번 이상 보낼 경우, 송신 측은 네트워크에 문제가 있다고 판단한다.이 두 가지 상황이 발생하면 송신 측은 혼잡이 발생했다고 보고 윈도우 크기를 줄인다.
Slow Start로 시작해 윈도우 크기를 지수적으로 빠르게 증가시킨다.
혼잡 상황이 발생하면, 윈도우 크기를 1로 줄이고 다시 Slow Start를 시작한다. 이때 ssthresh(Slow Start Threshold) 값을 윈도우 크기의 절반으로 설정하고, 이후 혼잡 제어는 AIMD 방식으로 진행된다.

이는 혼잡이 발생했을 때 윈도우 크기를 1로 줄이는 과정이 비효율적일 수 있다.
Reno는 Tahoe의 문제점을 개선한 방식으로, Slow Start로 시작하여 임계점(ssthresh)을 넘으면 AIMD 방식으로 전환된다.
ACK Duplicated 발생 시: Tahoe와 달리, 윈도우 크기를 1로 줄이지 않고 반으로만 줄이고 다시 합 증가 방식으로 윈도우 크기를 늘린다. 이를 통해 빠른 회복(Fast Recovery)가 가능하다.즉, Reno는 ACK 중복과 타임아웃을 구분하여 더 유연하게 대처한다. ACK 중복은 비교적 작은 혼잡 상황으로 간주해 회복 과정을 빠르게 처리하고, 타임아웃은 더 심각한 문제로 보고 더 강하게 대처하는 것이다.

Reference