TCP가 연결을 맺기 위해 세 번 주고받는 절차.
클라이언트 서버
│ ── SYN(seq=x) ──────────→ │ "연결하고 싶다. 내 번호는 x"
│ ←─ SYN+ACK(seq=y,ack=x+1) ─ │ "좋다. 내 번호는 y, 네 x 받았다"
│ ── ACK(ack=y+1) ────────→ │ "네 y 받았다"
│ 연결 성립 │
왜 세 번인가
두 번이면 부족하다
- 클라이언트는 "서버가 내 말을 들었다" 를 알지만
- 서버는 "클라이언트가 내 말을 들었다" 를 모른다
세 번이면 양쪽 모두 '보내기와 받기가 모두 가능함' 을 확인한다 동시에 양쪽의 초기 순서번호(ISN)를 교환한다
초기 순서번호는 무작위로 정한다 — 예측 가능하면 공격자가 연결에 끼어들 수 있기 때문이다.
종료는 네 번
│ ── FIN ─────────→ │ "나는 다 보냈다"
│ ←─ ACK ────────── │ "알았다" (아직 내 데이터는 남았을 수 있다)
│ ←─ FIN ────────── │ "나도 다 보냈다"
│ ── ACK ─────────→ │ "알았다"
↓
TIME_WAIT (2×MSL 동안 대기)
연결을 끊는 건 양방향이 각각이라 네 번이 된다(한쪽만 닫는 half-close도 가능하다).
TIME_WAIT — 실무에서 자주 만난다
먼저 닫은 쪽이 2×MSL(보통 60초) 동안 남는다
왜?
- ① 마지막 ACK 가 유실되면 상대가 FIN 을 재전송한다 — 받아 줄 누군가가 있어야 한다
- ② 같은 포트 조합의 '옛 패킷' 이 뒤늦게 도착해 새 연결을 오염시키는 것을 막는다
ss -s # 상태별 소켓 수
netstat -an | grep TIME_WAIT | wc -l
증상: 짧은 연결을 대량으로 맺는 서버에서 TIME_WAIT 이 수만 개 쌓인다
- 포트가 고갈되어 새 연결을 못 맺는다
처방
- keep-alive 로 연결을 재사용한다 (근본 해법)
- net.ipv4.tcp_tw_reuse = 1 (클라이언트 측 재사용)
- SO_REUSEADDR 로 서버 재시작 시 바인딩 문제 회피
- ⚠️ tcp_tw_recycle 은 NAT 환경에서 문제를 일으켜 커널에서 제거됐다
SYN 플러딩
SYN 만 잔뜩 보내고 ACK 를 안 보낸다
- 서버의 대기 큐(백로그)가 가득 차 정상 연결을 못 받는다
- SYN 쿠키로 방어한다 (큐에 저장하지 않고 순서번호에 정보를 담아 되돌린다)
면접 함정
- ❌ "종료도 3번이면 된다" → 각 방향을 따로 닫아 4번이다.
- ❌ "TIME_WAIT은 서버에 쌓인다" → 먼저 닫은 쪽에 쌓인다. 서버가 먼저 닫으면 서버에 쌓인다.
상태 전이를 눈으로
ss -tan | head
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*
# ESTAB 0 0 10.0.0.5:8080 10.0.0.9:51234
# TIME-WAIT 0 0 10.0.0.5:8080 10.0.0.9:51233
주요 상태
LISTEN 연결을 기다린다
SYN_SENT SYN 을 보내고 응답 대기 (여기서 멈춰 있으면 방화벽·경로 문제)
SYN_RECV SYN 을 받고 SYN+ACK 를 보낸 상태 (여기가 쌓이면 SYN 플러딩 의심)
ESTABLISHED 연결됨
CLOSE_WAIT 상대가 FIN 을 보냈는데 내가 close() 를 안 했다 ← 애플리케이션 버그 신호
TIME_WAIT 먼저 닫은 쪽이 2×MSL 대기
CLOSE_WAIT이 쌓이면 100% 애플리케이션 버그다 — 소켓을 닫지 않는 코드가 있다는 뜻이라, 커널 파라미터로는 해결되지 않는다.
keep-alive는 다른 이야기
-
TCP keep-alive — 연결이 살아 있는지 주기적으로 확인 (기본 2시간 — 매우 길다)
-
HTTP keep-alive — 응답 후에도 TCP 연결을 끊지 않고 재사용 (핸드셰이크를 아낀다)
-
이름이 같지만 계층도 목적도 다르다