[CS300 #149] UDP — 8바이트 헤더가 남겨 둔 자유와 책임
컴퓨터공학 300 주제 시리즈의 149번째 글이다. 전체 지도는 여기.
한 줄 요약
UDP 는 포트 번호와 체크섬만 붙여 IP 위에 데이터그램을 그대로 실어 보내는 프로토콜이다. 연결도, 재전송도, 순서 보장도, 혼잡 제어도 없다. 그 빈자리는 응용이 채운다.
왜 필요한가
TCP 의 신뢰성은 공짜가 아니다. 핸드셰이크 왕복, 손실 시 뒤 데이터까지 멈추는 순서 보장, 커널이 정한 혼잡 제어가 따라온다. 다음 경우에는 그게 오히려 짐이다.
- DNS 질의처럼 요청 하나, 응답 하나로 끝나는 짧은 교환.
- 음성·영상 통화처럼 늦게 온 데이터는 버리는 게 나은 실시간 전송.
- QUIC 처럼 TCP 의 동작을 사용자 공간에서 새로 설계하고 싶을 때.
UDP 를 이해하면 “왜 DNS 는 UDP 를 쓰나”, “왜 HTTP/3 는 UDP 위에 올라가나”, “왜 UDP 서비스는 방화벽과 NAT 에서 자주 문제를 일으키나”를 설명할 수 있다.
핵심 개념
헤더
UDP 는 1980년 RFC 768 에서 정의됐다. 명세 전체가 세 쪽 남짓이다. 헤더는 8바이트다.
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| 출발지 포트 | 목적지 포트 |
+--------+--------+--------+--------+
| 길이 | 체크섬 |
+--------+--------+--------+--------+
| 데이터 ... |
- 길이: 헤더 포함 데이터그램 전체 길이. 최소 8.
- 체크섬: IP 주소가 담긴 의사 헤더(pseudo-header)와 UDP 헤더, 데이터를 16비트 1의 보수 합으로 계산한다. IPv4 에서는 0 을 넣어 “계산 안 함”을 표시할 수 있지만, IPv6 에서는 반드시 계산해야 한다(RFC 8200).
길이 필드가 16비트이므로 데이터그램 최대는 65,535바이트다. IPv4 헤더 20바이트와 UDP 헤더 8바이트를 빼면 IPv4 에서 실을 수 있는 데이터는 최대 65,507바이트다. 하지만 이더넷 MTU(1500)를 넘으면 IP 단편화가 일어나고, 조각 하나만 잃어도 데이터그램 전체를 잃는다. 그래서 실무에서는 경로 MTU 보다 작게 보내는 것이 원칙이다.
TCP 와 비교
| 항목 | TCP | UDP |
|---|---|---|
| 연결 | 핸드셰이크로 수립 | 없음. 바로 보냄 |
| 단위 | 바이트 스트림(경계 없음) | 데이터그램(경계 보존) |
| 신뢰성 | 재전송, 순서 보장 | 없음. 손실·중복·뒤바뀜 가능 |
| 흐름·혼잡 제어 | 커널이 수행 | 없음. 응용 책임 |
| 헤더 | 최소 20바이트 | 8바이트 |
| 대표 사용 | HTTP/1.1·2, SSH, DB | DNS, QUIC(HTTP/3), RTP, DHCP, NTP |
“경계 보존”은 중요한 차이다. UDP 로 100바이트를 두 번 보내면 받는 쪽은 recvfrom() 두 번으로 100바이트씩 받는다. TCP 는 200바이트가 한 번에 올 수도, 50바이트씩 네 번 올 수도 있다(150번 글).
UDP 를 쓰는 응용의 책임
IETF 는 UDP 사용 지침을 RFC 8085 로 정리했다. 요지는 “TCP 가 해 주던 일 중 필요한 것을 응용이 직접 하라”다.
- 혼잡 제어: 손실이 늘면 보내는 속도를 줄여야 한다. 안 그러면 다른 흐름까지 망친다.
- 메시지 크기: 경로 MTU 를 넘지 않게 한다.
- 신뢰성: 필요하면 타임아웃과 재전송, 순서 번호를 직접 구현한다. DNS 클라이언트는 응답이 없으면 다시 묻는다.
- 연결 상태 유지: NAT 와 방화벽은 UDP 매핑을 시간으로 지운다. 오래 유지할 흐름이면 주기적으로 keepalive 를 보낸다.
보안 측면
UDP 는 핸드셰이크가 없어 출발지 주소 위조가 쉽다. 공격자가 피해자 주소로 위조한 작은 요청을 보내 큰 응답을 피해자에게 쏟아지게 하는 반사·증폭 공격이 여기서 나온다. QUIC 이 첫 패킷 크기를 일정 이상으로 채우고 주소 검증을 하는 이유다(RFC 9000).
직접 해 보기
로컬호스트에서 UDP 소켓 두 개로 경계 보존을 확인하고, 체크섬을 직접 계산해 본다. 체크섬 계산에는 문서용 주소를 쓴다.
import socket, struct
# 1) 데이터그램 경계 보존
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.bind(("127.0.0.1", 0)); rx.settimeout(2)
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for msg in (b"first", b"second-message", b"3"):
tx.sendto(msg, rx.getsockname())
for _ in range(3):
data, addr = rx.recvfrom(2048)
print("받음:", data, len(data))
# 2) UDP 체크섬 (RFC 768: 의사 헤더 + UDP 헤더 + 데이터, 16비트 1의 보수 합)
def csum16(b):
if len(b) % 2: b += b"\x00"
s = sum(struct.unpack(f"!{len(b)//2}H", b))
while s >> 16: s = (s & 0xFFFF) + (s >> 16)
return ~s & 0xFFFF
def udp_checksum(src, dst, sport, dport, payload):
length = 8 + len(payload)
pseudo = socket.inet_aton(src) + socket.inet_aton(dst) + struct.pack("!BBH", 0, 17, length)
hdr = struct.pack("!HHHH", sport, dport, length, 0)
c = csum16(pseudo + hdr + payload)
return c or 0xFFFF # 계산 결과 0 은 0xFFFF 로 보낸다
c = udp_checksum("192.0.2.10", "198.51.100.20", 50000, 53, b"query")
print("체크섬: 0x%04x" % c)
# 받는 쪽 검증: 체크섬 필드까지 넣고 다시 합하면 0 이 나와야 한다
length = 8 + 5
pseudo = socket.inet_aton("192.0.2.10") + socket.inet_aton("198.51.100.20") + struct.pack("!BBH", 0, 17, length)
seg = struct.pack("!HHHH", 50000, 53, length, c) + b"query"
print("검증 결과(0 이면 정상):", csum16(pseudo + seg))
print("IPv4 최대 UDP 데이터:", 65535 - 20 - 8)
rx.close(); tx.close()
받음: b'first' 5
받음: b'second-message' 14
받음: b'3' 1
체크섬: 0x0014
검증 결과(0 이면 정상): 0
IPv4 최대 UDP 데이터: 65507
세 번 보낸 것이 정확히 세 덩어리로 왔다. 로컬호스트라 손실이 없었을 뿐, 실제 망에서는 일부가 사라지거나 순서가 바뀌어도 UDP 는 아무 말을 하지 않는다.
현업에서는
- 쿠버네티스 클러스터의 DNS(CoreDNS)는 기본적으로 UDP 53 을 쓴다. UDP DNS 질의가 conntrack 경쟁 조건에 걸려 늦어지거나 conntrack 표를 채우는 문제가 알려져 있어, 노드마다 DNS 캐시를 두는 NodeLocal DNSCache 구성이 공식 문서에 소개돼 있다.
- 방화벽 규칙을 열 때 “TCP 443 만 열었는데 HTTP/3 가 안 된다”는 일이 생긴다. HTTP/3 는 UDP 443 을 쓴다. 브라우저는 실패하면 TCP 로 돌아가므로 장애로 보이진 않지만 성능 이점을 잃는다.
- VPN(WireGuard)과 오버레이 네트워크(VXLAN)도 UDP 위에 터널을 만든다. 상태가 없는 UDP 가 캡슐화 운반체로 쓰기 좋기 때문이다(158번 글).
- UDP 서비스 장애는 “응답이 없다” 하나로만 보인다. 서버가 죽었는지, 패킷이 버려졌는지, 응답이 다른 경로로 갔는지 구분하려면 양쪽에서 패킷 캡처를 해야 한다.
확인 문제
- UDP 헤더의 네 필드는 무엇인가?
- IPv4 에서 UDP 체크섬 값 0 은 무슨 의미인가? IPv6 에서는 어떤가?
- UDP 로 2000바이트 데이터그램을 이더넷(MTU 1500) 위로 보내면 어떤 일이 생기고, 왜 위험한가?
- TCP 와 달리 UDP 수신 쪽에서 메시지 구분자를 따로 둘 필요가 없는 이유는?
- RFC 8085 가 UDP 응용에 요구하는 책임 두 가지를 들라.
풀이
- 출발지 포트, 목적지 포트, 길이, 체크섬.
- IPv4 에서는 송신자가 체크섬을 계산하지 않았다는 뜻이다. IPv6 에서는 체크섬이 필수라 0 을 쓸 수 없다(터널 같은 특정 예외 제외).
- IP 단편화로 두 조각이 된다. 한 조각이라도 잃으면 데이터그램 전체가 버려지고, 단편을 막는 방화벽도 있어 전달 실패 가능성이 커진다.
- UDP 는 데이터그램 단위로 경계를 보존해, 한 번 보낸 것은 한 번에 받기 때문이다.
- 혼잡 제어(손실 시 속도 줄이기), 경로 MTU 이하로 메시지 크기 제한. 그 밖에 필요한 신뢰성 구현, NAT 매핑 유지 등이 있다.
더 읽을거리 (References)
- RFC 768, User Datagram Protocol: https://www.rfc-editor.org/rfc/rfc768.html
- RFC 8085, UDP Usage Guidelines: https://www.rfc-editor.org/rfc/rfc8085.html
- RFC 8200, Internet Protocol, Version 6 (IPv6) Specification: https://www.rfc-editor.org/rfc/rfc8200.html
- udp(7) 매뉴얼: https://manpages.debian.org/bookworm/manpages/udp.7.en.html