HTTP keep-alive와 connection pool을 정리하면서, 결국 그 아래에 깔린 TCP 연결 자체가 어떻게 열리고 닫히는지 한 번 더 짚어두는 게 좋겠다고 느꼈다. "3-way handshake로 연결을 연다, 4-way로 닫는다"는 한 줄은 외우고 있었지만, 각 단계에서 어떤 플래그가 오가고 어떤 상태(state)로 옮겨가는지는 흐릿했다. 특히 TIME_WAIT 상태가 왜 필요한지, CLOSE_WAIT이 쌓이면 무엇이 문제인지 같은 질문에는 매번 자신이 없었다.
이 글은 RFC 9293(2022년에 RFC 793을 갈음한 현재 TCP 명세)을 따라가며, 연결 수립과 종료의 상태 전이를 학습 노트 톤으로 정리한 것이다.
TCP는 양 끝점이 각자 자기 쪽 시퀀스 번호(ISN)를 상대에게 알리고 확인받는 절차로 연결을 연다. 닫을 때도 한 방향씩 따로 종료한다.
RFC 9293 §3.5에 따르면 TCP 연결은 "두 endpoint 사이의 full-duplex 바이트 스트림"이다. 핵심 단어는 full-duplex — 한쪽 방향과 반대 방향이 독립적인 채널이라는 의미다. 그래서 닫을 때도 한 방향씩 끝낸다. 3-way로 열고 4-way로 닫는 비대칭은 여기서 나온다.
연결의 신뢰성을 위해 각 endpoint는 자기만의 Initial Sequence Number(ISN)를 가진다. ISN은 보안상의 이유로 예측 불가능하게 골라야 한다(§3.4.1). 핸드셰이크의 본질은 "내 ISN은 이거다 → 받았다 → 너 것도 받았다"라는 ISN의 상호 확인이다.
여기서 자주 헷갈리는 게 시퀀스 번호와 ACK 번호의 의미다. SEQ는 "이 세그먼트의 첫 데이터 바이트 번호", ACK는 "다음에 받기를 기대하는 바이트 번호"다. SYN과 FIN은 페이로드가 없어도 시퀀스 번호를 1 소비하기 때문에, 핸드셰이크에서 ACK = SEQ + 1로 응답이 온다.
RFC 9293 §3.5는 다음 세 세그먼트를 정의한다.
Client Server
| |
| (1) SYN, seq=X |
|------------------------------------------------------>|
| LISTEN → SYN-RECEIVED
| (2) SYN+ACK, seq=Y, ack=X+1 |
|<------------------------------------------------------|
| SYN-SENT → ESTABLISHED |
| (3) ACK, seq=X+1, ack=Y+1 |
|------------------------------------------------------>|
| SYN-RECEIVED → ESTABLISHED
세 단계의 의미를 한 줄씩 정리하면 이렇다.
CLOSED → SYN-SENT.ack=X+1을 돌려보내면서, 동시에 자기 ISN인 Y를 SYN으로 알린다. 상태는 LISTEN → SYN-RECEIVED. 두 가지 일(상대 ISN 확인 + 내 ISN 통보)을 한 세그먼트로 묶기 때문에 4단계가 아니라 3단계로 끝난다.ack=Y+1을 보낸다. 이 순간 양쪽 다 ESTABLISHED.자주 헷갈리는 지점은 왜 4-way가 아니냐다. 논리적으로 보면 "내 SYN, 너 ACK" + "너 SYN, 내 ACK" = 4번이지만, 서버 쪽 ACK과 SYN을 piggyback해서 하나의 세그먼트로 합칠 수 있다. RFC 9293 §3.5에서 이 합치기를 명시적으로 허용한다.
또 하나, SYN flood가 노리는 게 정확히 2단계다. 서버가 SYN을 받고 SYN-RECEIVED 상태로 자원을 잡아둔 뒤 클라이언트의 마지막 ACK를 기다리는데, 공격자가 ACK를 안 보내면 이 자원이 쌓인다. SYN cookies(§3.10.7에 권장)는 서버가 상태를 저장하지 않고 ACK에 담겨 오는 정보로 검증하는 방식이다.
종료는 양쪽이 각자 자기 방향을 닫는 모양이다.
Client (active close) Server (passive close)
| |
| (1) FIN, seq=U |
|------------------------------------------------------>|
| ESTABLISHED → FIN-WAIT-1 |
| ESTABLISHED → CLOSE-WAIT
| (2) ACK, ack=U+1 |
|<------------------------------------------------------|
| FIN-WAIT-1 → FIN-WAIT-2 |
| (서버 애플리케이션이 close() 호출할 때까지 대기)
| (3) FIN, seq=V |
|<------------------------------------------------------|
| CLOSE-WAIT → LAST-ACK
| (4) ACK, ack=V+1 |
|------------------------------------------------------>|
| FIN-WAIT-2 → TIME-WAIT |
| LAST-ACK → CLOSED
| (2*MSL 대기) |
| TIME-WAIT → CLOSED |
여기서 두 가지가 중요하다.
(a) 왜 ACK과 FIN을 합치지 않나: 3-way와 달리 종료는 합치지 않는다. 클라이언트가 FIN을 보냈다고 해서 서버가 즉시 FIN을 보낼 준비가 됐다는 보장이 없기 때문이다. 서버 애플리케이션이 아직 보낼 데이터가 남아 있거나, close()를 호출하지 않았을 수 있다. 그래서 서버는 일단 ACK만 돌려주고, 자기 쪽 데이터를 다 보낸 뒤에야 FIN을 보낸다. 이 사이의 상태가 CLOSE-WAIT이다.
(b) TIME-WAIT은 왜 2*MSL인가: RFC 9293 §3.4.2가 정의한다. MSL(Maximum Segment Lifetime)은 한 세그먼트가 네트워크에 떠다닐 수 있는 최대 시간(일반적으로 2분으로 가정). 마지막 ACK가 유실됐을 때 상대가 FIN을 재전송할 수 있는데, 그 재전송이 도착할 시간을 보장하기 위해 2 * MSL을 기다린다. 동시에, 같은 4-tuple(src_ip:src_port ↔ dst_ip:dst_port)로 새 연결이 곧바로 만들어졌을 때 옛 세그먼트가 끼어드는 사고를 막는다.
CLOSE_WAIT이 서버에 쌓이면 → 애플리케이션이 socket을 close()하지 않고 있다는 신호다. 보통은 connection을 풀에 반환하지 않거나, exception 경로에서 자원 해제를 빠뜨린 경우.TIME_WAIT이 클라이언트에 잔뜩 보이면 → 정상이다. 다만 같은 호스트에서 short-lived connection을 폭주시키면 ephemeral port가 고갈될 수 있다. 해결은 connection 재사용(persistent connection, pool)이다.연결 상태를 눈으로 보는 가장 간단한 도구는 ss다. 로컬에서 짧은 HTTP 요청을 던지고 곧장 상태를 찍으면 흐름이 보인다.
# 한 터미널: 80 포트로 한 번만 요청
curl -s http://example.com >/dev/null &
# 다른 터미널: TCP 상태 스냅샷
ss -tan state established,fin-wait-1,fin-wait-2,time-wait \
'( dport = :80 or sport = :80 )'
# 예시 출력
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# ESTAB 0 0 10.0.0.5:51844 93.184.216.34:80
# (요청 끝나면)
# TIME-WAIT 0 0 10.0.0.5:51844 93.184.216.34:80
엣지 케이스 하나 — simultaneous close. 양쪽이 거의 동시에 close()를 호출하면 둘 다 FIN을 먼저 보내고, 둘 다 CLOSING 상태를 거친 뒤 TIME-WAIT으로 간다(§3.6 Figure 6). 자주 일어나진 않지만 양방향 RPC에서 가끔 보이는 상태다.
또 하나 — half-close. shutdown(SOCK_WR)로 자기 쪽만 닫는 패턴이다. 이쪽 방향은 FIN이 나가지만 상대 → 나 방향은 그대로 살아 있다. HTTP/1.x 클라이언트 일부가 "요청 다 보냈으니 응답만 받겠다"는 신호로 쓰기도 한다.
3-way는 ISN을 상호 확인하는 절차, 4-way는 full-duplex를 한 방향씩 끄는 절차다.
CLOSE_WAIT 누적은 애플리케이션 버그 신호. TIME_WAIT 누적은 정상이지만 port 고갈을 주의.TIME_WAIT의 2*MSL은 마지막 ACK 유실 대비 + 옛 세그먼트와 새 연결의 충돌 방지를 위한 안전망.다음에 더 파고들 만한 주제: TLS 1.3 handshake의 1-RTT/0-RTT 흐름(RFC 8446 §2)이 TCP 위에 어떻게 얹히는지, 그리고 QUIC이 TCP+TLS의 2단 핸드셰이크를 어떻게 1단으로 합치는지(RFC 9000).
ss(8) 매뉴얼