---
title: "UDP 는 아무것도 안 해 준다. 그래서 골라 살 수 있다"
description: "TCP 의 보장은 통째로 사거나 통째로 안 사거나였습니다. UDP 는 아무것도 안 해 주는 대신, 필요한 보장만 골라서 직접 얹을 수 있게 합니다. RUDP 가 무엇을 얹고 무엇을 빠뜨리기 쉬운지 봅니다."
author: "유니얼"
pubDate: 2026-09-18
category: "programming/network"
tags: []
canonical: https://blog.unialgames.com/posts/net-udp-rudp/
---
앞 편의 마지막 문제는 이것이었습니다.
**앞의 하나가 늦으면 뒤에 도착한 것이 전부 묶입니다.**

그런데 묶인 것들을 들여다보면, 대부분은 기다릴 필요가 없는 것들이었습니다.
캐릭터 위치는 최신 하나만 있으면 되니까요.

TCP 는 그걸 구별해 주지 않습니다. **구별할 방법이 없습니다.**
TCP 가 보는 것은 바이트 줄 하나뿐이고, 그 줄의 어디가 위치이고 어디가 아이템인지는 모릅니다.
아는 것은 우리뿐입니다.

그래서 방향을 바꿉니다. **아무것도 안 해 주는 것으로 시작해서, 아는 만큼만 직접 얹습니다.**

## 하는 일이 거의 없다

UDP 헤더는 여덟 바이트입니다. 들어 있는 것이 이게 전부입니다.

| 칸 | 크기 | 하는 일 |
| --- | --- | --- |
| 출발지 포트 | 2바이트 | 어느 프로그램이 보냈는가 |
| 도착지 포트 | 2바이트 | 어느 프로그램에게 가는가 |
| 길이 | 2바이트 | 이 덩어리가 몇 바이트인가 |
| 체크섬 | 2바이트 | 오는 길에 값이 깨졌는가 |

TCP 헤더는 최소 20바이트입니다. 순서 번호, 답한 번호, 창 크기, 각종 깃발이 들어갑니다.
**그 12바이트 차이가 곧 TCP 가 해 주는 일의 목록**입니다. UDP 에는 그게 없습니다.

없다는 것은 이런 뜻입니다.

- 순서를 안 맞춰 줍니다. 온 순서 그대로 올라옵니다
- 빠진 것을 안 알려 줍니다. 안 온 것과 원래 없던 것을 구별하지 못합니다
- 다시 안 보냅니다. 사라지면 사라진 것입니다
- 얼마나 보낼지 안 정해 줍니다. 보내는 대로 나갑니다

대신 **묶어 두지 않습니다.** 도착한 것은 그 자리에서 바로 올라옵니다.

## 연결이 없다는 말의 뜻

TCP 에서 "연결" 은 양쪽이 상태를 들고 있다는 뜻이었습니다.
UDP 에는 그 상태가 없습니다. 열고 닫는 절차도 없습니다. 그냥 주소를 적어서 보냅니다.

가벼운 대신 따라오는 것이 있습니다.

**중간 장비가 연결을 볼 수 없습니다.**
집이나 회사의 공유기는 안쪽 여러 기기를 바깥의 주소 하나로 바꿔 내보냅니다. NAT 라고 합니다.
답이 돌아오면 그걸 안쪽 누구에게 줄지 판단해야 하는데, 그 판단의 근거가 "아까 이 기기가 저기로 보냈다" 는 기록입니다.

TCP 는 언제 연결이 시작되고 끝나는지가 헤더에 드러나서, 장비가 그 기록을 언제 만들고 지울지 압니다.
UDP 에는 그게 없습니다. 그래서 장비는 **마지막으로 보낸 지 얼마나 됐는지**로 짐작합니다.
한동안 조용하면 기록을 지웁니다. 지워진 뒤에는 바깥에서 오는 답이 갈 곳을 잃습니다.

그래서 UDP 를 쓰는 쪽은 할 말이 없어도 주기적으로 무언가를 보냅니다.
아래에서 이야기할 하트비트가 이 일도 겸합니다.

## 신뢰성은 전부 아니면 전무가 아니다

여기가 이 편의 중심입니다.

게임이 보내는 값들을 늘어놓고 **각각 무엇이 필요한지** 물어봅니다.

| 보내는 것 | 빠지면 | 순서가 바뀌면 | 그래서 필요한 것 |
| --- | --- | --- | --- |
| 캐릭터 위치 | 다음 것이 덮어씀 | 옛것을 버리면 그만 | **아무것도.** 최신만 쓰면 됨 |
| 아이템 획득 | 아이템이 사라짐 | 상관없음 | **반드시 도착.** 순서는 무관 |
| 채팅 | 말이 빠짐 | 대화가 꼬임 | **도착 + 순서** |
| 발사 이펙트 | 티가 덜 남 | 상관없음 | 아무것도 |

TCP 는 이 표의 모든 줄에 **마지막 칸의 가장 강한 보장**을 매깁니다.
위치에도 "반드시 도착하고 순서까지 맞춰서" 를 적용합니다.
그게 앞 편에서 본 줄 서기의 정체입니다.

<x-compare>
<script type="application/json">
{
  "label": "무엇을 사고 무엇을 안 살 것인가",
  "tabs": [
    { "label": "신뢰 + 순서", "text": "TCP 와 같습니다. 빠지면 다시 보내고, 앞의 것이 올 때까지 뒤엣것을 잡아 둡니다. 채팅이나 순서가 의미를 가지는 명령에 씁니다. 여기에 담은 것은 앞 편의 줄 서기를 그대로 물려받습니다.", "stats": [["다시 보내나", "보냄"], ["순서를 맞추나", "맞춤"], ["막힐 수 있나", "막힘"]], "tone": "bad" },
    { "label": "신뢰 + 무순서", "text": "빠지면 다시 보내지만, 앞의 것을 기다리지는 않습니다. 도착한 것은 바로 올려 줍니다. 아이템 획득처럼 반드시 도착해야 하지만 순서는 상관없는 것에 씁니다.", "stats": [["다시 보내나", "보냄"], ["순서를 맞추나", "안 맞춤"], ["막힐 수 있나", "안 막힘"]], "tone": "good" },
    { "label": "비신뢰 + 최신만", "text": "빠져도 그냥 둡니다. 늦게 온 옛것은 버립니다. 캐릭터 위치처럼 다음 값이 앞의 값을 덮어쓰는 것에 씁니다. 다시 보내는 것보다 새로 보내는 것이 빠르고 정확합니다.", "stats": [["다시 보내나", "안 보냄"], ["순서를 맞추나", "옛것은 버림"], ["막힐 수 있나", "안 막힘"]], "tone": "good" }
  ]
}
</script>
</x-compare>

**이 세 가지를 한 연결 안에서 섞어 쓰는 것**이 UDP 위에 뭔가를 얹는 이유의 전부입니다.
그 얹은 것을 흔히 RUDP 라고 부릅니다. 표준 이름이 아니라, 같은 일을 하는 구현들을 묶어 부르는 말입니다.

## 그래서 무엇을 얹는가

### 번호를 붙인다

UDP 덩어리 하나하나에 번호를 붙입니다. TCP 와 달리 바이트가 아니라 **덩어리 단위**로 셉니다.

번호가 생기면 세 가지가 가능해집니다.
빠진 것을 알아채고, 순서가 바뀐 것을 알아채고, 두 번 온 것을 버립니다.
**앞 편에서 본 것들이 전부 여기서부터 시작합니다.**

### 받았다는 답을 몰아서 보낸다

TCP 는 "여기까지 빠짐없이" 하나만 말했습니다. 그래서 중간이 비면 답이 멈췄습니다.

RUDP 는 대개 다르게 합니다.
**가장 큰 번호 하나와, 그 앞 몇 개가 왔는지를 비트로 같이 보냅니다.**

<x-seq>
<script type="application/json">
{
  "label": "빠진 것을 어떻게 알리고, 무엇만 다시 보내는가",
  "sides": [
    { "name": "클라이언트", "role": "보내는 쪽" },
    { "name": "서버", "role": "받는 쪽" }
  ],
  "dwell": 2.6,
  "steps": [
    {
      "from": 0, "to": 1, "msg": "10  위치",
      "state": ["10 보냄", "10 받음"],
      "text": "덩어리마다 번호를 붙여 보냅니다. 10번은 캐릭터 위치입니다."
    },
    {
      "from": 0, "to": 1, "msg": "11  아이템 획득", "lost": true,
      "state": ["11 보냄", "10 까지"],
      "text": "11번은 가는 길에 사라졌습니다. 보낸 쪽은 아직 그 사실을 모릅니다. 받는 쪽도 아직은 \"안 온 것\" 과 \"원래 없던 것\" 을 구별하지 못합니다."
    },
    {
      "from": 0, "to": 1, "msg": "12  위치",
      "state": ["12 보냄", "10, 12 받음"],
      "text": "12번이 도착합니다. 번호가 11을 건너뛰었으니 받는 쪽은 여기서 처음으로 \"11이 빠졌다\" 를 압니다. 그리고 12번은 잡아 두지 않고 바로 올려 줍니다. 여기가 TCP 와 갈리는 자리입니다."
    },
    {
      "from": 1, "to": 0, "msg": "ack=12  bits=101",
      "state": ["11이 빠진 것을 앎", "10, 12 받음"],
      "text": "받은 것 중 가장 큰 번호가 12, 그 앞 세 개가 왔는지를 비트로 붙입니다. 101 이면 12와 10은 왔고 11이 빠졌다는 뜻입니다. 답 하나로 여러 개를 한꺼번에 알리니, 답이 하나 사라져도 다음 답이 그 자리를 메웁니다."
    },
    {
      "from": 0, "to": 1, "msg": "13  위치 (11은 안 보냄)",
      "state": ["빠진 11은 버림", "13 받음"],
      "text": "빠진 11이 위치였다면 다시 보내지 않습니다. 그 위치는 이미 지난 값이고, 지금 보내는 13이 그보다 최신입니다. 다시 보내는 것이 오히려 손해입니다."
    },
    {
      "from": 0, "to": 1, "msg": "14  아이템 획득 (다시)",
      "state": ["아이템은 다시 보냄", "14 받음"],
      "text": "빠진 것이 아이템 획득이었다면 반드시 다시 보냅니다. 같은 내용을 새 번호에 실어 보내면 됩니다. 무엇을 다시 보내고 무엇을 버릴지, 그 판단을 우리가 합니다."
    }
  ],
  "note": "여기서는 앞 세 개만 비트로 그렸습니다. 실제 구현은 32개나 64개를 한 번에 싣습니다."
}
</script>
</x-seq>

이 그림의 핵심은 **다시 보낼지 말지를 우리가 정한다**는 것입니다.
TCP 에서는 그 판단이 없었습니다. 빠진 것은 무조건 다시 보내고, 그동안 뒤엣것은 기다렸습니다.

### 한 연결 안을 여러 칸으로 나눈다

보낼 것마다 필요한 보장이 다르니, 연결 하나를 여러 칸으로 나눕니다. 흔히 채널이라고 부릅니다.

- 0번 칸은 비신뢰, 최신만. 위치와 회전이 여기로
- 1번 칸은 신뢰, 무순서. 아이템 획득과 피해 판정이 여기로
- 2번 칸은 신뢰, 순서 보장. 채팅과 방 입장이 여기로

**칸이 나뉘어 있으면 2번 칸이 막혀도 0번 칸은 계속 흐릅니다.**
채팅 한 줄이 늦는다고 캐릭터가 멈추지 않습니다.

여기서 조심할 것이 하나 있습니다.
**신뢰 + 순서 칸에 이것저것 다 넣으면 앞 편의 줄 서기가 그대로 돌아옵니다.**
UDP 위에 만들었다고 면제되는 것이 아닙니다. 그 보장을 산 칸 안에서는 똑같이 막힙니다.

### 조용해도 계속 부른다

연결이라는 개념이 없으니 **직접 만들어야 합니다.**

일정 간격으로 작은 덩어리를 보냅니다. 하트비트라고 합니다.
그리고 상대에게서 한동안 아무것도 안 오면 끊어진 것으로 칩니다.

이걸로 세 가지가 한꺼번에 해결됩니다.

- 상대가 살아 있는지 압니다
- 앞에서 이야기한 NAT 의 기록이 안 지워집니다
- 오가는 시간을 계속 잴 수 있습니다. 이 값이 5편과 6편에서 쓰입니다

간격을 어떻게 잡을지가 판단입니다.
촘촘하면 끊긴 것을 빨리 알지만 그만큼 계속 나갑니다.
느슨하면 조용하지만, 실제로 끊긴 뒤에도 한참 모릅니다.

### 한 덩어리 안에 들어가게 자른다

선 위를 지나는 덩어리에는 크기 한계가 있습니다. MTU 라고 합니다. 보통 1500바이트쯤입니다.

그보다 큰 것을 보내면 중간에서 여러 조각으로 잘립니다.
문제는 **조각 중 하나만 잃어도 전부 못 쓴다**는 것입니다.
받는 쪽은 조각을 다 모아야 원래 덩어리를 복원할 수 있는데, 조각 단위로 다시 달라고 할 방법이 없습니다.

<x-calc>
<script type="application/json">
{
  "label": "조각내면 잃을 확률이 어떻게 되나",
  "inputs": [
    { "id": "n", "label": "조각 수", "options": [1, 2, 3, 4], "default": 3, "suffix": "조각" },
    { "id": "p", "label": "조각 하나가 사라질 확률", "options": [1, 3, 5, 10], "default": 3, "suffix": "%" }
  ],
  "bars": [
    { "label": "조각난 채로 보낼 때 못 쓰게 될 확률", "expr": "100 - 100 * Math.pow(1 - p / 100, n)", "unit": "%" },
    { "label": "MTU 안에 맞춰 보낼 때", "expr": "p", "unit": "%", "accent": true }
  ],
  "verdict": { "expr": "bars[0] > bars[1] * 1.5", "yes": "조각내면 손실이 눈에 띄게 늘어납니다", "no": "차이가 크지 않습니다" },
  "decimals": 1,
  "note": "조각 하나만 잃어도 원래 덩어리를 통째로 못 씁니다. 조각만 따로 다시 달라고 할 방법이 없습니다."
}
</script>
</x-calc>

그래서 보낼 것을 **MTU 안에 들어가게 잘라서 보냅니다.**
반대로 작은 것들은 모아서 한 덩어리로 보냅니다. 앞 편의 Nagle 과 목적이 같지만,
**언제 모으고 언제 그냥 보낼지를 우리가 정한다**는 점이 다릅니다.

## 빠뜨리기 쉬운 것

여기까지가 흔히 RUDP 에 얹는 것들입니다. 하나가 더 남았습니다.

**혼잡 제어입니다.**

앞 편에서 TCP 가 손실을 보고 보내는 양을 줄인다고 했습니다.
그 일은 공짜로 되고 있었습니다. 우리가 아무것도 안 해도 알아서 돌았습니다.

UDP 위에 직접 만들면 그게 없습니다.
**보내는 대로 그냥 나갑니다.**

길이 이미 막혀 있어도 계속 보냅니다. 큐가 더 차고, 지연이 더 늘고, 손실이 더 납니다.
손실이 나니 신뢰 칸에서 다시 보냅니다. 그게 큐를 또 채웁니다.

1편에서 큐잉이 지연의 유일한 조절 가능한 조각이라고 했는데,
여기서는 그걸 **잘못된 방향으로** 돌리고 있는 셈입니다.

이게 RUDP 를 직접 짜는 것이 위험하다고 하는 진짜 이유입니다.
번호를 붙이고 다시 보내는 것은 어렵지 않습니다. 하루면 돌아가는 것을 만듭니다.
**어려운 것은 언제 덜 보낼지 정하는 쪽**이고, 그건 잘 돌아가는 동안 티가 안 납니다.
회선이 나쁜 사용자에게서만 드러납니다.

그래서 대개는 이미 만들어진 것을 씁니다.
직접 짠다면 최소한 **보내는 양에 상한을 두고, 답이 늦어지면 줄이는 것**은 넣어야 합니다.

## 정리

- UDP 헤더는 여덟 바이트. 하는 일은 **포트 구분과 체크섬**뿐입니다
- 연결이 없으니 **중간 장비도 연결을 못 봅니다.** NAT 기록이 지워지는 문제가 여기서 나옵니다
- **신뢰성은 전부 아니면 전무가 아닙니다.** 위치는 최신만, 아이템은 반드시, 채팅은 순서대로
- 번호를 붙이면 빠짐 · 뒤바뀜 · 중복을 알아챌 수 있습니다
- 답은 **가장 큰 번호 + 비트필드**로 몰아서. 답 하나가 사라져도 다음 답이 메웁니다
- **무엇을 다시 보낼지 우리가 정합니다.** 위치는 다시 안 보내고 최신을 보냅니다
- 칸을 나누면 한 칸이 막혀도 다른 칸은 흐릅니다. 대신 **신뢰+순서 칸은 여전히 막힙니다**
- 하트비트로 연결이라는 개념을 직접 만듭니다
- MTU 안에 맞춥니다. 조각나면 하나만 잃어도 전부 잃습니다
- **혼잡 제어를 빠뜨리기 쉽습니다.** TCP 가 공짜로 해 주던 것이고, 없으면 스스로 회선을 막습니다

여기까지가 "무엇을 타고 가는가" 입니다.
선 위에서 벌어지는 일은 대충 다 봤습니다.

다음 편부터는 질문이 바뀝니다.
**무엇을 보낼 것인가, 그리고 누구 말이 진짜인가.**