
3-Way Handshake: TCP 연결 수립
TCP는 예의 바릅니다. 본격적인 대화를 시작하기 전에 '들려?', '어 들려. 너는 들려?', '어 나도 들려' 하고 인사를 세 번이나 나눕니다.

TCP는 예의 바릅니다. 본격적인 대화를 시작하기 전에 '들려?', '어 들려. 너는 들려?', '어 나도 들려' 하고 인사를 세 번이나 나눕니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

TCP를 처음 배울 때, "연결 지향(Connection-oriented) 프로토콜"이라는 말이 도대체 무슨 뜻인지 감이 안 왔습니다. 그런데 어느 날 전화 통화를 하다가 무릎을 탁 쳤습니다. 아, 이거 전화 거는 거랑 똑같잖아?
우리가 전화를 걸 때를 생각해보면:
이 3단계 인사가 끝나야 비로소 본론(데이터 전송)이 시작됩니다. 만약 이 과정이 없다면? 상대방이 듣고 있는지, 선이 끊어졌는지도 모른 채 혼자 떠드는 꼴이 됩니다. 제가 서비스를 만들면서 네트워크 에러를 디버깅할 때, 이 개념을 제대로 이해하고 나니까 문제가 어디서 발생했는지 바로 보이더군요.
처음엔 "그냥 인사 3번 하는 거네"라고 생각했는데, 알고 보니 내부적으로는 훨씬 복잡한 일이 벌어지고 있었습니다. 시퀀스 번호(Sequence Number)를 교환하여 신뢰성 있는 통신을 준비하는 과정이더군요.
클라이언트가 서버에게 "접속 요청합니다. 제 시작 번호(ISN)는 100번입니다"라고 말합니다. 이때 패킷 플래그는 SYN=1, ACK=0이고, 클라이언트는 SYN_SENT 상태가 됩니다.
여기서 중요한 건, 클라이언트가 난수로 생성된 ISN(Initial Sequence Number) 100을 보낸다는 점입니다. "앞으로 제가 보낼 데이터는 이 번호부터 시작합니다"라고 알리는 거죠. 처음엔 "왜 0부터 시작 안 하지?"라고 생각했는데, 보안상의 이유(시퀀스 번호 예측 공격 방지)와 오래된 패킷과 새 패킷을 구분하기 위해서라는 걸 나중에 알았습니다.
서버가 클라이언트에게 "알겠습니다(ACK 101). 당신 말 잘 들립니다. 저도 연결하겠습니다. 제 시작 번호는 2000번입니다(SYN)"라고 응답합니다. 패킷 플래그는 SYN=1, ACK=1이고, 서버는 SYN_RCVD 상태가 됩니다.
이 단계가 재미있는 게, 두 가지 일을 동시에 하거든요:
양방향 통신이니까 서버도 "나도 말할게 있다"고 알리는 겁니다. 이걸 이해하고 나니까, 왜 "3-Way"인지 명확해졌습니다.
클라이언트가 서버에게 "네, 2000번 확인했습니다(ACK 2001). 이제 연결 확정입니다"라고 최종 확인을 보냅니다. 패킷 플래그는 SYN=0, ACK=1이고, 클라이언트는 ESTABLISHED 상태가 됩니다. 이 패킷을 받은 서버도 ESTABLISHED가 되죠.
이 시점부터 논리적인 연결 터널(Socket)이 생성된 것으로 간주합니다. 이제 데이터를 주고받을 수 있는 상태가 된 겁니다.
제가 처음 이걸 배울 때 가장 궁금했던 질문입니다. "그냥 SYN 보내고, 서버가 알았다고(SYN-ACK) 하면 바로 시작하면 안 되나?" 실제로 실무에서도 자주 마주치는 질문이더군요.
네트워크는 불안정합니다. 패킷은 늦게 도착하거나 순서가 뒤바뀔 수 있죠. 이런 시나리오를 상상해봤다:
SYN_A를 보냈는데, 네트워크 혼잡으로 늦게 도착합니다SYN_B)을 보내서 통신을 잘 마치고 종료했습니다SYN_A가 뒤늦게 서버에 도착합니다만약 2-Way Handshake라면? 서버는 SYN_A를 보자마자 "오, 새 연결이네!" 하고 즉시 연결을 성립시키고 메모리를 할당합니다. 하지만 클라이언트는 이미 끝난 일이죠. 서버 혼자 아무도 없는 허공에 대고 "여보세요?" 하고 자원을 낭비하게 됩니다.
3-Way Handshake에서는:
SYN_A를 받고 SYN-ACK를 보냅니다즉, 3번째 ACK는 "이 연결 요청이 지금 유효한 진짜 요청이다"라는 최종 확인 도장입니다. 이걸 이해하고 나니까, TCP가 왜 이렇게 설계되었는지 납득이 갔습니다.
연결을 끊을 때는 3번이 아니라 4번의 과정을 거칩니다. 왜냐하면 아직 전송 중인 데이터가 있을 수 있기 때문이죠.
FIN_WAIT_1CLOSE_WAITLAST_ACKTIME_WAIT → 종료클라이언트는 마지막 ACK를 보내고 바로 죽지 않습니다. 약 2분간 TIME_WAIT 상태로 살아있죠. 처음엔 "왜 바로 안 끊지?"라고 생각했는데, 이유가 있더군요.
만약 마지막 ACK가 유실되면? 서버는 "내 FIN 못 들었나?" 하고 FIN을 재전송합니다. 이때 클라이언트가 이미 꺼져있으면? 서버는 영원히 종료를 못 하고 에러가 납니다. 그래서 클라이언트는 "혹시나 상대가 못 들었을까 봐" 잠시 기다려주는 배려를 하는 겁니다.
제 서비스에서 netstat -an | grep TIME_WAIT를 쳤을 때 수천 개씩 떠 있는 걸 보고 놀랐던 적이 있습니다. 알고 보니 클라이언트가 연결을 너무 자주 맺고 끊고 있다는 신호였죠. 이 문제를 해결하려면 Connection Pool을 사용해야 합니다. 매번 3-Way Handshake를 하는 비용(CPU + Latency)을 아끼기 위해, 미리 연결을 여러 개 맺어두고 재사용하는 거죠.
눈으로 직접 봐야 믿을 수 있다고 생각해서, Wireshark를 켜고 실제 패킷을 캡처해봤습니다.
1. 192.168.0.2 -> 8.8.8.8 [SYN] Seq=0
2. 8.8.8.8 -> 192.168.0.2 [SYN, ACK] Seq=0 Ack=1
3. 192.168.0.2 -> 8.8.8.8 [ACK] Seq=1 Ack=1
여기서 Seq=0은 와이어샤크가 보기 편하게 상대적 번호(Relative Sequence Number)로 변환해준 겁니다. 실제로는 39281912 같은 무작위 큰 숫자죠.
서버 포트가 닫혀있다면, 서버는 SYN을 받자마자 RST(Reset) 패킷을 날립니다.
1. Client -> Server [SYN]
2. Server -> Client [RST, ACK]
이때 자바에서는 java.net.ConnectException: Connection refused가 발생합니다. 제가 개발하면서 이 에러를 수없이 봤는데, 이제는 "아, 서버 프로세스가 안 떠 있구나"라고 바로 알 수 있습니다.
서버 앞단의 방화벽(AWS Security Group 등)이 패킷을 드롭(Drop)하면, 서버는 응답하지 않습니다(묵묵부답).
1. Client -> Server [SYN]
2. (응답 없음...)
3. Client -> Server [SYN] (Retransmission)
4. (응답 없음...)
클라이언트는 몇 번 재전송하다가 결국 SocketTimeoutException을 뱉고 죽습니다. 에러 메시지가 "Refused"면 "서버는 켜져 있는데 프로그램이 안 뜬 것"이고, "Timeout"이면 "네트워크/방화벽 문제"일 확률이 99%입니다. 이 차이를 알고 나니까 트러블슈팅이 훨씬 빨라졌습니다.
개발자가 코드로 제어할 수 있는 건 connect()와 close() 뿐이지만, OS 커널 내부에서는 복잡한 상태 천이가 일어납니다.
ServerSocket)connect()를 호출했습니다여기서 3번 상태(SYN_RCVD)에서 멈추면 SYN Flooding 공격입니다. 해커가 ACK를 보내지 않고 무수히 많은 SYN만 보내, 서버의 대기 큐(Backlog)를 가득 채워버리는 디도스(DDoS) 공격이죠.
CLOSE_WAIT 상태로 계속 남아있다면? 이건 Code Leak입니다. input.close() 안 한 거죠. 제가 처음 서비스를 만들 때 이 실수를 해서, 서버가 점점 느려지다가 결국 죽는 걸 경험했습니다.
Handshake가 끝나면 바로 데이터를 콸콸 쏟아부을까요? 아닙니다. 받는 사람(Receiver)과 길(Network)의 상태를 봐야 합니다.
이 모든 것이 TCP 내부(커널)에서 알아서 일어납니다. 우리가 socket.send(data)를 할 때, OS는 이 모든 복잡한 알고리즘을 수행하며 데이터를 쪼개서 보냅니다. UDP는 이런 게 없어서 개발자가 직접 구현해야 하죠.
3-Way Handshake는 안전하지만 느립니다. 매번 1 RTT(왕복 시간)를 낭비하죠. 구글은 TCP Fast Open을 제안했습니다(RFC 7413).
아이디어는 간단합니다. "어제 통화했던 사인데, 인사 생략하죠?" 첫 접속 때 Cookie를 발급받고, 두 번째 접속부터는 SYN 패킷 안에 Data + Cookie를 같이 실어 보냅니다. 서버는 쿠키를 확인하고 Handshake가 끝나기도 전에 데이터를 처리합니다. 최신 브라우저와 리눅스 서버에서 널리 쓰이고 있습니다.
TCP는 데이터의 순서(1, 2, 3)를 엄격히 지킵니다. 만약 패킷 2번이 유실되면? 3, 4, 5번이 이미 도착했어도 OS는 애플리케이션에 주지 않고 2번이 재전송될 때까지 꽉 잡고 기다립니다. 전체가 멈추는 현상이죠.
이 때문에 HTTP/3(QUIC)는 TCP를 버리고 UDP를 선택했습니다. UDP는 순서 강박이 없어서 스트림이 독립적으로 흐를 수 있기 때문입니다. 처음엔 "왜 UDP를 쓰지?"라고 의아했는데, 이 문제를 알고 나니까 이해가 갔습니다.
리눅스 커널 파라미터(sysctl.conf)를 공부하면서, 대규모 트래픽을 처리하는 환경에서는 기본 TCP 설정만으로는 부족할 수 있다는 걸 알게 됐습니다. 이런 설정들이 있다는 걸 알아두면 유용합니다.
SYN 패킷이 폭주하면 커널의 대기열(Backlog)이 꽉 차서 정상적인 연결도 거부를 당합니다.
# SYN 대기열 크기 확인
sysctl net.ipv4.tcp_max_syn_backlog
# 128 (기본값) -> 4096 등으로 늘려야 함
SYN Flooding 공격을 방어하기 위해 쿠키 기술을 켭니다.
sysctl -w net.ipv4.tcp_syncookies=1
TCP 헤더의 Window Size 필드는 16비트(65KB)가 한계입니다. 옛날엔 충분했지만, 기가비트 시대엔 턱없이 부족하죠. Window Scaling 옵션을 켜면 쉬프트 연산을 통해 윈도우 크기를 1GB까지 늘릴 수 있습니다.
sysctl -w net.ipv4.tcp_window_scaling=1
이런 설정들은 Redis나 Kafka 같은 고성능 미들웨어를 운영할 때 필수적으로 검토하게 되는 값들이라고 합니다.
4-Way Handshake에서 FIN을 보낸다는 건 "더 이상 보낼 데이터가 없다"는 뜻이지, "안 듣겠다"는 뜻이 아닙니다. 이 미묘한 차이를 이용한 것이 Half-Close입니다.
socket.close(): 읽기/쓰기 스트림을 모두 닫습니다. 상대가 데이터를 더 보내면 에러가 납니다socket.shutdown(SHUT_WR): "쓰기"만 닫습니다 (FIN 전송). 하지만 상대방이 보내는 데이터는 계속 읽을 수 있습니다클라이언트가 서버에 파일을 업로드하는 경우를 생각해봤다. 클라이언트는 파일을 다 보냈으면 shutdown(WR)을 호출해 FIN을 보냅니다. 하지만 서버는 아직 "파일 잘 받았다"는 응답을 보내지 않았을 수 있죠. 클라이언트는 쓰기만 닫고, 서버의 응답을 기다리는(읽기 열림) 상태를 유지해야 합니다. 이것이 우아한 종료의 정석입니다.
TCP 3-Way Handshake를 처음 배울 때는 "그냥 인사 3번 하는 거네"라고 생각했습니다. 하지만 깊이 파고들수록, 이게 얼마나 정교하게 설계된 프로토콜인지 알게 됐습니다.
이 3단계가 있기 때문에, 우리는 불안정한 네트워크에서도 신뢰성 있는 통신을 할 수 있습니다. 좀비 패킷 문제를 방지하고, 양방향 통신을 보장하고, 시퀀스 번호를 교환해서 데이터 순서를 지킬 수 있죠.
제 서비스를 만들면서 네트워크 에러를 디버깅할 때, 이 개념을 제대로 이해하고 있으니까 문제를 훨씬 빠르게 찾을 수 있었습니다. "Refused"와 "Timeout"의 차이, TIME_WAIT의 의미, Connection Pool의 필요성... 이 모든 게 3-Way Handshake를 이해하면서 자연스럽게 연결됐습니다.
결국 TCP는 "예의 바른 프로토콜"입니다. 말하기 전에 먼저 듣고, 끊기 전에 상대방을 배려하죠. 이 철학을 이해하면, 네트워크 프로그래밍이 훨씬 쉬워집니다.