TCP vs UDP 전송 프로토콜 비교 | 신뢰성·지연·QUIC까지 선택 가이드

이 글의 핵심

스테이징에서 REST 호출이 간헐적으로 8초씩 걸렸는데 원인이 앱이 아니라 VPN 경로의 MTU와 재전송이었던 사례로 시작합니다. UDP가 항상 빠르다는 말이 손실이 없을 때만 맞는 이유, 늦게라도 끝까지 오는 TCP와 늦은 프레임은 버리는 UDP의 트레이드오프, 그 사이에 선 QUIC의 위치를 짧은 소켓 예제와 함께 설명합니다.

개인적으로 TCP와 UDP의 차이는 이론보다 Wireshark로 익히는 편이 빠르다고 생각합니다. 비교표를 외워 두어도, 실제로 이 지식이 필요해지는 순간은 대개 “API가 가끔 느리다”는 문제를 앞에 두고 캡처를 떠 놓은 상황이기 때문입니다. 그래서 이 글은 RFC 번호로 시작하지 않고, 캡처에서 두 프로토콜이 어떻게 다르게 보이는지를 중심으로 설명합니다.

8초씩 걸리던 REST 호출

스테이징 환경에서 REST 호출이 간헐적으로 8초 가까이 걸리는 문제는 네트워크 문제의 전형적인 패턴입니다. 애플리케이션 로그에는 “응답이 늦었다”는 사실만 남고, curl로 반복 호출하면 가끔씩 재현되며, DB 쿼리는 가볍습니다. 이럴 때 tcp port 443 필터로 Wireshark를 켜 보면 같은 요청이 나가는 구간에 TCP Retransmission과 Dup ACK가 끼어 있는 것이 보입니다.

이런 증상에서 자주 나오는 원인 중 하나가 VPN이나 터널 구간의 MTU 불일치입니다. 터널은 패킷에 자체 헤더를 덧붙이기 때문에 실제로 통과할 수 있는 패킷 크기가 일반 이더넷의 1500바이트보다 작습니다. TCP는 보통 DF(Don’t Fragment) 비트를 켜고 보내므로, 큰 패킷이 이 구간을 지나지 못하면 라우터가 “조각화가 필요하다”는 ICMP 메시지를 돌려보내야 합니다. 그런데 방화벽이 이 ICMP를 막고 있으면 송신 측은 패킷이 왜 사라졌는지 모른 채 같은 크기로 계속 재전송합니다. 이것이 PMTUD 블랙홀입니다. 작은 요청은 문제없이 지나가고 응답 본문이 커서 큰 세그먼트를 쓰는 경우에만 막히기 때문에 “간헐적”으로 보입니다.

8초라는 숫자에도 이유가 있습니다. TCP는 재전송 타임아웃(RTO)이 지날 때마다 대기 시간을 두 배로 늘립니다. 초기 RTO를 1초로 잡으면 1초, 2초, 4초를 기다린 뒤 네 번째 시도에서 더 작은 세그먼트로 바꾸거나(Linux의 MTU 탐색 등) 경로가 복구되어 통과하는데, 대기 시간을 더하면 7초 안팎이 됩니다. 캡처에서 재전송 간격이 1초, 2초, 4초로 늘어나는 패턴이 보이면 “손실이 반복되고 있다”는 강력한 신호입니다. 비교표에서 “TCP = 신뢰성”만 외웠다면 이 상황에서 애플리케이션 타임아웃을 늘리거나 재시도를 넣는 쪽으로 갔을 가능성이 높습니다. 캡처를 보면 결론은 “앱이 아니라 경로”이고, 해결책은 터널 인터페이스의 MTU를 낮추거나 MSS 클램핑을 설정하는 것입니다.

TCP: 늦더라도 끝까지 온다

TCP는 설명할 것이 많습니다. 3-way handshake, 시퀀스 번호와 ACK, 수신 윈도우, 혼잡 제어, 재전송까지 모두 “보낸 바이트가 순서대로 빠짐없이 도착한다”는 하나의 약속을 지키기 위한 장치입니다. 애플리케이션 입장에서 TCP 소켓은 파일처럼 읽고 쓰는 바이트 스트림이고, 중간에 패킷이 사라지거나 순서가 바뀌어도 커널이 알아서 재전송하고 재조립합니다.

이 약속에는 대가가 있습니다. 패킷 하나가 손실되면 그 뒤에 이미 도착한 데이터도 애플리케이션에 전달되지 못하고 커널 버퍼에서 기다립니다. 빠진 조각이 재전송되어 채워져야 순서대로 넘겨줄 수 있기 때문입니다. 이것을 Head-of-Line(HOL) 블로킹이라고 부릅니다. 손실이 드문 유선망에서는 거의 드러나지 않지만, 손실률이 몇 퍼센트만 되어도 지연과 지터(지연 변동)가 크게 늘어납니다. 또 혼잡 제어는 손실을 “네트워크가 붐빈다”는 신호로 해석해 전송 속도를 줄이므로, 무선 구간처럼 혼잡과 무관한 손실이 잦은 환경에서는 처리량이 필요 이상으로 떨어집니다.

UDP: 보내고 끝, 나머지는 애플리케이션 몫

UDP는 IP 위에 포트 번호와 체크섬만 얹은 얇은 계층입니다. 도착 여부, 순서, 중복, 속도 조절을 전혀 관리하지 않고, 보낸 데이터그램 하나가 그대로 도착하거나 통째로 사라집니다. 연결 설정도 없어서 첫 패킷부터 바로 데이터를 실어 보낼 수 있습니다.

DNS가 UDP를 주로 쓰는 것도 이 특성 때문입니다. 질의와 응답이 각각 패킷 하나에 들어가는 짧은 교환이라, TCP 핸드셰이크 왕복을 추가하면 전체 시간이 두 배 이상 늘어납니다. 응답이 사라지면 클라이언트가 타임아웃 후 다시 물어보면 되고, 같은 질문을 두 번 해도 문제가 없습니다. 다만 응답이 커지면(DNSSEC, 레코드가 많은 경우) 서버가 응답에 TC(truncated) 비트를 켜고, 클라이언트는 TCP로 다시 질의합니다. 전통적인 UDP DNS 응답 한도는 512바이트였고, EDNS0 확장으로 더 큰 크기를 협상할 수 있습니다.

VoIP와 실시간 게임이 UDP를 쓰는 이유는 조금 다릅니다. 여기서는 “늦게 도착한 데이터는 쓸모가 없다”는 점이 핵심입니다. 200ms 전의 음성 조각이나 이미 지나간 게임 틱의 위치 정보를 재전송받아 봐야 재생하거나 반영할 시점이 지났습니다. TCP를 쓰면 이 오래된 데이터를 기다리느라 최신 데이터까지 막히지만, UDP에서는 빠진 것을 건너뛰고 다음 패킷을 바로 처리할 수 있습니다.

그래서 “UDP가 항상 더 빠르다”는 말은 손실이 없을 때만 맞습니다. 손실이 있을 때 UDP는 빠른 것이 아니라 데이터가 없는 것입니다. 캡처에서 UDP 패킷이 사라지면 재전송 흔적도 없이 그냥 비어 있습니다. 애플리케이션이 그 빈자리를 허용할 수 있는지가 선택의 기준이지, 속도 자체가 기준은 아닙니다.

QUIC(HTTP/3): 그 사이에 선 세 번째 선택지

지금의 웹은 “TCP 아니면 UDP”만으로 설명되지 않습니다. QUIC은 UDP 위에 신뢰성 있는 전송, TLS 1.3 암호화, 다중 스트림을 한 계층으로 합친 프로토콜이고, HTTP/3가 이 위에서 동작합니다. 분류상으로는 UDP 기반이 맞지만, 애플리케이션이 체감하는 동작은 “또 하나의 신뢰 전송”에 가깝습니다.

QUIC이 해결하려는 문제는 크게 세 가지입니다. 첫째, 연결 설정 지연입니다. TCP + TLS 1.3은 TCP 핸드셰이크 1 RTT와 TLS 핸드셰이크 1 RTT가 따로 필요하지만, QUIC은 둘을 합쳐 1 RTT로 끝내고, 이전에 접속한 서버에는 0-RTT로 첫 요청을 바로 보낼 수도 있습니다. 둘째, HOL 블로킹입니다. HTTP/2는 요청 여러 개를 하나의 TCP 연결에 섞어 보내기 때문에 패킷 하나가 손실되면 모든 요청이 함께 멈춥니다. QUIC은 스트림별로 순서를 관리하므로 손실된 스트림만 기다리고 나머지는 계속 진행합니다. 셋째, 연결 이동입니다. TCP 연결은 IP·포트 조합으로 식별되어 Wi-Fi에서 LTE로 바뀌면 끊기지만, QUIC은 연결 ID로 식별해 네트워크가 바뀌어도 연결을 유지할 수 있습니다.

curl --http3로 같은 사이트를 HTTP/2와 HTTP/3로 각각 요청해 보면(curl이 HTTP/3 지원으로 빌드되어 있고 서버도 지원한다면) 핸드셰이크 단계가 줄어든 것을 확인할 수 있습니다. HTTP/3 연결이 안 된다면 원인은 대개 망이나 방화벽이 UDP 443을 막고 있는 것입니다. 기업망에서는 UDP를 통째로 막는 경우가 흔한데, 브라우저는 이런 환경에서 자동으로 TCP 기반 HTTP/2로 되돌아가므로 사용자는 알아채지 못하고 성능 이점만 사라집니다.

소켓 코드로 차이 감 잡기

코드는 감을 잡는 용도입니다. TCP(스트림) 소켓은 연결을 맺고 바이트를 쓰면 끝이고, 애플리케이션은 재전송이 일어났는지 알 필요가 없습니다.

import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("example.com", 80))
# HTTPS(443)라면 ssl.create_default_context().wrap_socket(sock, server_hostname="example.com")로 감싸야 함
sock.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
print(sock.recv(4096))
sock.close()

평문 HTTP 요청을 443 포트에 그대로 보내면 서버는 TLS 핸드셰이크를 기대하고 있으므로 400 응답이나 알 수 없는 바이트를 돌려주고 연결을 끊습니다. 그래서 예제는 80 포트를 쓰고, HTTPS가 필요하면 ssl 모듈로 소켓을 감싸야 합니다. 예전 예제에 자주 나오던 ssl.wrap_socket()은 Python 3.12에서 제거되었습니다. 또 recv(4096)는 “최대 4096바이트”를 읽을 뿐이라, 응답이 크면 여러 번 나눠 읽어야 합니다. TCP는 메시지 경계를 보존하지 않는 스트림이기 때문에, 보낸 쪽이 한 번에 쓴 데이터가 받는 쪽에서 여러 번에 나뉘어 오거나 두 번 쓴 데이터가 한 번에 올 수 있습니다. 소켓 프로그래밍을 처음 할 때 가장 흔한 버그가 이 경계를 가정하는 코드입니다.

UDP는 연결 없이 주소를 지정해 데이터그램을 보냅니다. 아래는 example.com의 A 레코드를 묻는 최소한의 DNS 질의입니다.

import socket, struct
# DNS 헤더: ID, 플래그(재귀 요청), 질문 1개
header = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
qname = b"".join(bytes([len(p)]) + p.encode() for p in "example.com".split(".")) + b"\x00"
question = qname + struct.pack(">HH", 1, 1)  # QTYPE=A, QCLASS=IN
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2)  # UDP는 응답이 안 올 수 있으므로 타임아웃 필수
sock.sendto(header + question, ("8.8.8.8", 53))
data, addr = sock.recvfrom(512)
print(len(data), data[:12].hex())
sock.close()

b"query" 같은 임의의 바이트를 53번 포트에 보내면 DNS 서버는 형식이 맞지 않는 패킷을 버리고, 타임아웃이 없는 recvfrom은 영원히 기다립니다. UDP 코드에서 settimeout을 빠뜨리면 응답 패킷이 하나만 사라져도 프로그램이 멈추므로, 실제 코드에서는 타임아웃과 재시도를 직접 구현해야 합니다. 이것이 “신뢰성은 애플리케이션 몫”이라는 말의 구체적인 의미입니다.

캡처에서 무엇을 볼 것인가

Wireshark에서 TCP는 Follow → TCP Stream으로 대화 전체를 이어서 볼 수 있고, 재전송은 검은 바탕의 [TCP Retransmission], 순서가 어긋난 도착은 [TCP Dup ACK], 수신 측 버퍼가 가득 찬 상태는 [TCP ZeroWindow]로 표시됩니다. 여기서 알아 두어야 할 것은 재전송 표시가 곧 손실을 뜻하지는 않는다는 점입니다. Spurious Retransmission은 수신 측이 이미 받은 데이터를 다시 보낸 경우로, 지연이 RTO보다 길어서 송신 측이 성급하게 재전송했다는 뜻입니다. 또 캡처를 어느 지점에서 떴는지도 중요합니다. 클라이언트에서 뜬 캡처에서 재전송이 보이면 클라이언트가 보낸 패킷이나 서버의 ACK가 중간에서 사라진 것이고, 서버에서 뜬 캡처와 나란히 놓고 비교해야 어느 방향 어느 구간인지 좁힐 수 있습니다.

UDP나 QUIC에서는 이런 표시가 없습니다. QUIC은 페이로드까지 암호화되어 있어서 Wireshark에 TLS 키를 넣어 주지 않으면(SSLKEYLOGFILE 환경 변수로 브라우저가 키를 기록하게 할 수 있음) 재전송 여부조차 보이지 않습니다. 그래서 UDP 계열은 패킷 수와 간격, 응답 누락 여부를 보고 판단하게 됩니다.

느린데 서버 CPU와 애플리케이션 지표는 멀쩡하다면 ss -ti(Linux)로 연결별 RTT와 재전송 횟수를 먼저 확인하는 것이 빠릅니다. 경로 문제가 의심되면 mtr로 구간별 손실을 보고, iperf3로 순수 전송 성능을 측정하면 “망이 나쁜가, 앱이 나쁜가”를 가를 수 있습니다. MTU 문제라면 ping -M do -s 1472 <호스트>(Linux, Windows는 ping -f -l 1472)처럼 조각화 금지 옵션으로 크기를 줄여 가며 보내 보면 어느 크기부터 막히는지 바로 드러납니다. 같은 “8초 타임아웃” 증상이라도 원인은 제각각이므로, 한 번은 캡처를 떠서 눈으로 확인해 보는 것이 가장 확실합니다.

실무에서의 선택 기준

한 줄로 정리하면, 빠짐없이 순서대로 도착해야 하는 바이트가 중요하면 TCP(대부분 TLS를 얹어서)를, 늦게 오면 버려도 되는 데이터라면 UDP 계열(또는 그 위의 RTP, QUIC)을 씁니다. 웹 API, 파일 전송, 데이터베이스 연결은 거의 예외 없이 TCP이고, 음성·영상 통화, 실시간 게임의 상태 동기화, DNS와 NTP 같은 짧은 질의는 UDP입니다.

UDP를 직접 골랐다면 재전송, 순서 정렬, 혼잡 제어 중 무엇이 필요한지부터 따져야 합니다. 이 셋이 모두 필요하다고 판단되면 UDP 위에 직접 만드는 것보다 QUIC 라이브러리를 쓰는 편이 낫습니다. 혼잡 제어 없이 UDP로 대량 전송을 하면 같은 망의 다른 트래픽을 밀어내고, 결국 손실이 늘어 자기 자신도 느려집니다. HTTP/3를 켤지 결정할 때는 서버만이 아니라 로드 밸런서, CDN, 방화벽이 UDP 443을 통과시키는지까지 함께 확인해야 합니다.

더 깊은 내용은 TCP 프로토콜, UDP 실전 활용, HTTP 프로토콜 글에서 각각 다룹니다. 외울 때는 TCP = 스트림, UDP = 데이터그램, 그리고 요즘 웹에는 QUIC도 함께 있다는 정도면 충분합니다. 실제로 이해했는지는 표가 아니라 캡처 한 장을 해석할 수 있는지로 확인하는 편이 정확합니다.

같이 보면 좋은 글