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

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

이 글의 핵심

TCP는 바이트 스트림 기반의 연결 지향·신뢰성 있는 전송을 제공하며, 흐름·혼잡 제어와 소켓 옵션 이해가 서비스 안정성을 좌우합니다.

들어가며

TCP(Transmission Control Protocol)는 인터넷에서 가장 널리 쓰이는 신뢰성 있는 전송 프로토콜입니다. HTTP/HTTPS, SSH, 대부분의 데이터베이스 프로토콜, 내부 마이크로서비스 통신이 TCP 위에서 동작하며, 연결 상태·재전송·순서 보장이 “그냥 된다”고 느껴지는 이유가 바로 커널의 TCP 구현과 소켓 API에 있습니다.

반면 지연(latency)·처리량·서버당 동시 연결 수는 설정과 트래픽 패턴에 크게 좌우됩니다. 이 글은 RFC와 커널 동작을 실무 언어로 연결하고, 소켓 옵션과 흔한 장애까지 한 흐름으로 정리합니다.

비유로 이해하기 (UDP 글과 짝지어)

TCP전화에 가깝습니다. 연결을 맺고(통화 시작), 말한 순서빠진 말이 없는지를 맞추는 쪽에 가깝습니다. UDP우편이라면, TCP는 확인 가능한 대화에 가깝다고 보시면 됩니다. (정확한 동작은 항상 RFC·커널 기준입니다.)

왜 TCP가 기본축인가요?

대부분의 API·DB·원격 셸바이트가 순서대로·빠짐없이 도착해야 합니다. 애플리케이션이 매번 재전송·순서 로직을 새로 쓰는 비용을 커널 TCP가 대신 집니다.

프로덕션에서 주의할 점

  • TCP_NODELAY 남발은 작은 패킷이 늘어 CPU·대역 비용이 커질 수 있습니다. 측정 후 켭니다.
  • TIME_WAIT·포트 고갈짧은 연결 폭주에서 터집니다. Keep-Alive·연결 풀로 패턴을 먼저 봅니다.
  • 커널 튜닝 sysctl은 배포판·보안 가이드에 따라 부작용이 있으므로, 관측 없이 복사·붙여넣기하지 않습니다.

이 글을 읽으면

  • 3-way handshake·4-way 종료상태 전이를 설명할 수 있습니다
  • 흐름 제어(수신 윈도우)혼잡 제어(Reno/CUBIC 등)의 역할을 구분할 수 있습니다
  • C++/Python/JavaScript에서 기본 소켓 패턴·타임아웃·에러 처리를 적용할 수 있습니다
  • TIME_WAIT, Nagle, keepalive 등 운영에서 자주 마주치는 이슈에 대응할 수 있습니다

목차

  1. 프로토콜 개요
  2. 동작 원리
  3. 실전 프로그래밍
  4. 성능 특성
  5. 실무 활용 사례
  6. 최적화 팁
  7. 흔한 문제와 해결
  8. 마무리

프로토콜 개요

역사 및 개발 배경

TCP는 1974년 Vint Cerf·Bob Kahn의 연구를 바탕으로 발전했고, RFC 793(1981)이 전통적인 기준 문서였습니다. 이후 선택적 확인(SACK), 창 크기 확장, 혼잡 제어 알고리즘 개선, 타임스탬프 옵션 등이 RFC 9293(2022)에 통합·정리되며, 오늘날 커널 구현은 BSD 계열 Reno, Linux 기본 CUBIC 등 플랫폼별로 세부가 다릅니다.

OSI 7계층에서의 위치

TCP는 OSI 4계층(전송 계층)에 해당합니다. IP(3계층)가 호스트 간 패킷 전달을 담당하면, TCP는 프로세스 간(포트 단위) 신뢰성 있는 바이트 스트림을 제공합니다. 애플리케이션은 소켓으로 스트림을 읽고 쓰며, 세그먼트 분할·재전송·순서 정렬은 TCP가 처리합니다.

핵심 특징

특징설명
연결 지향데이터 전송 전 논리적 연결을 설정합니다(상태 기계).
신뢰성손실 시 재전송, 중복·순서 뒤집힘을 시퀀스 번호로 정리합니다.
순서 보장애플리케이션에 전달되는 바이트 순서를 보장합니다(단, 별도 메시지 경계는 없음).
흐름 제어수신자 버퍼에 맞춰 송신 속도를 조절합니다(슬라이딩 윈도우).
혼잡 제어네트워크 혼잡을 감지해 전송 속도를 조절합니다.
전이중단일 연결에서 양방향 스트림(실제 구현은 복잡한 버퍼/ACK 상호작용).

동작 원리

3-way handshake

연결 설정은 SYN → SYN-ACK → ACK 세 단계로 완료됩니다. 클라이언트가 초기 시퀀스 번호(ISN)를 보내고, 서버가 자신의 ISN과 확인응답을 보내며, 클라이언트가 확인으로 응답합니다.

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

4-way termination

연결 종료는 FIN/ACK 교환으로 한쪽 방향씩 닫힙니다. 한쪽이 FIN을 보내면 상대는 ACK로 확인하고, 반대 방향도 동일하게 닫으면 완전 종료에 이릅니다. 마지막 ACK를 보낸 쪽은 TIME_WAIT 상태로 잠시 머물러 지연된 중복 세그먼트가 새 연결과 섞이지 않게 합니다.

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)

흐름 제어(슬라이딩 윈도우)

수신 측 rwnd(수신 윈도우)는 “지금 더 받을 수 있는 버퍼 공간”을 알려줍니다. 송신자는 확인되지 않은 바이트 수가 허용 범위를 넘지 않게 전송을 조절합니다. 이는 수신자 과부하를 막기 위한 메커니즘입니다.

혼잡 제어

혼잡 제어는 라우터 큐 적체·패킷 드롭에 반응해 네트워크 전체 보호를 목표로 합니다. 전통적으로 Renoslow start, congestion avoidance, fast retransmit/recovery를 포함합니다. Linux 기본 CUBIC은 고대역폭·긴 RTT 링크에서 윈도우 성장 곡선을 조정해 처리량을 개선하는 데 초점을 둡니다. 정확한 파라미터는 커널 버전·sysctl에 따라 다릅니다.

flowchart TB
  subgraph fc [흐름 제어]
    R[수신 버퍼 rwnd]
  end
  subgraph cc [혼잡 제어]
    CWND[cwnd / 알고리즘]
  end
  SEND[실제 전송 가능 데이터]
  R --> SEND
  CWND --> SEND

실전 프로그래밍

아래는 교육용 최소 예제입니다. 프로덕션에서는 비동기 I/O, TLS, 연결 풀, 로깅이 필수입니다.

C++ (Berkeley socket)

// 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;
}
  • 에러 처리: socket/connect/send/recv는 모두 반환값 검사가 필요합니다.
  • 부분 송수신: send/recv는 요청한 길이만큼 안 갈 수 있어 루프로 채워 보내기/모으기가 일반적입니다.

Python 3

#!/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·timeout을 한 번에 처리하는 고수준 API입니다.
  • sendall: 부분 전송을 내부에서 반복합니다.

JavaScript (Node.js)

// 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;
});
  • 연결 실패: error 이벤트로 처리합니다. 읽기 지연setTimeout으로 별도 제한하는 편이 명확합니다.

타임아웃·에러 처리 패턴

  • 연결 타임아웃: connect가 오래 걸리면 사용자 체감 지연이 커지므로 OS별 소켓 옵션 또는 비동기 래퍼로 제한합니다.
  • 읽기 타임아웃: SO_RCVTIMEO(C), socket.setTimeout(Node), sock.settimeout(Python) 등으로 무한 대기를 방지합니다.
  • 재시도: 애플리케이션 레벨에서 멱등 요청만 자동 재시도하는 것이 안전합니다.

성능 특성

지연 시간(Latency)

RTT(Round-Trip Time)는 TCP 처리량의 상한에 큰 영향을 줍니다. 작은 메시지를 주고받는 요청-응답 패턴에서는 패킷 수·ACK 타이밍·Nagle 때문에 RTT가 곧 응답 시간의 하한에 가깝게 나타나기도 합니다.

처리량(Throughput)

대역폭이 충분할 때 윈도 크기(수신·혼잡)가 BDP(대역폭 × 지연)에 못 미치면 파이프를 채우지 못해 처리량이 제한됩니다. 고속·고지연 링크에서는 TCP 윈도 스케일링, 버퍼 튜닝, 애플리케이션 I/O 패턴이 중요합니다.

오버헤드

각 세그먼트는 IP 헤더 + TCP 헤더(기본 20바이트 + 옵션)를 가집니다. TLS를 쓰면 핸드셰이크·암호화 오버헤드가 추가됩니다. 작은 페이로드 비율이 높으면 헤더 대비 효율이 떨어집니다.

벤치마크 참고

실제 수치는 CPU, 커널, NIC 오프로딩, 혼잡 상태에 따라 크게 달라집니다. 예를 들어 동일 서버 내 로컬 루프백Gbps급도 가능하지만, 크로스 리전RTT와 손실에 의해 수 Mbps~수십 Mbps 수준으로 제한되는 경우가 흔합니다. 서비스 도입 전에는 목표 환경에서 iperf3 등으로 측정하는 것이 안전합니다.


실무 활용 사례

웹 서버·API

HTTP/1.1·HTTP/2·HTTP/3(QUIC) 논의와 별개로, 대부분의 HTTP/1.1·HTTP/2는 TCP 위에서 동작합니다(HTTP/3은 UDP 기반 QUIC). 리버스 프록시·로드밸런서연결 재사용·타임아웃·버퍼 설정이 성능에 직접적인 영향을 줍니다.

데이터베이스

PostgreSQL, MySQL 등은 기본적으로 TCP로 접속합니다. 커넥션 풀TCP+인증 핸드셰이크 비용을 줄이기 위한 대표 패턴입니다.

파일 전송

FTP(제어는 TCP), SFTP, rsync over SSH 등은 신뢰성·순서가 중요해 TCP가 자연스럽습니다. 대용량 전송에서는 윈도·버퍼·디스크 I/O가 병목인지 네트워크인지 프로파일링이 필요합니다.


최적화 팁

Nagle 알고리즘

Nagle은 작은 세그먼트를 묶어 패킷 수를 줄이려 합니다. 반면 지연 증가가 문제가 될 수 있어, 인터랙티브 트래픽에서는 **TCP_NODELAY**로 끄는 경우가 많습니다.

TCP_NODELAY

소켓 옵션으로 Nagle을 비활성화합니다. 작은 쓰기를 즉시 전송하지만, 패킷 수 증가대역폭 효율 저하 트레이드오프가 있습니다.

SO_KEEPALIVE

유휴 연결이 살아 있는지 주기적으로 탐지합니다. 애플리케이션 하트비트와 역할이 겹칠 수 있으므로, 프록시·방화벽 타임아웃과 함께 설계합니다.

버퍼·윈도

서버에서는 **SO_SNDBUF / SO_RCVBUF**와 시스템 전역 한도(sysctl)가 처리량·메모리 사용에 영향을 줍니다. 변경은 측정 후 점진적으로 적용하세요.


흔한 문제와 해결

흔한 실수와 해결

실수결과해결
recv 한 번에 전체 메시지가 온다고 가정부분 수신으로 버퍼가 잘림루프로 길이만큼 모으기 또는 프레이밍 규칙
비멱등 POST를 자동 재시도중복 생성멱등 키·재시도 정책 분리
혼잡을 TCP만 믿고 앱에서 무한 송신상대·공유 링크 기아앱 레벨 큐·백프레셔
TIME_WAIT만 sysctl로 때려누름환경에 따라 연결 꼬임·보안 이슈연결 재사용·아키텍처를 먼저 검토

TIME_WAIT 과다

짧은 연결이 매우 많으면 로컬 포트·커널 테이블 압력이 생깁니다. HTTP keep-alive, 연결 풀, 서버 측 튜닝(환경에 따라 tcp_tw_reuse 등—커널·보안 가이드 확인 필수)을 검토합니다.

연결 끊김·리셋

RST는 상대가 포트를 열지 않았거나, 방화벽·LB가 끊었거나, 타임아웃 후 잘못된 세그먼트가 온 경우 등 여러 원인이 있습니다. tcpdump/캡처누가 FIN/RST를 보냈는지 추적하는 것이 빠릅니다.

느린 전송

송신 버퍼 고갈, 수신 앱이 recv를 늦게 함, cwnd 제한, 디스크 병목이 섞여 나타납니다. **단일 연결 iperf**와 애플리케이션 프로파일을 분리해 보세요.


마무리

핵심 요약

  • TCP는 신뢰성·순서·흐름/혼잡 제어를 제공하는 전송 계층의 기본축입니다.
  • 핸드셰이크·종료·TIME_WAIT은 운영 이슈와 직결됩니다.
  • Nagle, 윈도, 버퍼, keepalive지연과 처리량 사이의 레버입니다.

추천 사용 시나리오

  • 파일·API·DB·원격 셸처럼 데이터 무결성이 우선이면 TCP가 기본 선택입니다. 저지연·실시간이 더 중요하면 UDP 실전 가이드WebRTC 가이드를 함께 비교하세요.