“SYN → SYN-ACK → ACK"를 외우는 건 어렵지 않다. 하지만 “왜 2번이 아니라 3번인가?“라는 질문에는 막히는 경우가 많다. 단계를 아는 것과 왜 그래야만 하는지를 이해하는 건 다르다.

이 글에서는 매 단계마다 “이게 없다면 어떤 문제가 생기는가?“를 따라가며, TCP 연결의 시작과 끝을 깊이 있게 들여다본다.


1. 3-way Handshake: 연결 수립

Client                       Server
  |--- SYN (seq=100) -------->|   1) "연결하고 싶어, 내 seq는 100"
  |<-- SYN-ACK (seq=300, ack=101) --|   2) "OK, 내 seq는 300, 네 100 받았어"
  |--- ACK (ack=301) -------->|   3) "확인, 시작하자"

왜 2번이 아니라 3번인가?

TCP는 양방향 통신이다. 양쪽 모두 **“내가 보낸 걸 상대가 받을 수 있다”**는 걸 확인해야 한다.

  • 1번(SYN): 클라이언트 → 서버 방향 전송이 가능한지 확인 시작
  • 2번(SYN-ACK): 서버가 받았다. 동시에 서버 → 클라이언트 방향도 확인
  • 3번(ACK): 클라이언트가 서버의 응답을 받았음을 확인 — 양방향 확인 완료

만약 2번에서 끝난다면? 서버는 자신의 SYN-ACK가 클라이언트에 도착했는지 알 수 없다. 서버 → 클라이언트 방향이 검증되지 않은 채로 데이터를 보내기 시작하는 셈이다.

3-way Handshake가 하는 일

단순히 “연결 확인"만 하는 게 아니다. 세 가지를 동시에 수행한다.

  1. 양방향 통신 가능 확인 — 상대가 살아있는지, 패킷이 도달하는지
  2. 초기 Sequence Number 교환 — 이후 데이터 순서 추적의 기반
  3. 수신 버퍼 크기(Window Size) 협상 — 흐름 제어의 시작점

여기서 2번이 특히 중요하다. 다음 섹션에서 왜 중요한지 살펴보자.

Sequence Number는 양쪽이 각각 독립이다

위 다이어그램을 다시 보면, 클라이언트는 seq=100에서, 서버는 seq=300에서 시작한다. TCP는 양방향(full-duplex)이므로 각 방향마다 별도의 seq를 독립적으로 관리한다.

  • Client → Server 방향: client의 seq로 추적
  • Server → Client 방향: server의 seq로 추적
  • ACK는 상대방의 seq에 대해 “여기까지 받았어"라고 알려주는 것

이 구조를 이해하면 이후 데이터 전송과 4-way handshake의 seq 흐름이 자연스럽게 읽힌다.

실무 포인트: 이 과정이 만드는 비용

3-way handshake는 공짜가 아니다. 최소 1.5 RTT(Round Trip Time)가 소요된다. 여기에 TLS까지 더하면 추가 RTT가 필요하다. 이 비용이 만든 실무 패턴 두 가지:

Connection Pool: 매 요청마다 handshake를 하면 오버헤드가 크다. 그래서 DB 커넥션 풀이나 HTTP/1.1의 Keep-Alive가 존재한다. 한 번 수립한 연결을 재사용하는 것이다. Go의 http.Client도 기본으로 connection pool을 사용한다.

SYN Flood 공격: 공격자가 SYN만 대량으로 보내고 3번째 ACK를 보내지 않으면, 서버는 half-open 상태의 연결을 SYN 큐에 쌓아둔 채 ACK를 기다린다. 큐가 꽉 차면 정상적인 연결도 거부된다. 이에 대한 대응이 SYN Cookie — 서버가 상태를 저장하지 않고 seq에 검증 정보를 인코딩하는 방식이다.


2. Sequence Number가 만드는 신뢰성

handshake에서 교환한 seq는 장식이 아니다. TCP의 신뢰성 보장 전체가 이 번호 위에 세워져 있다.

Sender                     Receiver
  |--- [seq=101] 데이터 ---->|
  |<-- [ack=102] 받았어 -----|   "101 받았으니 다음은 102 보내"
  |--- [seq=102] 데이터 ---->|
  |         (유실! ❌)        |
  |    ...타임아웃...          |
  |--- [seq=102] 재전송 ---->|   "ACK 안 왔네? 다시 보내자"
  |<-- [ack=103] 받았어 -----|

세 가지 메커니즘

  1. Sequence Number — 패킷 순서 추적. 어디까지 보냈는지, 어디까지 받았는지를 번호로 관리한다.
  2. ACK — 수신 확인. 데이터를 받으면 “다음에 기대하는 seq"를 ACK로 응답한다.
  3. 재전송 — 유실 복구. 일정 시간 내 ACK가 오지 않으면 같은 seq로 다시 보낸다.

이 세 가지가 맞물려서 “보낸 데이터가 유실되면 감지하고, 자동으로 복구한다"는 TCP의 신뢰성이 만들어진다.

ACK를 매번 보내면 비효율적이지 않나?

맞다. 그래서 몇 가지 최적화가 존재한다.

  • Delayed ACK: 즉시 보내지 않고 잠깐 기다린다. 응답 데이터가 있으면 ACK를 함께 묶어서 보낸다.
  • Cumulative ACK: “103번까지 다 받았어"처럼 한 번에 여러 패킷의 수신을 확인한다.
  • Sliding Window: ACK를 기다리지 않고 윈도우 크기만큼 여러 패킷을 한꺼번에 보낸다. 윈도우 크기는 handshake에서 협상한 수신 버퍼 크기에 기반한다.

3. 4-way Handshake: 연결 종료

Client (seq=1000)              Server (seq=5000)
  |--- FIN (seq=1000) ------->|   1) "나는 보낼 거 다 보냈어"
  |<-- ACK (ack=1001) --------|   2) "알겠어"
  |<-- FIN (seq=5000) --------|   3) "나도 다 보냈어, 끊자"
  |--- ACK (ack=5001) ------->|   4) "확인"

왜 3번이 아니라 4번인가?

수립할 때는 2번에서 SYN+ACK를 한 패킷으로 합칠 수 있었다. 서버가 “네 SYN 받았어(ACK)” + “나도 연결하자(SYN)“를 동시에 보낼 수 있었던 이유는 간단하다 — 그 시점에 서버가 보낼 데이터가 없기 때문이다.

종료는 다르다. 클라이언트가 FIN을 보냈다고 해서 서버가 즉시 FIN을 보낼 수 있는 건 아니다. 서버에 아직 전송해야 할 데이터가 남아있을 수 있기 때문이다. ACK(“네 FIN 받았어”)와 FIN(“나도 끝났어”)을 합칠 수 없으므로 4번이 필요하다.

Half-close 예시

클라이언트가 HTTP 요청을 다 보내고 FIN을 보냈지만, 서버는 아직 응답 데이터를 전송 중인 상황:

Client (seq=1000)              Server (seq=5000)
  |--- [seq=1000] FIN -------->|   1) 클라이언트: "요청 다 보냈어, 내 쪽은 종료"
  |<-- [seq=5000, ack=1001] ACK|   2) 서버: "알겠어" (하지만 응답 데이터 아직 남음!)
  |                            |
  |  ← half-close 구간 →       |   클라이언트는 못 보내고, 서버는 계속 보낼 수 있음
  |                            |
  |<-- [seq=5001] 데이터 1KB --|   서버: 응답 본문 전송 중...
  |--- [ack=6001] ACK -------->|
  |<-- [seq=6001] 데이터 512B -|   서버: 응답 본문 마저 전송
  |--- [ack=6513] ACK -------->|
  |                            |
  |<-- [seq=6513] FIN -------->|   3) 서버: "나도 다 보냈어, 끊자"
  |--- [seq=1001, ack=6514] ACK|   4) 클라이언트: "확인"

이것이 half-close다. 한쪽이 종료를 선언해도 반대쪽은 남은 데이터를 계속 보낼 수 있다. 2번에서 ACK+FIN을 합쳤다면 서버는 이 나머지 데이터를 보낼 수 없게 된다.

실무 포인트: TIME_WAIT

4번째 ACK를 보낸 클라이언트는 소켓을 바로 닫지 않는다. TIME_WAIT 상태로 보통 2분간 대기한다.

왜 필요한가? 두 가지 이유다.

마지막 ACK 유실 대비: 4번째 ACK가 네트워크에서 유실되면, 서버는 FIN을 재전송한다. 클라이언트가 이미 소켓을 닫았다면 이 FIN에 응답할 수 없다. TIME_WAIT 동안 살아있으면 재전송에 대응할 수 있다.

지연 패킷 오염 방지: 같은 IP:Port 조합으로 즉시 새 연결을 열면, 이전 연결에서 지연된 패킷이 새 연결의 데이터로 오인될 수 있다. TIME_WAIT는 이전 연결의 패킷이 완전히 사라질 때까지 기다리는 안전장치다.

포트 고갈 문제: 트래픽이 많은 서버에서 TIME_WAIT 상태의 소켓이 대량으로 쌓이면 사용 가능한 포트가 고갈된다. 이때 SO_REUSEADDR 소켓 옵션으로 TIME_WAIT 상태의 포트를 재사용할 수 있다. Go의 net 패키지는 이 옵션이 기본 활성화되어 있다.


4. 정리: TCP vs UDP

이 모든 과정이 없는 프로토콜이 UDP다.

TCPUDP
연결 수립3-way handshake없음, 그냥 보냄
상대 생존 확인확인 후 전송모르고 보냄
패킷 순서seq로 추적뒤죽박죽 가능
유실 감지ACK + 재전송알 수 없음
흐름 제어window size 협상없음 (수신 측 과부하 가능)
연결 종료4-way handshake없음

TCP는 느리지만 신뢰성이 있고, UDP는 빠르지만 신뢰성이 없다. 어느 쪽이 좋은 게 아니라 용도에 따른 트레이드오프다. HTTP, 데이터베이스 연결처럼 데이터 유실이 치명적인 곳에서는 TCP를, DNS 조회나 실시간 스트리밍처럼 속도가 우선인 곳에서는 UDP를 쓴다.