TCP 심화와 실무
TCP의 기본 메커니즘을 이해했으니, 이제 실무에서 실제로 마주치는 TCP 관련 문제와 최적화 기법을 살펴보겠습니다. 이론적으로 TCP는 이렇게 동작한다를 넘어, 실제 서비스 운영에서 TCP 때문에 이런 일이 벌어진다는 것을 아는 것이 중요합니다.
Nagle 알고리즘과 지연 ACK
작은 패킷을 자주 보내는 것은 비효율적입니다. 1바이트의 데이터를 보내도 TCP 헤더(20바이트) + IP 헤더(20바이트)가 붙습니다. 1바이트를 위해 40바이트의 오버헤드가 발생하는 것입니다.
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가 도착하면 한꺼번에 전송합니다.
if (미확인 데이터가 없음) {
즉시 전송;
} else if (버퍼에 MSS만큼 쌓였음) {
즉시 전송; // 꽉 찼으면 기다리지 않음
} else {
ACK가 올 때까지 버퍼에 모아둠;
ACK 도착하면 모은 데이터를 한 번에 전송;
}지연 ACK(Delayed ACK)는 수신 측의 최적화입니다. ACK만을 위한 패킷을 즉시 보내는 대신, 일정 시간(보통 200ms) 동안 기다렸다가 다른 데이터와 함께 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의 불필요한 지연이 발생합니다.
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_time | 7200초 (2시간) | 첫 Probe까지 대기 시간 | 300~600초 |
| tcp_keepalive_intvl | 75초 | Probe 재전송 간격 | 30~75초 |
| tcp_keepalive_probes | 9회 | 응답 없을 시 Probe 횟수 | 5~9회 |
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
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 관련 문제를 정리해 보겠습니다.
증상: 연결 지연/실패
│
├── 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() 호출 누락을 찾아야 합니다.
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 Timeout | SYN → SYN-ACK | 1~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 (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 고갈 방지#!/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 -lTCP 성능 튜닝 체크리스트
| 항목 | 확인 명령 | 문제 기준 | 조치 |
|---|---|---|---|
| TIME_WAIT 수 | ss -s | > 10,000 | tcp_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 | 너무 작음 | 최대값 증가 |
| RTT | ss -ti | > 100ms | 서버 위치 최적화, CDN |
| Retransmit | netstat -s | grep retransmit | 비율 > 1% | 네트워크 품질 확인 |
| Nagle 지연 | 애플리케이션 로그 | 200ms 주기 지연 | TCP_NODELAY |
다음 장에서는 TCP와는 정반대의 철학을 가진 UDP를 살펴보고, 언제 어떤 프로토콜을 선택해야 하는지를 판단하는 기준을 세워 보겠습니다.