TCP 연결 상태 11가지: TIME_WAIT·CLOSE_WAIT가 쌓이는 이유와 netstat·ss 디버깅
이 글의 핵심
TCP 11가지 연결 상태(LISTEN, SYN_SENT, ESTABLISHED, FIN_WAIT, TIME_WAIT 등)의 동작 원리와 상태 전이 다이어그램. netstat으로 네트워크 디버깅하는 실전 가이드.
들어가며: TCP 상태를 이해해야 하는 이유
네트워크 애플리케이션을 개발하거나 디버깅할 때 TCP 연결 상태를 이해하는 것은 필수입니다. netstat 명령어로 본 ESTABLISHED, TIME_WAIT, CLOSE_WAIT 같은 상태들이 무엇을 의미하는지, 언제 발생하는지 알아야 성능 문제와 버그를 해결할 수 있습니다.
이 글에서 다룰 내용:
- TCP 11가지 연결 상태
- 3-Way Handshake와 4-Way Handshake
- 상태 전이 다이어그램
- netstat/ss로 상태 확인
- 실전 디버깅 시나리오
실무에서 TCP 상태를 들여다보게 되는 계기는 대개 둘 중 하나입니다. “갑자기 Cannot assign requested address가 난다”(TIME_WAIT로 인한 로컬 포트 고갈)거나, “서버가 며칠 지나면 Too many open files로 죽는다”(CLOSE_WAIT 누수)입니다. 두 증상은 원인이 정반대입니다. TIME_WAIT는 내가 먼저 닫은 연결의 정상적인 후처리이고, CLOSE_WAIT는 상대가 닫았는데 내가 닫지 않은 연결입니다. 그래서 TIME_WAIT는 커널 설정이나 연결 재사용으로 다루고, CLOSE_WAIT는 거의 항상 애플리케이션 버그로 다룹니다. 이 구분 하나만 알고 있어도 ss 출력 한 번으로 조사 방향을 절반은 정할 수 있습니다.
TCP 연결 상태 한눈에 보기
TCP 상태 머신
TCP는 상태 기반 프로토콜입니다. 각 소켓은 11가지 상태 중 하나에 있으며, 패킷 송수신에 따라 상태가 전이됩니다.
11가지 TCP 상태
| 상태 | 설명 | 발생 시점 |
|---|---|---|
| CLOSED | 연결 없음 | 초기 상태 |
| LISTEN | 연결 대기 중 | 서버가 포트를 열고 대기 |
| SYN_SENT | 연결 요청 전송 | 클라이언트가 SYN 전송 후 |
| SYN_RECEIVED | 연결 요청 수신 | 서버가 SYN 받고 SYN-ACK 전송 |
| ESTABLISHED | 연결 수립 완료 | 3-Way Handshake 완료 |
| FIN_WAIT_1 | 종료 요청 전송 | 능동적 종료 시작 (FIN 전송) |
| FIN_WAIT_2 | 종료 요청 승인 대기 | FIN에 대한 ACK 수신 |
| CLOSE_WAIT | 종료 요청 수신 | 상대방이 FIN 전송 (수동적 종료) |
| CLOSING | 동시 종료 | 양쪽이 동시에 FIN 전송 |
| LAST_ACK | 최종 ACK 대기 | CLOSE_WAIT에서 FIN 전송 후 |
| TIME_WAIT | 연결 종료 대기 | 능동적 종료 완료, 2MSL 대기 |
이 표에서 기억해 둘 점은 상태가 소켓(연결) 단위로, 양쪽에 각각 존재한다는 것입니다. 같은 연결이라도 클라이언트 쪽은 FIN_WAIT_2, 서버 쪽은 CLOSE_WAIT처럼 서로 다른 상태에 있을 수 있고, netstat은 내 호스트 쪽 상태만 보여 줍니다. 또 “클라이언트/서버”보다 “먼저 닫는 쪽/나중에 닫는 쪽”이 종료 상태를 결정합니다. 서버가 먼저 close()하면 TIME_WAIT는 서버에 쌓이고, 클라이언트가 먼저 닫으면 클라이언트에 쌓입니다. 리눅스 ss는 SYN_RECEIVED를 SYN-RECV, ESTABLISHED를 ESTAB처럼 줄여서 표시하므로 grep할 때 이름 차이에 주의해야 합니다.
연결 수립: 3-Way Handshake
왜 3-Way인가? (2-Way가 아니라)
TCP는 양방향 신뢰성 있는 통신을 제공합니다. 3-Way Handshake는 다음을 보장합니다:
- 클라이언트 → 서버 경로 확인: SYN
- 서버 → 클라이언트 경로 확인: SYN-ACK
- 양방향 통신 준비 완료: ACK
2-Way가 불충분한 이유:
2-Way 시나리오 (불안전):
Client → Server: SYN
Server → Client: SYN-ACK
문제:
- 서버는 SYN-ACK를 보냈지만, 클라이언트가 받았는지 알 수 없음
- 만약 SYN-ACK가 손실되면?
→ 서버는 ESTABLISHED 상태로 전환
→ 클라이언트는 여전히 SYN_SENT
→ 서버가 데이터를 보내도 클라이언트가 받지 못함
3-Way 해결책:
Client → Server: SYN
Server → Client: SYN-ACK
Client → Server: ACK ← 이것이 핵심!
- ACK를 받은 서버만 ESTABLISHED로 전환
- 클라이언트도 SYN-ACK를 받았으므로 ESTABLISHED
- 양방향 통신 확실히 가능
3-Way Handshake 과정
sequenceDiagram
participant Client
participant Server
Note over Client: CLOSED
Note over Server: LISTEN
Client->>Server: 1. SYN (seq=100)
Note over Client: SYN_SENT
Note right of Client: 초기 시퀀스 번호(ISN) 전송\n커널이 랜덤 생성
Server->>Client: 2. SYN-ACK (seq=200, ack=101)
Note over Server: SYN_RECEIVED
Note left of Server: 서버의 ISN + 클라이언트 ACK\n백로그 큐에 추가
Client->>Server: 3. ACK (ack=201)
Note over Client: ESTABLISHED
Note over Server: ESTABLISHED
Note right of Client: accept() 큐로 이동\n애플리케이션이 처리 가능
Note over Client,Server: 데이터 전송 가능
상태 전이 및 커널 동작
클라이언트: CLOSED → SYN_SENT → ESTABLISHED
서버: CLOSED → LISTEN → SYN_RECEIVED → ESTABLISHED
클라이언트 측 커널 동작:
// socket() 호출: TCB (Transmission Control Block) 할당
int sock = socket(AF_INET, SOCK_STREAM, 0);
// 상태: CLOSED
// 커널: TCB 생성, 소켓 구조체 초기화
// connect() 호출: SYN 전송
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
// 1. 초기 시퀀스 번호(ISN) 생성: 현재 시간 + 난수
// 보안: 예측 불가능한 ISN으로 SYN Flood 방어
// 2. SYN 패킷 생성:
// TCP 헤더: SYN 플래그 설정, seq = ISN
// 3. IP 계층으로 전달 → 네트워크로 송신
// 4. 상태: CLOSED → SYN_SENT
// 5. 재전송 타이머 시작 (보통 1초, 2초, 4초... 최대 75초)
// 6. SYN-ACK 대기 (connect()는 블로킹)
// SYN-ACK 수신 시:
// 1. ack 값 검증: ack == ISN + 1인지 확인
// 2. ACK 패킷 생성 및 전송
// 3. 상태: SYN_SENT → ESTABLISHED
// 4. connect() 반환 (블로킹 해제)
서버 측 커널 동작:
// socket() + bind() + listen() 호출
int server = socket(AF_INET, SOCK_STREAM, 0);
bind(server, (struct sockaddr*)&addr, sizeof(addr));
listen(server, 128); // 백로그 큐 크기
// 상태: CLOSED → LISTEN
// 커널:
// - SYN 큐 (미완성 연결, 크기: tcp_max_syn_backlog)
// - Accept 큐 (완성된 연결, 크기: listen backlog)
// SYN 수신 시:
// 1. SYN 큐에 공간 있는지 확인
// - 가득 차면: SYN Cookies 사용 또는 드롭
// 2. 반-완성 연결 생성 (Half-Open Connection)
// 3. 서버의 ISN 생성
// 4. SYN-ACK 패킷 생성 및 전송:
// seq = 서버 ISN
// ack = 클라이언트 ISN + 1
// 5. 상태: LISTEN → SYN_RECEIVED
// 6. 재전송 타이머 시작 (SYN-ACK 재전송)
// ACK 수신 시:
// 1. SYN 큐에서 제거
// 2. Accept 큐로 이동
// 3. 상태: SYN_RECEIVED → ESTABLISHED
// 4. accept() 호출 시 Accept 큐에서 꺼냄
// - 큐가 비어있으면 accept()는 블로킹
int client = accept(server, ...);
// accept() 반환: Accept 큐에서 ESTABLISHED 소켓 반환
백로그 큐 관리:
[SYN 큐] [Accept 큐]
↓ ↓
┌──────────────────────┐ ┌─────────────────────┐
│ SYN_RECEIVED 연결들 │→│ ESTABLISHED 연결들 │→ accept()
│ (미완성) │ │ (완성, 대기 중) │
│ tcp_max_syn_backlog │ │ listen backlog │
└──────────────────────┘ └─────────────────────┘
SYN Flood 공격 시:
- SYN 큐가 가득 참 → 새 SYN 드롭
- 정상 클라이언트도 연결 불가 (DoS)
해결책:
1. SYN Cookies 활성화
- SYN 큐 우회, 무상태(Stateless) 처리
- ISN에 연결 정보 인코딩
2. 백로그 증가
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192
sudo sysctl -w net.core.somaxconn=4096
시퀀스 번호(Sequence Number)의 중요성:
왜 랜덤 ISN을 사용하나?
고정 ISN (예: 0)의 위험:
1. 이전 연결의 지연된 패킷이 새 연결에 섞일 수 있음
2. 공격자가 ISN을 예측하여 위조 패킷 전송 가능
랜덤 ISN (RFC 6528):
ISN = M + F(src_ip, src_port, dst_ip, dst_port, secret)
M = 4 마이크로초마다 증가하는 카운터
F = 해시 함수 (MD5 또는 SHA-1)
secret = 부팅 시 생성된 랜덤 키
→ 32비트 공간을 약 4.55시간에 순환
→ 예측 불가능 (보안)
→ 이전 연결과 충돌 방지
Python으로 3-Way Handshake 관찰
import socket
import time
def client_connect():
"""클라이언트 연결 과정"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print("📍 상태: CLOSED")
# connect() 호출 시 SYN 전송
print("📤 SYN 전송 중...")
print("📍 상태: SYN_SENT")
sock.connect(('example.com', 80))
print("✅ 연결 수립")
print("📍 상태: ESTABLISHED")
return sock
def server_listen():
"""서버 리스닝 과정"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8080))
print("📍 상태: CLOSED")
server.listen(5)
print("👂 포트 8080에서 대기 중...")
print("📍 상태: LISTEN")
client, addr = server.accept()
print(f"✅ {addr}로부터 연결 수립")
print("📍 상태: ESTABLISHED")
return client
패킷 레벨 분석
# tcpdump로 3-Way Handshake 캡처
sudo tcpdump -i any -nn 'tcp port 80' -c 3
# 출력:
# 1. 192.168.1.100.54321 > 93.184.216.34.80: Flags [S], seq 100
# 2. 93.184.216.34.80 > 192.168.1.100.54321: Flags [S.], seq 200, ack 101
# 3. 192.168.1.100.54321 > 93.184.216.34.80: Flags [.], ack 201
연결 종료: 4-Way Handshake
정상 종료 (4-Way Handshake)
sequenceDiagram
participant Client as Client\n(능동적 종료)
participant Server as Server\n(수동적 종료)
Note over Client,Server: ESTABLISHED
Client->>Server: 1. FIN (seq=300)
Note over Client: FIN_WAIT_1
Server->>Client: 2. ACK (ack=301)
Note over Client: FIN_WAIT_2
Note over Server: CLOSE_WAIT
Note over Server: 애플리케이션이\nclose() 호출할 때까지 대기
Server->>Client: 3. FIN (seq=400)
Note over Server: LAST_ACK
Client->>Server: 4. ACK (ack=401)
Note over Client: TIME_WAIT
Note over Server: CLOSED
Note over Client: 2MSL (60초) 대기
Note over Client: CLOSED
능동적 종료 vs 수동적 종료
# 능동적 종료 (Active Close)
# - close()를 먼저 호출하는 쪽
# - FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED
client_socket.close() # 클라이언트가 먼저 종료
# 상태: ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED
# 수동적 종료 (Passive Close)
# - 상대방의 FIN을 받는 쪽
# - CLOSE_WAIT → LAST_ACK → CLOSED
# 서버는 클라이언트의 FIN을 받음
# 상태: ESTABLISHED → CLOSE_WAIT
# 애플리케이션이 close() 호출
server_socket.close()
# 상태: CLOSE_WAIT → LAST_ACK → CLOSED
11가지 TCP 상태 하나씩 보기
CLOSED
초기 상태. 연결이 존재하지 않음.
LISTEN
서버가 특정 포트에서 연결 요청을 기다리는 상태.
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5) # 백로그 큐 크기 5
# 이 시점에서 상태: LISTEN
print("Server is LISTEN on port 8080")
# 확인
netstat -an | grep 8080
# tcp4 0 0 *.8080 *.* LISTEN
SYN_SENT
클라이언트가 SYN 패킷을 전송하고 SYN-ACK를 기다리는 상태.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False) # 논블로킹 모드
try:
sock.connect(('example.com', 80))
except BlockingIOError:
# 이 시점에서 상태: SYN_SENT
print("Connection in progress (SYN_SENT)")
# select로 연결 완료 대기
import select
_, writable, _ = select.select([], [sock], [], 5)
if writable:
print("Connection ESTABLISHED")
SYN_SENT가 오래 유지되는 경우:
- 방화벽이 SYN 패킷 차단
- 서버가 다운되어 응답 없음
- 네트워크 지연
SYN_RECEIVED
서버가 SYN을 받고 SYN-ACK를 전송한 후, 최종 ACK를 기다리는 상태.
SYN Flood 공격 상세 분석:
flowchart LR
Attacker[공격자] -->|대량 SYN| Server[서버]
Server -->|SYN-ACK| Void[응답 없음]
Note1["서버의 SYN_RECEIVED\n큐가 가득 참"]
style Attacker fill:#ff6b6b
style Void fill:#ff6b6b
SYN Flood 공격 내부 메커니즘:
일반 연결:
Client → Server: SYN
Server → Client: SYN-ACK
Client → Server: ACK ← 정상 완료
Server: SYN 큐에서 Accept 큐로 이동
SYN Flood 공격:
Attacker → Server: SYN (위조 IP: 1.1.1.1)
Server → 1.1.1.1: SYN-ACK ← 응답 없음 (위조 IP)
Server: SYN 큐에 SYN_RECEIVED 상태로 대기
Attacker → Server: SYN (위조 IP: 2.2.2.2)
Server → 2.2.2.2: SYN-ACK ← 응답 없음
Server: SYN 큐에 추가
... 수천 개 반복 ...
Server: SYN 큐 가득 참 (1024개)
→ 정상 클라이언트 SYN도 드롭
→ 서비스 거부 (DoS)
SYN 큐 메모리 소비:
각 SYN_RECEIVED 연결:
- TCB (Transmission Control Block): ~280 bytes
- 재전송 타이머
- 소켓 구조체
1024개 × 280 bytes = ~280KB (작지만 리소스 고갈)
재전송 타이머:
1초 → 2초 → 4초 → 8초 → 16초 → 32초
총 63초 동안 SYN 큐 점유
공격 효과:
- 초당 1000개 SYN
- 각각 63초 유지
- 필요 큐 크기: 63,000개
- 서버 마비
SYN Cookies 방어 메커니즘:
SYN Cookies 원리 (RFC 4987):
일반 방식 (상태 유지):
1. SYN 수신 → TCB 할당 → SYN 큐 저장
2. SYN-ACK 전송
3. ACK 수신 → TCB 조회 → Accept 큐 이동
문제: SYN 큐 메모리 소비 (공격에 취약)
SYN Cookies 방식 (무상태):
1. SYN 수신 → TCB 할당 안 함!
2. ISN 계산 (Stateless):
ISN = hash(
src_ip, src_port,
dst_ip, dst_port,
timestamp, ← 시간 정보
secret ← 서버 비밀키
) + MSS_encoding + timestamp_encoding
ISN 구조 (32 bits):
[31:27] timestamp (5 bits, 32초 순환)
[26:24] MSS encoding (3 bits, 8가지 MSS)
[23:0] hash 값 (24 bits)
3. SYN-ACK 전송 (seq=ISN)
→ SYN 큐에 저장 안 함! (메모리 절약)
4. ACK 수신 시:
- ack 값에서 정보 복원:
* ack - 1 = ISN
* ISN에서 timestamp, MSS 추출
* hash 재계산하여 검증
검증:
calculated_hash = hash(...)
if (ack - 1) matches calculated_hash:
→ 정상 연결
→ ESTABLISHED 생성
else:
→ 위조 패킷
→ 드롭
장점:
- SYN 큐 불필요 (메모리 무제한)
- SYN Flood 공격 무효화
단점:
- TCP 옵션 손실 (Window Scale, SACK 등)
- MSS만 8가지로 제한
- 성능 약간 저하
활성화:
Linux:
net.ipv4.tcp_syncookies=1
동작:
- SYN 큐 < 75%: 일반 방식
- SYN 큐 ≥ 75%: SYN Cookies 자동 활성화
확인:
$ netstat -s | grep "SYNs to LISTEN"
123 SYNs to LISTEN sockets dropped
456 times used SYN cookies
백로그 큐 (Backlog Queue) 상세:
listen(fd, backlog) 내부 구조:
Linux 커널 큐 2개:
1. SYN Queue (미완성 연결):
크기: net.ipv4.tcp_max_syn_backlog (기본 1024)
상태: SYN_RECEIVED
[SYN1] [SYN2] [SYN3] ... [SYNn]
↓
각 항목:
- 클라이언트 IP:Port
- ISN
- 재전송 타이머
- TCP 옵션
2. Accept Queue (완성된 연결):
크기: min(backlog, net.core.somaxconn)
상태: ESTABLISHED
[CONN1] [CONN2] [CONN3] ... [CONNn]
↓
accept() 호출 시 꺼냄
흐름:
SYN 수신 → SYN Queue 추가 (SYN_RECEIVED)
ACK 수신 → Accept Queue 이동 (ESTABLISHED)
accept() → Accept Queue에서 제거
큐 오버플로우:
SYN Queue 가득:
- SYN Cookies 활성화 (tcp_syncookies=1)
- 또는 SYN 드롭
Accept Queue 가득:
- 새 ACK 무시
- 클라이언트는 SYN-ACK 재전송 대기
- 클라이언트 타임아웃 발생
튜닝:
# SYN 큐 증가
net.ipv4.tcp_max_syn_backlog=8192
# Accept 큐 증가
net.core.somaxconn=4096
# 애플리케이션
listen(fd, 4096) # 큐 크기 명시
확인:
ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 0 128 0.0.0.0:80
↑ ↑
Accept큐 Accept큐 최대 크기
현재 대기 (listen backlog)
# SYN_RECEIVED 상태 확인
netstat -an | grep SYN_RECV
# SYN Flood 방어 (Linux)
sudo sysctl -w net.ipv4.tcp_syncookies=1
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192
# 백로그 확인
ss -lnt
ESTABLISHED
연결이 수립되어 데이터를 주고받을 수 있는 상태.
# ESTABLISHED 상태에서 데이터 송수신
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('example.com', 80))
# 상태: ESTABLISHED
sock.send(b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n')
response = sock.recv(4096)
print(response.decode())
# ESTABLISHED 연결 수 확인
netstat -an | grep ESTABLISHED | wc -l
# 특정 포트의 ESTABLISHED 연결
netstat -an | grep ':80.*ESTABLISHED'
FIN_WAIT_1
능동적 종료를 시작하여 FIN을 전송한 상태. ACK를 기다림.
# 클라이언트가 먼저 종료
client_socket.close() # FIN 전송
# 상태: ESTABLISHED → FIN_WAIT_1
# 서버의 ACK 대기
FIN_WAIT_2
FIN에 대한 ACK를 받았지만, 상대방의 FIN을 기다리는 상태.
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: ESTABLISHED
C->>S: FIN
Note over C: FIN_WAIT_1
S->>C: ACK
Note over C: FIN_WAIT_2
Note over S: CLOSE_WAIT
Note over S: 서버가 close() 호출할 때까지\nFIN_WAIT_2 유지
FIN_WAIT_2 타임아웃:
# Linux에서 FIN_WAIT_2 타임아웃 설정 (기본 60초)
sudo sysctl -w net.ipv4.tcp_fin_timeout=30
CLOSE_WAIT - 가장 흔한 소켓 리소스 누수의 원인
상대방이 FIN을 보내 연결 종료를 요청한 상태.
애플리케이션이 close()를 호출해야 함.
CLOSE_WAIT vs TIME_WAIT 핵심 차이:
TIME_WAIT:
- 능동적 종료 측 (close()를 먼저 호출)
- 커널이 자동 관리
- 60초 후 자동 정리
- 정상적인 상태 (걱정 불필요)
CLOSE_WAIT:
- 수동적 종료 측 (FIN을 먼저 받음)
- 애플리케이션이 명시적으로 close() 호출 필요
- 애플리케이션이 close()를 안 부르면 영원히 유지
- 버그의 신호! (리소스 누수)
CLOSE_WAIT 발생 메커니즘:
# 서버 코드
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5)
client_sock, addr = server.accept()
# 상태: ESTABLISHED
# 클라이언트가 연결을 닫음 (FIN 전송)
# 커널이 자동으로 ACK 응답
# 상태: ESTABLISHED → CLOSE_WAIT
# ⚠️ 문제: 애플리케이션이 close()를 호출하지 않음
# 상태: CLOSE_WAIT (영구 유지)
# 소켓 파일 디스크립터 누수
# 메모리 누수
# 포트 고갈 (서버 측에서는 문제 적지만)
# ✅ 해결: 반드시 close() 호출
client_sock.close()
# 상태: CLOSE_WAIT → LAST_ACK → CLOSED
CLOSE_WAIT 문제 (소켓 리소스 누수):
# ❌ 나쁜 예 1: Exception 발생 시 close() 누락
def handle_request(sock):
data = sock.recv(1024)
if not data:
return # ❌ close() 호출 안 함
process(data)
sock.send(b'OK')
sock.close() # ❌ 여기 도달하지 못할 수 있음
# ❌ 나쁜 예 2: 무한 루프에서 close() 누락
def handle_client(sock):
while True:
data = sock.recv(1024)
if not data:
break # ❌ close() 없이 종료
process(data)
# ❌ 나쁜 예 3: 멀티스레드에서 소켓 관리 실패
def worker(sock):
try:
process(sock)
except Exception as e:
log_error(e)
# ❌ Exception 발생 시 close() 누락
# ✅ 좋은 예 1: try-finally
def handle_request(sock):
try:
data = sock.recv(1024)
if not data:
return
process(data)
sock.send(b'OK')
finally:
sock.close() # ✅ 항상 실행됨
# ✅ 좋은 예 2: Context Manager
def handle_request(sock):
with sock: # ✅ __exit__에서 자동 close()
data = sock.recv(1024)
process(data)
sock.send(b'OK')
# ✅ 좋은 예 3: recv(0) 감지
def handle_client(sock):
try:
while True:
data = sock.recv(1024)
if not data: # FIN 수신 (상대방이 닫음)
break
process(data)
finally:
sock.close() # ✅ 반드시 닫기
CLOSE_WAIT 디버깅 실전 가이드:
# 1. CLOSE_WAIT 소켓 수 확인
netstat -an | grep CLOSE_WAIT | wc -l
# 100개 이상이면 문제!
# 2. 프로세스별 소켓 수 확인
lsof -p <PID> | grep TCP | wc -l
# 또는
ls -l /proc/<PID>/fd | grep socket | wc -l
# 3. 어떤 소켓이 CLOSE_WAIT인지 확인
sudo ss -tanp | grep CLOSE_WAIT
# 출력 예시:
# CLOSE-WAIT 0 0 192.168.1.100:8080 192.168.1.200:54321 users:(("python",pid=1234,fd=5))
# ↑ 서버 포트 ↑ 클라이언트 포트 ↑ 프로세스 정보
# 4. 특정 프로세스의 소켓 상태
sudo lsof -p <PID> -a -i TCP
# 출력 예시:
# python 1234 user 5u IPv4 12345 TCP *:8080 (LISTEN)
# python 1234 user 6u IPv4 12346 TCP 192.168.1.100:8080->192.168.1.200:54321 (CLOSE_WAIT)
# ↑ 문제!
# 5. 파일 디스크립터 한계 확인
ulimit -n
# 1024 ← 기본값
# CLOSE_WAIT가 1024개 쌓이면 새 연결 불가!
# 6. 커널 소켓 통계
cat /proc/net/sockstat
# 출력:
# TCP: inuse 150 orphan 0 tw 50 alloc 200 mem 10
# ↑ 사용 중 ↑ 고아 ↑ TIME_WAIT ↑ 할당됨
실제 프로덕션 사례:
# 사례 1: Django 뷰에서 외부 API 호출
import requests
def my_view(request):
response = requests.get('https://api.example.com/data')
# ❌ 문제: requests는 내부적으로 소켓 사용
# Connection: close로 응답 시 서버가 먼저 FIN 전송
# requests가 제대로 정리 안 하면 CLOSE_WAIT 누적
# ✅ 해결책 1: Session 사용 (연결 재사용)
session = requests.Session()
response = session.get('https://api.example.com/data')
session.close() # 명시적 종료
# ✅ 해결책 2: Context Manager
with requests.Session() as session:
response = session.get('https://api.example.com/data')
# 사례 2: 데이터베이스 연결 누수
import psycopg2
def query_database():
conn = psycopg2.connect(
host='localhost',
database='mydb',
user='user',
password='pass'
)
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
results = cursor.fetchall()
# ❌ conn.close() 누락!
# PostgreSQL 서버에서 CLOSE_WAIT 누적
# ✅ 해결책
try:
# ... 쿼리 실행 ...
finally:
cursor.close()
conn.close()
# 사례 3: WebSocket 연결 관리
import websocket
def handle_websocket():
ws = websocket.create_connection('ws://server.com/socket')
ws.send('Hello')
result = ws.recv()
# ❌ ws.close() 누락!
# ✅ 해결책
try:
ws.send('Hello')
result = ws.recv()
finally:
ws.close()
CLOSE_WAIT 모니터링 자동화:
#!/bin/bash
# close_wait_monitor.sh
THRESHOLD=50
CLOSE_WAIT_COUNT=$(netstat -an | grep CLOSE_WAIT | wc -l)
if [ $CLOSE_WAIT_COUNT -gt $THRESHOLD ]; then
echo "⚠️ ALERT: CLOSE_WAIT count is $CLOSE_WAIT_COUNT (threshold: $THRESHOLD)"
# 프로세스별 통계
echo "Top processes with CLOSE_WAIT:"
sudo ss -tanp | grep CLOSE_WAIT | awk '{print $NF}' | sort | uniq -c | sort -rn | head -5
# 슬랙 알림 전송
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"CLOSE_WAIT alert: $CLOSE_WAIT_COUNT connections\"}" \
https://hooks.slack.com/services/YOUR/WEBHOOK/URL
fi
# Python 모니터링 스크립트
import subprocess
import time
def count_close_wait():
result = subprocess.run(['netstat', '-an'], capture_output=True, text=True)
count = result.stdout.count('CLOSE_WAIT')
return count
def monitor_close_wait(threshold=50, interval=60):
"""CLOSE_WAIT 소켓을 주기적으로 모니터링"""
while True:
count = count_close_wait()
print(f"CLOSE_WAIT count: {count}")
if count > threshold:
# 경고 로그
print(f"⚠️ WARNING: CLOSE_WAIT count exceeded threshold ({count} > {threshold})")
# 상세 정보 수집
result = subprocess.run(['ss', '-tanp'], capture_output=True, text=True)
with open('/var/log/close_wait_debug.log', 'a') as f:
f.write(f"\n=== {time.ctime()} ===\n")
f.write(result.stdout)
time.sleep(interval)
if __name__ == '__main__':
monitor_close_wait(threshold=50, interval=60)
CLOSE_WAIT 근본 원인 분석:
CLOSE_WAIT가 쌓이는 3가지 근본 원인:
1. 코드 버그
- try-finally 누락
- Exception 처리 미흡
- 조기 return에서 close() 누락
2. 아키텍처 문제
- 장시간 실행되는 백그라운드 작업
- 연결을 전역 변수에 저장 후 관리 실패
- 멀티스레딩/비동기에서 소켓 생명주기 관리 실패
3. 라이브러리 버그
- 서드파티 라이브러리의 소켓 정리 버그
- 오래된 버전 사용
해결 원칙:
→ 모든 소켓 open에 대응하는 close 보장
→ Context Manager 활용
→ 리소스 누수 테스트 자동화
CLOSE_WAIT에는 타임아웃이 없다는 점이 TIME_WAIT와 결정적으로 다릅니다. 커널은 애플리케이션이 아직 소켓으로 데이터를 보낼 수도 있다고 보기 때문에(반쪽 닫힘, half-close), 프로세스가 close()를 부르거나 종료할 때까지 이 상태를 무한정 유지합니다. 그래서 CLOSE_WAIT는 시간이 지나도 줄지 않고 단조 증가하며, 결국 프로세스의 파일 디스크립터 한도(ulimit -n)에 닿으면 accept()가 EMFILE (Too many open files)로 실패해 새 연결을 받지 못합니다. 서버가 멀쩡히 떠 있는데 새 요청만 전부 실패하는 이상한 장애의 흔한 원인입니다.
제가 가장 자주 본 패턴은 커넥션 풀을 쓰는 클라이언트 쪽 CLOSE_WAIT입니다. 상대 서버(또는 중간 로드밸런서)가 유휴 연결을 먼저 닫으면 풀 안의 소켓은 CLOSE_WAIT가 되는데, 풀이 그 연결을 다시 꺼내 쓰거나 정리하기 전까지 그대로 남습니다. 요청이 뜸한 서비스에서 이런 소켓이 수십~수백 개 보이는 것은 대개 정상에 가깝지만, 요청량과 무관하게 계속 늘어난다면 응답 본문을 끝까지 읽지 않거나(requests에서 stream=True 후 close() 누락) 응답 객체를 닫지 않아 연결이 풀로 반환되지 않는 버그입니다. 판단 기준은 개수보다 추세입니다.
CLOSING
양쪽이 동시에 FIN을 전송한 경우 (드물게 발생).
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: ESTABLISHED
C->>S: FIN
Note over C: FIN_WAIT_1
S->>C: FIN (동시 전송)
Note over S: FIN_WAIT_1
Note over C: CLOSING
Note over S: CLOSING
C->>S: ACK
S->>C: ACK
Note over C: TIME_WAIT
Note over S: TIME_WAIT
LAST_ACK
수동적 종료에서 FIN을 전송하고 최종 ACK를 기다리는 상태.
# 서버가 수동적으로 종료
# 1. 클라이언트가 FIN 전송 → 서버는 CLOSE_WAIT
# 2. 서버가 close() 호출 → FIN 전송, LAST_ACK 상태
# 3. 클라이언트의 ACK 수신 → CLOSED
TIME_WAIT - TCP의 가장 오해받는 상태
연결 종료 후 2MSL(Maximum Segment Lifetime) 동안 대기.
지연된 패킷 처리 및 포트 재사용 방지.
TIME_WAIT가 반드시 필요한 2가지 이유:
1. 마지막 ACK 손실 시나리오
정상적인 4-Way Handshake:
Client Server
| |
| ------ FIN ----> |
| <----- ACK ----- |
| <----- FIN ----- |
| ------ ACK ----> | ← 마지막 ACK
| |
TIME_WAIT CLOSED
(60초 대기)
만약 마지막 ACK가 손실된다면:
Client Server
| |
| ------ ACK ----> | (손실!)
| |
TIME_WAIT LAST_ACK
(재전송 타이머)
| |
| <----- FIN ----- | ← FIN 재전송
| ------ ACK ----> | ← 재응답
| |
TIME_WAIT CLOSED
TIME_WAIT가 없다면?
→ 클라이언트가 이미 CLOSED
→ FIN 재전송에 RST로 응답
→ 서버가 비정상 종료로 인식
→ 애플리케이션 로그에 에러 발생
2. 지연된 패킷 문제
시나리오: 빠른 연결-종료-재연결
연결 A:
Client:12345 ←→ Server:80
- 데이터 전송 중 일부 패킷이 네트워크에서 지연
- 연결 종료
즉시 재연결 (TIME_WAIT 없이):
Client:12345 ←→ Server:80 (동일한 4-튜플!)
문제:
- 이전 연결 A의 지연된 패킷이 도착
- 커널이 새 연결 B의 패킷으로 오인
- 데이터 손상 발생!
TIME_WAIT 해결책:
- 60초 동안 같은 4-튜플 사용 금지
- MSL(보통 30초) * 2 = 60초
- 네트워크의 모든 패킷이 소멸될 때까지 대기
- 60초 후에만 포트 재사용 가능
2MSL (Maximum Segment Lifetime) 계산:
MSL = 세그먼트가 네트워크에 머물 수 있는 최대 시간
= RFC 793은 2분으로 정의 (구현마다 30초~2분 사용)
2MSL = 한 방향으로 간 세그먼트와 그 응답이
모두 네트워크에서 사라질 때까지의 시간
Linux 구현:
TIME_WAIT 지속 시간 = 60초 (커널 상수 TCP_TIMEWAIT_LEN)
→ sysctl로 바꿀 수 없음
흔한 오해:
net.ipv4.tcp_fin_timeout (기본 60초)은 TIME_WAIT가 아니라
애플리케이션이 닫은(orphan) 소켓의 FIN_WAIT_2 유지 시간
→ 줄여도 TIME_WAIT 개수는 변하지 않음
TIME_WAIT 문제 (포트 고갈)
# 문제 재현 코드
import socket
import time
# 클라이언트가 짧은 연결을 반복 생성
for i in range(65000): # 포트 범위 초과 시도
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('server.com', 80))
sock.send(b'GET / HTTP/1.1\r\n\r\n')
sock.recv(1024)
sock.close() # 능동적 종료 → TIME_WAIT
# 이 소켓은 60초 동안 포트 점유
print(f"Connection {i} closed")
# 에러 발생:
# OSError: [Errno 99] Cannot assign requested address
# 원인: 사용 가능한 포트(32768~60999) 모두 TIME_WAIT 상태
# TIME_WAIT 소켓 수 확인
netstat -an | grep TIME_WAIT | wc -l
# 많은 경우 (수천 개):
# - 클라이언트가 짧은 연결을 반복적으로 생성
# - 로드 밸런서, 프록시에서 흔함
# - HTTP/1.0 (Connection: close)
# 포트 범위 확인
cat /proc/sys/net/ipv4/ip_local_port_range
# 32768 60999
# → 약 28,000개 포트만 사용 가능
TIME_WAIT 최적화 전략:
# 1. SO_REUSEADDR (서버 측)
# - TIME_WAIT 소켓이 있어도 바인딩 허용
# - 서버 재시작 시 "Address already in use" 방지
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8080))
# 이전 서버의 TIME_WAIT 소켓이 있어도 바인딩 성공
SO_REUSEADDR는 TIME_WAIT를 줄이는 옵션이 아니라, TIME_WAIT 소켓이 남아 있는 포트에 새 리스닝 소켓을 bind()할 수 있게 해 주는 옵션입니다. 서버를 재시작했더니 OSError: [Errno 98] Address already in use가 나는 경우가 전형적인 대상이며, 대부분의 서버 프레임워크는 이미 기본으로 켜 둡니다. 이름이 비슷한 SO_REUSEPORT는 목적이 다릅니다. 여러 프로세스가 동시에 같은 포트에서 LISTEN하게 해 커널이 연결을 분산시키는 기능이라, 워커 프로세스별로 accept 큐를 나누는 고성능 서버에서 씁니다. 클라이언트 쪽 포트 고갈에는 두 옵션 모두 해결책이 아닙니다.
# 2. SO_LINGER (위험! 신중히 사용)
import socket
import struct
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# linger = (onoff, linger_time)
# onoff=1, linger_time=0: close() 시 즉시 RST 전송 (TIME_WAIT 건너뜀)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
sock.connect(('server.com', 80))
sock.send(b'data')
sock.close() # RST 전송, TIME_WAIT 없음
# ⚠️ 위험:
# - 데이터 손실 가능 (송신 버퍼에 남은 데이터 버림)
# - 서버가 "Connection reset by peer" 에러 발생
# - 오직 로컬 테스트용으로만 사용
SO_LINGER(1, 0)은 TIME_WAIT를 “해결”하는 게 아니라 TCP의 정상 종료 절차 자체를 건너뛰는 것입니다. 송신 버퍼에 아직 나가지 않은 데이터가 있으면 버려지고, 상대는 마지막 응답을 받기 전에 RST를 받을 수 있습니다. HTTP 응답을 쓰고 바로 이렇게 닫으면 클라이언트가 응답 일부를 잃는 간헐적 버그가 생기는데, 로컬에서는 버퍼가 순식간에 비워져 재현되지 않아 원인을 찾기 어렵습니다. 부하 테스트 도구처럼 데이터 무결성이 중요하지 않은 곳이 아니라면 쓰지 않는 편이 좋습니다.
# 3. net.ipv4.tcp_tw_reuse (클라이언트 측)
# - 새로운 아웃바운드 연결 시 TIME_WAIT 소켓 재사용
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
# 안전한 이유:
# - 타임스탬프 옵션으로 이전 패킷 구분
# - 나가는(outgoing) 연결에만 적용 (들어오는 연결에는 영향 없음)
# - Linux 4.19 이후 기본값은 2 (루프백 연결에만 재사용 허용)
# 4. net.ipv4.tcp_tw_recycle (금지!)
# - Linux 4.12에서 제거됨
# - NAT 환경에서 심각한 문제 발생
sudo sysctl -w net.ipv4.tcp_tw_recycle=1 # ❌ 사용 금지!
# 문제:
# - 같은 NAT 뒤의 여러 클라이언트가 같은 IP로 보임
# - 타임스탬프 역전 시 패킷 드롭
# - 무작위 연결 실패
TIME_WAIT 근본 해결책: Keep-Alive
# ❌ 나쁜 예: 매번 새 연결 (TIME_WAIT 누적)
for i in range(1000):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('server.com', 80))
sock.send(b'GET / HTTP/1.1\r\nHost: server.com\r\n\r\n')
sock.recv(1024)
sock.close() # TIME_WAIT
# ✅ 좋은 예: 연결 재사용 (HTTP/1.1 Keep-Alive)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('server.com', 80))
for i in range(1000):
sock.send(b'GET / HTTP/1.1\r\nHost: server.com\r\nConnection: keep-alive\r\n\r\n')
sock.recv(1024)
# 한 번만 close() → TIME_WAIT 1개만
sock.close()
이 예제는 원리를 보여 주기 위한 것이라 한 가지를 단순화했습니다. recv(1024)는 응답 전체가 아니라 도착한 만큼만 읽으므로, 실제로 연결을 재사용하려면 Content-Length나 chunked 인코딩을 해석해 응답 경계까지 정확히 읽어야 합니다. 덜 읽은 채 다음 요청을 보내면 이전 응답의 나머지가 다음 응답처럼 읽히는 버그가 생깁니다. 직접 구현하기보다 requests.Session, httpx.Client, 언어별 HTTP 클라이언트의 커넥션 풀을 쓰는 이유가 여기에 있습니다.
# HTTP/2: 멀티플렉싱
# - 하나의 TCP 연결로 여러 요청을 동시에 처리
# - TIME_WAIT 문제 거의 없음
# (HTTP/3은 TCP가 아니라 UDP 기반 QUIC을 쓰므로 TCP 상태 자체가 없음)
언제 TIME_WAIT가 문제가 되나?
문제 되는 경우:
1. 로드 밸런서 / 리버스 프록시
- 백엔드 서버로 짧은 연결 반복 생성
- 포트 고갈 위험
2. 크롤러 / 스크래퍼
- 수천 개 사이트에 연결
- 빠른 속도로 연결 생성/종료
3. 부하 테스트 도구
- 초당 수만 개 요청
- 클라이언트 포트 고갈
문제 안 되는 경우:
1. 일반 웹 서버 (TIME_WAIT가 많아도 포트 고갈은 없음)
- 서버가 keep-alive 타임아웃으로 먼저 닫으면 TIME_WAIT는 서버에 쌓임
- 하지만 모두 같은 로컬 포트(:443)를 쓰고 4-튜플이 달라
새 연결 수락을 막지 않음 (소켓당 작은 메모리만 차지)
2. Keep-Alive 사용하는 애플리케이션
- 연결 재사용
- TIME_WAIT 최소화
3. 서버 측 (listen 소켓)
- LISTEN 소켓은 TIME_WAIT 없음
- Accept된 소켓만 영향받음
상태 전이 다이어그램
완전한 TCP 상태 전이
stateDiagram-v2
[*] --> CLOSED
CLOSED --> LISTEN: passive open\n(server)
CLOSED --> SYN_SENT: active open\n(client)
LISTEN --> SYN_RECEIVED: recv SYN\nsend SYN-ACK
SYN_SENT --> ESTABLISHED: recv SYN-ACK\nsend ACK
SYN_SENT --> SYN_RECEIVED: recv SYN\nsend SYN-ACK
SYN_RECEIVED --> ESTABLISHED: recv ACK
ESTABLISHED --> FIN_WAIT_1: close()\nsend FIN
ESTABLISHED --> CLOSE_WAIT: recv FIN\nsend ACK
FIN_WAIT_1 --> FIN_WAIT_2: recv ACK
FIN_WAIT_1 --> CLOSING: recv FIN\nsend ACK
FIN_WAIT_2 --> TIME_WAIT: recv FIN\nsend ACK
CLOSE_WAIT --> LAST_ACK: close()\nsend FIN
CLOSING --> TIME_WAIT: recv ACK
LAST_ACK --> CLOSED: recv ACK
TIME_WAIT --> CLOSED: 2MSL timeout
CLOSED --> [*]
클라이언트 vs 서버 상태 흐름
flowchart TB
subgraph Client["클라이언트 (능동적 종료)"]
C1[CLOSED] --> C2[SYN_SENT]
C2 --> C3[ESTABLISHED]
C3 --> C4[FIN_WAIT_1]
C4 --> C5[FIN_WAIT_2]
C5 --> C6[TIME_WAIT]
C6 --> C7[CLOSED]
end
subgraph Server["서버 (수동적 종료)"]
S1[CLOSED] --> S2[LISTEN]
S2 --> S3[SYN_RECEIVED]
S3 --> S4[ESTABLISHED]
S4 --> S5[CLOSE_WAIT]
S5 --> S6[LAST_ACK]
S6 --> S7[CLOSED]
end
netstat/ss로 상태 확인
netstat 명령어
기본 사용법
# 모든 TCP 연결 확인
netstat -an | grep tcp
# 특정 상태만 필터링
netstat -an | grep ESTABLISHED
netstat -an | grep TIME_WAIT
netstat -an | grep CLOSE_WAIT
# 프로세스 정보 포함 (Linux)
sudo netstat -anp | grep :80
# 통계 정보
netstat -s | grep -i tcp
상태별 개수 확인
# Linux/Mac
netstat -an | awk '/tcp/ {print $6}' | sort | uniq -c
# 출력 예시:
# 150 ESTABLISHED
# 50 TIME_WAIT
# 5 CLOSE_WAIT
# 3 LISTEN
ss 명령어 (더 빠름)
# 모든 TCP 소켓
ss -tan
# ESTABLISHED 상태만
ss -tan state established
# TIME_WAIT 상태만
ss -tan state time-wait
# 프로세스 정보 포함
ss -tanp
# 통계
ss -s
ss 필터 예시
# 특정 포트
ss -tan '( dport = :80 or sport = :80 )'
# 특정 IP
ss -tan dst 192.168.1.100
# 여러 상태 조합
ss -tan state established state syn-sent
# 수신 큐와 송신 큐 크기 확인
ss -tan | awk '{print $2, $3}'
Windows PowerShell
# TCP 연결 확인
Get-NetTCPConnection
# ESTABLISHED 상태만
Get-NetTCPConnection -State Established
# 특정 포트
Get-NetTCPConnection -LocalPort 80
# 상태별 개수
Get-NetTCPConnection | Group-Object -Property State | Select-Object Name, Count
TIME_WAIT·CLOSE_WAIT·SYN_SENT 디버깅 시나리오
시나리오 1: TIME_WAIT 과다 (포트 고갈)
문제 증상
$ netstat -an | grep TIME_WAIT | wc -l
5000
# 새 연결 시도 시 에러
# "Cannot assign requested address"
원인
# ❌ 잘못된 코드: 짧은 연결 반복 생성
for i in range(10000):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('api.example.com', 80))
sock.send(b'GET / HTTP/1.1\r\n\r\n')
sock.recv(1024)
sock.close() # TIME_WAIT 상태로 전환
# 60초 동안 포트 재사용 불가!
해결 방법
# ✅ 해결 1: 연결 재사용 (Keep-Alive)
import requests
session = requests.Session()
for i in range(10000):
response = session.get('http://api.example.com/')
# 같은 연결 재사용, TIME_WAIT 발생 안 함
# ✅ 해결 2: SO_REUSEADDR 설정
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# ✅ 해결 3: 커널 파라미터 조정 (Linux)
# /etc/sysctl.conf
# net.ipv4.tcp_tw_reuse = 1
# net.ipv4.tcp_fin_timeout = 30
시나리오 2: CLOSE_WAIT 누적 (리소스 누수)
문제 증상
$ netstat -an | grep CLOSE_WAIT | wc -l
1000
# 시간이 지나도 줄어들지 않음
# 결국 "Too many open files" 에러
원인
# ❌ 잘못된 코드: 소켓을 닫지 않음
def handle_client(sock):
data = sock.recv(1024)
process(data)
# sock.close() 누락!
# 클라이언트가 연결을 닫으면 CLOSE_WAIT 상태로 남음
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5)
while True:
client, addr = server.accept()
handle_client(client) # 소켓 누수!
해결 방법
# ✅ 해결 1: try-finally로 확실히 닫기
def handle_client(sock):
try:
data = sock.recv(1024)
process(data)
finally:
sock.close() # 반드시 실행
# ✅ 해결 2: Context Manager 사용
def handle_client(sock):
with sock:
data = sock.recv(1024)
process(data)
# 자동으로 close() 호출
# ✅ 해결 3: 타임아웃 설정
sock.settimeout(30) # 30초 후 자동 종료
디버깅
# CLOSE_WAIT 소켓을 가진 프로세스 찾기
lsof -i -n | grep CLOSE_WAIT
# 프로세스별 CLOSE_WAIT 개수
lsof -i -n | grep CLOSE_WAIT | awk '{print $2}' | sort | uniq -c
# 특정 프로세스의 소켓 상태
lsof -p <PID> | grep TCP
시나리오 3: SYN_SENT 타임아웃
문제 증상
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
try:
sock.connect(('unreachable-server.com', 80))
except socket.timeout:
print("❌ Connection timeout (SYN_SENT)")
원인 및 해결
# 1. 방화벽 확인
sudo iptables -L -n | grep 80
# 2. 라우팅 확인
traceroute unreachable-server.com
# 3. 서버 상태 확인
ping unreachable-server.com
# 4. 포트 스캔
nmap -p 80 unreachable-server.com
연결 실패는 에러 종류로 원인 위치를 가를 수 있습니다. 즉시 Connection refused(ECONNREFUSED)가 나면 패킷은 상대 호스트까지 갔고, 그 포트에 LISTEN 중인 프로세스가 없어서 RST가 돌아온 것입니다. 서버 프로세스가 죽었거나 127.0.0.1에만 바인딩된 경우가 흔합니다. 반대로 한참 기다린 뒤 타임아웃이 나면 SYN이나 SYN-ACK가 중간 어딘가에서 조용히 버려진 것이고, 방화벽의 DROP 규칙이나 클라우드 보안 그룹이 대표적인 원인입니다. 타임아웃을 지정하지 않으면 Linux는 SYN을 net.ipv4.tcp_syn_retries(기본 6)번 재전송하므로 실패를 알기까지 2분 가까이 걸립니다. 운영 코드에서 connect 타임아웃을 반드시 짧게 명시해야 하는 이유입니다.
시나리오 4: 대량 ESTABLISHED 연결
문제 증상
$ netstat -an | grep ESTABLISHED | wc -l
10000
# 서버 응답 느려짐
# CPU, 메모리 사용량 증가
원인
# ❌ 유휴 연결을 정리하는 로직이 없음
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# SO_KEEPALIVE는 "상대가 죽었는지"만 확인할 뿐, 살아 있는 유휴 연결을 닫지 않음
# 기본값(tcp_keepalive_time=7200초)이면 죽은 상대도 2시간 넘게 ESTABLISHED로 남음
TCP keepalive와 HTTP keep-alive는 이름만 같고 전혀 다른 기능이라 혼동하기 쉽습니다. TCP keepalive(SO_KEEPALIVE)는 한동안 트래픽이 없으면 커널이 빈 probe 패킷을 보내 상대가 아직 존재하는지 확인하는 기능이고, 응답이 없으면 연결을 끊어 half-open 연결을 정리합니다. HTTP keep-alive는 요청 하나가 끝나도 TCP 연결을 닫지 않고 다음 요청에 재사용하는 애플리케이션 계층의 약속입니다. ESTABLISHED가 너무 많은 문제는 대개 후자의 유휴 타임아웃이 너무 길거나 없는 데서 오고, 전자는 죽은 연결을 치우는 청소부 역할입니다. Linux 기본 tcp_keepalive_time이 2시간이라 사실상 쓸모가 없으므로, 켜려면 아래처럼 소켓별로 값을 짧게 줘야 의미가 있습니다.
해결 방법
# ✅ 해결 1: 타임아웃 설정
sock.settimeout(60) # recv가 60초간 데이터 없으면 socket.timeout 예외 → 잡아서 close()
# ✅ 해결 2: Keep-Alive 파라미터 조정
import socket
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Linux
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # 60초 후 첫 probe
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # 10초 간격
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # 3번 시도
# ✅ 해결 3: 연결 풀 크기 제한
from concurrent.futures import ThreadPoolExecutor
max_connections = 1000
executor = ThreadPoolExecutor(max_workers=max_connections)
포트 고갈과 커널 파라미터 튜닝
Port Exhaustion (포트 고갈) 문제
클라이언트 포트 고갈 메커니즘:
TCP 연결 4-Tuple:
(Client IP, Client Port, Server IP, Server Port)
서버:
- 고정 포트 (예: 80, 443)
- 여러 클라이언트 IP 수용
클라이언트:
- 동적 포트 (Ephemeral Port)
- 범위: 32768 ~ 60999 (Linux)
- 총 개수: 28232개
예시: 클라이언트가 API 서버에 대량 요청
for i in range(100000):
sock = requests.get('https://api.example.com')
sock.close()
연결 과정:
1. 새 연결 생성:
Client (192.168.1.100:32768) → Server (1.2.3.4:443)
2. 요청 완료, close() 호출:
FIN 전송 → TIME_WAIT (60초)
3. 다음 연결:
Client (192.168.1.100:32769) → Server (1.2.3.4:443)
4. 포트 증가...
32770, 32771, ... 60999
5. 포트 고갈:
28232개 모두 TIME_WAIT 상태
→ 새 연결 불가!
증상:
socket.error: [Errno 99] Cannot assign requested address
또는
socket.error: [Errno 24] Too many open files
TIME_WAIT 소켓 확인:
$ netstat -an | grep TIME_WAIT | wc -l
25000 ← 포트 거의 고갈
4-Tuple 유니크 제약:
(Client IP, Client Port, Server IP, Server Port)
같은 서버 (1.2.3.4:443)에 연결 시:
- Client IP: 고정 (192.168.1.100)
- Client Port: 28232개로 제한
- Server IP:Port: 고정 (1.2.3.4:443)
→ 최대 28232개 동시 연결
해결책:
1. TIME_WAIT 재사용 활성화
2. 포트 범위 확장
3. 연결 풀 사용
4. HTTP Keep-Alive
해결 방법 상세:
1. TIME_WAIT 재사용 (net.ipv4.tcp_tw_reuse):
기본 동작:
- TIME_WAIT 소켓은 60초간 포트 점유
- 새 연결은 사용 불가
tcp_tw_reuse=1:
- 타임스탬프 기반 재사용 허용
- 조건:
* 새 연결의 타임스탬프 > 이전 연결
* 나가는(outgoing) 연결에만 적용
* 서버에서 켜도 들어오는 연결에는 영향 없음
(서버가 백엔드로 나가는 연결을 만드는 프록시라면 효과 있음)
설정:
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
효과:
- TIME_WAIT 소켓을 1초 후 재사용 가능
- 포트 고갈 문제 해결
- 클라이언트 벤치마크/크롤러에 유용
주의:
- NAT 환경에서 타임스탬프 꼬일 수 있음
- 방화벽이 타임스탬프 제거하면 효과 없음
2. 포트 범위 확장:
기본:
net.ipv4.ip_local_port_range = 32768 60999
총 28232개
확장:
sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535"
총 55536개 (거의 2배)
효과:
- 동시 연결 수 증가
- TIME_WAIT 대기 시간 동안 더 많은 포트 사용 가능
3. SO_LINGER 옵션 (주의 필요):
sock.setsockopt(
socket.SOL_SOCKET,
socket.SO_LINGER,
struct.pack('ii', 1, 0)
)
효과:
- close() 시 FIN 대신 RST 전송
- TIME_WAIT 우회 → 즉시 포트 재사용
부작용:
- 비정상 종료로 간주
- 버퍼에 남은 데이터 손실
- 상대방 "Connection reset by peer" 에러
→ 실무에서 비권장
4. 연결 풀 (Connection Pool):
문제:
for i in range(10000):
r = requests.get(url) # 매번 새 연결
TIME_WAIT 대량 발생
해결:
session = requests.Session() # 연결 풀 생성
for i in range(10000):
r = session.get(url) # 연결 재사용
HTTP Keep-Alive:
Connection: keep-alive
→ 같은 소켓으로 여러 요청
→ TIME_WAIT 최소화
예시:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
adapter = HTTPAdapter(
pool_connections=10, # 호스트당 연결 풀 크기
pool_maxsize=20, # 최대 연결 수
max_retries=Retry(total=3)
)
session.mount('https://', adapter)
for i in range(10000):
r = session.get('https://api.example.com')
결과:
- 연결 재사용
- TIME_WAIT 10~20개로 제한
- 포트 고갈 문제 해결
5. 여러 IP 사용 (Multi-Homing):
4-Tuple 제약:
(Client IP, Client Port, Server IP, Server Port)
Client IP 추가:
eth0: 192.168.1.100 → 28232개 포트
eth0:0: 192.168.1.101 → 28232개 포트
eth0:1: 192.168.1.102 → 28232개 포트
총: 84696개 동시 연결 가능
코드:
sock = socket.socket()
sock.bind(('192.168.1.101', 0)) # 특정 IP 바인딩
sock.connect(('1.2.3.4', 443))
6. 서버 측 대응 (역방향 문제):
클라이언트가 서버를 공격:
- 대량 연결 생성 후 즉시 종료
- 서버에 TIME_WAIT 대량 발생
- 서버 포트는 고정이므로 직접 영향 없음
- 하지만 메모리/소켓 디스크립터 고갈
해결:
- 클라이언트 IP당 연결 수 제한
- Rate Limiting
- DDoS 방어 시스템
포트 고갈 모니터링:
# TIME_WAIT 소켓 수 확인
netstat -an | grep TIME_WAIT | wc -l
# 특정 서버로의 TIME_WAIT
netstat -an | grep '1.2.3.4:443' | grep TIME_WAIT | wc -l
# 포트 범위 확인
cat /proc/sys/net/ipv4/ip_local_port_range
# 현재 사용 중인 포트
ss -tan | awk '{print $4}' | grep -o ':[0-9]*$' | sort | uniq | wc -l
# 실시간 모니터링
watch -n 1 'ss -tan state time-wait | wc -l'
# 포트별 상태 분포
ss -tan | awk '{print $1}' | sort | uniq -c
Linux 커널 파라미터
# /etc/sysctl.conf
# TIME_WAIT 재사용 허용 (클라이언트 측)
net.ipv4.tcp_tw_reuse = 1
# (tcp_tw_recycle은 Linux 4.12에서 제거됨 — 최신 커널에서 이 줄을 넣으면
# sysctl -p가 "cannot stat /proc/sys/net/ipv4/tcp_tw_recycle" 에러를 냄)
# 포트 범위 확장 (클라이언트)
net.ipv4.ip_local_port_range = 10000 65535
# FIN_WAIT_2 타임아웃 (기본 60초)
net.ipv4.tcp_fin_timeout = 30
# SYN 백로그 큐 크기
net.ipv4.tcp_max_syn_backlog = 8192
# 최대 연결 수 (서버 listen backlog)
net.core.somaxconn = 65535
# Keep-Alive 설정
net.ipv4.tcp_keepalive_time = 60 # 60초 후 첫 probe
net.ipv4.tcp_keepalive_intvl = 10 # 10초 간격
net.ipv4.tcp_keepalive_probes = 3 # 3번 시도
# 최대 파일 디스크립터 수
fs.file-max = 2097152
# 적용
sudo sysctl -p
SO_LINGER 옵션
import socket
import struct
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_LINGER 설정
# l_onoff=1, l_linger=0: close() 시 RST 전송 (TIME_WAIT 회피)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
sock.connect(('example.com', 80))
sock.send(b'data')
# close() 호출 시:
# - 정상: FIN 전송 → TIME_WAIT
# - SO_LINGER(0): RST 전송 → 즉시 CLOSED
sock.close()
# ⚠️ 주의: RST는 비정상 종료로 간주됨
# 상대방이 "Connection reset by peer" 에러 발생 가능
연결 풀 패턴
import queue
import socket
import threading
class ConnectionPool:
def __init__(self, host, port, pool_size=10):
self.host = host
self.port = port
self.pool = queue.Queue(maxsize=pool_size)
# 미리 연결 생성
for _ in range(pool_size):
sock = self._create_connection()
self.pool.put(sock)
def _create_connection(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((self.host, self.port))
return sock
def get_connection(self, timeout=5):
"""연결 가져오기"""
try:
return self.pool.get(timeout=timeout)
except queue.Empty:
return self._create_connection()
def return_connection(self, sock):
"""연결 반환"""
try:
self.pool.put_nowait(sock)
except queue.Full:
sock.close()
def close_all(self):
"""모든 연결 종료"""
while not self.pool.empty():
try:
sock = self.pool.get_nowait()
sock.close()
except queue.Empty:
break
# 사용
pool = ConnectionPool('api.example.com', 80, pool_size=20)
def make_request():
sock = pool.get_connection()
try:
sock.send(b'GET / HTTP/1.1\r\n\r\n')
response = sock.recv(4096)
return response
finally:
pool.return_connection(sock) # 재사용
# 여러 스레드에서 사용
for _ in range(1000):
threading.Thread(target=make_request).start()
# TIME_WAIT 발생 안 함 (연결 재사용)
Half-Open, RST, 동시 열기
TCP Half-Open 연결
# Half-Open: 한쪽은 ESTABLISHED, 다른 쪽은 연결 정보가 없음
# 발생 원인: 네트워크 단절, 상대 호스트 전원 차단·커널 패닉
# (프로세스만 죽은 경우는 커널이 대신 FIN/RST를 보내므로 해당 없음)
# 감지 방법: Keep-Alive
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Keep-Alive probe 전송 → 응답 없으면 연결 종료
TCP Reset (RST)
# RST 패킷이 전송되는 경우:
# 1. 존재하지 않는 포트로 연결 시도
# 2. SO_LINGER(0)로 close() 호출
# 3. 애플리케이션 강제 종료
# 4. 방화벽이 연결 차단
# RST 수신 시:
# - 즉시 CLOSED 상태로 전환
# - "Connection reset by peer" 에러
TCP Simultaneous Open
sequenceDiagram
participant A as Host A
participant B as Host B
Note over A: CLOSED
Note over B: CLOSED
A->>B: SYN
Note over A: SYN_SENT
B->>A: SYN (동시 전송)
Note over B: SYN_SENT
Note over A: SYN_RECEIVED
Note over B: SYN_RECEIVED
A->>B: SYN-ACK
B->>A: SYN-ACK
Note over A: ESTABLISHED
Note over B: ESTABLISHED
실전 모니터링 스크립트
Python 모니터링 도구
#!/usr/bin/env python3
import subprocess
import time
from collections import Counter
def get_tcp_states():
"""TCP 상태별 개수 조회"""
try:
# ss 명령어 사용 (더 빠름)
result = subprocess.run(
['ss', '-tan'],
capture_output=True,
text=True
)
lines = result.stdout.strip().split('\n')[1:] # 헤더 제외
states = []
for line in lines:
parts = line.split()
if len(parts) > 0:
state = parts[0]
states.append(state)
return Counter(states)
except FileNotFoundError:
# ss가 없으면 netstat 사용
result = subprocess.run(
['netstat', '-an'],
capture_output=True,
text=True
)
states = []
for line in result.stdout.split('\n'):
if 'tcp' in line.lower():
parts = line.split()
if len(parts) >= 6:
states.append(parts[5])
return Counter(states)
def monitor_tcp_states(interval=5):
"""TCP 상태 실시간 모니터링"""
print("🔍 TCP 연결 상태 모니터링 시작...\n")
try:
while True:
states = get_tcp_states()
print(f"\n{'='*50}")
print(f"⏰ {time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*50}")
for state, count in sorted(states.items()):
bar = '█' * min(count, 50)
print(f"{state:15s} {count:5d} {bar}")
total = sum(states.values())
print(f"{'─'*50}")
print(f"{'TOTAL':15s} {total:5d}")
# 경고
if states.get('CLOSE_WAIT', 0) > 100:
print("\n⚠️ WARNING: Too many CLOSE_WAIT connections!")
print(" → Check if application is closing sockets properly")
if states.get('TIME_WAIT', 0) > 5000:
print("\n⚠️ WARNING: Too many TIME_WAIT connections!")
print(" → Consider using connection pooling")
time.sleep(interval)
except KeyboardInterrupt:
print("\n\n✅ 모니터링 종료")
if __name__ == '__main__':
monitor_tcp_states(interval=5)
Bash 원라이너 모음
# 1. 상태별 개수
netstat -an | awk '/tcp/ {print $6}' | sort | uniq -c | sort -rn
# 2. 특정 포트의 연결 수
netstat -an | grep ':80 ' | wc -l
# 3. IP별 연결 수 (상위 10개)
netstat -an | grep ESTABLISHED | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -10
# 4. TIME_WAIT 비율
echo "scale=2; $(netstat -an | grep TIME_WAIT | wc -l) / $(netstat -an | grep tcp | wc -l) * 100" | bc
# 5. 연결 수 실시간 모니터링
watch -n 1 'netstat -an | grep tcp | awk "{print \$6}" | sort | uniq -c'
프로그래밍 언어별 상태 확인
Python
import socket
import psutil
# 현재 프로세스의 연결 상태
connections = psutil.net_connections(kind='tcp')
for conn in connections:
if conn.status == 'ESTABLISHED':
print(f"{conn.laddr.ip}:{conn.laddr.port} -> {conn.raddr.ip}:{conn.raddr.port}")
print(f" Status: {conn.status}")
print(f" PID: {conn.pid}")
# 상태별 개수
from collections import Counter
states = Counter(conn.status for conn in connections)
print(states)
Node.js
const { exec } = require('child_process');
function getTCPStates(callback) {
exec('netstat -an | grep tcp', (error, stdout) => {
if (error) {
callback(error);
return;
}
const lines = stdout.trim().split('\n');
const states = {};
lines.forEach(line => {
const parts = line.split(/\s+/);
const state = parts[5];
states[state] = (states[state] || 0) + 1;
});
callback(null, states);
});
}
// 사용
getTCPStates((err, states) => {
if (!err) {
console.log('TCP States:', states);
}
});
Go
package main
import (
"fmt"
"github.com/shirou/gopsutil/v3/net"
)
func main() {
// TCP 연결 조회
connections, err := net.Connections("tcp")
if err != nil {
panic(err)
}
// 상태별 개수
states := make(map[string]int)
for _, conn := range connections {
states[conn.Status]++
}
// 출력
for state, count := range states {
fmt.Printf("%s: %d\n", state, count)
}
}
로드 밸런서·DB·HTTP 클라이언트에서 연결 재사용하기
로드 밸런서 설정
# Nginx에서 Keep-Alive 설정
upstream backend {
server backend1.example.com:8080;
server backend2.example.com:8080;
# Keep-Alive 연결 유지
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
# Keep-Alive 헤더
proxy_http_version 1.1;
proxy_set_header Connection "";
# 타임아웃
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
Nginx는 기본적으로 백엔드와 HTTP/1.0으로 통신하고 요청마다 Connection: close를 보내므로, 이 설정이 없으면 Nginx 서버에 백엔드 방향 TIME_WAIT가 요청 수만큼 쌓입니다. 사용자 쪽 연결은 keep-alive인데 프록시 뒤에서 포트 고갈이 나는 전형적인 경우입니다. upstream 블록의 keepalive 지시어로 유휴 연결 풀을 두고, proxy_http_version 1.1과 빈 Connection 헤더로 연결을 닫지 말라고 알려야 재사용이 됩니다. 한 가지 더, 백엔드의 keep-alive 유휴 타임아웃은 Nginx 쪽보다 길게 잡아야 합니다. 백엔드가 먼저 연결을 닫는 순간 Nginx가 그 연결로 요청을 보내면 upstream prematurely closed connection이나 502가 간헐적으로 나기 때문입니다.
데이터베이스 연결 풀
import psycopg2
from psycopg2 import pool
# 연결 풀 생성
connection_pool = psycopg2.pool.ThreadedConnectionPool(
minconn=5, # 최소 연결 수
maxconn=20, # 최대 연결 수
host='localhost',
database='mydb',
user='user',
password='pass'
)
def query_database(sql):
conn = connection_pool.getconn()
try:
cursor = conn.cursor()
cursor.execute(sql)
result = cursor.fetchall()
return result
finally:
connection_pool.putconn(conn) # 연결 반환
# TIME_WAIT 발생 안 함 (연결 재사용)
HTTP 클라이언트 최적화
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
# 세션 재사용 + 재시도 전략
session = requests.Session()
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504]
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=10, # 연결 풀 크기
pool_maxsize=20
)
session.mount("http://", adapter)
session.mount("https://", adapter)
# 사용
for i in range(1000):
response = session.get('http://api.example.com/data')
# 연결 재사용, TIME_WAIT 최소화
상태별 점검 항목
TIME_WAIT 과다
- HTTP Keep-Alive 사용 확인
- 연결 풀 구현 확인
-
net.ipv4.tcp_tw_reuse활성화 - 어느 쪽이 먼저 닫는지 확인 (TIME_WAIT는 먼저 닫은 쪽에 쌓임)
- 클라이언트 포트 범위 확대 (
tcp_fin_timeout은 TIME_WAIT와 무관하므로 조정 대상 아님)
# 클라이언트 포트 범위 확대
sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535"
CLOSE_WAIT 누적
- 애플리케이션 코드에서
close()호출 확인 -
try-finally또는with문 사용 - 타임아웃 설정 확인
- 예외 처리 확인
- 프로세스 재시작 (임시 해결)
# CLOSE_WAIT 디버깅
import traceback
def handle_connection(sock):
try:
data = sock.recv(1024)
process(data)
except Exception as e:
print(f"❌ Error: {e}")
traceback.print_exc()
finally:
print("🔒 Closing socket")
sock.close() # 반드시 실행
ESTABLISHED 과다
- 유휴 연결 타임아웃 설정
- Keep-Alive 파라미터 조정
- 최대 연결 수 제한
- 연결 풀 크기 조정
- 로드 밸런서 설정 확인
자주 묻는 질문 (FAQ)
Q. Connection reset by peer 에러는 어떤 상황에서 발생하나요?
A. 상대가 FIN으로 정상 종료하는 대신 RST 패킷을 보냈다는 뜻이며, 이를 받으면 연결은 TIME_WAIT 같은 종료 절차 없이 즉시 CLOSED로 전환됩니다. 존재하지 않는 포트로 연결을 시도했을 때, 상대가 SO_LINGER를 0으로 두고 close()를 호출했을 때, 애플리케이션이 강제 종료됐을 때, 방화벽이 연결을 끊었을 때가 대표적입니다. 반면 SYN_SENT 상태에서 응답 없이 타임아웃이 난다면 RST가 아니라 방화벽이나 라우팅에서 패킷이 버려지는 경우가 많으므로 iptables와 traceroute로 경로부터 확인합니다.
참고 자료
- RFC 793 - TCP Specification
- TCP/IP Illustrated, Volume 1
- Linux TCP Tuning
- ss(8) man page TCP 연결 상태를 이해하면 TIME_WAIT 포트 고갈, CLOSE_WAIT 소켓 누수 같은 네트워크 문제를 효과적으로 디버깅하고 해결할 수 있습니다.
같이 보면 좋은 글
- HTTP/1.1에서 HTTP/3까지 - TCP 기반 상위 프로토콜
- C++ 소켓 프로그래밍 | TCP·UDP·비동기 I/O - TCP 소켓 구현
- Kubernetes Pod가 안 뜰 때: CrashLoopBackOff·ImagePullBackOff·Pending 원인 찾기 - 네트워크 문제 디버깅
- DRM 동작 방식 - TCP 기반 스트리밍
- FFmpeg 실전 가이드 - 네트워크 스트리밍
- C++ 비동기 I/O | epoll·io_uring - TCP 고성능 처리
- C++로 HTTP 클라이언트·서버 구현하기
- C++ WebSocket 구현
- C++ REST API 클라이언트