“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가 하는 일
단순히 “연결 확인"만 하는 게 아니다. 세 가지를 동시에 수행한다.
- 양방향 통신 가능 확인 — 상대가 살아있는지, 패킷이 도달하는지
- 초기 Sequence Number 교환 — 이후 데이터 순서 추적의 기반
- 수신 버퍼 크기(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] 받았어 -----|
세 가지 메커니즘
- Sequence Number — 패킷 순서 추적. 어디까지 보냈는지, 어디까지 받았는지를 번호로 관리한다.
- ACK — 수신 확인. 데이터를 받으면 “다음에 기대하는 seq"를 ACK로 응답한다.
- 재전송 — 유실 복구. 일정 시간 내 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다.
| TCP | UDP | |
|---|---|---|
| 연결 수립 | 3-way handshake | 없음, 그냥 보냄 |
| 상대 생존 확인 | 확인 후 전송 | 모르고 보냄 |
| 패킷 순서 | seq로 추적 | 뒤죽박죽 가능 |
| 유실 감지 | ACK + 재전송 | 알 수 없음 |
| 흐름 제어 | window size 협상 | 없음 (수신 측 과부하 가능) |
| 연결 종료 | 4-way handshake | 없음 |
TCP는 느리지만 신뢰성이 있고, UDP는 빠르지만 신뢰성이 없다. 어느 쪽이 좋은 게 아니라 용도에 따른 트레이드오프다. HTTP, 데이터베이스 연결처럼 데이터 유실이 치명적인 곳에서는 TCP를, DNS 조회나 실시간 스트리밍처럼 속도가 우선인 곳에서는 UDP를 쓴다.