UDP의 특성
포트와 데이터그램, 체크섬만 남긴 UDP의 얇은 구조를 정리합니다. 8바이트 헤더와 의사 헤더를 포함한 체크섬 계산을 직접 해 보고, 메시지 경계를 지키는 소켓의 동작과 연결하지 않는 소켓 API, 단편화 없이 보낼 수 있는 크기를 계산하는 방법을 함께 봅니다.
TCP는 연결을 맺고, 빠진 데이터를 다시 보내고, 순서를 맞추고, 보낼 양을 조절합니다. UDP(user datagram protocol)는 이 가운데 아무것도 하지 않습니다. RFC 768이 정의하는 UDP는 포트 번호로 프로그램을 구별하고, 데이터를 데이터그램 하나에 담아 보내고, 체크섬으로 손상을 검출하는 것이 전부입니다. 그래서 UDP를 "빠른 TCP"로 이해하기보다, 전송 계층이 맡는 일을 거의 비워 두고 나머지를 위의 프로토콜에 맡긴 것으로 이해하는 편이 정확합니다.
UDP가 하지 않는 일
UDP에는 연결이 없습니다(connectionless). 보내는 쪽은 핸드셰이크 없이 목적지 주소와 포트를 적어 데이터그램을 바로 보냅니다. 첫 데이터를 보내기까지 기다릴 왕복이 없는 대신, 상대가 받을 준비가 되었는지, 그 주소에 누가 있기는 한지도 확인하지 않습니다.
UDP는 전달을 보장하지 않습니다(unreliable). 데이터그램이 사라져도 다시 보내지 않고, 순서가 바뀌어 도착해도 맞추지 않으며, 같은 데이터그램이 두 번 도착해도 하나를 버리지 않습니다. 받는 쪽이 감당할 수 있는지를 보는 흐름 제어도, 네트워크의 혼잡을 보는 혼잡 제어도 없습니다.
이것은 결함이라기보다 선택입니다. DNS처럼 질문 하나에 답 하나면 끝나는 프로토콜은 연결을 맺는 왕복이 아깝고, 음성 통화처럼 늦게 온 데이터가 쓸모없는 서비스는 재전송을 기다리느니 버리는 편이 낫습니다. 다만 이런 일을 하지 않는다고 해서 신경 쓰지 않아도 되는 것은 아닙니다. RFC 8085는 UDP를 쓰는 애플리케이션이 필요한 만큼의 재전송과 타임아웃을 스스로 갖추고, 특히 많은 데이터를 보낸다면 혼잡 제어도 직접 해야 한다고 권합니다. TCP가 해 주던 일 가운데 무엇이 필요한지를 애플리케이션이 고르는 것입니다.
8바이트 헤더
UDP 헤더는 언제나 8바이트이고, 필드는 네 개뿐입니다.
| 필드 | 크기 | 설명 |
|---|---|---|
| Source Port | 16비트 | 보낸 쪽의 포트. 답장이 필요 없으면 0으로 둘 수 있다 |
| Destination Port | 16비트 | 받을 프로그램의 포트 |
| Length | 16비트 | 헤더 8바이트를 포함한 데이터그램 전체의 길이 |
| Checksum | 16비트 | 의사 헤더와 헤더, 데이터 전체의 오류 검출 값 |
TCP 헤더에 있던 순서 번호, ACK 번호, 윈도우, 플래그가 하나도 없습니다. 그 일들을 하지 않으니 필드도 필요 없는 것입니다.
체크섬은 주소까지 함께 검사한다
UDP의 체크섬은 UDP 헤더와 데이터만 보지 않습니다. IP 헤더에서 출발지 주소, 목적지 주소, 프로토콜 번호(UDP는 17)와 UDP 길이를 가져와 의사 헤더(pseudo header)를 만들고, 이것까지 함께 계산합니다. 의사 헤더는 계산할 때만 쓰이고 실제로 전송되지는 않습니다. 이렇게 하면 데이터가 멀쩡해도 엉뚱한 주소로 배달된 데이터그램을 받는 쪽에서 걸러 낼 수 있습니다.
계산은 IP 헤더 체크섬과 같은 방식입니다. 전체를 16비트 단위로 나누어 더하되 16비트를 넘친 자리는 다시 아래로 더하는 1의 보수 합을 구하고, 그 값을 뒤집은 것을 체크섬으로 적습니다. 받는 쪽이 체크섬까지 넣어 같은 합을 구하면 손상이 없을 때 모든 비트가 1인 0xffff가 나옵니다. 다음 코드는 192.0.2.1의 50000번 포트에서 198.51.100.2의 53번 포트로 hi 두 바이트를 보내는 데이터그램의 체크섬을 계산하고, 받는 쪽에서 검증합니다.
import struct
from ipaddress import IPv4Address
def ones_complement_sum(data):
if len(data) % 2:
data += b"\x00" # 길이가 홀수면 0을 한 바이트 붙여 계산한다
total = sum(struct.unpack(f"!{len(data) // 2}H", data))
while total > 0xFFFF:
total = (total & 0xFFFF) + (total >> 16) # 넘친 자리를 아래로 더한다
return total
def pseudo_header(src, dst, length):
return IPv4Address(src).packed + IPv4Address(dst).packed + struct.pack("!BBH", 0, 17, length)
payload = b"hi"
length = 8 + len(payload)
header = struct.pack("!HHHH", 50000, 53, length, 0) # 체크섬 칸을 0으로 두고 계산한다
checksum = 0xFFFF - ones_complement_sum(pseudo_header("192.0.2.1", "198.51.100.2", length) + header + payload)
print(f"체크섬: {checksum:#06x}")
datagram = struct.pack("!HHHH", 50000, 53, length, checksum) + payload
for dst in ["198.51.100.2", "198.51.100.3"]:
result = ones_complement_sum(pseudo_header("192.0.2.1", dst, length) + datagram)
print(f"목적지 {dst} 기준 검증: {result:#06x}", "정상" if result == 0xFFFF else "버림")체크섬: 0xe7b3
목적지 198.51.100.2 기준 검증: 0xffff 정상
목적지 198.51.100.3 기준 검증: 0x0001 버림열한 개의 16비트 값을 더하면 0x31849가 되고, 넘친 3을 아래로 더하면 0x184c, 이를 뒤집으면 체크섬 0xe7b3이 됩니다. 원래 목적지에서 검증하면 0x184c + 0xe7b3 = 0xffff로 정상이지만, 데이터그램이 198.51.100.3에 잘못 배달되었다면 의사 헤더가 달라져 합이 맞지 않고, 받는 쪽은 이 데이터그램을 버립니다. 같은 이유로 4장 3편의 NAT처럼 주소나 포트를 바꾸는 장비는 UDP 체크섬도 함께 고쳐 써야 합니다.
체크섬이 맞지 않는 데이터그램은 소리 없이 버려질 뿐, UDP가 재전송을 요청하지는 않습니다. IPv4에서는 체크섬 칸에 0을 적으면 "계산하지 않았다"는 뜻이 되므로, 계산 결과가 0이면 같은 값인 0xffff로 바꾸어 적습니다. IPv6에는 IP 헤더 체크섬이 없어서 UDP 체크섬이 필수이며(RFC 8200 §8.1), 일부 터널 용도에만 예외가 있습니다. 1의 보수 합은 16비트 값 두 개가 서로 자리를 바꾸는 오류처럼 잡지 못하는 손상도 있으므로, 중요한 데이터는 위의 계층에서 더 강한 검사를 함께 씁니다.
데이터그램 하나가 메시지 하나다
6장 1편에서 TCP는 send()를 몇 번 불렀는지 기억하지 않는다고 했습니다. UDP는 반대입니다. sendto()를 한 번 부르면 데이터그램 하나가 나가고, 받는 쪽의 recvfrom()은 한 번에 데이터그램 하나를 통째로 돌려줍니다. 다음 코드는 한 프로세스 안에서 루프백으로 데이터그램 세 개를 보내고 받습니다.
import socket
receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
receiver.bind(("127.0.0.1", 0))
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sender.bind(("127.0.0.1", 0))
for msg in [b"Hello", b"World", b"UDP"]:
sender.sendto(msg, receiver.getsockname()) # 보낼 때마다 목적지를 적는다
receiver.settimeout(1.0) # 오지 않는 데이터그램을 끝없이 기다리지 않는다
for _ in range(3):
data, addr = receiver.recvfrom(2048)
print(data, "보낸 쪽 주소와 같다:", addr == sender.getsockname())
sender.close()
receiver.close()b'Hello' 보낸 쪽 주소와 같다: True
b'World' 보낸 쪽 주소와 같다: True
b'UDP' 보낸 쪽 주소와 같다: True데이터그램 셋이 이어 붙지 않고 보낸 단위 그대로 도착했습니다. 다만 이것은 손실이 거의 없는 루프백의 결과입니다. 실제 네트워크에서는 Hello만 사라지거나, UDP가 World보다 먼저 오거나, 같은 데이터그램이 두 번 올 수 있습니다. 메시지 경계가 지켜진다는 것과 순서, 중복, 전달이 보장된다는 것은 서로 다른 이야기입니다. settimeout()을 둔 것도 그 때문으로, 오지 않을 수 있는 데이터그램을 기다리는 코드는 언제까지 기다릴지를 반드시 정해야 합니다.
받는 버퍼가 데이터그램보다 작으면 남는 부분은 버려지고 다음 호출로 이어지지 않습니다. recv(2)에 따르면 Linux는 잘렸다는 사실을 recvmsg()가 돌려주는 MSG_TRUNC 플래그로 알려 주고, Windows의 recvfrom은 버퍼를 데이터그램의 앞부분으로 채운 뒤 WSAEMSGSIZE 오류를 돌려줍니다. 그래서 UDP를 쓰는 프로토콜은 메시지의 최대 크기를 정해 두고, 그보다 큰 버퍼로 받습니다.
더 깊이: 데이터그램을 하나씩 보내는 비용
데이터그램 하나가 메시지 하나라는 성질은 성능의 비용이 되기도 합니다. TCP는 애플리케이션이 큰 데이터를 한 번에 넘기면 커널과 네트워크 카드가 세그먼트로 잘라 보내지만, UDP는 sendto() 한 번이 데이터그램 하나입니다. 그래서 1472바이트짜리 데이터그램으로 많은 데이터를 보내려면 데이터그램 수만큼 시스템 호출을 되풀이해야 하고, 보내는 양이 많은 서버일수록 이 비용이 커집니다.
Linux는 이 비용을 줄이는 방법을 몇 가지 둡니다. sendmmsg()(Linux 3.0부터)와 recvmmsg()는 시스템 호출 한 번으로 데이터그램 여러 개를 보내거나 받습니다. udp(7)에 따르면 Linux 4.18부터는 UDP_SEGMENT 옵션으로 큰 버퍼 하나와 데이터그램 하나의 크기를 함께 넘기면, 커널이 그 크기로 잘라 한 번에 최대 64개의 데이터그램을 만드는 UDP 분할 오프로드(segmentation offload)를 쓸 수 있습니다. Linux 5.0부터는 받는 쪽에도 UDP_GRO가 있어서, 여러 데이터그램을 버퍼 하나로 받고 데이터그램 하나의 크기를 보조 데이터로 함께 받습니다.
이렇게 모아 보내도 선 위로 나가는 것은 여전히 따로따로의 데이터그램이므로, 받는 쪽이 받는 메시지의 단위는 바뀌지 않습니다. 다만 UDP_GRO를 켠 소켓은 버퍼 하나에 여러 데이터그램이 이어 붙어 오므로, 함께 받은 크기 정보로 다시 나누어 읽어야 합니다.
연결 없는 소켓 API
UDP 서버에는 listen()도 accept()도 없습니다. 소켓 하나를 포트에 바인드해 두고, 어느 클라이언트에게서 온 데이터그램이든 같은 소켓으로 받습니다. recvfrom()이 보낸 쪽의 주소를 함께 돌려주므로, 답장은 그 주소를 적어 sendto()로 보냅니다.
| 하는 일 | TCP | UDP |
|---|---|---|
| 소켓 만들기 | socket(AF_INET, SOCK_STREAM) | socket(AF_INET, SOCK_DGRAM) |
| 연결 기다리기 | listen(), accept() | 없음 |
| 보내기 | 연결된 소켓에 send() | 매번 주소를 적어 sendto() |
| 받기 | recv(), 바이트 스트림의 일부 | recvfrom(), 데이터그램 하나와 보낸 쪽 주소 |
| 상대가 사라짐 | FIN이나 RST로 알 수 있음 | 알려 주는 신호가 없음 |
UDP 소켓에도 connect()를 부를 수는 있지만 핸드셰이크는 일어나지 않습니다. connect(2)에 따르면 데이터그램 소켓의 connect()는 기본 목적지를 정하고, 그 주소에서 온 데이터그램만 받게 합니다. 목적지를 매번 적지 않고 send()와 recv()를 쓸 수 있고, 다른 주소에서 온 데이터그램이 섞이지 않으며, 상대 포트가 닫혀 돌아온 ICMP Port Unreachable을 운영체제가 다음 호출의 오류로 알려 주기도 합니다. 한 서버와만 주고받는 DNS 클라이언트 같은 프로그램이 이 방식을 많이 씁니다.
얼마나 크게 보낼 수 있나
UDP 데이터그램의 크기에는 서로 다른 한도가 여러 겹 있습니다. Length 필드가 정하는 한도, IP 패킷 전체 길이가 정하는 한도, 경로의 MTU가 정하는 한도가 각각 다릅니다.
UDP · 데이터 크기
필드 한도와 경로 MTU는 서로 다른 예산이다
UDP의 Length 한도에서 가능한 데이터 크기와 IP·경로에서 단편화 없이 보낼 크기는 다릅니다. 아래는 기본 헤더를 가정한 계산이며 IPv6 점보그램은 제외합니다.
| 계산 기준 | 최대 데이터 | 전제 |
|---|---|---|
| Length 필드 | 65,527 B | 16비트 UDP 길이 65,535B − UDP 헤더 8B |
| IPv4 전체 길이 | 65,507 B | IP 전체 길이 65,535B − 기본 IPv4 20B − UDP 8B |
| IPv4 · MTU 1500 | 1,472 B | IPv4 기본 헤더, IP 옵션·터널 헤더 없음 |
| IPv6 · MTU 1280 | 1,232 B | IPv6 기본 헤더 40B, 확장 헤더 없음 |
| 실제 경로 | 경로에 맞춰 조정 | VPN·터널·확장 헤더 등의 추가 공간을 제외 |
- Length 필드
- 최대 데이터: 65,527 B전제: 16비트 UDP 길이 65,535B − UDP 헤더 8B
- IPv4 전체 길이
- 최대 데이터: 65,507 B전제: IP 전체 길이 65,535B − 기본 IPv4 20B − UDP 8B
- IPv4 · MTU 1500
- 최대 데이터: 1,472 B전제: IPv4 기본 헤더, IP 옵션·터널 헤더 없음
- IPv6 · MTU 1280
- 최대 데이터: 1,232 B전제: IPv6 기본 헤더 40B, 확장 헤더 없음
- 실제 경로
- 최대 데이터: 경로에 맞춰 조정전제: VPN·터널·확장 헤더 등의 추가 공간을 제외
위 값은 조건별 계산값입니다. 실제 경로 MTU나 이 크기의 전달 성공을 측정한 결과가 아닙니다.
Length 필드로는 65535 − 8 = 65527바이트까지 적을 수 있지만, IPv4 패킷 전체가 65535바이트를 넘을 수 없으므로 IPv4에서는 65535 − 20 − 8 = 65507바이트가 실제 상한입니다. 하지만 이렇게 큰 데이터그램은 경로의 MTU를 넘으므로 IP 단편화를 거쳐야 하고, 조각 가운데 하나만 사라져도 데이터그램 전체를 복원할 수 없습니다. 조각 하나의 손실 확률이 1%라도 45조각으로 나뉜 데이터그램은 그보다 훨씬 자주 사라집니다. 게다가 IPv6에서는 중간 라우터가 패킷을 나누지 않으므로(RFC 8200 §4.5), 보내는 쪽이 경로에 맞추지 않으면 큰 데이터그램은 그대로 버려집니다.
그래서 실무에서는 단편화가 일어나지 않을 크기에 맞춥니다. Ethernet의 MTU 1500에서 IPv4라면 1500 − 20 − 8 = 1472바이트, 모든 IPv6 링크가 보장해야 하는 최소 MTU 1280에서는 1280 − 40 − 8 = 1232바이트입니다. DNS가 큰 응답을 UDP로 보낼 때 1232바이트를 기본 상한으로 권하는 것도 이 계산에서 나왔습니다. VPN이나 터널을 지나는 경로라면 그 헤더만큼 더 빼야 합니다.
정리
- UDP는 포트로 프로그램을 구별하고, 데이터그램을 보내고, 체크섬으로 손상을 검출하는 일만 하며, 연결, 재전송, 순서 정렬, 흐름 제어, 혼잡 제어는 위의 계층에 맡깁니다.
- 헤더는 출발지 포트, 목적지 포트, 길이, 체크섬의 8바이트로 고정되어 있습니다.
- 체크섬은 IP 주소와 프로토콜, 길이로 만든 의사 헤더까지 포함한 1의 보수 합이어서 잘못 배달된 데이터그램도 걸러 내며, 맞지 않으면 소리 없이 버려집니다. IPv6에서는 필수입니다.
sendto()한 번이 데이터그램 하나가 되어 메시지 경계가 지켜지지만, 순서와 중복, 전달은 보장되지 않으므로 받는 코드는 타임아웃을 정해야 합니다.- UDP 소켓에는
listen()과accept()가 없고,connect()는 기본 목적지를 정할 뿐 핸드셰이크를 하지 않습니다. - 큰 데이터그램은 단편화되어 손실에 약하므로, IPv4와 MTU 1500에서는 1472바이트, IPv6 최소 MTU에서는 1232바이트처럼 단편화되지 않는 크기에 맞춥니다.
연습 문제
1. 하나를 받았을 때 알 수 있는 것
데이터그램 두 개를 보냈고, 받는 쪽의 첫 recvfrom()이 그 가운데 하나를 돌려주었습니다. 다음 호출이 나머지 하나를 반드시 돌려준다고 말할 수 있나요? 받는 버퍼를 데이터그램보다 작게 잡았다면, 남는 바이트를 다음 호출에서 이어 읽을 수 있나요?
해설 보기
말할 수 없습니다. 나머지 하나는 사라졌을 수도 있고, 늦게 오거나 두 번 올 수도 있습니다. 그래서 다음 recvfrom()에는 타임아웃을 두고, 오지 않았을 때 다시 요청할지 포기할지를 애플리케이션이 정해야 합니다. 버퍼가 작아서 잘린 데이터그램의 남는 바이트는 버려지므로 이어 읽을 수 없습니다. TCP에서처럼 버퍼에 조각을 모으는 방식이 통하지 않으므로, 프로토콜이 정한 최대 메시지 크기보다 큰 버퍼로 받아야 합니다.
2. 애플리케이션 헤더까지 넣은 크기
경로 MTU가 1280바이트인 IPv6 경로이고 확장 헤더나 터널은 없습니다. 애플리케이션이 UDP 데이터의 맨 앞에 자기 헤더 12바이트를 붙인다면, 단편화 없이 보내려면 본문을 최대 몇 바이트까지 넣을 수 있나요? 그때 UDP의 Length 필드 값은 얼마인가요?
해설 보기
UDP 데이터에 쓸 수 있는 크기는 1280 − 40 − 8 = 1232바이트이고, 그 안에 애플리케이션 헤더 12바이트가 들어가므로 본문은 1232 − 12 = 1220바이트까지입니다. Length 필드는 UDP 헤더를 포함하므로 8 + 12 + 1220 = 1240이고, IPv6 패킷 전체는 40 + 1240 = 1280바이트로 MTU에 꼭 맞습니다. 체크섬 계산에 쓰는 의사 헤더는 실제로 전송되지 않으므로 크기에서 빼지 않습니다.
3. 체크섬을 고치지 않는 NAT
어느 NAT 장비가 버그 때문에 UDP 데이터그램의 출발지 포트만 바꾸고 체크섬은 그대로 둡니다. 받는 쪽에서는 어떤 일이 생기나요? 보낸 쪽이 IPv4에서 체크섬 칸에 0을 적어 보냈다면 어떻게 달라지나요?
해설 보기
출발지 포트는 체크섬 계산에 포함되므로, 포트가 바뀐 데이터그램을 받는 쪽이 검증하면 합이 0xffff가 되지 않습니다. 받는 쪽은 이 데이터그램을 소리 없이 버리고, UDP는 아무것도 알려 주지 않으므로 보낸 쪽에서는 응답이 오지 않는 장애로만 보입니다. 체크섬 칸이 0이었다면 IPv4에서는 체크섬을 계산하지 않았다는 뜻이므로 받는 쪽도 검증하지 않고, 바뀐 포트를 가진 데이터그램이 그대로 받아들여집니다. 이 경우는 우연히 동작하는 것일 뿐이며, 체크섬이 없으므로 진짜 손상도 걸러지지 않습니다.
다음 글 TCP vs UDP 판단 기준에서는 서비스의 요구에 따라 TCP와 UDP 가운데 무엇을 고를지, UDP 위에 필요한 기능을 어떻게 더할지를 살펴봅니다.