서버를 운영하다 보면 ss -tan을 쳐봤을 때 ESTABLISHED, TIME_WAIT, CLOSE_WAIT 같은 상태값이 줄줄이 나오는 걸 보게 된다. 이 상태값들은 사실 TCP 연결이 어떻게 맺어지고 어떻게 끊어지는가를 그대로 반영한 것이다.
이 글에서는 TCP 연결의 시작인 3-way handshake와 종료인 4-way handshake를 정리하고, 그 과정에서 등장하는 상태 전이를 백엔드 개발자 관점에서 짚어본다.
들어가기 전에: TCP는 왜 핸드셰이크가 필요한가
TCP는 연결 지향(connection-oriented) 프로토콜이다. 데이터를 보내기 전에 양쪽이 "우리 이제 통신할 준비 됐어"라는 합의를 먼저 하고, 통신이 끝나면 "이제 그만 보낼게"라는 작별 인사를 한다.
이게 왜 필요할까. TCP가 보장하려는 것들 때문이다.
- 신뢰성: 보낸 데이터가 누락 없이, 순서대로 도착해야 한다.
- 순서 보장: 패킷마다 시퀀스 번호(sequence number)를 매겨 순서를 추적한다.
- 흐름 제어: 상대가 받을 수 있는 만큼만 보낸다.
이 모든 것의 출발점이 시퀀스 번호의 초기값을 서로 교환하고 동기화하는 것이다. 핸드셰이크는 본질적으로 "데이터 보내기 전에 시작 번호를 맞추는 절차"라고 이해하면 깔끔하다.
여기서 자주 등장할 TCP 헤더의 플래그(flag)를 먼저 정리하고 간다.
플래그 의미
| SYN | Synchronize. 연결을 시작하며 시퀀스 번호를 동기화하자는 신호 |
| ACK | Acknowledgment. "잘 받았다"는 확인 응답 |
| FIN | Finish. "내 쪽에서 보낼 데이터는 다 끝났다"는 신호 |
3-way handshake: 연결의 시작
클라이언트가 서버에 연결을 맺는 과정이다. 이름 그대로 패킷 3개가 오간다.
Client Server
| |
| ---------- 1. SYN ----------------> |
| seq = x |
| |
| <------- 2. SYN + ACK ------------- |
| seq = y, ack = x + 1 |
| |
| ---------- 3. ACK ----------------> |
| ack = y + 1 |
| |
[ESTABLISHED] [ESTABLISHED]
1단계 — SYN (클라이언트 → 서버)
클라이언트가 "연결하고 싶다"고 요청한다. 이때 자신의 초기 시퀀스 번호(ISN, Initial Sequence Number) x를 담아 보낸다. ISN은 보안상 예측 불가능하도록 랜덤하게 생성된다.
2단계 — SYN + ACK (서버 → 클라이언트)
서버는 두 가지 일을 한 번에 한다.
- 클라이언트의 SYN을 잘 받았다는 확인(ACK). ack = x + 1을 보내 "다음엔 x+1번부터 받을게"라고 알린다.
- 동시에 서버도 자신의 초기 시퀀스 번호 y를 담은 SYN을 보낸다.
서버 입장에서 SYN과 ACK를 굳이 따로 보낼 이유가 없으니 하나의 패킷으로 합친다. 3-way가 4-way가 아닌 이유가 바로 여기에 있다.
3단계 — ACK (클라이언트 → 서버)
클라이언트가 서버의 SYN에 대한 확인(ack = y + 1)을 보낸다. 이 패킷이 도착하면 양쪽 모두 상대의 ISN을 알게 되었고, 연결이 완성된다.
상태 전이로 본 3-way handshake
Client Server
CLOSED LISTEN ← 서버는 미리 대기 중
| |
SYN_SENT ---------- SYN ------------> |
| SYN_RECEIVED
| <------- SYN + ACK ---------------- |
ESTABLISHED |
| ---------- ACK -----------------> |
| ESTABLISHED
- 서버는 socket → bind → listen을 마치고 LISTEN 상태로 대기한다.
- 클라이언트가 connect를 호출하면 SYN_SENT로 진입한다.
- 서버는 SYN을 받으면 SYN_RECEIVED가 되고, 마지막 ACK를 받으면 양쪽 모두 ESTABLISHED가 된다.
백엔드 관점 한 줄: 서버의 SYN_RECEIVED 상태를 악용하는 것이 바로 SYN Flood 공격이다. 공격자가 SYN만 잔뜩 보내고 마지막 ACK를 보내지 않으면 서버의 백로그 큐가 가득 차 정상 연결을 못 받게 된다. 리눅스의 tcp_syncookies가 이를 완화하는 대표적인 방어책이다.
4-way handshake: 연결의 종료
연결을 끊을 때는 패킷이 4개 오간다. 시작보다 한 단계가 더 많은데, 그 이유가 이 글의 핵심 포인트다.
Client Server
(active close) (passive close)
| |
| ---------- 1. FIN ----------------> |
FIN_WAIT_1 CLOSE_WAIT
| |
| <--------- 2. ACK ----------------- |
FIN_WAIT_2 |
| (남은 데이터 전송...)
| <--------- 3. FIN ----------------- |
| LAST_ACK
TIME_WAIT |
| ---------- 4. ACK ----------------> |
| CLOSED
(2MSL 대기 후)
CLOSED
왜 3-way가 아니라 4-way인가
3-way handshake에서는 서버가 SYN과 ACK를 하나로 합칠 수 있었다. 하지만 종료할 때는 그게 안 된다.
연결을 먼저 끊자고 한 쪽(active close, 보통 클라이언트)이 FIN을 보내도, 반대쪽은 아직 보낼 데이터가 남아 있을 수 있다. TCP 연결은 양방향(full-duplex)이라 각 방향을 독립적으로 닫아야 하기 때문이다.
그래서 종료는 이렇게 진행된다.
- FIN (Client → Server): "나는 보낼 데이터 다 끝났어."
- ACK (Server → Client): "알았어. 근데 나는 아직 보낼 게 남았을 수도 있어." — 이 시점이 half-close 상태다. 클라이언트→서버 방향만 닫혔다.
- FIN (Server → Client): 서버가 남은 데이터를 다 보낸 뒤 "이제 나도 끝났어."
- ACK (Client → Server): "확인했어. 잘 가."
서버의 ACK(2단계)와 FIN(3단계) 사이에 시간 간격이 존재할 수 있기 때문에 둘을 합칠 수 없고, 그래서 4-way가 된다. (남은 데이터가 정말 없다면 구현에 따라 ACK와 FIN이 합쳐져 3개로 끝나기도 한다.)
상태 전이로 본 4-way handshake
연결을 먼저 닫는 쪽(active close)과 받는 쪽(passive close)의 상태가 다르다.
Active close (먼저 끊는 쪽)
- FIN_WAIT_1: FIN을 보내고 ACK를 기다림
- FIN_WAIT_2: 상대의 ACK를 받음. 상대의 FIN을 기다림
- TIME_WAIT: 상대의 FIN을 받고 마지막 ACK를 보낸 뒤, 일정 시간 대기
Passive close (끊김을 당하는 쪽)
- CLOSE_WAIT: 상대의 FIN을 받고 ACK를 보냄. 내 쪽 FIN은 아직
- LAST_ACK: 내 FIN을 보내고 마지막 ACK를 기다림
- CLOSED: 마지막 ACK를 받고 연결 종료
실무에서 마주치는 두 가지 상태: TIME_WAIT과 CLOSE_WAIT
이 두 상태는 서버 운영 중 ss나 netstat로 자주 보게 되는데, 의미가 완전히 다르다.
TIME_WAIT — 정상이지만 쌓이면 문제
연결을 먼저 끊은 쪽에서 나타나는 상태다. 마지막 ACK를 보낸 뒤 바로 사라지지 않고 2MSL(Maximum Segment Lifetime의 2배) 동안 머무른다. 리눅스에서는 보통 60초다.
왜 굳이 기다릴까. 두 가지 이유다.
- 마지막 ACK가 유실될 경우를 대비한다. 상대가 ACK를 못 받으면 FIN을 재전송하는데, 이쪽이 이미 CLOSED 되어 버렸다면 응답해 줄 주체가 없다. TIME_WAIT으로 남아 있어야 재전송된 FIN에 다시 ACK를 보내줄 수 있다.
- 이전 연결의 패킷이 새 연결에 섞이는 것을 방지한다. 같은 (IP, port) 조합으로 새 연결이 곧바로 생기면, 네트워크를 떠돌던 옛 패킷이 새 연결로 잘못 배달될 수 있다. 2MSL은 네트워크상의 모든 잔존 패킷이 소멸하기에 충분한 시간이다.
TIME_WAIT이 쌓이는 문제: 짧은 연결을 대량으로 맺고 끊는 서버(예: 다른 서비스로 HTTP 요청을 폭발적으로 보내는 경우)에서는 클라이언트 측 로컬 포트가 고갈될 수 있다. 각 TIME_WAIT이 포트 하나를 60초간 점유하기 때문이다.
대응책:
- Keep-Alive / 커넥션 풀 사용: 매번 연결을 새로 맺지 말고 재사용한다. httpx.AsyncClient를 요청마다 만들지 않고 애플리케이션 수명 동안 하나만 두는 것이 대표적인 예다.
- net.ipv4.ip_local_port_range 확대
- tcp_tw_reuse 활성화 (아웃바운드 연결에 한해 TIME_WAIT 소켓 재사용 허용)
CLOSE_WAIT — 거의 항상 버그 신호
CLOSE_WAIT은 상대가 FIN을 보냈는데, 내 애플리케이션이 아직 close()를 호출하지 않은 상태다.
상태 전이를 다시 보면, passive close 쪽은 FIN을 받으면 커널이 자동으로 ACK를 보내 CLOSE_WAIT으로 간다. 그다음 LAST_ACK로 넘어가려면 애플리케이션이 직접 소켓을 닫아줘야 한다.
즉 CLOSE_WAIT이 계속 쌓인다면, 코드 어딘가에서 소켓(또는 응답 객체, DB 커넥션 등)을 닫지 않고 누수시키고 있다는 강력한 신호다.
- TIME_WAIT은 커널이 알아서 정리한다. → 보통 정상.
- CLOSE_WAIT은 애플리케이션이 닫아야 사라진다. → 보통 버그.
ss -tan state close-wait 결과가 계속 증가한다면 리소스 정리 로직(async with, finally, 컨텍스트 매니저)을 점검해야 한다.
직접 관찰해 보기
개념을 코드와 명령어로 확인해 보면 훨씬 분명해진다.
연결 상태를 상태별로 집계해서 보는 명령:
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
특정 상태의 소켓만 보기:
ss -tan state time-wait
ss -tan state close-wait
핸드셰이크 패킷을 직접 캡처하려면 tcpdump로 플래그를 따라가 보면 된다.
sudo tcpdump -i any -nn 'tcp port 8000 and (tcp[tcpflags] & (tcp-syn|tcp-fin) != 0)'
이렇게 하면 출력에서 [S](SYN), [S.](SYN+ACK), [.](ACK), [F.](FIN+ACK) 같은 플래그가 오가는 순서를 눈으로 확인할 수 있다. 3-way로 시작해서 4-way로 끝나는 흐름이 그대로 보인다.
정리
구분 3-way handshake 4-way handshake
| 목적 | 연결 수립 | 연결 종료 |
| 패킷 수 | 3개 | 4개 (보통) |
| 핵심 플래그 | SYN, SYN+ACK, ACK | FIN, ACK, FIN, ACK |
| 합쳐지는 단계 | 서버의 SYN+ACK | 없음 (양방향을 독립적으로 닫아야 함) |
| 주요 상태 | LISTEN, SYN_SENT, SYN_RECEIVED, ESTABLISHED | FIN_WAIT_1/2, CLOSE_WAIT, LAST_ACK, TIME_WAIT |
핵심을 다시 짚으면 이렇다.
- 3-way가 3개인 이유: 서버가 SYN과 ACK를 한 패킷으로 합칠 수 있어서.
- 4-way가 4개인 이유: TCP는 양방향 연결이고, 한쪽이 끝나도 반대쪽은 보낼 데이터가 남아 있을 수 있어서 각 방향을 따로 닫아야 함.
- TIME_WAIT은 마지막 ACK 유실과 패킷 혼선을 막기 위한 정상적인 안전장치. 다만 단명 연결이 많으면 포트 고갈을 유발하므로 커넥션 풀로 대응.
- CLOSE_WAIT이 쌓이면 애플리케이션이 소켓을 안 닫고 있다는 버그 신호.
ss 출력에 찍히는 상태값 하나하나가 이 핸드셰이크 흐름의 한 지점이라는 걸 알고 나면, 서버 트러블슈팅이 훨씬 직관적으로 다가온다.
'컴퓨터 네트워크' 카테고리의 다른 글
| [컴퓨터 네트워크] SSH, SSHD, ssh.service와 ssh.socket의 관계 및 차이점 (0) | 2025.05.14 |
|---|---|
| [컴퓨터 네트워크] SSH 원격 포트 포워딩으로 내부망 서버에 접속하기 (0) | 2025.05.13 |
| [컴퓨터 네트워크] 방화벽 (0) | 2025.05.08 |
| [컴퓨터 네트워크] LAN 네트워크에서의 MAC 주소와 IP 주소의 역할 (0) | 2025.05.08 |
| [컴퓨터 네트워크] ARP 프로토콜 개요 (0) | 2025.05.06 |