icon

안동민 개발노트

6장 : TCP

TCP 심화와 실무


TCP의 기본 메커니즘을 이해했으니, 이제 실무에서 실제로 마주치는 TCP 관련 문제와 최적화 기법을 살펴보겠습니다. 이론적으로 TCP는 이렇게 동작한다를 넘어, 실제 서비스 운영에서 TCP 때문에 이런 일이 벌어진다는 것을 아는 것이 중요합니다.


Nagle 알고리즘과 지연 ACK

작은 패킷을 자주 보내는 것은 비효율적입니다. 1바이트의 데이터를 보내도 TCP 헤더(20바이트) + IP 헤더(20바이트)가 붙습니다. 1바이트를 위해 40바이트의 오버헤드가 발생하는 것입니다.

작은 패킷 문제 (Silly Window Syndrome)
Telnet에서 키보드 입력
  'H' → [IP 20B][TCP 20B][Data 1B] = 41바이트 전송
  'e' → [IP 20B][TCP 20B][Data 1B] = 41바이트 전송
  'l' → [IP 20B][TCP 20B][Data 1B] = 41바이트 전송
  'l' → [IP 20B][TCP 20B][Data 1B] = 41바이트 전송
  'o' → [IP 20B][TCP 20B][Data 1B] = 41바이트 전송

"Hello" (5바이트)를 보내기 위해 205바이트 사용
→ 효율: 5/205 = 2.4%

Nagle 알고리즘은 이 문제를 해결하기 위해, 작은 데이터를 모아서 한 번에 보내는 방식입니다. 이전에 보낸 데이터에 대한 ACK가 아직 오지 않았으면, 새로운 작은 데이터를 버퍼에 모아두었다가 ACK가 도착하면 한꺼번에 전송합니다.

Nagle 알고리즘 규칙
if (미확인 데이터가 없음) {
    즉시 전송;
} else if (버퍼에 MSS만큼 쌓였음) {
    즉시 전송;  // 꽉 찼으면 기다리지 않음
} else {
    ACK가 올 때까지 버퍼에 모아둠;
    ACK 도착하면 모은 데이터를 한 번에 전송;
}

지연 ACK(Delayed ACK)는 수신 측의 최적화입니다. ACK만을 위한 패킷을 즉시 보내는 대신, 일정 시간(보통 200ms) 동안 기다렸다가 다른 데이터와 함께 ACK를 보냅니다.

Nagle + 지연 ACK 교착
송신측 (Nagle ON)              수신측 (Delayed ACK ON)
  │                              │
  │── 200B 전송 ──────────→      │ "ACK를 200ms 기다리자"
  │                              │  (Delayed ACK)
  │  "ACK가 안 왔으니            │
  │   다음 100B를 버퍼에 모아둠" │
  │  (Nagle 대기)                │
  │                              │
  │  ← 200ms 경과 →              │
  │                              │
  │  ←──── ACK ───────────────── │ 드디어 ACK 전송
  │                              │
  │── 100B 전송 ───────────→     │
  │                              │
  불필요한 200ms 지연 발생!

문제는 Nagle과 지연 ACK가 동시에 동작할 때 발생합니다. Nagle은 ACK를 기다리고 있고, 지연 ACK는 ACK 전송을 미루고 있으니, 양쪽이 서로를 기다리는 교착 상태가 됩니다. 결과적으로 200ms의 불필요한 지연이 발생합니다.

tcp_nodelay_example.py
import socket

# Nagle 알고리즘 비활성화 (TCP_NODELAY)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.connect(("server.example.com", 8080))

# 이제 작은 데이터도 즉시 전송됨
sock.sendall(b"small data")

# TCP_NODELAY를 사용해야 하는 경우:
# - 게임 서버 (실시간 입력 전달)
# - 대화형 프로토콜 (SSH, Telnet)
# - HTTP/2 프레임 전송
# - Redis/memcached 같은 캐시 서버

# TCP_NODELAY를 쓰면 안 되는 경우:
# - 대량 파일 전송 (Nagle이 효율적)
# - 벌크 데이터 스트리밍

TCP Keep-Alive

TCP 연결은 한 번 수립되면, 양쪽에서 데이터를 보내지 않아도 계속 유지됩니다. 하지만 상대방이 갑자기 네트워크에서 사라지면(케이블이 빠지거나, 서버가 전원이 꺼지면), 이를 감지할 방법이 없습니다. 출발지는 연결이 살아있다고 생각하지만, 실제로는 죽어있는 반개방(Half-Open) 상태가 됩니다.

반개방 연결 문제
정상 상태
  Client ←── TCP 연결 ──→ Server

Server 전원 꺼짐 (FIN/RST 전송 불가)
  Client ←── TCP 연결? ──→ Server (꺼짐)

    └── Client: "연결이 살아있다고 생각"
        → 데이터를 보내려 할 때까지 감지 불가
        → 리소스(메모리, fd) 계속 점유

Keep-Alive 활성화 시
  2시간 무통신 후...
  Client ──── Keep-Alive Probe ────→ Server (꺼짐)
  (응답 없음)
  Client ──── Keep-Alive Probe ────→ (재시도)
  (응답 없음 × 9회)
  Client: "연결이 죽었다" → 연결 정리

TCP Keep-Alive는 일정 시간(기본 2시간) 동안 데이터가 오가지 않으면, 빈 패킷을 보내 상대방이 아직 살아있는지 확인하는 메커니즘입니다. 응답이 없으면 연결이 끊어진 것으로 판단하고 연결을 정리합니다.

파라미터Linux 기본값설명실무 권장
tcp_keepalive_time7200초 (2시간)첫 Probe까지 대기 시간300~600초
tcp_keepalive_intvl75초Probe 재전송 간격30~75초
tcp_keepalive_probes9회응답 없을 시 Probe 횟수5~9회
keepalive_config.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# TCP Keep-Alive 활성화
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

# Linux에서 Keep-Alive 파라미터 조정
import platform
if platform.system() == "Linux":
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 300)   # 5분
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 30)   # 30초
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)      # 5회

# 총 감지 시간: 300 + 30 × 5 = 450초 (7.5분)
# 기본값 대비: 7200 + 75 × 9 = 7875초 (약 2시간 11분) → 대폭 단축

HTTP의 Keep-Alive와 TCP Keep-Alive는 다른 개념입니다. HTTP Keep-Alive는 같은 TCP 연결을 재사용하여 여러 HTTP 요청을 처리하겠다는 것이고, TCP Keep-Alive는 연결이 살아있는지 주기적으로 확인하겠다는 것입니다.


TCP Fast Open

일반적인 TCP 연결에서는 3-way handshake가 완료된 후에야 데이터를 보낼 수 있습니다. 1 RTT가 소비되는 것입니다.

TCP Fast Open 동작
일반 TCP
  Client ── SYN ──────────→ Server    │ 1 RTT
  Client ←─ SYN-ACK ─────── Server    │ (handshake)
  Client ── ACK + Data ───→ Server    │ 2 RTT
  Client ←─ Response ─────── Server   │ (응답)

TCP Fast Open (최초 연결)
  Client ── SYN ──────────→ Server    │
  Client ←─ SYN-ACK+Cookie  Server    │ 1 RTT (쿠키 발급)
  Client ── ACK ──────────→ Server    │

TCP Fast Open (재연결)
  Client ── SYN+Cookie+Data→ Server   │ 0 RTT!
  Client ←─ SYN-ACK+Response Server   │ (데이터와 함께)
  Client ── ACK ──────────→ Server    │

→ 재연결 시 SYN에 데이터를 함께 실어 보냄
→ 1 RTT 절약

TCP Fast Open(TFO)는 최초 연결 시 서버가 발급하는 TFO 쿠키를 활용하여, 재연결 시 SYN 패킷에 데이터를 함께 실어 보내는 기술입니다. 3-way handshake가 완료되기 전에 데이터 전송이 시작되므로, 첫 요청에서 1 RTT를 절약할 수 있습니다.

TFO는 모든 브라우저와 서버가 지원하는 것은 아니며, 보안 고려(재전송 공격 방지)가 필요합니다. Linux는 기본적으로 TFO를 지원합니다.


실무에서 자주 만나는 TCP 문제

실제 서비스를 운영하면서 자주 만나는 TCP 관련 문제를 정리해 보겠습니다.

TCP 문제 진단 흐름도
증상: 연결 지연/실패

  ├── SYN_SENT 누적? ── 서버 도달 불가 (방화벽, 서버 다운)

  ├── SYN_RECV 대량? ── SYN Flood 공격

  ├── TIME_WAIT 대량? ── 짧은 연결 반복 (커넥션 풀 미사용)

  ├── CLOSE_WAIT 대량? ── 애플리케이션 close() 누락 (버그!)

  ├── ESTABLISHED + 응답 없음? ── 반개방 상태 (Keep-Alive 필요)

  └── 간헐적 지연? ── Nagle+Delayed ACK 교착 (TCP_NODELAY)

TIME_WAIT 누적: 고트래픽 서버에서 짧은 연결이 자주 생성/종료되면, TIME_WAIT 상태의 소켓이 수만 개까지 쌓일 수 있습니다. 사용 가능한 로컬 포트 번호가 고갈되어 새로운 연결을 만들 수 없는 상황이 발생합니다. net.ipv4.tcp_tw_reuse 커널 파라미터를 활성화하거나, 커넥션 풀을 사용하여 연결을 재사용하는 것이 해결 방법입니다.

CLOSE_WAIT 누적: CLOSE_WAIT은 상대방이 FIN을 보냈는데 우리 쪽이 아직 close()를 호출하지 않은 상태입니다. 이것이 누적된다면 애플리케이션 버그입니다. 소켓을 제대로 닫지 않는 코드가 있다는 뜻이므로, 코드 리뷰를 통해 close() 호출 누락을 찾아야 합니다.

close_wait_prevention.py
import socket

# 나쁜 예: close() 호출 누락 가능
def bad_example():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("server.example.com", 80))
    sock.sendall(b"GET / HTTP/1.0\r\n\r\n")
    data = sock.recv(4096)
    # 예외 발생 시 close()가 호출되지 않음!
    # → CLOSE_WAIT 상태로 남을 수 있음
    sock.close()

# 좋은 예: with문으로 자동 정리
def good_example():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect(("server.example.com", 80))
        sock.sendall(b"GET / HTTP/1.0\r\n\r\n")
        data = sock.recv(4096)
    # with 블록을 벗어나면 자동으로 close() 호출
    # 예외가 발생해도 반드시 정리됨

커넥션 풀 고갈: 데이터베이스나 외부 API와의 연결을 커넥션 풀로 관리하는데, 갑작스런 트래픽 증가로 풀의 모든 연결이 사용 중이 되면 새로운 요청이 대기 상태에 빠집니다. 풀 크기를 적절히 설정하고, 연결 타임아웃과 최대 대기 시간을 구성해야 합니다.

연결 타임아웃 vs 읽기 타임아웃: HTTP 클라이언트에서 두 가지 타임아웃을 구분해야 합니다.

타임아웃 유형구간적정값초과 시 의미
Connection TimeoutSYN → SYN-ACK1~5초서버 도달 불가 또는 포트 미응답
Read Timeout연결 후 → 응답 데이터5~30초서버 처리 지연
Write Timeout데이터 전송 완료5~15초네트워크 혼잡 또는 상대방 수신 불가
Idle Timeout마지막 데이터 이후30~300초유휴 연결 정리

SYN Flood 공격과 방어

SYN Flood 공격: 공격자가 대량의 SYN 패킷을 보내되, SYN-ACK에 대한 3단계 ACK를 보내지 않으면, 서버는 반쯤 열린 연결을 위한 자원을 계속 할당하게 됩니다.

SYN Flood 공격과 SYN Cookie 방어
SYN Flood 공격
  공격자 ── SYN (src=위조IP1) ──→ Server
  공격자 ── SYN (src=위조IP2) ──→ Server  Backlog Queue
  공격자 ── SYN (src=위조IP3) ──→ Server  [■■■■■■■ FULL]
  ...                                      │
  정상 Client ── SYN ──────→ Server        │
                              │ Accept 불가! ← Queue 가득
                              └─── 서비스 거부!

SYN Cookie 방어
  SYN 수신 시 Backlog에 저장하지 않음
  대신 seq 번호에 암호화된 정보를 인코딩:
    ISN = f(timestamp, src_ip, src_port, dst_ip, dst_port, secret)

  정상 Client만 올바른 ACK(ISN+1)로 응답 가능
  → ACK를 받아야 비로소 자원 할당
  → Backlog 고갈 방지
syn_flood_defense.sh
#!/bin/bash
# === SYN Flood 방어 설정 ===

# SYN Cookie 활성화
echo "SYN Cookie: $(cat /proc/sys/net/ipv4/tcp_syncookies)"
# sudo sysctl -w net.ipv4.tcp_syncookies=1

# SYN Backlog 크기 증가
echo "SYN Backlog: $(cat /proc/sys/net/ipv4/tcp_max_syn_backlog)"
# sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535

# SYN-ACK 재전송 횟수 감소 (기본 5 → 2)
echo "SYN-ACK retries: $(cat /proc/sys/net/ipv4/tcp_synack_retries)"
# sudo sysctl -w net.ipv4.tcp_synack_retries=2

# 현재 SYN_RECV 상태 확인
echo ""
echo "=== SYN_RECV 연결 수 ==="
ss -tan state syn-recv | wc -l

TCP 성능 튜닝 체크리스트

항목확인 명령문제 기준조치
TIME_WAIT 수ss -s> 10,000tcp_tw_reuse=1, 커넥션 풀
CLOSE_WAIT 수ss -tan state close-wait지속 증가애플리케이션 코드 수정
Backlog 큐ss -tnlp큐 오버플로somaxconn 증가
혼잡 제어sysctl net.ipv4.tcp_congestion_control환경 부적합BBR 전환
버퍼 크기sysctl net.ipv4.tcp_rmem너무 작음최대값 증가
RTTss -ti> 100ms서버 위치 최적화, CDN
Retransmitnetstat -s | grep retransmit비율 > 1%네트워크 품질 확인
Nagle 지연애플리케이션 로그200ms 주기 지연TCP_NODELAY

다음 장에서는 TCP와는 정반대의 철학을 가진 UDP를 살펴보고, 언제 어떤 프로토콜을 선택해야 하는지를 판단하는 기준을 세워 보겠습니다.

목차