TCP는 왜 연결에 3번, 종료엔 4번 주고받는가

seonwoo_jung·2026년 5월 26일

1. 도입

HTTP keep-alive와 connection pool을 정리하면서, 결국 그 아래에 깔린 TCP 연결 자체가 어떻게 열리고 닫히는지 한 번 더 짚어두는 게 좋겠다고 느꼈다. "3-way handshake로 연결을 연다, 4-way로 닫는다"는 한 줄은 외우고 있었지만, 각 단계에서 어떤 플래그가 오가고 어떤 상태(state)로 옮겨가는지는 흐릿했다. 특히 TIME_WAIT 상태가 왜 필요한지, CLOSE_WAIT이 쌓이면 무엇이 문제인지 같은 질문에는 매번 자신이 없었다.

이 글은 RFC 9293(2022년에 RFC 793을 갈음한 현재 TCP 명세)을 따라가며, 연결 수립과 종료의 상태 전이를 학습 노트 톤으로 정리한 것이다.

2. 핵심 개념

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로 응답이 온다.

3. 내부 동작

3.1 3-way handshake — 연결 수립

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

세 단계의 의미를 한 줄씩 정리하면 이렇다.

  1. 클라이언트가 자기 ISN인 X를 SYN 플래그와 함께 보낸다. 상태는 CLOSEDSYN-SENT.
  2. 서버는 X를 잘 받았다는 의미로 ack=X+1을 돌려보내면서, 동시에 자기 ISN인 Y를 SYN으로 알린다. 상태는 LISTENSYN-RECEIVED. 두 가지 일(상대 ISN 확인 + 내 ISN 통보)을 한 세그먼트로 묶기 때문에 4단계가 아니라 3단계로 끝난다.
  3. 클라이언트가 서버 ISN인 Y에 대해 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에 담겨 오는 정보로 검증하는 방식이다.

3.2 4-way handshake — 연결 종료

종료는 양쪽이 각자 자기 방향을 닫는 모양이다.

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_portdst_ip:dst_port)로 새 연결이 곧바로 만들어졌을 때 옛 세그먼트가 끼어드는 사고를 막는다.

3.3 운영에서 자주 보는 신호

  • CLOSE_WAIT이 서버에 쌓이면 → 애플리케이션이 socket을 close()하지 않고 있다는 신호다. 보통은 connection을 풀에 반환하지 않거나, exception 경로에서 자원 해제를 빠뜨린 경우.
  • TIME_WAIT이 클라이언트에 잔뜩 보이면 → 정상이다. 다만 같은 호스트에서 short-lived connection을 폭주시키면 ephemeral port가 고갈될 수 있다. 해결은 connection 재사용(persistent connection, pool)이다.

4. 예시 / 코드

연결 상태를 눈으로 보는 가장 간단한 도구는 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 클라이언트 일부가 "요청 다 보냈으니 응답만 받겠다"는 신호로 쓰기도 한다.

5. 정리

3-way는 ISN을 상호 확인하는 절차, 4-way는 full-duplex를 한 방향씩 끄는 절차다.

  • 핸드셰이크의 본질은 시퀀스 번호의 상호 확인. SYN/FIN은 페이로드가 없어도 시퀀스를 1 소비한다.
  • 3-way에서 SYN과 ACK이 합쳐지는 건 서버가 즉시 응답할 준비가 됐기 때문. 종료에서는 그 보장이 없어서 합치지 않는다.
  • 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).

참고 자료

  • RFC 9293 "Transmission Control Protocol (TCP)" §3.4 (Sequence Numbers), §3.5 (Establishing a Connection), §3.6 (Closing a Connection)
  • RFC 9293 §3.4.2 "TIME-WAIT State"
  • RFC 9293 §3.10.7 "SYN cookies"
  • ss(8) 매뉴얼

0개의 댓글