---
title: "MessagePack-CSharp 의 리더는 정수를 쓴 폭이 아니라 값으로 읽는다"
description: "1바이트로 쓴 200 을 int 로 읽으면 되고 sbyte 로 읽으면 OverflowException 입니다. MessagePackReader 는 64비트로 넓혔다가 checked 로 좁히고, 거짓 헤더와 잘린 글자도 할당 전에 막습니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-reader/
---
```csharp
byte[] bytes = { 0xCC, 0xC8 };                  // MessagePack 의 uint8 200

var a = new MessagePackReader(bytes);
int x = a.ReadInt32();                          // 200

var b = new MessagePackReader(bytes);
sbyte y = b.ReadSByte();                        // OverflowException
```

1바이트짜리 정수를 4바이트 `int` 로 읽어도 200 이 나오고, 1바이트 `sbyte` 로 읽으면 오히려 실패합니다. 쓰는 쪽은 값에 맞는 최소 폭을 고르니 읽는 쪽은 어떤 폭이 와도 받아야 하고, 입력은 조각나 있거나 거짓말을 할 수도 있습니다.

`MessagePackReader` 는 **몇 바이트로 쓰였는가가 아니라 값이 목표 타입에 들어가는가로 읽고**, 경계에 걸린 토큰과 거짓 헤더는 할당 없이 처리합니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 `aa16e71`)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

## 경계에 걸린 토큰은 스택에 모아 같은 디코더로 다시 읽는다

```csharp
// src/MessagePack/MessagePackReader.Integers.cs (ReadInt32 줄임)
public Int32 ReadInt32()
{
    var r = MessagePackPrimitives.TryReadInt32(this.reader.UnreadSpan, out Int32 value, out int tokenSize);
    if (r == MessagePackPrimitives.DecodeResult.Success)
    {
        this.reader.Advance(tokenSize);
        return value;
    }
    return SlowPath(ref this, r, value, ref tokenSize);
}
```

디코더는 세그먼트를 모르는 순수 함수입니다. span 하나를 받아 `Success`, `TokenMismatch`, `EmptyBuffer`, `InsufficientBuffer` 중 하나를 돌려주고, **실패해도 `tokenSize` 에 몇 바이트가 필요한지를 알려 줍니다.** 첫 바이트만 보면 값의 크기가 정해지는 포맷이라 가능한 설계입니다. `300` 을 쓴 `CD 01 2C` 가 놓인 모양에 따라 같은 코드가 다른 길을 걷습니다.

<x-flow>
<script type="application/json">
{
  "label": "ReadInt32 가 300 (CD 01 2C) 을 읽는 길 (줄인 코드)",
  "lines": [
    "var r = TryReadInt32(reader.UnreadSpan, out int value, out int tokenSize);",
    "if (r == Success) { reader.Advance(tokenSize); return value; }",
    "return SlowPath(r, value, tokenSize);",
    "// SlowPath",
    "switch (r)",
    "  case Success: reader.Advance(tokenSize); return value;",
    "  case TokenMismatch: throw ThrowInvalidCode(UnreadSpan[0]);",
    "  case EmptyBuffer or InsufficientBuffer:",
    "    Span<byte> buffer = stackalloc byte[tokenSize];",
    "    if (reader.TryCopyTo(buffer))",
    "      return SlowPath(TryReadInt32(buffer, out value, out tokenSize), value, tokenSize);",
    "    else throw ThrowNotEnoughBytesException();"
  ],
  "cases": [
    {
      "label": "한 세그먼트",
      "state": "세그먼트 = [CD 01 2C] · 읽은 위치 0",
      "path": [
        { "line": 0, "why": "CD 는 uint16 입니다. 뒤 2바이트가 이 span 안에 있으니 바로 300 을 읽고 tokenSize = 3 을 돌려줍니다." },
        { "line": 1, "verdict": "true", "state": "세그먼트 = [CD 01 2C] · 읽은 위치 3", "out": "300" }
      ],
      "skip": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
      "note": "흔한 경우는 span 인덱싱과 분기 몇 개로 끝납니다."
    },
    {
      "label": "경계에 걸림",
      "state": "세그먼트 1 = [CD] · 세그먼트 2 = [01 2C]",
      "path": [
        { "line": 0, "why": "지금 span 에는 CD 한 바이트뿐입니다. 결과는 InsufficientBuffer 지만 tokenSize = 3 은 알려 줍니다." },
        { "line": 1, "verdict": "false" },
        { "line": 2 },
        { "line": 4 },
        { "line": 7 },
        { "line": 8, "why": "필요한 3바이트만큼 스택에 버퍼를 잡습니다. 힙 할당이 아닙니다.", "state": "buffer = [_ _ _]" },
        { "line": 9, "verdict": "true", "why": "두 세그먼트를 넘나들며 3바이트를 긁어 옵니다.", "state": "buffer = [CD 01 2C]" },
        { "line": 10, "why": "같은 디코더를 연속된 버퍼로 다시 부릅니다. 이번에는 Success 입니다." },
        { "line": 4 },
        { "line": 5, "state": "읽은 위치 3", "out": "300" }
      ],
      "note": "경계 처리는 디코더마다 따로 짜지 않고 TryCopyTo 한 줄로 모았습니다."
    },
    {
      "label": "정말 모자람",
      "state": "세그먼트 = [CD 01] (끝)",
      "path": [
        { "line": 0, "why": "InsufficientBuffer, tokenSize = 3." },
        { "line": 1, "verdict": "false" },
        { "line": 2 },
        { "line": 4 },
        { "line": 7 },
        { "line": 8, "state": "buffer = [_ _ _]" },
        { "line": 9, "verdict": "false", "why": "시퀀스 전체를 다 뒤져도 2바이트뿐입니다." },
        { "line": 11, "out": "EndOfStreamException" }
      ]
    }
  ]
}
</script>
</x-flow>

경계에 걸린 경우만 디코더를 두 번 도는 값을 치릅니다. 5바이트 `int32` 값을 모든 위치에서 두 세그먼트로 잘라 읽어 봤고 네 번 모두 같은 값이 나왔습니다. 입력이 `byte[]` 한 덩어리면 리더는 `ReadOnlySequence` 를 씌우지 않고 인덱스만 옮기는 모드로 돌아, `ReadInt32` 100번과 배열 `Skip` 의 할당이 0 B 였습니다.

## 64비트로 넓혔다가 checked 로 좁힌다

```csharp
// src/MessagePack/MessagePackPrimitives.Readers.Integers.cs (TryReadInt32)
DecodeResult result = Decoders.Int64JumpTable[source[0]].Read(source, out long longValue, out tokenSize);
value = checked((Int32)longValue);
```

`Int64JumpTable` 은 첫 바이트가 곧 칸 번호인 256칸 배열입니다. 전부 "틀린 코드" 디코더로 채운 뒤 정수 코드와 fixint 범위만 덮어씁니다. 부호 있는 목표는 이 표를, 부호 없는 목표는 `UInt64JumpTable` 을 씁니다.

<x-flow>
<script type="application/json">
{
  "label": "정수 하나를 목표 타입으로 읽는 순서 (줄인 코드)",
  "lines": [
    "var decoder = (부호 있는 목표 ? Int64JumpTable : UInt64JumpTable)[source[0]];",
    "var r = decoder.Read(source, out wide, out tokenSize);   // 64비트로 넓힌다",
    "value = checked((목표 타입)wide);                          // 목표로 좁힌다",
    "return r;"
  ],
  "cases": [
    {
      "label": "CC C8 → ReadInt32",
      "state": "목표 = int",
      "path": [
        { "line": 0, "why": "CC 칸에는 uint8 디코더가 있습니다." },
        { "line": 1, "state": "목표 = int · wide = 200 · tokenSize = 2" },
        { "line": 2, "verdict": "들어감", "state": "value = 200" },
        { "line": 3, "out": "200" }
      ]
    },
    {
      "label": "CC C8 → ReadSByte",
      "state": "목표 = sbyte",
      "path": [
        { "line": 0 },
        { "line": 1, "state": "목표 = sbyte · wide = 200 · tokenSize = 2" },
        { "line": 2, "verdict": "안 들어감", "why": "sbyte 는 127 까지입니다. checked 캐스트가 던집니다.", "out": "OverflowException" }
      ]
    },
    {
      "label": "CA (float32) → ReadInt32",
      "state": "목표 = int",
      "path": [
        { "line": 0, "why": "CA 칸은 처음 채워 둔 틀린 코드 디코더 그대로입니다." },
        { "line": 1, "verdict": "TokenMismatch", "out": "Unexpected msgpack code 202 (float 32)" }
      ],
      "skip": [2, 3]
    }
  ],
  "note": "쓴 폭은 tokenSize 에만 영향을 줍니다. 성공 여부는 값이 목표 타입에 들어가느냐로 갈립니다."
}
</script>
</x-flow>

| 쓴 것 | 읽기 | 결과 |
| --- | --- | --- |
| `CC C8` (uint8 200) | `ReadInt32`, `ReadByte`, `ReadSingle` | 200 |
| `CD 01 2C` (300) | `ReadByte` | `OverflowException` |
| `FF` (-1) | `ReadUInt32` | `OverflowException` |
| `D2 00 00 00 01` (int32 1) | `ReadByte` | 1 |
| `C0` (nil) | `ReadInt32` | `Unexpected msgpack code 192 (nil)` |

실수 쪽은 정수 코드를 모두 받고, 정수 쪽은 실수 코드를 모두 거부합니다. `nil` 은 0 이 아닙니다. 필드를 **넓히면** 옛 데이터가 그대로 읽히고, **좁히면** 옛 데이터 가운데 새 타입을 넘는 값만 골라서 터집니다. 라이브러리 프로젝트는 `CheckForOverflowUnderflow` 를 켜 두어 정수 산술이 전부 기본 checked 이고, 일부러 비트를 자르는 자리에만 `unchecked(...)` 를 적습니다.

## 거짓 배열 헤더는 할당 전에 끊는다

입력이 모자라면 `EndOfStreamException`, 올 수 없는 코드면 `MessagePackSerializationException`, 좁히기 실패면 `OverflowException` 입니다. `Deserialize<T>` 를 거치면 셋 다 `MessagePackSerializationException` 에 한 번 감싸입니다. 배열 헤더에는 검사가 하나 더 있습니다.

```csharp
// src/MessagePack/MessagePackReader.cs
public int ReadArrayHeader()
{
    ThrowInsufficientBufferUnless(this.TryReadArrayHeader(out int count));
    // Protect against corrupted or mischievous data that may lead to allocating way too much memory.
    // We allow for each primitive to be the minimal 1 byte in size.
    this.ReserveMinimumChildBytes(count);
    return count;
}
```

원소 하나는 아무리 작아도 1바이트이니, "원소 N개" 헤더 뒤에 N바이트도 없으면 그 헤더는 거짓입니다(맵은 `count * 2`). 배열 포매터는 헤더를 읽자마자 `new T[len]` 을 하므로 이 검사가 할당을 막습니다. 5바이트 입력 `DD 7F FF FF FF` 가 원소 21억 개를 주장해도 할당 전에 `EndOfStreamException` 으로 끝났습니다. 바깥 헤더가 이미 약속받은 바이트를 빼고 안쪽을 검사하므로, 안쪽만 보면 맞는 입력도 가려냅니다.

<x-flow>
<script type="application/json">
{
  "label": "ReserveMinimumChildBytes 가 중첩 헤더를 검사하는 순서 (줄인 코드)",
  "lines": [
    "count = 헤더에서 읽은 원소 수;",
    "since = Consumed - checkpoint;",
    "minRemaining = Math.Max(0, minRemaining - since);   // 약속 중 이미 도착한 만큼 뺀다",
    "checkpoint = Consumed;",
    "if (!(minRemaining <= Remaining && count <= Remaining - minRemaining))",
    "    throw new EndOfStreamException();",
    "minRemaining += count;                                // 원소 하나에 최소 1바이트"
  ],
  "cases": [
    {
      "label": "92 93 01 02 03",
      "state": "[[1, 2, 3], 4] 에서 마지막 04 가 없는 입력 · minRemaining = 0 · checkpoint = 0",
      "path": [
        { "line": 0, "why": "바깥 헤더 92, 원소 2개입니다.", "state": "count = 2 · Consumed = 1 · Remaining = 4" },
        { "line": 1, "state": "since = 1" },
        { "line": 2, "state": "minRemaining = 0" },
        { "line": 3, "state": "checkpoint = 1" },
        { "line": 4, "verdict": "통과", "why": "2 ≤ 4 - 0" },
        { "line": 6, "state": "minRemaining = 2 (바깥이 약속받은 몫)" },
        { "line": 0, "why": "안쪽 헤더 93, 원소 3개입니다.", "state": "count = 3 · Consumed = 2 · Remaining = 3" },
        { "line": 1, "why": "바깥이 약속받은 2바이트 중 1바이트(93)가 도착했습니다.", "state": "since = 1" },
        { "line": 2, "state": "minRemaining = 1 (바깥 두 번째 원소 몫)" },
        { "line": 3, "state": "checkpoint = 2" },
        { "line": 4, "verdict": "실패", "why": "안쪽만 보면 3 ≤ 3 이라 맞습니다. 바깥 두 번째 원소 몫 1바이트를 빼면 2바이트뿐입니다." },
        { "line": 5, "out": "EndOfStreamException (배열을 만들기 전)" }
      ]
    }
  ],
  "note": "04 가 붙은 온전한 입력은 마지막 검사가 3 ≤ 4 - 1 로 통과합니다."
}
</script>
</x-flow>

`TryReadArrayHeader` 는 이 검사를 하지 않습니다. 스트림에서 "헤더는 다 왔는가" 만 물을 때 쓰는 판이라, `9A 01 02` 에도 예외 없이 `true` 와 10 을 돌려줬습니다.

## 글자 중간에서 잘린 문자열은 Decoder 로 잇는다

UTF-8 한 글자는 여러 바이트라 세그먼트 경계가 글자 한가운데를 지날 수 있습니다. `ReadString` 은 한 조각에 다 있으면 바로 `GetString` 하고, 걸치면 풀에서 빌린 `char[]` 와 `Decoder` 로 반쪽 글자를 잇습니다.

<x-flow>
<script type="application/json">
{
  "label": "ReadString 이 \"가나다\" (9바이트) 를 읽는 길 (줄인 코드)",
  "lines": [
    "uint byteLength = GetStringLengthInBytes();",
    "if (UnreadSpan.Length >= byteLength)",
    "    return UTF8.GetString(UnreadSpan.Slice(0, byteLength));   // 빠른 길",
    "char[] chars = ArrayPool<char>.Shared.Rent(최대 글자 수);",
    "Decoder decoder = UTF8.GetDecoder();",
    "while (남은 바이트 > 0)",
    "    n = Math.Min(남은 바이트, UnreadSpan.Length);",
    "    decoder.GetChars(UnreadSpan.Slice(0, n), chars, flush: 마지막 조각인가);",
    "string s = new string(chars, 0, 글자 수);  ArrayPool<char>.Shared.Return(chars);"
  ],
  "cases": [
    {
      "label": "한 세그먼트",
      "state": "세그먼트 = [A9 | EA B0 80 EB 82 98 EB 8B A4]",
      "path": [
        { "line": 0, "why": "A9 는 9바이트짜리 fixstr 입니다.", "state": "byteLength = 9 · 이 세그먼트에 남은 것 = 9바이트" },
        { "line": 1, "verdict": "true" },
        { "line": 2, "out": "가나다" }
      ],
      "skip": [3, 4, 5, 6, 7, 8]
    },
    {
      "label": "글자 중간에서 잘림",
      "state": "세그먼트 1 = [A9 EA] · 세그먼트 2 = [B0 80 EB 82 98 EB 8B A4]",
      "path": [
        { "line": 0, "state": "byteLength = 9 · 이 세그먼트에 남은 것 = [EA]" },
        { "line": 1, "verdict": "false" },
        { "line": 3, "why": "char 버퍼는 공용 풀에서 빌립니다." },
        { "line": 4 },
        { "line": 5, "verdict": "true" },
        { "line": 6, "state": "n = 1 · 남은 바이트 = 8" },
        { "line": 7, "why": "EA 하나로는 글자가 되지 않습니다. 디코더가 EA 를 들고 기다립니다.", "state": "chars = [] · 디코더 안 = [EA]" },
        { "line": 5, "verdict": "true" },
        { "line": 6, "state": "n = 8 · 남은 바이트 = 0" },
        { "line": 7, "why": "들고 있던 EA 와 B0 80 을 합쳐 '가', 이어서 '나', '다'. 마지막 조각이라 flush 합니다.", "state": "chars = [가 나 다] · 디코더 안 = []" },
        { "line": 5, "verdict": "false" },
        { "line": 8, "out": "가나다" }
      ]
    }
  ],
  "note": "헤더를 포함한 10바이트를 자를 수 있는 아홉 곳 모두에서 잘라 읽어 봤고, 아홉 번 모두 가나다 가 나왔습니다."
}
</script>
</x-flow>

`"hello, world"` 를 한 세그먼트에서 읽으면 48 B(문자열 하나), 두 세그먼트에 걸치면 104 B 였습니다. 늘어난 56 B 는 호출마다 새로 만드는 디코더(`DecoderNLS`) 몫입니다. 잘못된 UTF-8 은 예외가 되지 않습니다. `A2 FF FE` 는 `U+FFFD` 두 개가 든 문자열이 됩니다. 라이브러리가 쓰는 `new UTF8Encoding(false)` 의 기본 동작입니다.

## 주의할 점

### 1. 필드 타입을 좁히는 스키마 변경은 큰 값에서만 터진다

**안 지키면 생기는 문제.** `int` 로 저장하던 필드를 `byte` 로 바꾸면 0~255 인 옛 데이터는 멀쩡히 읽히고, 300 같은 값이 든 레코드에서만 `OverflowException` 이 납니다. 테스트 데이터가 작으면 배포 뒤에 드문드문 실패합니다.

**이렇게 쓴다.** 저장이나 전송에 쓰는 정수 필드는 넓히는 쪽으로만 바꾸고, 읽는 쪽을 먼저 배포합니다. 좁혀야 한다면 새 키 번호로 새 필드를 만들고 옮기는 코드를 둡니다. `nil` 이 올 수 있는 필드는 처음부터 `int?` 로 선언합니다.

### 2. TryReadArrayHeader 로 읽은 수로 배열을 만들지 않는다

**안 지키면 생기는 문제.** 최소 바이트 예약 검사는 `ReadArrayHeader` 와 `ReadMapHeader` 에만 있습니다. 직접 쓴 포매터가 `TryReadArrayHeader` 로 원소 수를 받아 `new T[count]` 를 하면, 5바이트 입력이 21억 칸 배열 할당을 끌어낼 수 있습니다.

**이렇게 쓴다.** 포매터 안에서는 `ReadArrayHeader`, `ReadMapHeader` 를 씁니다. `Try` 판은 헤더가 다 왔는지만 확인할 때 쓰고, `false` 뒤의 위치는 정의되지 않으니 미리 떠 둔 복사본(`var retry = reader;`)으로 되돌립니다.

### 3. 바이트가 그대로 돌아와야 하는 값은 string 으로 받지 않는다

**안 지키면 생기는 문제.** 틀린 UTF-8 은 예외 없이 `U+FFFD` 로 바뀝니다. 서명이나 해시, 불투명한 토큰을 `string` 필드로 받았다가 다시 보내면 원본과 다른 바이트가 나가고, 어디서 바뀌었는지 남지 않습니다. 쓰는 쪽도 같아서 짝 없는 서로게이트 `"\uD800"` 은 `A3 EF BF BD` 로 바뀌어 나갔습니다.

**이렇게 쓴다.** 텍스트라고 보장되지 않는 값은 `byte[]` 필드로 선언해 bin 으로 주고받습니다. 검증이 필요하면 받은 쪽에서 `new UTF8Encoding(false, throwOnInvalidBytes: true)` 로 따로 디코딩합니다.

## 정리

`MessagePackReader` 는 **정수를 쓴 폭이 아니라 값으로 읽고**, 조각난 입력과 거짓 입력을 할당 전에 처리합니다.

- 모든 `Read*` 는 지금 span 에서 한 번 시도하고, 모자라면 알려 준 `tokenSize` 만큼 스택에 모아 같은 디코더로 다시 읽습니다.
- 정수는 64비트로 넓혀 `checked` 로 좁히므로, 필드를 넓히면 호환되고 좁히면 큰 값에서만 터집니다.
- `ReadArrayHeader` 는 원소당 1바이트를 예약해 거짓 헤더를 할당 전에 끊고, 문자열은 `Decoder` 로 반쪽 글자를 이으며 틀린 UTF-8 은 `U+FFFD` 로 바꿉니다.