TCP 동작 원리: 3-way handshake, 흐름·혼잡 제어(CUBIC·BBR)와 소켓 디버깅

이 글의 핵심

3-way handshake와 TIME_WAIT, rwnd·cwnd로 이뤄지는 흐름·혼잡 제어, Nagle과 지연 ACK의 충돌, Reno·CUBIC·BBR의 차이를 설명하고, C++·Python·Node.js 클라이언트 예제와 RST·ECONNRESET·느린 전송을 캡처로 진단하는 방법을 정리합니다.

“가끔 타임아웃”만 찍히는 장애는 TCP를 이해해야 풀리는 대표적인 문제입니다. 앱 로그에는 read timed out만 남고, DB에도 느린 쿼리가 없는데 에러가 특정 리전이나 특정 경로에만 몰리는 식입니다. 이때 흔히 ping과 traceroute를 먼저 돌리지만, 두 도구는 ICMP 기준이라 TCP 연결 안에서 벌어지는 일을 보여 주지 않습니다. 저는 이런 상황에서 추측으로 sysctl 값을 바꾸기보다 먼저 tcpdump나 Wireshark로 패킷이 실제로 어떻게 움직였는지를 봅니다. 캡처를 열어 보면 패킷 손실이 조용히 쌓이고, TCP가 재전송으로 버티다가 재전송 타임아웃(RTO)이 지수적으로 늘어나면서 지연이 수 초 단위로 튀는 흐름이 한눈에 보이는 경우가 많습니다. “앱이 느리다”가 아니라 “경로에서 손실이 난다”로 문제가 좁혀지는 순간, 조사해야 할 범위가 절반으로 줄어듭니다.

TCP(Transmission Control Protocol)는 HTTP/1.1·HTTP/2, SSH, 대부분의 DB 프로토콜, 내부 마이크로서비스 통신이 올라가는 전송 계층 프로토콜입니다. UDP가 “보내고 끝나는 우편”이라면 TCP는 순서를 맞추고 빠진 부분을 다시 요청하는 “전화 통화”에 가깝습니다. 애플리케이션마다 재전송·순서 정렬·흐름 조절을 직접 구현하기는 어렵기 때문에 운영체제 커널이 이를 대신 처리합니다. 그 대가로 지연, 처리량, TIME_WAIT, Nagle 알고리즘, 소켓 버퍼 같은 요소가 개발자 눈에 보이지 않는 곳에서 성능을 좌우하게 되며, 이 글은 그 동작을 코드와 디버깅 관점에서 정리합니다. 정확한 동작은 RFC 9293(TCP 본 명세)과 사용하는 운영체제 커널 문서를 기준으로 삼아야 합니다.

3-way Handshake와 연결 종료

연결을 열 때 주고받는 SYN → SYN-ACK → ACK 세 개의 세그먼트가 3-way handshake이고, 연결을 끊을 때는 양쪽이 각자 FIN을 보내고 ACK를 받는 4-way 과정을 거칩니다. 핸드셰이크가 세 번인 이유는 양쪽이 각자의 초기 시퀀스 번호(ISN)를 상대에게 알리고, 상대가 그것을 받았다는 확인까지 받아야 하기 때문입니다. 이 과정 때문에 새 연결마다 최소 1 RTT가 추가되고, TLS까지 얹으면 1~2 RTT가 더 붙습니다. 연결 재사용(keep-alive)과 커넥션 풀이 성능에 큰 영향을 주는 근본 이유가 여기에 있습니다.

먼저 연결을 끊는 쪽(마지막 ACK를 보낸 쪽)은 TIME_WAIT 상태에 2MSL(Linux에서는 60초 고정) 동안 머뭅니다. 마지막 ACK가 유실됐을 때 상대의 FIN 재전송에 다시 응답하고, 네트워크에 늦게 떠돌던 이전 연결의 세그먼트가 같은 포트 조합을 쓰는 새 연결에 섞이지 않게 하려는 장치입니다. “TIME_WAIT 소켓이 왜 이렇게 많냐”는 질문은 대부분 짧은 연결을 초당 수천 개씩 만드는 클라이언트에서 나옵니다. TIME_WAIT 자체는 메모리를 거의 쓰지 않지만, 같은 목적지로 나가는 연결은 로컬 포트 범위(net.ipv4.ip_local_port_range, 기본 약 28,000개)를 공유하므로 포트가 고갈되면 connect가 “EADDRNOTAVAIL: Cannot assign requested address”로 실패합니다. 이때 먼저 할 일은 keep-alive와 커넥션 풀로 연결 수 자체를 줄이는 것이고, tcp_tw_reuse 같은 커널 값은 트래픽 패턴을 확인한 뒤에 조정해야 합니다. 예전 글에서 자주 보이던 tcp_tw_recycle은 NAT 뒤의 클라이언트 연결을 끊는 문제로 Linux 4.12에서 제거되었습니다.

흐름 제어와 혼잡 제어

흐름 제어는 “상대의 수신 버퍼가 감당할 만큼만 보낸다”는 규칙입니다. 수신 측은 ACK마다 남은 버퍼 크기를 rwnd(수신 윈도)로 알려 주고, 송신 측은 확인받지 않은 데이터가 이 크기를 넘지 않게 슬라이딩 윈도를 유지합니다. 혼잡 제어는 상대가 아니라 네트워크 경로를 보호하는 장치로, 송신 측이 손실이나 지연 증가를 관찰해 cwnd(혼잡 윈도)를 스스로 조절합니다. 실제로 한 번에 보낼 수 있는 양은 min(rwnd, cwnd), 즉 둘 중 더 작은 쪽이 병목이 됩니다.

TCP 헤더의 윈도 필드는 16비트라 최대 64KB까지만 표현할 수 있습니다. 대역폭×지연(BDP)이 큰 링크, 예를 들어 1Gbps에 RTT 100ms인 경로에서는 파이프를 채우는 데 약 12.5MB가 필요하므로, 핸드셰이크 때 협상하는 윈도 스케일 옵션(RFC 7323)으로 이 한계를 늘립니다. 중간 장비가 SYN의 옵션을 벗겨 내면 윈도가 64KB에 묶여 “회선은 빠른데 단일 연결 처리량만 이상하게 낮은” 증상이 나타납니다. 수신 앱이 recv를 늦게 호출해 버퍼가 가득 차면 rwnd가 0이 되는 제로 윈도 상태가 되고, 송신 측은 persist 타이머로 주기적으로 윈도 탐침을 보내며 기다립니다. Wireshark에서 TCP ZeroWindow가 반복해서 보인다면 네트워크가 아니라 수신 애플리케이션이 느린 것입니다.

Nagle 알고리즘은 확인받지 못한 작은 세그먼트가 있으면 다음 작은 데이터를 모아 두었다가 함께 보내 패킷 수를 줄입니다. 문제는 수신 측의 지연 ACK(ACK를 최대 수십 ms 늦춰 모아 보내는 동작)와 겹칠 때입니다. 요청을 두 번의 작은 write로 나눠 보내면 첫 조각의 ACK가 지연되는 동안 두 번째 조각이 Nagle에 묶여, 요청마다 약 40ms(Linux 지연 ACK 최소값 기준) 지연이 생기는 고전적인 문제가 발생합니다. 인터랙티브 프로토콜이나 RPC에서 TCP_NODELAY를 켜는 이유가 이것이며, 반대로 대량 전송에서는 Nagle을 끄면 작은 패킷이 늘어 오히려 손해일 수 있습니다. 무조건 켜거나 끄기보다 캡처와 지연 메트릭으로 확인한 뒤 결정하는 것이 맞습니다.

혼잡 제어 알고리즘: Reno, CUBIC, BBR

Reno는 손실이 없으면 RTT마다 cwnd를 1 MSS씩 늘리고, 손실을 감지하면 절반으로 줄이는 AIMD(가산 증가·승산 감소) 방식입니다. 연결 초기에는 슬로 스타트로 cwnd를 RTT마다 두 배씩 키우다가 임계값(ssthresh)을 넘으면 선형 증가로 바뀝니다. Reno는 RTT가 긴 고대역폭 경로에서 손실 한 번 뒤 윈도를 회복하는 데 너무 오래 걸린다는 약점이 있어, Linux는 2.6.19부터 CUBIC을 기본값으로 씁니다. CUBIC은 윈도 증가를 RTT가 아니라 마지막 손실 이후 경과 시간의 3차 함수로 계산해서, 직전 최대치 근처에서는 조심스럽게, 멀어지면 빠르게 늘립니다.

BBR은 접근 방식이 다릅니다. 손실을 혼잡 신호로 보지 않고, 병목 대역폭과 최소 RTT를 직접 추정해 그 값에 맞춰 전송 속도를 정합니다. 그래서 무선망처럼 혼잡과 무관한 손실이 잦은 경로나, 라우터 버퍼가 커서 손실 대신 지연만 늘어나는 버퍼블로트 경로에서 유리할 수 있습니다. 대신 BBR v1은 CUBIC 플로우와 같은 병목을 공유할 때 대역폭을 과하게 차지하거나 큐를 채운다는 공정성 문제가 보고되었고, 이를 개선한 BBRv2·v3가 개발되었습니다. 서버에서 sysctl net.ipv4.tcp_congestion_control=bbr 한 줄로 바꿀 수 있지만 “바꾸면 끝”은 아니며, 실제 사용자 경로에서 처리량과 재전송률을 비교해 봐야 합니다. ECN은 라우터가 패킷을 버리는 대신 헤더에 “혼잡 경험” 표시를 남겨 송신 측이 미리 속도를 줄이게 하는 방식인데, 경로상의 장비와 양 끝단이 모두 지원해야 효과가 있어 데이터센터 내부(DCTCP 등)에서 주로 활용됩니다.

소켓 프로그래밍 예제: C++, Python, Node.js

아래는 TCP 클라이언트의 동작을 확인하기 위한 최소 예제입니다. 프로덕션 코드라면 TLS, 커넥션 풀, 비동기 I/O, 재시도 정책, 로깅이 추가로 필요합니다. 테스트용 서버는 nc -l 8080(또는 ncat -l 8080)으로 띄우면 됩니다.

// g++ -std=c++17 -O2 tcp_client.cpp -o tcp_client
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
  const char* host = argc > 1 ? argv[1] : "127.0.0.1";
  const uint16_t port = argc > 2 ? static_cast<uint16_t>(std::stoi(argv[2])) : 8080;
  int fd = ::socket(AF_INET, SOCK_STREAM, 0);
  if (fd < 0) { perror("socket"); return 1; }
  sockaddr_in addr{};
  addr.sin_family = AF_INET;
  addr.sin_port = htons(port);
  if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
    std::cerr << "inet_pton failed\n";
    return 1;
  }
  if (connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
    perror("connect");
    close(fd);
    return 1;
  }
  const std::string msg = "ping\n";
  ssize_t n = send(fd, msg.data(), msg.size(), 0);
  if (n < 0) { perror("send"); close(fd); return 1; }
  char buf[4096];
  n = recv(fd, buf, sizeof(buf) - 1, 0);
  if (n < 0) { perror("recv"); close(fd); return 1; }
  if (n == 0) std::cout << "peer closed\n";
  else { buf[n] = '\0'; std::cout << buf; }
  close(fd);
  return 0;
}

이 예제에서 가장 중요한 줄은 recv의 반환값 처리입니다. TCP는 메시지 경계가 없는 바이트 스트림이라, 상대가 send를 한 번 호출했다고 recv도 한 번에 전부 받는다는 보장이 없습니다. 작은 메시지는 로컬 테스트에서 늘 한 번에 도착하기 때문에 이 가정이 숨어 있다가, 실제 네트워크나 큰 메시지에서만 “JSON 파싱이 가끔 실패한다” 같은 증상으로 드러납니다. 실무에서는 길이 접두사(앞 4바이트에 메시지 길이)나 구분자(\n)로 메시지 경계를 직접 정하고, 경계가 채워질 때까지 recv를 반복합니다. send도 마찬가지로 요청한 바이트보다 적게 보낼 수 있으므로 반환값만큼 오프셋을 옮기며 루프를 돌아야 합니다. recv가 0을 반환하면 상대가 FIN을 보내 정상 종료한 것이고, 음수면 errno를 확인합니다. 또 이 예제는 타임아웃이 없어서 서버가 응답하지 않으면 recv에서 무한히 멈추므로, setsockopt의 SO_RCVTIMEO나 poll로 대기 시간을 제한하는 것이 좋습니다.

#!/usr/bin/env python3
import socket
import sys
def main() -> None:
    host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
    with socket.create_connection((host, port), timeout=10.0) as sock:
        sock.sendall(b"ping\n")
        data = sock.recv(4096)
        if not data:
            print("peer closed")
        else:
            print(data.decode("utf-8", errors="replace"))
if __name__ == "__main__":
    main()

Python의 socket.create_connection은 DNS 조회 결과(IPv4·IPv6 주소 여러 개)를 차례로 시도하고 타임아웃까지 한 번에 설정해 줍니다. sendall은 C 예제에서 직접 짜야 했던 부분 전송 루프를 대신 돌려 주지만, recv(4096)은 여전히 도착한 만큼만 반환하므로 메시지 경계 문제는 그대로 남습니다. 타임아웃이 지나면 socket.timeout(Python 3.10부터는 TimeoutError의 별칭) 예외가 발생합니다.

Node.js의 net 모듈은 이벤트 기반이라 data 이벤트가 청크 단위로 여러 번 올 수 있고, 청크 경계는 역시 보장되지 않습니다. setTimeout은 연결 자체를 끊지 않고 timeout 이벤트만 발생시키므로, 예제처럼 직접 destroy를 호출해야 합니다. error 리스너가 없으면 연결 실패 시 프로세스가 예외로 종료되므로 반드시 달아 둡니다.

// node tcp_client.mjs
import net from "node:net";
const host = process.argv[2] ?? "127.0.0.1";
const port = Number(process.argv[3] ?? 8080);
const socket = net.createConnection({ host, port });
socket.setTimeout(10_000);
socket.on("timeout", () => socket.destroy(new Error("idle timeout")));
socket.on("connect", () => {
  socket.write("ping\n");
});
socket.on("data", (chunk) => {
  process.stdout.write(chunk);
});
socket.on("error", (err) => {
  console.error(err.message);
  process.exitCode = 1;
});

디버깅: RST와 느린 전송 분석

RST(리셋)는 원인 후보가 여럿입니다. 닫힌 포트로 SYN을 보내면 즉시 RST가 오고 클라이언트는 “ECONNREFUSED: Connection refused”를 받습니다. 이미 연결된 상태에서 RST를 받으면 “ECONNRESET: Connection reset by peer”가 되는데, 상대 프로세스가 수신 버퍼에 읽지 않은 데이터를 남긴 채 소켓을 닫았거나, 로드밸런서·방화벽이 유휴 연결을 조용히 정리한 뒤 늦게 도착한 패킷에 RST로 응답한 경우가 흔합니다. RST를 받은 소켓에 계속 쓰면 “EPIPE: Broken pipe”가 납니다. 애플리케이션 로그만으로는 누가 끊었는지 알 수 없으므로, 양쪽 또는 중간 지점에서 캡처해 어느 쪽이 FIN/RST를 먼저 보냈는지를 확인하는 것이 가장 빠릅니다.

로드밸런서 유휴 타임아웃은 특히 자주 걸리는 함정입니다. 예를 들어 AWS ALB의 기본 유휴 타임아웃은 60초인데, 백엔드 서버의 keep-alive 타임아웃이 그보다 짧으면 서버가 먼저 연결을 닫는 순간과 LB가 그 연결로 요청을 보내는 순간이 겹쳐 간헐적인 502 에러가 납니다. 백엔드의 keep-alive 타임아웃을 LB보다 길게 두는 것이 일반적인 해결책입니다.

전송이 느릴 때는 송신 버퍼, 수신 애플리케이션, cwnd, 디스크 I/O가 한 덩어리로 섞여 보이므로 원인을 분리해야 합니다. iperf3로 같은 경로의 순수 TCP 처리량을 먼저 측정해 기준선을 긋고, 애플리케이션 처리량이 그보다 훨씬 낮으면 앱 쪽을, 비슷하게 낮으면 네트워크 경로를 의심합니다. Linux에서는 ss -ti로 연결별 cwnd, RTT, 재전송 횟수를 바로 볼 수 있어 캡처 없이도 1차 판단이 가능합니다.

제가 보아 온 흔한 착오는 대략 이렇습니다. recv 한 번에 메시지가 다 온다고 믿는 것, 타임아웃 후 비멱등 POST 요청을 자동 재시도해 결제나 주문이 두 번 처리되는 것, TCP가 알아서 해 줄 거라 믿고 애플리케이션 수준 타임아웃이나 하트비트 없이 연결을 무한정 들고 있는 것, 그리고 TIME_WAIT가 많다는 이유만으로 커널 값을 바꾸는 것입니다. 문서에서 본 커널 튜닝 값을 그대로 복사했다가 다른 문제를 만드는 경우가 적지 않으므로, 항상 캡처와 메트릭으로 관측한 뒤에 설정을 바꾸는 순서를 지키는 편이 안전합니다.

핸드셰이크 시퀀스 다이어그램

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: SYN, seq=x
  S->>C: SYN-ACK, seq=y, ack=x+1
  C->>S: ACK, seq=x+1, ack=y+1
  Note over C,S: ESTABLISHED
sequenceDiagram
  participant A as Host A
  participant B as Host B
  A->>B: FIN
  B->>A: ACK
  B->>A: FIN
  A->>B: ACK
  Note over A: TIME_WAIT (2MSL)

마무리: TCP를 언제 쓰나

HTTP/1.1과 HTTP/2, 대부분의 DB 프로토콜, 파일 전송, API 호출, 원격 셸처럼 “순서대로, 빠짐없이”가 중요한 통신에는 TCP가 기본값입니다. 반대로 한 패킷의 손실 때문에 뒤따르는 모든 데이터가 기다려야 하는 Head-of-Line 블로킹이 치명적인 실시간 음성·영상·게임에서는 UDP나 UDP 위에서 동작하는 QUIC(HTTP/3)을 검토합니다. 두 프로토콜의 선택 기준은 TCP vs UDP 비교 글과 UDP 실전 가이드에, TCP 위에 암호화를 얹는 과정은 TLS 가이드에 정리해 두었습니다.

같이 보면 좋은 글