UDP 뜯어보기: 데이터그램 경계부터 MTU까지

UDP 뜯어보기: 데이터그램 경계부터 MTU까지

UDP를 설명하는 문장은 대개 비슷하게 시작합니다. “TCP에서 신뢰성을 뺀 프로토콜”이라는 식이죠. 틀린 말은 아닙니다. 그런데 이 설명만 들고 있으면 막상 UDP로 뭔가를 만들어야 할 때 손이 잘 안 떨어집니다. 무엇이 빠졌는지는 알겠는데, 그래서 무엇을 직접 챙겨야 하는지는 여전히 흐릿하거든요. 🤔

TCP와 UDP의 차이는 예전에 나란히 놓고 비교한 적이 있는데요. 이번에는 비교를 잠시 접어두고 UDP 하나만 들여다보려고 합니다. 헤더 8바이트 안에 무엇이 들어 있는지, 왜 데이터그램에는 경계가 있는지, 한 번에 얼마나 보낼 수 있는지를 코드로 직접 확인해 보겠습니다.

데이터그램에는 경계가 있다

UDP와 TCP를 가르는 가장 실질적인 차이는 신뢰성이 아니라 경계입니다.

TCP 소켓은 바이트 스트림(byte stream)입니다. 보내는 쪽에서 세 번 나눠 보내도 받는 쪽에서는 한 덩어리로 붙어 도착할 수 있고, 반대로 한 번에 보낸 것이 여러 번에 나뉘어 도착할 수도 있습니다. 그래서 TCP로 메시지를 주고받으려면 길이 접두어나 구분자 같은 규칙을 따로 정해야 하죠.

UDP는 다릅니다. 보낸 데이터그램(datagram) 하나가 받는 쪽에서도 정확히 하나로 읽힙니다. Bun으로 확인해 볼까요?

udp-boundary.ts
const server = await Bun.udpSocket({
  socket: {
    // 데이터그램이 도착할 때마다 한 번씩 불린다
    data(socket, buf, port, addr) {
      console.log(`${addr}:${port}${buf.length}바이트 "${buf.toString()}"`);
    },
  },
});

console.log(`서버가 ${server.port}번 포트에서 대기합니다`);

const client = await Bun.udpSocket({});
client.send("하나", server.port, "127.0.0.1");
client.send("둘", server.port, "127.0.0.1");
client.send("셋", server.port, "127.0.0.1");
결과
서버가 65316번 포트에서 대기합니다
127.0.0.1:62102 6바이트 "하나"
127.0.0.1:62102 3바이트 "둘"
127.0.0.1:62102 3바이트 "셋"

data 콜백이 세 번 불렸습니다. 세 번 보냈으니 세 번 도착한 것이죠. TCP였다면 “하나둘셋”이 한 덩어리로 붙어 올 수도 있었을 겁니다.

바이트 수도 눈여겨볼 만합니다. “하나”는 6바이트인데 “둘”과 “셋”은 각각 3바이트인데요. 한글 한 글자가 UTF-8에서 3바이트를 차지하기 때문입니다.

경계가 유지된다는 성질은 생각보다 쓸모가 큽니다. 메시지 하나가 그대로 한 덩어리로 오니 파싱 규칙을 따로 만들 필요가 없거든요. 소켓을 열 때 SOCK_STREAM 대신 SOCK_DGRAM을 고르는 순간부터 이 성질이 따라옵니다.

헤더는 8바이트가 전부다

UDP 헤더에는 네 필드밖에 없습니다. 각각 2바이트씩이라 합쳐서 8바이트죠.

UDP 헤더 구조
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------------------------+---------------------------------+
|           출발 포트              |           도착 포트              |
+---------------------------------+---------------------------------+
|             길이                 |            검사합               |
+---------------------------------+---------------------------------+

1980년 RFC 768이 정의한 뒤로 이 구조는 한 번도 바뀌지 않았습니다. 명세 전체가 세 쪽밖에 안 되는데, 그 짧은 분량이 프로토콜의 성격을 그대로 보여줍니다.

재미있는 건 출발 포트가 선택 사항이라는 점입니다. 응답을 받을 생각이 없다면 0으로 두어도 됩니다. 로그를 한 방향으로만 흘려보내는 syslog 같은 경우가 여기 해당하죠. TCP는 연결을 맺어야 하니 출발 포트가 반드시 있어야 하는데, UDP는 연결이라는 개념 자체가 없어서 생략할 수 있습니다.

길이 필드는 헤더까지 포함한 전체 크기를 담습니다. 데이터가 없는 빈 데이터그램을 보내면 이 값이 8이 되죠. 빈 데이터그램도 엄연히 전달되기 때문에, “0바이트를 받았다”와 “아무것도 안 왔다”를 구분해야 하는 상황이 실제로 생깁니다.

검사합은 꺼둘 수도 있었다

네 번째 필드인 검사합은 데이터가 도중에 깨졌는지 확인하는 값입니다. 그런데 IPv4에서는 이 필드마저 선택 사항이었습니다. 0으로 채워 보내면 받는 쪽이 검증을 건너뛰거든요.

지금 기준으로는 이상하게 들리지만, 1980년에는 검사합을 계산하는 비용이 무시할 수 없었습니다. 링크 계층에서 이미 오류를 걸러내니 위에서 또 검사할 필요가 없다는 판단도 있었고요.

IPv6에서는 이 선택권이 사라졌습니다. IPv6 헤더 자체에 검사합이 없어서, 상위 계층이 검사를 생략하면 오류를 잡을 곳이 아예 없어지기 때문입니다. 그래서 IPv6 위에서 UDP를 쓸 때는 검사합이 필수입니다.

검사합을 계산할 때 UDP는 자기 헤더와 데이터만 보지 않습니다. 출발지와 목적지 IP 주소를 담은 의사 헤더(pseudo header)를 앞에 붙여서 함께 계산하죠. 덕분에 패킷이 엉뚱한 주소로 잘못 배달되는 상황까지 걸러낼 수 있습니다. 계층을 깔끔하게 나눈다는 원칙에서 보면 전송 계층이 IP 주소를 들여다보는 셈이라 어색하지만, 실용을 택한 결정입니다.

65507바이트라는 상한, 그리고 더 낮은 현실

길이 필드가 16비트라서 데이터그램 하나는 최대 65535바이트입니다. 여기서 UDP 헤더 8바이트를 빼면 65527바이트가 남고, IPv4 패킷 전체도 65535바이트를 넘을 수 없으니 IP 헤더 20바이트를 더 빼면 실제 상한은 65507바이트가 됩니다.

그런데 이 숫자를 믿고 코드를 짜면 곧바로 벽에 부딪힙니다. 운영 체제가 먼저 막거든요.

udp-limit.ts
const server = await Bun.udpSocket({ socket: { data() {} } });
const client = await Bun.udpSocket({});

for (const size of [1472, 8192, 9216, 9217, 65507]) {
  try {
    client.send(new Uint8Array(size), server.port, "127.0.0.1");
    console.log(`${size}바이트 → 성공`);
  } catch (e) {
    console.log(`${size}바이트 → ${(e as Error).message}`);
  }
}
결과 (macOS)
1472바이트 성공
8192바이트 성공
9216바이트 성공
9217바이트 EMSGSIZE: message too long, send
65507바이트 EMSGSIZE: message too long, send

9216바이트에서 정확히 끊깁니다. macOS의 기본 설정값이 그렇게 잡혀 있기 때문인데요.

커널 설정 확인
sysctl net.inet.udp.maxdgram
결과
net.inet.udp.maxdgram: 9216

리눅스는 다른 값을 쓰고, 설정을 바꾸면 또 달라집니다. 명세상의 상한과 실제로 보낼 수 있는 크기가 이렇게 다르니, 이식성을 생각한다면 애초에 큰 데이터그램을 피하는 편이 안전합니다.

MTU를 넘으면 IP가 대신 쪼갠다

크기 제한에는 더 낮은 층도 있습니다. 최대 전송 단위(MTU, Maximum Transmission Unit)인데요. 이더넷은 보통 1500바이트입니다. 여기서 IPv4 헤더 20바이트와 UDP 헤더 8바이트를 빼면 1472바이트가 남죠.

이보다 큰 데이터그램을 보내면 어떻게 될까요? UDP는 아무것도 하지 않습니다. 대신 아래 IP 계층이 조각으로 쪼개서 보내고, 받는 쪽 IP 계층이 다시 붙입니다. 단편화(fragmentation)라고 부르는 동작이죠.

문제는 이 조각이 전부 도착해야 원래 데이터그램이 복원된다는 점입니다. 조각 하나만 사라져도 나머지가 멀쩡히 도착했든 말든 전체가 버려집니다. 1500바이트짜리 데이터그램을 두 조각으로 쪼개 보내면, 손실 확률이 대략 두 배로 뛰는 셈입니다.

그래서 UDP를 쓰는 프로토콜은 대부분 단편화가 일어나지 않을 만한 크기를 스스로 정해두었습니다. DNS는 오랫동안 512바이트를 상한으로 삼았고, 확장 규격이 나온 뒤에도 권장값은 1232바이트에 머물러 있습니다. QUIC는 최소 1200바이트의 경로를 확보하지 못하면 아예 연결을 시작하지 않고요. 숫자는 조금씩 다르지만 이유는 같습니다. 한 데이터그램이 한 패킷에 들어가게 만들자는 것이죠.

한 소켓으로 여러 상대와 주고받는다

TCP 서버는 접속한 클라이언트마다 소켓이 하나씩 생깁니다. 백 명이 붙으면 소켓도 백 개죠.

UDP는 소켓 하나로 끝납니다. 연결이라는 상태가 없으니 누가 보내든 같은 소켓으로 들어오고, 보낼 때마다 주소를 지정하면 아무에게나 보낼 수 있습니다. 앞선 예제에서 data 콜백이 portaddr를 함께 넘겨받은 것도 이 때문입니다. 그 정보가 없으면 누가 보냈는지 알 방법이 없거든요.

그래도 상대가 하나로 정해져 있다면 매번 주소를 적는 일이 번거롭습니다. 이럴 때 쓰라고 연결된 UDP라는 방식이 있습니다.

udp-connected.ts
const client = await Bun.udpSocket({
  connect: { hostname: "127.0.0.1", port: 41234 },
});

// 주소를 적지 않아도 된다
client.send("Hello");

여기서 말하는 연결은 TCP의 연결과 다릅니다. 핸드셰이크를 주고받지도 않고, 상대가 살아 있는지 확인하지도 않습니다. 그저 커널에 목적지를 기억시켜 두는 것뿐이죠. 부수 효과로 그 주소에서 온 패킷만 받게 되니 필터 역할도 겸합니다.

브로드캐스트와 멀티캐스트

UDP만 할 수 있는 일도 있습니다. 한 번에 여러 상대에게 보내는 것인데요. TCP는 두 지점을 잇는 프로토콜이라 구조적으로 불가능합니다.

브로드캐스트(broadcast)는 같은 네트워크에 있는 모두에게 뿌리는 방식입니다. IP 주소를 아직 못 받은 기기가 DHCP 서버를 찾을 때 쓰죠. 받을 사람의 주소를 모르니 전부에게 외치는 수밖에 없습니다.

멀티캐스트(multicast)는 관심 있는 기기만 골라 듣는 방식입니다. 같은 네트워크의 프린터나 스피커를 자동으로 찾아내는 mDNS가 224.0.0.251의 5353번 포트를 쓰고, 미디어 기기를 탐색하는 SSDP는 239.255.255.250의 1900번 포트를 씁니다. 노트북에서 에어플레이 기기 목록이 알아서 뜨는 것도 이 위에서 벌어지는 일입니다.

신뢰성은 응용이 직접 얹는다

여기까지 보면 UDP는 빠진 것투성이입니다. 재전송도 없고, 순서 보장도 없고, 혼잡 제어도 없죠. 그런데 실제로 UDP를 쓰는 프로토콜은 이 빈자리를 그냥 두지 않습니다. 필요한 만큼만 직접 채웁니다.

DNS 질의를 예로 들어볼까요. 질의 하나와 응답 하나면 끝나는 대화라 연결을 맺는 비용이 아깝습니다. 그래서 UDP를 쓰고, 대신 응답이 안 오면 다시 보내는 규칙을 응용이 갖습니다.

udp-dns.ts
function buildQuery(hostname: string): Uint8Array {
  const labels = hostname.split(".");
  const size = 12 + labels.reduce((n, l) => n + 1 + l.length, 0) + 1 + 4;
  const buf = new Uint8Array(size);
  const view = new DataView(buf.buffer);

  view.setUint16(0, 0x1234); // 질의 ID
  view.setUint16(2, 0x0100); // 표준 질의, 재귀 요청
  view.setUint16(4, 1); // 질문 1개

  let off = 12;
  for (const label of labels) {
    buf[off++] = label.length;
    for (const ch of label) buf[off++] = ch.charCodeAt(0);
  }
  buf[off++] = 0;
  view.setUint16(off, 1); // A 레코드
  view.setUint16(off + 2, 1); // IN 클래스
  return buf;
}

const query = buildQuery("daleseo.com");
console.log(`질의 크기: ${query.length}바이트`);

const socket = await Bun.udpSocket({
  socket: {
    data(socket, buf, port, addr) {
      const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
      console.log(`${addr}:${port}에서 ${buf.length}바이트 응답`);
      console.log(`답변 레코드 ${view.getUint16(6)}개`);
      clearTimeout(timer);
      socket.close();
    },
  },
});

// UDP는 응답을 보장하지 않으니 직접 시간을 잰다
const timer = setTimeout(() => {
  console.log("2초 안에 응답이 없습니다. 다시 보내야 합니다");
  socket.close();
}, 2000);

socket.send(query, 53, "8.8.8.8");
결과
질의 크기: 29바이트
8.8.8.8:53에서 61바이트 응답
답변 레코드 2개

29바이트를 보내고 61바이트를 받아왔습니다. 연결을 맺고 끊는 절차가 없으니 왕복 한 번으로 끝나죠. setTimeout으로 시간을 재는 부분이 곧 UDP가 안 해주는 일을 응용이 대신하는 자리입니다. 질의 ID를 넣어둔 것도 어느 질의의 응답인지 스스로 짝지어야 하기 때문입니다.

같은 발상을 훨씬 크게 밀어붙인 것이 QUIC입니다. HTTP의 최신 버전인 HTTP/3가 이 위에서 동작하는데요. TCP가 커널 안에 있어 고치기 어렵다는 문제를 피하려고, 재전송과 순서 보장과 혼잡 제어를 전부 UDP 위에 새로 구현했습니다. UDP를 “기능이 부족한 프로토콜”이 아니라 “아무것도 강요하지 않는 바닥”으로 본 셈이죠.

마치며

UDP를 TCP의 축소판으로 보면 빠진 기능만 눈에 들어옵니다. 하지만 데이터그램 경계가 유지되고, 소켓 하나로 여러 상대와 주고받을 수 있고, 여럿에게 한 번에 뿌릴 수 있다는 성질은 TCP에는 아예 없는 것들입니다. 빼기만 한 게 아니라 다른 것을 고른 결과라고 보는 편이 실제에 가깝습니다.

정리하면 이렇습니다. 헤더는 8바이트뿐이고 그중 검사합은 IPv4에서 생략까지 가능했습니다. 한 번에 보낼 수 있는 크기는 명세상 65507바이트지만 운영 체제와 MTU가 훨씬 먼저 막아섭니다. 그리고 신뢰성이 필요하다면 그건 응용이 직접 얹어야 할 몫입니다.

UDP를 이해하고 나면 그 위에 무엇을 쌓을 수 있는지가 궁금해지는데요. 핸드셰이크를 한 왕복으로 줄이고 스트림을 서로 떼어낸 QUIC가 그 답을 가장 크게 보여주는 사례입니다.

예제에 쓴 소켓 API는 Bun UDP 문서에 정리되어 있으니, 직접 만들어볼 때 참고하시면 좋겠습니다. ☕

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord