본문으로 건너뛰기
Previous
Next
TCP 프로토콜 완전 가이드 | 3-way handshake·흐름·혼잡 제어·소켓 실전

TCP 프로토콜 완전 가이드 | 3-way handshake·흐름·혼잡 제어·소켓 실전

TCP 프로토콜 완전 가이드 | 3-way handshake·흐름·혼잡 제어·소켓 실전

이 글의 핵심

슬라이딩 윈도·rwnd/cwnd·윈도 스케일링·영 윈도 프로빙부터 Reno/CUBIC/BBR·ECN/DCTCP까지, TCP 흐름·혼잡 제어를 커널·RFC 관점에서 심화 정리한 전문가용 가이드입니다.

옛날에 프로덕션에서 “가끔 타임아웃”만 찍히는 장애를 본 적 있어요. 앱 로그엔 read timed out 뿐이고, DB도 느린 쿼리는 없는데, 에러가 리전 A에서만 몰렸죠. 초반엔 보통 ping이랑 traceroute 한번 돌리고, 그다음 뭐 할지 갈리는데—솔직히 그 단계에선 Wireshark가 답입니다라고 박고 싶어요. 추측으로 sysctl 바꾸는 것보다, 먼저 “패킷이 실제로 어떻게 움직였는지”를 봅니다. 캡처 뜯다 보면 패킷 로스가 조용히 쌓이고, TCP가 재전송으로 버티다가 RTO가 늘어나서 지연이 꼬이는 흐름이 한눈에 잡힐 때가 많거든요. “앱이 느리다”가 아니라 “경로가 흔들린다”로 문제가 갈리는 순간, 원인이 반으로 줄어요.

TCP(Transmission Control Protocol)는 HTTP/SSH/대부분 DB, 내부 MS 통신이 다 올라가는, 말하자면 전화 같은 거예요. UDP가 우편 느낌이면, TCP는 말의 순서랑 “빠진 말”을 맞추는 쪽에 가깝고요(정밀한 건 항상 RFC·커널 기준). 앱이 매번 재전송·정렬을 구현하긴 힘드니 커널이 대신 해주는 겁니다. 대신 지연, 처리량, TIME_WAIT, Nagle, 버퍼는 내가 의식 안 하면 그냥 망가집니다.

연결을 열 때 SYN → SYN-ACK → ACK 세 발이 3-way handshake고, 끊을 땐 FIN/ACK가 양방향으로 돌아 4-way로 정리돼요. 마지막 ACK 보낸 쪽이 TIME_WAIT에 잠깐 머무는 건, 늦게 도착한 중복 세그가 새 연결에 섞이는 걸 피하려는 여유 구간이에요. “소켓이 왜 이리 많냐”는 이야기는 여기서 자주 나옵니다. 짧은 연결이 쏟아지면 로컬 포트커널 테이블이 먼저 압박받죠. Keep-alive나 풀부터 보고, sysctl은 트래픽 패턴 본 뒤에.

흐름 제어는 “상대 수신 버퍼에 맞게 보낼래”이고, 슬라이딩 윈도rwnd를 주고받아요. 혼잡 제어는 “라우터 큐·드롭 보면 cwnd 줄일래” 쪽. 실제로 얼마나 쏠 수 있느냐는 min(rwnd, cwnd) 둘 중 더 쪼이는 쪽이 병목이에요. 윈도 스케일은 BDP 큰 링크에서 16비트 한계 풀려고 쓰는 거고, 영 윈도(Zero Window) 쪽이면 persist 타이머가 1바이트 탐침으로 깨울 수 있어요—앱이 recv를 늦게 하면 rwnd=0이 오래 잡힌다, 이런 이야기. Nagle은 작은 패킷을 묶어 효율을 올리는 대신 지연이 늘 수 있어서, 인터랙티브면 TCP_NODELAY 켤 때도 있고, 대량 전송이면 켜는 게 손해일 때도 있어요(무조건 ON/OFF는 금지, 측정이 답—근데 측정은 역시 캡처·메트릭 둘 다).

혼잡 알고리즘은 Reno 계열 AIMD, Linux 기본 CUBIC, BBR 같은 것들이 “환경 따라 다름”의 정석이에요. BBR은 버퍼블로트 꽉 찬 경로에서 손실만 보고 뒤로 물러나는 케이스에 기대는 경우가 있지만, 다른 플로우랑 섞이면 공정성·큐 점유 이슈가 나온 적도 있어서 “바꾸면 끝”은 아님. ECN은 드롭 전에 “혼잡 있음” 찍는 루트인데, 경로 전체가 맞아야 의미가 있죠.

밑에는 그냥 “동작은 이렇게 보면 됨”용 최소 예제들이에요. 프로덕션이면 TLS, 풀, 비동기, 로깅은 필수고요.

// 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;
}

send/recv는 한 번에 다 안 갈 수 있어서 루프로 모으는 게 기본이고, 에러는 전부 반환값 보라는 거… 알죠.

#!/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()

create_connection이 DNS·타임아웃을 묶어주고, sendall이 부분 전송을 반복해 주는 게 편해요.

// 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 뜨는 건 “상대가 안 듣는다”, “중간에 잘렸다”, “타이밍 꼬였다” 같이 원인 후보가 여럿이라, 로그만 보지 말고 누가 FIN/RST 쐈는지 캡처로 잡는 게 훨씬 빨라요. 느린 전송이면 “송신 버퍼/수신 앱/ cwnd/ 디스크”가 한 덩어리로 섞여서 나오니, iperf3로 선을 하나 긋고 앱을 또 보는 식이 좋아요.

개인적으론 이런 것도 박을게요. 흔한 착오: recv 한 번에 다 온다고 믿기(부분 수신), 비멱등 POST를 재시도로 망치기, TCP만 믿고 앱에서 무한 송신, TIME_WAIT에만 sysctl 냅다 박기. 흐름은: 일단 Wireshark로 “패킷 로스로 프로덕션 장애” 난 경로의 손실·재전송·RTO를 눈으로 확정하고, 그다음 프록시·LB 타임아웃·Nagle 이런 쪽을 의심 목록에 올리면 됩니다. 문서만 믿고 커널 값을 복붙한 건 망한 적이 한두 번이 아니에요—관측이 먼저예요, 솔직히.

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)

HTTP/1.1·2는 대부분 TCP, DB도 TCP, 파일·API·쉘처럼 “순서랑 빠짐없음”이면 TCP가 기본값이죠. 실시간이 더 중요하면 UDP나 QUIC 쪽 비교 글이랑 UDP 가이드를 같이 보는 게 맞고요. TLS는 여기.

검색에 걸릴 키워드: 네트워크, TCP, 전송 프로토콜, 3-way handshake, 흐름 제어, 혼잡 제어, socket