Flow Control: 수신 버퍼를 보호하는 TCP 흐름 제어
흐름 제어(Flow Control)는 송신자가 수신자의 처리 속도와 버퍼 용량을 넘지 않도록 전송량을 조절하는 기능이다. TCP는 수신 윈도(Receive Window, rwnd)를 알리고 슬라이딩 윈도(Sliding Window) 방식으로 확인되지 않은 데이터의 양을 제한한다. 네트워크 혼잡을 다루는 혼잡 제어(Congestion Control)와 목적이
By 송민성
1. 개념
흐름 제어(Flow Control)는 송신자가 수신자의 데이터 처리 능력에 맞춰 전송 속도와 전송량을 조절하는 방식이다.
TCP 수신자는 네트워크에서 받은 데이터를 운영체제의 수신 버퍼(Receive Buffer)에 저장한다. 애플리케이션이 데이터를 읽는 속도보다 네트워크에서 데이터가 들어오는 속도가 빠르면 버퍼가 가득 찰 수 있다. TCP는 남은 버퍼 공간을 수신 윈도(Receive Window, rwnd)로 송신자에게 알려 이를 방지한다.
수신 윈도 = 수신 버퍼 크기 - 아직 애플리케이션이 읽지 않은 데이터 크기송신자는 확인 응답(Acknowledgment, ACK)을 받지 않은 데이터의 양이 수신 윈도를 넘지 않도록 제한한다.
흐름 제어와 혼잡 제어(Congestion Control)는 제어 대상이 다르다.
| 구분 | 보호 대상 | 주요 기준 |
|---|---|---|
| 흐름 제어 | 수신자의 버퍼 | 수신 윈도(rwnd) |
| 혼잡 제어 | 네트워크 경로 | 혼잡 윈도(cwnd) |
실제 TCP 송신량은 두 윈도 중 작은 값의 제한을 받는다.
유효 송신 윈도 = min(rwnd, cwnd)2. 왜 사용하는가
송신자와 수신자의 처리 속도는 같지 않다. 고속 서버가 데이터를 빠르게 보내더라도 저사양 클라이언트나 바쁜 애플리케이션은 데이터를 즉시 처리하지 못할 수 있다.
흐름 제어가 없으면 다음 문제가 발생한다.
- 수신 버퍼가 가득 차 새 데이터를 저장하지 못한다.
- 버퍼 초과로 세그먼트가 폐기되고 재전송이 증가한다.
- 불필요한 네트워크 대역폭과 CPU 시간을 사용한다.
- 애플리케이션의 일시적인 처리 지연이 연결 성능 저하로 확대된다.
TCP 흐름 제어는 수신자가 현재 받을 수 있는 데이터 양을 지속적으로 알려 송신자가 과도한 데이터를 보내지 않게 한다.
3. 동작 원리
수신 윈도 광고
TCP 헤더에는 윈도 크기(Window Size) 필드가 있다. 수신자는 ACK를 보낼 때 현재 수신 가능한 바이트 수를 이 필드에 기록한다.
수신 버퍼가 8,000바이트이고 아직 애플리케이션이 읽지 않은 데이터가 3,000바이트라면 다음 값을 알린다.
rwnd = 8,000 - 3,000 = 5,000바이트송신자는 확인되지 않은 데이터가 최대 5,000바이트를 넘지 않도록 전송한다.
슬라이딩 윈도
TCP는 바이트 단위의 슬라이딩 윈도(Sliding Window)를 사용한다. ACK가 도착하면 확인된 구간이 제거되고 새로운 데이터를 보낼 수 있는 범위가 오른쪽으로 이동한다.
전송 완료 및 ACK 수신 전송했지만 ACK 대기 전송 가능
|----------------------|----------------------|--------------------|
↑ ↑
가장 오래된 미확인 바이트 송신 윈도 끝예를 들어 수신자가 rwnd = 4,000을 광고했고 송신자가 이미 1,500바이트를 보낸 뒤 ACK를 기다리고 있다면 추가로 보낼 수 있는 양은 2,500바이트다.
추가 전송 가능량 = 4,000 - 1,500 = 2,500바이트TCP ACK는 일반적으로 누적 확인 응답(Cumulative Acknowledgment)이다. ACK = 5001은 시퀀스 번호 5000까지의 바이트를 연속해서 받았으며 다음 바이트로 5001을 기대한다는 뜻이다.
윈도 이동
- 송신자가 수신 윈도 안에서 데이터를 전송한다.
- 수신자는 데이터를 수신 버퍼에 저장한다.
- 수신 애플리케이션이 버퍼의 데이터를 읽는다.
- 수신 가능한 공간이 늘어난다.
- 수신자는 다음 ACK에서 갱신된 윈도 크기를 알린다.
- 송신자는 새 윈도 범위만큼 추가 데이터를 전송한다.
제로 윈도
수신 버퍼가 가득 차면 수신자는 제로 윈도(Zero Window)를 광고한다.
Window Size = 0송신자는 일반 데이터 전송을 멈춘다. 이후 수신자는 애플리케이션이 데이터를 읽어 공간이 생기면 윈도 갱신(Window Update)을 보낸다.
윈도 갱신 세그먼트가 손실되면 양쪽이 계속 기다릴 수 있다. 이를 막기 위해 송신자는 지속 타이머(Persist Timer)를 사용하고 주기적으로 제로 윈도 탐색(Zero Window Probe)을 보낸다. 수신자는 현재 윈도 크기를 응답한다.
윈도 스케일링
TCP 헤더의 윈도 크기 필드는 16비트이므로 기본 최댓값은 65,535바이트다. 대역폭과 왕복 시간(Round-Trip Time, RTT)이 큰 연결에서는 부족할 수 있다.
윈도 스케일 옵션(Window Scale Option)은 연결 수립 과정에서 교환되며 윈도 값을 왼쪽으로 이동할 비트 수를 정한다.
실제 수신 윈도 = TCP 헤더의 윈도 값 × 2^스케일 값스케일 값이 7이고 헤더의 윈도 값이 50,000이면 실제 윈도는 다음과 같다.
50,000 × 2^7 = 6,400,000바이트4. 코드 예제
다음 코드는 수신 버퍼와 슬라이딩 윈도를 단순화해 구현한다. 실제 TCP 스택을 구현하지는 않으며 rwnd 변화에 따라 송신 가능한 데이터가 달라지는 과정을 보여준다.
class Receiver {
private bufferedBytes = 0;
constructor(private readonly capacity: number) {}
get receiveWindow(): number {
return this.capacity - this.bufferedBytes;
}
receive(bytes: number): number {
const accepted = Math.min(bytes, this.receiveWindow);
this.bufferedBytes += accepted;
return accepted;
}
read(bytes: number): number {
const consumed = Math.min(bytes, this.bufferedBytes);
this.bufferedBytes -= consumed;
return consumed;
}
}
class Sender {
private offset = 0;
constructor(private readonly dataSize: number) {}
send(receiver: Receiver): number {
const remaining = this.dataSize - this.offset;
const bytesToSend = Math.min(remaining, receiver.receiveWindow);
const accepted = receiver.receive(bytesToSend);
this.offset += accepted;
return accepted;
}
get completed(): boolean {
return this.offset === this.dataSize;
}
}
const receiver = new Receiver(8);
const sender = new Sender(20);
let round = 1;
while (!sender.completed) {
const sent = sender.send(receiver);
console.log(
`${round}회차: ${sent}바이트 전송, 남은 수신 윈도 ${receiver.receiveWindow}바이트`,
);
if (receiver.receiveWindow === 0) {
const consumed = receiver.read(5);
console.log(
`애플리케이션: ${consumed}바이트 처리, 갱신된 수신 윈도 ${receiver.receiveWindow}바이트`,
);
}
round += 1;
}실행 방법은 다음과 같다.
npx tsx flow-control.ts실행 결과에서 수신 윈도가 0이 되면 송신 가능한 공간이 사라진다. 수신 애플리케이션이 5바이트를 처리하면 윈도가 다시 열리고 다음 전송이 진행된다.
Node.js 스트림에서는 write()의 반환값과 drain 이벤트로 애플리케이션 수준의 역압력(Backpressure)을 처리한다.
import { createServer } from "node:net";
const server = createServer((socket) => {
let sequence = 0;
const send = (): void => {
while (sequence < 100_000) {
const chunk = Buffer.alloc(1024, sequence % 256);
sequence += 1;
if (!socket.write(chunk)) {
socket.once("drain", send);
return;
}
}
socket.end();
};
send();
});
server.listen(8080, "127.0.0.1", () => {
console.log("127.0.0.1:8080에서 대기 중");
});socket.write()가 false를 반환하면 사용자 공간의 쓰기 버퍼가 설정된 기준 이상으로 쌓였다는 뜻이다. 이때 전송을 멈추고 drain 이벤트를 기다려야 메모리 사용량이 계속 증가하는 상황을 피할 수 있다.
5. 시간 복잡도 또는 성능 특성
흐름 제어는 입력 크기에 따라 답을 계산하는 알고리즘이 아니므로 전체 동작을 빅오(Big-O) 시간 복잡도로 표현하는 것은 적절하지 않다. 윈도 크기 갱신과 전송 가능량 계산 자체는 상수 시간인 O(1) 연산이다.
연결이 충분한 처리량을 내려면 송신 윈도가 대역폭-지연 곱(Bandwidth-Delay Product, BDP) 이상이어야 한다.
BDP = 대역폭 × RTT대역폭이 초당 100메가비트이고 RTT가 100밀리초인 경로의 BDP는 다음과 같다.
100,000,000비트/초 × 0.1초
= 10,000,000비트
= 1,250,000바이트이 경로를 충분히 활용하려면 흐름 제어와 혼잡 제어가 허용하는 유효 송신 윈도가 약 1.25메가바이트 이상이어야 한다. 수신 윈도가 이보다 작으면 송신자는 ACK를 기다리며 쉬는 시간이 생겨 최대 처리량에 도달하지 못한다.
단순화한 처리량 상한은 다음과 같이 볼 수 있다.
처리량 상한 ≈ 유효 송신 윈도 ÷ RTT수신 윈도가 64킬로바이트이고 RTT가 100밀리초라면 흐름 제어 기준 처리량 상한은 약 655,350바이트/초다.
6. 실무 사용 사례
대용량 파일 전송
서버가 대용량 파일을 보내는 동안 클라이언트의 디스크 쓰기나 데이터 처리 속도가 느리면 수신 버퍼가 차오른다. TCP 수신 윈도가 줄어들면서 서버의 전송량도 자동으로 제한된다.
Node.js 스트림
HTTP 응답, 파일 스트림, 압축 스트림을 연결할 때 역압력을 무시하면 애플리케이션 메모리에 데이터가 계속 쌓일 수 있다. stream.pipe()나 pipeline()은 읽기와 쓰기 속도를 조율한다.
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
await pipeline(
createReadStream("input.bin"),
createWriteStream("output.bin"),
);TCP 흐름 제어는 운영체제의 수신 버퍼를 보호한다. 스트림 역압력은 애플리케이션의 사용자 공간 버퍼와 처리 단계를 보호한다. 두 기능은 연결돼 작동하지만 같은 개념은 아니다.
프록시와 게이트웨이
프록시는 클라이언트에서 읽은 데이터를 백엔드로 전달한다. 백엔드 전송이 느린데 클라이언트 입력을 계속 읽으면 프록시 메모리에 데이터가 쌓인다. 프록시는 양쪽 연결의 흐름을 함께 조절해야 한다.
데이터베이스 결과 스트리밍
대량 조회 결과를 한꺼번에 메모리에 올리지 않고 커서(Cursor)나 스트림으로 처리하면 소비 속도에 맞춰 데이터를 읽을 수 있다. TCP 흐름 제어만 믿고 애플리케이션 메모리 제한을 생략해서는 안 된다.
7. 주의할 점
흐름 제어와 혼잡 제어를 혼동하지 않는다
수신 윈도가 크더라도 네트워크 혼잡이 감지되면 혼잡 윈도가 작아져 전송량이 제한된다. 반대로 네트워크가 한산해도 수신 윈도가 작으면 송신자는 더 보낼 수 없다.
실제 미확인 데이터 한도 = min(rwnd, cwnd)작은 윈도 갱신의 반복
수신 애플리케이션이 적은 데이터를 읽을 때마다 작은 윈도를 광고하면 송신자가 작은 세그먼트를 반복해서 보낼 수 있다. 이를 실리 윈도 증후군(Silly Window Syndrome)이라고 한다.
수신자는 충분한 공간이 생길 때까지 윈도 갱신을 늦추고, 송신자는 네이글 알고리즘(Nagle's Algorithm) 등을 사용해 작은 세그먼트의 반복을 줄일 수 있다.
제로 윈도는 연결 종료가 아니다
rwnd = 0은 수신자가 일시적으로 데이터를 받을 공간이 없다는 뜻이다. 연결이 끊겼다는 의미가 아니다. 송신자는 지속 타이머를 사용해 윈도가 다시 열렸는지 확인한다.
애플리케이션 역압력도 필요하다
TCP가 수신 버퍼를 보호해도 애플리케이션 내부 큐까지 자동으로 보호하지는 않는다. 네트워크에서 읽은 데이터를 작업 큐에 무제한으로 넣으면 TCP 흐름 제어와 별개로 메모리가 고갈될 수 있다.
패킷 캡처의 윈도 값 해석
패킷 분석 도구에 표시되는 윈도 값은 윈도 스케일이 반영된 계산값일 수 있다. 원시 TCP 헤더 값과 실제 수신 윈도를 구분해야 한다. 윈도 스케일은 TCP 연결 수립 시 협상되며 연결 중간에 변경되지 않는다.
8. 핵심 정리
- 흐름 제어(Flow Control)는 송신 속도를 수신자의 버퍼와 처리 능력에 맞춘다.
- TCP 수신자는 ACK의 윈도 크기 필드로 수신 윈도(
rwnd)를 광고한다. - 송신자는 슬라이딩 윈도 안에서만 확인되지 않은 데이터를 전송한다.
- 실제 송신량은 수신 윈도와 혼잡 윈도 중 작은 값의 제한을 받는다.
- 수신 버퍼가 가득 차면 제로 윈도를 광고하고 송신자는 일반 데이터 전송을 멈춘다.
- 긴 RTT와 높은 대역폭을 활용하려면 대역폭-지연 곱에 맞는 충분한 윈도 크기가 필요하다.
- TCP 흐름 제어와 애플리케이션 역압력을 함께 적용해야 운영체제와 프로세스의 버퍼를 모두 보호할 수 있다.