지난번 IP와 포트 이야기, 잘 보셨나요? IP가 '일단 던지고 보자!'는 무심한 택배 기사였다면, 그 위에서 실제로 '물건 제대로 갔는지', '너무 빨리 보내서 받는 사람이 힘들어하진 않는지' 등을 꼼꼼히 챙기거나, 혹은 '묻지도 따지지도 않고 최대한 빨리!' 배송하는 역할을 맡은 친구들이 바로 TCP와 UDP입니다.
오늘은 이 전송 계층의 양대 산맥, TCP와 UDP에 대해 좀 더 깊이 들어가 보겠습니다. 특히 '신뢰성 끝판왕' TCP가 어떻게 연결을 시작하고(3-Way Handshake), 데이터를 조각내어 보내며(Segment), 각종 제어 마법을 부리고, 또 어떻게 헤어지는지(4-Way Handshake) 그 풀 스토리를 알아볼 거예요. 그리고 그 과정에서 TCP의 상태가 어떻게 변하는지, '단순함과 속도가 매력'인 UDP는 또 어떤 특징을 가졌는지 함께 파헤쳐 봅시다!
TCP(Transmission Control Protocol)는 이름 그대로 '제어'에 진심인 프로토콜입니다. 데이터를 그냥 보내는 법이 없죠. 상대방과 먼저 논리적인 연결 통로를 만들고, 데이터가 정확하게, 순서대로 전달되도록 온갖 노력을 다합니다.
TCP 통신의 3막 극장
1막: 연결 수립 (Connection Establishment)
2막: 데이터 전송 (Data Transfer)
3막: 연결 종료 (Connection Termination)
TCP는 애플리케이션 데이터를 한 번에 통째로 보내지 않고, 잘게 쪼개서 보냅니다. 이 데이터 조각 하나하나를 세그먼트(Segment)라고 부르죠. 왜 쪼개냐고요? 네트워크 경로마다 한 번에 보낼 수 있는 데이터 크기 제한(MTU)이 있기 때문입니다. 보통 이더넷 환경(MTU 1500바이트)을 고려해서, IP 헤더와 TCP 헤더 크기를 뺀 나머지, 즉 세그먼트 하나에 담을 수 있는 최대 데이터 크기(MSS, Maximum Segment Size)가 결정됩니다.
각 세그먼트는 실제 데이터(Payload)와 함께, TCP의 온갖 제어 정보가 담긴 TCP 헤더를 머리에 이고 다닙니다. 이 헤더가 바로 TCP 신뢰성의 핵심이죠! 주요 필드들을 살펴봅시다.
+------------------------+------------------------+
| Source Port (16) | Destination Port (16) | <- 누구랑 통신하는지? (애플리케이션 식별)
+------------------------+------------------------+
| Sequence Number (32) | <- 데이터 조각 순서 번호 (이거 보고 조립!)
+------------------------+------------------------+
| Acknowledgment Number (32) | <- "몇 번까지 잘 받았으니, 다음 이거 줘!"
+--------+--------+--------+----------------------+
| Data |Reserved| Flags | Window Size (16) | <- 제어 스위치 + "나 이만큼 받을 수 있어!"
| Offset | (6) | (6) | |
| (4) | | | |
+--------+--------+--------+----------------------+
| Checksum (16) | Urgent Pointer (16) | <- 데이터 오류 검사 + 긴급 데이터 위치
+------------------------+------------------------+
| Options (variable) | <- 추가 기능 (MSS, Window Scale 등)
+------------------------+------------------------+
| Data (Payload) | <- 진짜 데이터!
+-------------------------------------------------+
Source/Destination Port: 이건 지난 시간에 봤죠? 어떤 프로그램끼리 이야기하는지 알려주는 번호.
Sequence Number (Seq): 보내는 데이터의 첫 번째 바이트에 부여되는 고유 번호입니다. 받는 쪽에서는 이 번호를 보고 흩어진 세그먼트들을 순서대로 착착 재조립할 수 있습니다. 연결 시작 시 임의의 값(ISN)으로 정해지고, 데이터를 보낼수록 1씩 증가하는 게 아니라 보낸 데이터의 바이트 수만큼 증가합니다.
Acknowledgment Number (Ack): 상대방으로부터 다음에 받아야 할 순서 번호를 알려주는 값입니다. 즉, '네가 보낸 Seq
번호 + 데이터 크기'까지는 내가 잘 받았으니, 이제 이 Ack 번호
부터 시작하는 데이터를 보내달라는 의미죠. 이 필드는 아래 ACK 플래그가 1일 때만 유효합니다.
Flags (제어 비트): 6개의 비트 스위치로, 세그먼트의 역할이나 상태를 나타냅니다. 특히 중요한 녀석들:
ACK
: Ack 번호 필드가 유효함을 알림 (연결 수립 후 거의 항상 켜짐).
SYN
: 연결 요청 또는 수락. Seq 번호를 동기화하자는 신호.
FIN
: 연결 종료 요청. "나 이제 보낼 거 없어~" 신호.
RST
: 연결 강제 리셋. 비정상적인 상황에서 연결을 끊음.
PSH
: 수신 측에게 데이터를 빨리 애플리케이션으로 전달하라고 요청.
URG
: Urgent Pointer 필드가 가리키는 긴급 데이터가 있음을 알림.
Window Size: 수신 측이 현재 받을 수 있는 데이터의 총량(버퍼 여유 공간)을 송신 측에 알리는 값입니다. 송신 측은 이 크기를 넘지 않도록 데이터 양을 조절하죠. 이게 바로 흐름 제어(Flow Control)의 핵심 원리!
TCP는 데이터를 보내기 전에 반드시 세 번의 악수(Handshake)를 통해 연결을 설정합니다. 왜? 서로 통신할 준비가 됐는지 확인하고, 앞으로 사용할 Sequence Number의 시작점을 서로에게 알려주고 동기화하기 위해서죠.
(SYN) 클라이언트 → 서버: "저기요, 서버님! 통신하고 싶은데요? 제 시작 번호는 A
입니다!" (클라이언트는 SYN
플래그를 켜고 자신의 ISN A
를 Seq 필드에 담아 보냅니다. 상태: SYN-SENT
)
(SYN+ACK) 서버 → 클라이언트: "어, 그래! 네 요청(A
) 잘 받았어! 확인 의미로 A+1
을 Ack 번호로 줄게. 나도 통신 준비됐고, 내 시작 번호는 B
야!" (서버는 SYN
과 ACK
플래그를 모두 켜고, 자신의 ISN B
를 Seq 필드에, A+1
을 Ack 필드에 담아 응답합니다. 상태: SYN-RECEIVED
)
(ACK) 클라이언트 → 서버: "네, 서버님 응답 잘 받았어요! 서버님 시작 번호(B
) 확인했고, 확인 의미로 B+1
을 Ack 번호로 보낼게요. 이제 진짜 통신 시작합시다!" (클라이언트는 ACK
플래그를 켜고, B+1
을 Ack 필드에 담아 마지막 확인을 보냅니다.)
이 마지막 ACK 패킷이 서버에 무사히 도착하면, 양쪽 모두 ESTABLISHED
상태가 되어 드디어 데이터를 주고받을 준비를 마칩니다! 먼저 연결을 시도하는 쪽(보통 클라이언트)을 Active Open, 연결 요청을 기다리는 쪽(보통 서버, LISTEN
상태)을 Passive Open이라고 합니다.
데이터 전송이 끝나면 연결을 깔끔하게 정리해야 합니다. 이때는 네 번의 악수가 필요합니다. 왜 3번이 아니라 4번일까요? 한쪽이 "나 이제 끝!"이라고 해도, 다른 쪽은 아직 보낼 데이터가 남아있을 수 있기 때문이죠(Half-Close).
(FIN) 호스트 A → 호스트 B: "나 이제 보낼 데이터 없어. 그만 끊고 싶어!" (A가 FIN
플래그를 켜서 보냅니다. A 상태: FIN-WAIT-1
)
(ACK) 호스트 B → 호스트 A: "알았어, 네 FIN(Seq=X
) 잘 받았어! 확인(Ack=X+1
) 보낸다!" (B는 일단 A의 종료 의사를 확인하는 ACK
를 보냅니다. 하지만 B는 아직 보낼 데이터가 남아있을 수 있습니다. A는 더 이상 보내지 못하지만, B는 보낼 수 있는 상태. A 상태: FIN-WAIT-2
, B 상태: CLOSE-WAIT
)
(FIN) 호스트 B → 호스트 A: "나도 이제 진짜 보낼 거 다 보냈어. 나도 끊을래!" (B가 자신의 데이터 전송을 마치고 FIN
플래그(Seq=Y
)를 켜서 보냅니다. B 상태: LAST-ACK
)
(ACK) 호스트 A → 호스트 B: "오케이, 너의 FIN(Seq=Y
)도 잘 받았어! 마지막 확인(Ack=Y+1
) 보낸다!" (A가 마지막 ACK
를 보냅니다.)
이 마지막 ACK를 받은 호스트 B는 바로 연결을 CLOSED
합니다. 하지만 마지막 ACK를 보낸 호스트 A는 바로 종료하지 않고 TIME_WAIT
상태에서 잠시 대기합니다. 왜냐하면 자신의 마지막 ACK가 중간에 유실될 경우, B가 ③번 FIN을 재전송할 수 있기 때문이죠. 이 재전송을 제대로 처리하고, 혹시 네트워크 상에 남아있을지 모르는 이전 연결의 찌꺼기 패킷들이 완전히 사라질 시간(보통 2*MSL, 약 1~4분)을 벌기 위함입니다. 이 시간이 지나야 A도 비로소 CLOSED
상태가 됩니다.
먼저 연결 종료를 요청하는 쪽을 Active Close, 상대방의 종료 요청을 받고 종료하는 쪽을 Passive Close라고 부릅니다.
TCP는 연결 시작부터 완전히 종료될 때까지 계속해서 자신의 상태를 추적하고 관리합니다. 그래서 Stateful Protocol이라고 불리죠. 이 상태 변화를 이해하면 네트워크 통신 과정을 디버깅하거나 분석할 때 큰 도움이 됩니다.
CLOSED
: 아무 연결도 없는 깨끗한 상태 (시작 또는 최종 상태).
LISTEN
: 서버가 클라이언트의 접속 요청을 귀 기울여 기다리는 상태 (Passive Open 준비 완료!).
SYN-SENT
: 클라이언트가 서버에게 SYN 보내고 응답(SYN+ACK) 기다리는 중 (Active Open 시작!).
SYN-RECEIVED
: 서버가 SYN 받고 SYN+ACK 응답 보낸 후, 클라이언트의 마지막 ACK 기다리는 중.
ESTABLISHED
: 3-Way Handshake 성공! 양쪽에서 자유롭게 데이터를 주고받는 가장 안정적인 상태.
FIN-WAIT-1
: 내가 먼저 연결 종료(FIN)를 요청하고, 상대방의 ACK 또는 FIN을 기다리는 상태 (Active Close 시작).
FIN-WAIT-2
: 내 FIN에 대한 ACK는 받았고, 이제 상대방이 보내올 FIN을 기다리는 상태.
CLOSE-WAIT
: 상대방으로부터 FIN을 받고 일단 ACK는 보낸 상태. 이제 내 쪽 애플리케이션이 close()
를 호출해서 FIN을 보낼 준비를 기다리는 중 (Passive Close 시작).
LAST-ACK
: CLOSE-WAIT 상태에서 내 FIN까지 모두 보내고, 상대방의 마지막 ACK만 기다리는 상태.
TIME_WAIT
: 내가 마지막 ACK를 보낸 후, 혹시 모를 사태에 대비해 잠시 대기하는 상태 (Active Close의 마무리 단계). 이 상태 때문에 서버 소켓을 너무 빨리 재사용하려고 하면 'Address already in use' 에러를 만날 수 있습니다!
CLOSING
: 양쪽이 거의 동시에 FIN을 보내서 상태가 약간 꼬인(?) 드문 경우. 서로의 FIN에 대한 ACK를 기다립니다.
(이 상태 변화를 그림으로 된 다이어그램, 'TCP State Transition Diagram'으로 검색해서 보시면 이해가 훨씬 쉬울 거예요!)
TCP가 신뢰성을 위해 이것저것 챙기느라 좀 복잡했다면, UDP(User Datagram Protocol)는 정반대의 매력을 가졌습니다. 바로 단순함과 속도죠.
연결? 그딴 거 없어 (Connectionless): 핸드셰이크? 필요 없습니다. 그냥 목적지 포트 보고 데이터그램(Datagram)을 휙 던집니다.
신뢰성? 보장 못 해 (Unreliable): 데이터 순서가 뒤바뀌든, 중간에 사라지든 UDP는 책임지지 않습니다. 재전송, 흐름 제어, 혼잡 제어? 그런 거 없습니다. 앱 레벨에서 필요하면 직접 구현해야 합니다.
가벼운 헤더: TCP 헤더(기본 20바이트)보다 훨씬 가벼운 8바이트 고정 헤더를 사용합니다. 오버헤드가 적죠.
그래서 빠르다!: 신뢰성을 위한 부가 기능이 없으니 당연히 TCP보다 전송 속도가 빠릅니다.
상태 관리? 안 해 (Stateless): 연결 상태 자체를 관리하지 않습니다.
UDP 데이터그램 구조는 정말 심플 그 자체입니다.
+------------------------+------------------------+
| Source Port (16) | Destination Port (16) | <- 누구랑?
+------------------------+------------------------+
| Length (16) | Checksum (16) | <- 전체 길이 + 오류 검사(선택)
+------------------------+------------------------+
| Data (Payload) | <- 진짜 데이터!
+-------------------------------------------------+
Source/Destination Port: TCP와 동일하게 애플리케이션 식별.
Length: UDP 헤더(8바이트)와 데이터(Payload)를 합친 전체 데이터그램의 길이(바이트 단위).
Checksum: 전송 중 데이터 오류(변형)가 있었는지 검사하는 필드. IPv4에서는 선택 사항이지만, IPv6에서는 필수입니다. 오류를 검출할 뿐, 수정하거나 재전송해주지는 않습니다.
딱 필요한 정보만 있죠? TCP의 복잡한 Seq, Ack, Flags, Window Size 같은 건 찾아볼 수 없습니다.
그럼 UDP는 언제 쓸까요?
약간의 데이터 손실은 괜찮지만 실시간성이 매우 중요할 때 (온라인 게임의 위치 정보 전송, 실시간 영상/음성 스트리밍(RTP), VoIP 등)
요청/응답이 매우 간단하고 빨라야 할 때 (DNS, DHCP 등)
브로드캐스트나 멀티캐스트 통신이 필요할 때
신뢰성을 애플리케이션 레벨에서 직접 정교하게 제어하고 싶을 때 (QUIC 프로토콜처럼)
오늘은 전송 계층의 두 핵심 플레이어, TCP와 UDP를 속속들이 파헤쳐 봤습니다. TCP는 3-Way/4-Way 핸드셰이크, 순서/확인 응답, 흐름/혼잡 제어, 상태 관리 등 복잡하지만 강력한 메커니즘을 통해 신뢰성 있는 데이터 전송을 보장하는 꼼꼼한 일꾼입니다. 반면 UDP는 이러한 부가 기능을 과감히 생략하고 빠르고 간결한 데이터 전송에 집중하는 쿨가이죠.
어떤 프로토콜을 선택할지는 결국 여러분이 만드는 서비스나 애플리케이션이 무엇을 더 중요하게 여기는지에 달려 있습니다. 데이터 하나하나의 정확성과 순서가 중요하다면 TCP를, 약간의 손실은 감수하더라도 속도가 생명이라면 UDP를 선택하는 것이 일반적입니다. 물론 최근에는 UDP 기반 위에서 TCP의 장점을 구현하려는 QUIC 같은 새로운 시도들도 활발히 이루어지고 있지만, TCP와 UDP의 기본적인 특성을 이해하는 것은 여전히 중요합니다.
네트워크의 내부 동작 원리를 아는 것은 당장 코드를 바꾸지 않더라도, 문제 해결 능력을 향상시키고 더 효율적이고 안정적인 시스템을 설계하는 데 든든한 밑거름이 될 겁니다. 여러분의 개발 여정에 이 글이 조금이나마 도움이 되었기를 바랍니다! 😊