---
title: "MessagePack-CSharp 의 스트림 리더는 메시지 끝을 건너뛰어 보며 찾는다"
description: "한 스트림에 이어 붙은 메시지 사이에는 길이도 구분자도 없습니다. MessagePackStreamReader 는 TrySkip 으로 값 하나의 끝을 할당 없이 찾지만, 조각나 도착하면 매번 처음부터 다시 훑고 크기 한도도 없습니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-stream/
---
```csharp
MessagePackSerializer.Serialize(stream, new[] { 1, 2, 3 });
MessagePackSerializer.Serialize(stream, "hello");
MessagePackSerializer.Serialize(stream, new Dictionary<string, int> { ["a"] = 1 });
// 93 01 02 03 | A5 68 65 6C 6C 6F | 81 A1 61 01
```

쓰는 쪽은 그냥 이어 씁니다. 메시지 사이에 길이도 구분자도 없는 14바이트를 받은 쪽은, 어디까지가 첫 메시지인지 알아야 하나씩 역직렬화할 수 있습니다.

MessagePack-CSharp 의 `MessagePackStreamReader` 는 **값을 만들지 않고 건너뛰어 보면서** 메시지의 끝을 찾습니다. 접두사가 필요 없고 할당도 없지만, 모자라서 실패할 때마다 처음부터 다시 훑습니다. 기준 판본은 v3.1.9(커밋 `aa16e71`)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

## Deserialize(Stream) 은 끝까지 읽고 첫 값만 돌려준다

먼저 떠오르는 것은 `Deserialize<T>(Stream)` 을 세 번 부르는 코드입니다.

```csharp
// MessagePack/MessagePackSerializer.cs (Deserialize<T>(Stream) 발췌)
if (TryDeserializeFromMemoryStream(stream, options, cancellationToken, out T? result))
    return result!;

do
{
    Span<byte> span = sequence.GetSpan(stream.CanSeek ? (int)Math.Min(options.SuggestedContiguousMemorySize, stream.Length - stream.Position) : 0);
    bytesRead = stream.Read(span);
    sequence.Advance(bytesRead);
}
while (bytesRead > 0);
return DeserializeFromSequenceAndRewindStreamIfPossible<T>(stream, options, sequence, cancellationToken);
```

`Read` 가 0 을 돌려줄 때까지, 곧 **스트림 끝까지** 다 읽고 첫 값 하나를 역직렬화합니다.

<x-compare>
<script type="application/json">
{
  "label": "세 메시지(14 B) 스트림에 Deserialize<int[]>(stream) 을 한 번 불렀을 때 (실측)",
  "tabs": [
    { "label": "MemoryStream", "text": "내부 버퍼를 직접 읽는 지름길로 가서, 첫 값을 읽은 뒤 소비한 만큼만 Position 을 옮깁니다. 다음 호출이 둘째 메시지를 읽을 수 있습니다.", "stats": [["반환", "[1,2,3]"], ["Position", "4"]], "tone": "good" },
    { "label": "1바이트씩 주는 비탐색 스트림", "text": "끝까지 다 읽고 첫 값만 돌려줍니다. 탐색이 안 되니 남은 10바이트를 되돌릴 방법이 없어 두 메시지가 사라집니다.", "stats": [["반환", "[1,2,3]"], ["Read 호출", "15회 (14 + 끝 1)"]], "tone": "bad" },
    { "label": "같은 스트림, DeserializeAsync", "text": "읽기만 비동기이고 동작은 같습니다. 받는 대로 조금씩 파싱하는 것이 아니라 다 받은 뒤 한 번 파싱합니다.", "stats": [["반환", "[1,2,3]"], ["ReadAsync 호출", "15회"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

탐색 가능한 스트림은 남은 바이트만큼 되감지만, 네트워크 스트림은 탐색이 안 됩니다. 게다가 상대가 연결을 열어 둔 소켓에서는 메시지가 이미 와 있어도 끝이 오지 않으니 반환하지 않습니다. 루프백 TCP 로 `[1,2,3]` 하나를 보내고 연결을 유지하자 5초 동안 끝나지 않다가, 보낸 쪽이 닫은 뒤 0.9 ms 만에 돌아왔습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 시크 가능한 스트림에서는 남은 길이만큼(1 MB 한도) 연속 공간을 한 번에 요청하므로, 1 MB 파일을 이 입구로 읽으면 결과 배열 말고도 호출마다 1 MB 버퍼가 새로 잡혔습니다.

## TrySkip 은 값을 만들지 않고 남은 개수만 센다

msgpack 은 모든 값의 크기가 첫 바이트와 길이 필드로 정해지는 **자기 구분적** 포맷이라, 값을 만들지 않고도 끝을 찾을 수 있습니다.

```csharp
// MessagePack/MessagePackReader.cs (TrySkip 발췌)
long remainingStructures = 1;
while (remainingStructures > 0)
{
    if (this.reader.Remaining == 0) return false;
    remainingStructures--;
    byte code = this.NextCode;
    switch (code)
    {
        case byte x when MessagePackCode.IsFixArray(x): ...
            remainingStructures = checked(remainingStructures + count);
            break;
        ...
    }
}
return true;
```

재귀 없이 "아직 건너뛸 값이 몇 개 남았나" 카운터 하나로 돕니다.

<x-flow>
<script type="application/json">
{
  "label": "TrySkip 이 값 하나의 끝을 찾는 과정 (줄인 코드)",
  "lines": [
    "long remaining = 1;",
    "while (remaining > 0)",
    "    if (남은 바이트 == 0) return false;",
    "    remaining--;  code = 다음 바이트;",
    "    배열 헤더면 remaining += 원소 수,  맵 헤더면 remaining += 원소 수 × 2;",
    "    str · bin · ext 면 길이만 읽고 본문을 건너뛴다,  나머지는 그 값의 크기만큼 건너뛴다;",
    "return true;"
  ],
  "cases": [
    {
      "label": "93 01 02 03 | A5 ...",
      "state": "remaining = 1 · 위치 0",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "true" },
        { "line": 3, "why": "93 은 원소 셋짜리 fixarray 입니다.", "state": "remaining = 0 · 위치 1" },
        { "line": 4, "state": "remaining = 3 · 위치 1" },
        { "line": 1, "verdict": "true" },
        { "line": 3, "why": "01 은 fixint, 한 바이트로 끝나는 값입니다.", "state": "remaining = 2 · 위치 2" },
        { "line": 1, "verdict": "true" },
        { "line": 3, "why": "02", "state": "remaining = 1 · 위치 3" },
        { "line": 1, "verdict": "true" },
        { "line": 3, "why": "03", "state": "remaining = 0 · 위치 4" },
        { "line": 1, "verdict": "false" },
        { "line": 6, "out": "true. 첫 메시지는 0..4, 뒤의 A5 부터는 보지 않았다" }
      ]
    },
    {
      "label": "93 01 02 (꼬리가 아직 안 옴)",
      "state": "remaining = 1 · 위치 0",
      "path": [
        { "line": 0 },
        { "line": 3, "state": "remaining = 0 · 위치 1" },
        { "line": 4, "state": "remaining = 3 · 위치 1" },
        { "line": 3, "why": "01", "state": "remaining = 2 · 위치 2" },
        { "line": 3, "why": "02", "state": "remaining = 1 · 위치 3" },
        { "line": 1, "verdict": "true", "why": "아직 값 하나가 남았습니다." },
        { "line": 2, "verdict": "true", "out": "false. 데이터가 모자란다" }
      ]
    }
  ],
  "note": "위치만 움직이고 아무것도 할당하지 않습니다. 비용은 바이트 수가 아니라 값(토큰) 수를 따라갑니다."
}
</script>
</x-flow>

`MessagePackStreamReader.ReadAsync` 는 버퍼 맨 앞에서 이것을 부르고, 성공하면 버퍼를 그 위치까지 **잘라서**(slice) 돌려주고, 실패하면 스트림에서 더 읽어 다시 시도합니다. 14바이트 세 메시지를 한 바이트씩 줘도 4 B, 6 B, 4 B 세 메시지가 정확히 나왔고, 꼬리가 잘린 스트림에서는 `null` 뒤에 `RemainingBytes` 로 미완성 꼬리를 보여줬습니다. 스택을 쓰지 않아 `0x91` 을 10만 겹 쌓은 입력도 `Skip` 이 통과했습니다.

```csharp
using var reader = new MessagePackStreamReader(stream, leaveOpen: true);
while (await reader.ReadAsync(cancellationToken) is ReadOnlySequence<byte> msgpack)
{
    var item = MessagePackSerializer.Deserialize<Item>(msgpack, options, cancellationToken);
}
```

## 조각나 도착하면 처음부터 다시 훑는다

실패한 `TrySkip` 이 센 카운터는 지역 변수라 함께 버려지고, 다음 시도는 버퍼 맨 앞을 가리키는 새 리더로 시작합니다. 리더가 `ref struct` 라 `await` 너머로 들고 있을 수 없기 때문입니다. 소스 주석도 이 자리를 개선 여지로 적어 둡니다.

<x-flow>
<script type="application/json">
{
  "label": "ReadAsync 루프가 메시지 하나(93 01 02 03)를 찾기까지",
  "lines": [
    "if (TryReadNextMessage(out var msg)) return msg;   // 버퍼 맨 앞부터 TrySkip",
    "if (!await TryReadMoreDataAsync()) return null;    // 스트림에서 더 읽는다"
  ],
  "cases": [
    {
      "label": "한 바이트씩 도착",
      "state": "버퍼 = [] · 훑은 토큰 누계 0",
      "path": [
        { "line": 0, "verdict": "false", "why": "버퍼가 비어 있습니다." },
        { "line": 1, "state": "버퍼 = [93] · 훑은 토큰 누계 0" },
        { "line": 0, "verdict": "false", "why": "93 하나를 보고 원소 셋이 더 필요하다는 것을 압니다.", "state": "버퍼 = [93] · 훑은 토큰 누계 1" },
        { "line": 1, "state": "버퍼 = [93 01] · 훑은 토큰 누계 1" },
        { "line": 0, "verdict": "false", "why": "처음부터 93, 01 을 다시 봅니다.", "state": "버퍼 = [93 01] · 훑은 토큰 누계 3" },
        { "line": 1, "state": "버퍼 = [93 01 02] · 훑은 토큰 누계 3" },
        { "line": 0, "verdict": "false", "why": "93, 01, 02 를 다시 봅니다.", "state": "버퍼 = [93 01 02] · 훑은 토큰 누계 6" },
        { "line": 1, "state": "버퍼 = [93 01 02 03] · 훑은 토큰 누계 6" },
        { "line": 0, "verdict": "true", "why": "넷을 다시 보고 끝을 찾았습니다.", "state": "버퍼 = [93 01 02 03] · 훑은 토큰 누계 10", "out": "토큰 4개짜리 메시지에 1+2+3+4 = 10번. n 개면 n(n+1)/2" }
      ]
    },
    {
      "label": "한 번에 도착",
      "state": "버퍼 = [] · 훑은 토큰 누계 0",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "state": "버퍼 = [93 01 02 03] · 훑은 토큰 누계 0" },
        { "line": 0, "verdict": "true", "state": "버퍼 = [93 01 02 03] · 훑은 토큰 누계 4", "out": "4번" }
      ]
    }
  ],
  "note": "이미 본 토큰을 기억하지 않아서, 조각 수와 토큰 수를 곱한 만큼 훑습니다."
}
</script>
</x-flow>

`int[N]` 메시지를 한 바이트씩 흘려 잰 값입니다(1회 관측).

| 메시지 | 바이트 | 한 바이트씩 | `MemoryStream` 한 번에 |
| --- | --- | --- | --- |
| `int[1000]` | 3,003 | 4.24 ms | 0.007 ms |
| `int[2000]` | 6,003 | 17.65 ms | 0.019 ms |
| `int[4000]` | 12,003 | 78.89 ms | 0.044 ms |
| `int[8000]` | 24,003 | 293.24 ms | 0.137 ms |
| `int[16000]` | 48,003 | **1,224.08 ms** | 0.351 ms |

N 을 두 배로 하면 네 배, O(n²) 입니다. 같은 바이트 수의 `bin` 하나는 24,003 B 에 2.39 ms 로 선형이었습니다. bin 본문은 길이 한 번에 건너뛰니 토큰이 하나뿐입니다. 비용은 바이트가 아니라 **토큰 수 × 조각 수**를 따라갑니다. 최상위 큰 배열은 `ReadArrayAsync` 로 헤더를 먼저 소비하고 원소를 하나씩 꺼내면 재스캔 범위가 원소 하나로 줄어듭니다.

## 메시지 크기 한도가 없다

생성자 인자는 `stream`, `leaveOpen`, `sequencePool` 뿐이라 최대 메시지 크기가 없고, 역직렬화 옵션을 받지 않으니 `UntrustedData` 도 닿지 않습니다. `TrySkip` 은 배열 헤더를 검사 없는 `TryReadArrayHeader` 로 읽어, 모자라면 그저 더 읽습니다. 배열 헤더 `DD 7F FF FF FF`(원소 21억 개) 뒤로 1 KB 씩 끝없이 주는 스트림을 걸었습니다.

| 시점 | 넘겨준 누적 바이트 | `GC.GetTotalMemory` 증가 | `ReadAsync` |
| --- | --- | --- | --- |
| 1초 | 578,560 B | 600,688 B | 진행 중 |
| 5초 | 1,316,864 B | 1,348,744 B | 진행 중 |
| 10초 | 1,866,752 B | 1,916,200 B | 진행 중 |

힙은 받은 만큼 계속 늘었고, 받아들이는 속도는 재스캔 때문에 시간이 갈수록 떨어졌습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 10초 뒤 취소 토큰으로 끊음). 헤더가 42억 개를 넘는 `DD FF FF FF FF` 는 오히려 `checked` 변환에서 곧바로 `OverflowException` 이었습니다.

## 주의할 점

### 1. 여러 메시지를 Deserialize(Stream) 으로 읽지 않는다

**안 지키면 생기는 문제.** `Deserialize<T>(Stream)` 은 스트림 끝까지 읽고 첫 값만 돌려줍니다. 탐색이 안 되는 스트림에서는 뒤 메시지가 사라지고, 열린 소켓에서는 상대가 닫을 때까지 반환하지 않습니다. `MemoryStream` 으로 한 테스트는 통과해서 잘 드러나지 않습니다.

**이렇게 쓴다.** 한 스트림에 메시지가 여러 개면 `MessagePackStreamReader` 를 씁니다. `Deserialize(Stream)` 은 파일 하나에 값 하나처럼 스트림 전체가 메시지 하나일 때만 씁니다.

### 2. ReadAsync 가 준 시퀀스를 다음 호출 뒤까지 들고 있지 않는다

**안 지키면 생기는 문제.** 돌려받은 `ReadOnlySequence<byte>` 는 리더 내부 버퍼의 한 구간이고, 다음 `ReadAsync` 첫 줄에서 반납됩니다. 받은 시퀀스를 큐에 넣고 나중에 역직렬화하면 이미 다른 바이트로 채워진 자리를 읽습니다. 리더는 스레드 안전하지도 않습니다.

**이렇게 쓴다.** 받은 루프 안에서 바로 `Deserialize` 하고, 넘겨야 하면 객체나 `ToArray()` 복사본을 넘깁니다. 리더 하나는 읽는 작업 하나가 소유하고, 스트림을 더 쓸 거면 `leaveOpen: true` 로 만듭니다. 기본 생성자는 `Dispose` 때 스트림도 닫습니다.

### 3. 신뢰할 수 없는 연결에는 크기 한도를 바깥에 둔다

**안 지키면 생기는 문제.** 거대한 배열 헤더를 보내고 바이트를 조금씩 흘리는 연결 하나가 메모리를 계속 키우고, 조각마다 처음부터 다시 훑어 CPU 를 제곱으로 씁니다. 48 KB `int[]` 를 한 바이트씩 받는 데 1.2초가 걸렸습니다.

**이렇게 쓴다.** 프로토콜을 정할 수 있으면 메시지 앞에 길이를 붙이고 한도를 넘는 길이는 바로 끊습니다. 바꿀 수 없으면 읽은 총 바이트와 경과 시간을 세는 래퍼 스트림을 리더 밑에 끼웁니다.

## 정리

`MessagePackStreamReader` 는 **값을 만들지 않고 건너뛰어 보며** 메시지 끝을 찾습니다.

- `Deserialize(Stream)` 은 끝까지 읽고 첫 값만 돌려주므로 이어 붙은 메시지에는 맞지 않습니다.
- `TrySkip` 은 재귀 없이 카운터 하나로 끝을 찾아 할당이 없고, 돌려주는 메시지는 다음 호출 전까지만 유효한 버퍼 조각입니다.
- 실패할 때마다 처음부터 다시 훑어 비용이 토큰 수 × 조각 수이고, 크기 한도가 없으니 신뢰할 수 없는 연결에는 바깥에서 막습니다.