MessagePack-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 입니다.
경계에 걸린 토큰은 스택에 모아 같은 디코더로 다시 읽는다
// 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 가 놓인 모양에 따라 같은 코드가 다른 길을 걷습니다.
경계에 걸린 경우만 디코더를 두 번 도는 값을 치릅니다. 5바이트 int32 값을 모든 위치에서 두 세그먼트로 잘라 읽어 봤고 네 번 모두 같은 값이 나왔습니다. 입력이 byte[] 한 덩어리면 리더는 ReadOnlySequence 를 씌우지 않고 인덱스만 옮기는 모드로 돌아, ReadInt32 100번과 배열 Skip 의 할당이 0 B 였습니다.
64비트로 넓혔다가 checked 로 좁힌다
// 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 을 씁니다.
| 쓴 것 | 읽기 | 결과 |
|---|---|---|
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 에 한 번 감싸입니다. 배열 헤더에는 검사가 하나 더 있습니다.
// 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 으로 끝났습니다. 바깥 헤더가 이미 약속받은 바이트를 빼고 안쪽을 검사하므로, 안쪽만 보면 맞는 입력도 가려냅니다.
TryReadArrayHeader 는 이 검사를 하지 않습니다. 스트림에서 “헤더는 다 왔는가” 만 물을 때 쓰는 판이라, 9A 01 02 에도 예외 없이 true 와 10 을 돌려줬습니다.
글자 중간에서 잘린 문자열은 Decoder 로 잇는다
UTF-8 한 글자는 여러 바이트라 세그먼트 경계가 글자 한가운데를 지날 수 있습니다. ReadString 은 한 조각에 다 있으면 바로 GetString 하고, 걸치면 풀에서 빌린 char[] 와 Decoder 로 반쪽 글자를 잇습니다.
"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로 바꿉니다.