UDP — 가볍게, 빠르게, 책임은 앱이
TCP는 신뢰성을 위해 *핸드셰이크·순서번호·재전송·흐름/혼잡 제어*라는 많은 절차와 상태를 짊어졌다. 그런데 그 모든 게 항상 필요할까? 화상 통화를 떠올려 보자 — 0.2초 전의 음성 한 조각이 길에서 사라졌다면, 그걸 재전송받아 뒤늦게 트는 것보다 그냥 버리고 지금 음성을 트는 게 낫다. 여기서는 신뢰성보다 지연이 중요하다. 이런 경우를 위한 가벼운 전송 계층이 **UDP**다.
UDP가 하는 일 — 사실 거의 안 한다
UDP는 IP의 best-effort를 거의 그대로 위층에 노출한다. 헤더라곤 출발/목적 포트, 길이, 체크섬이 전부다.
핸드셰이크가 없고(보내고 싶을 때 그냥 보낸다), 순서 보장이 없고, 재전송이 없고, 흐름·혼잡 제어가 없다. UDP가 더해 주는 건 사실상 포트(어느 프로그램에게 줄지)와 체크섬(깨졌는지 정도만 확인) 둘뿐이다. 그래서 빠르고 가볍다 — 연결 상태를 기억할 필요가 없으니 서버가 수많은 클라이언트를 가뿐히 상대하고, 첫 패킷부터 왕복 지연 없이 바로 나간다.
그래서 무엇이 다른가 — TCP vs UDP
| TCP | UDP | |
|---|---|---|
| 연결 | 핸드셰이크로 수립 | 없음(그냥 보냄) |
| 신뢰성 | 재전송·순서·중복 제거 | 없음(빠지면 그만) |
| 제어 | 흐름·혼잡 제어 | 없음 |
| 무게 | 상태·절차 많음 | 가벼움·빠름 |
| 적합 | 정확성이 중요(웹·파일·DB) | 지연이 중요(통화·게임·DNS) |
선택 기준은 한 줄이다 — 한 바이트도 틀리면 안 되는가(TCP), 아니면 늦느니 차라리 빠지는 게 나은가(UDP). 웹페이지·파일 전송·DB는 TCP, 실시간 음성·영상·온라인 게임은 UDP가 어울린다. DNS도 (짧은 질의·응답이라) 기본은 UDP를 쓴다(다음 편).
신뢰성이 필요하면? 앱이 골라서 얹는다
흥미로운 건 — UDP를 쓴다고 신뢰성을 영영 포기하는 게 아니라, 필요한 만큼만 애플리케이션이 직접 얹을 수 있다는 점이다. 게임은 "캐릭터 위치는 좀 빠져도 되지만(최신만 중요), 아이템 획득은 반드시 도착해야 한다"처럼 — 메시지마다 신뢰 수준을 다르게 설계한다. 이 발상을 표준으로 끌어올린 게 **QUIC**다. UDP 위에 신뢰성·순서·혼잡 제어·*암호화(TLS)*까지 얹되, TCP의 약점(연결마다 핸드셰이크 RTT, 한 스트림이 막히면 다 막히는 head-of-line 블로킹)을 개선했다 — *HTTP/3*가 바로 이 QUIC 위에서 돈다(HTTP 편에서 다시).
실무에서는 — "왜 게임/통화는 끊겨도 멈추진 않을까"의 답이 UDP다. 재전송으로 멈추기보다 빠진 채 진행하는 설계다. 반대로 DNS가 가끔 TCP로 떨어지는 경우도 있는데(응답이 UDP 한 패킷에 안 담길 만큼 클 때), 이는 "기본은 가볍게(UDP), 필요하면 무겁게(TCP)"의 전형이다. 그리고 *방화벽·NAT*가 UDP를 까다롭게 다루기도 해(상태가 없어 매핑이 빨리 만료), 실시간 서비스는 keepalive 패킷으로 매핑을 살려 두기도 한다.
실제로 눈으로 보기
핸드셰이크가 없다는 것을 확인한다
sudo tcpdump -i any -nn port 53 &
dig +short example.com @8.8.8.8
# 10.0.2.15.41022 > 8.8.8.8.53: 12345+ A? example.com. (29)
# 8.8.8.8.53 > 10.0.2.15.41022: 12345 1/0/0 A 93.184.216.34 (45)
패킷이 딱 두 개다. TCP 였다면 앞에 SYN·SYN+ACK·ACK 셋이 더 붙고 뒤에 종료 넷이 붙어 아홉 개가 된다. 질의 하나에 왕복 하나 — DNS 가 UDP 를 쓰는 이유가 이 숫자에 있다.
받는 쪽이 없어도 보내진다
nc -u -l 9999 & # 서버를 띄우고
echo "hello" | nc -u -w1 127.0.0.1 9999 # 도착한다
kill %1 # 서버를 죽인 뒤
echo "hello" | nc -u -w1 127.0.0.1 9999 # 오류 없이 그냥 끝난다
보내는 쪽은 실패했다는 것을 모른다. TCP 라면 연결 자체가 거부되지만, UDP 는 던지고 끝이다. 상대가 죽었는지 알려면 애플리케이션이 직접 확인해야 한다.
netstat -su | grep -A3 Udp:
# 412 packets received
# 18 packet receive errors ← 버퍼가 넘쳐 버려진 것
# 0 packets to unknown port
receive errors가 오르면 수신 버퍼가 넘쳐 조용히 버려지고 있다는 뜻이다. TCP 라면 흐름 제어가 보내는 쪽을 늦췄겠지만 UDP 에는 그 장치가 없다.
크기를 잘못 잡으면 잘린다
dig +notcp +bufsize=512 TXT big.example.com
# ;; Truncated, retrying in TCP mode. ← TC 비트가 서 있다
UDP 응답이 버퍼보다 크면 잘라서 보내고 "잘렸다" 표시만 준다. 그래서 DNS 는 이 경우 TCP 로 다시 물어본다 — "UDP 는 신뢰성이 없으니 앱이 알아서" 의 실제 사례다.
손실을 앱이 어떻게 다루나
DNS → 잘리면 TCP 로 재시도, 응답 없으면 다른 서버로
실시간 음성 → 재전송하지 않는다. 늦게 온 음성은 쓸모가 없다
게임 → 최신 상태만 의미가 있어 옛 패킷은 버린다
QUIC → UDP 위에 순서·재전송·혼잡 제어를 직접 구현했다
QUIC 이 특히 흥미롭다. TCP 의 기능이 필요했지만 커널에 박힌 TCP 는 고치기 어려웠다. 그래서 가장 아무것도 안 하는 UDP 위에 필요한 것만 직접 얹었다 — UDP 의 값어치가 "기능이 많아서" 가 아니라 "방해하지 않아서" 라는 점을 보여 준다.
정리하면, UDP는 IP의 best-effort를 거의 그대로 노출하는 가벼운 전송 계층이다 — 포트와 체크섬만 더하고, 핸드셰이크·재전송·제어는 없다. 그래서 빠르고 가벼워 지연이 중요한 실시간(통화·게임·DNS)에 어울린다. 신뢰성이 필요하면 앱이 필요한 만큼 직접 얹고, 그걸 표준화한 QUIC가 *HTTP/3*의 토대가 된다. 정확성이 중요하면 TCP, 지연이 중요하면 UDP — 전송 계층의 두 갈래다.