메모리와 GC - 할당을 줄이는 코드
이 장에서 배우는 것
앞 장에서 yield 로 연산자를 직접 만들면서, 코드가 겉으로 단순해 보여도 뒤에서 객체가 만들어진다는 점을 확인했다. 이 장은 그 객체가 메모리 어디에 놓이고, 누가 치우며, 어떻게 덜 만들 수 있는지를 다룬다. 택배 센터의 입고 목록을 읽어 허브별로 세는 작은 프로그램을 같은 줄 수로 두 번 짜 보고, 할당량이 어떻게 달라지는지 눈으로 확인한다.
- 값 형식과 참조 형식이 메모리에 놓이는 방식을 배열 수준에서 설명한다.
- 박싱(boxing)이 일어나는 자리를 알아보고 피하는 방법을 익힌다.
Span<T>,ReadOnlySpan<T>,Memory<T>로 문자열을 복사하지 않고 잘라 읽는다.ArrayPool<T>와stackalloc으로 임시 버퍼를 재사용하거나 스택에 둔다.- 가비지 컬렉터(garbage collector, GC)의 세대(generation) 구조와 큰 객체 힙을 관찰한다.
문제 상황
물류 센터는 새벽마다 협력사에게서 입고 목록을 텍스트로 받는다. 한 줄이 한 택배이고 형식은 1001,SEL,3500, 즉 송장 번호, 허브 코드, 무게(그램)다. 처음 만든 코드는 Split('\n') 으로 줄을 나누고 각 줄을 다시 Split(',') 으로 나눈 뒤 int.Parse 를 불렀다. 기능은 맞았지만 목록이 수십만 줄로 늘자 처리 시간이 들쭉날쭉해졌고, 모니터링에는 GC 횟수가 크게 늘어난 것으로 나타났다.
원인은 줄마다 만들어지는 문자열과 배열이다. 값을 읽는 데 필요한 것은 문자 범위뿐인데, 코드는 범위마다 새 문자열을 만들고 있었다. 이 장은 같은 일을 새 객체 없이 하는 방법과, 어쩔 수 없이 필요한 버퍼를 재사용하는 방법을 다룬다. 다만 할당을 줄이는 일은 목적이 아니라 수단이다. 실제로 병목인지 재는 방법은 뒤에서 성능 측정을 다루는 장에서 따로 배우고, 여기서는 할당 바이트 수를 비교하는 정도로만 확인한다.
값 형식과 참조 형식의 배치
기본서에서 struct 는 값 형식, class 는 참조 형식이라고 배웠다. 메모리 관점에서 이 차이는 "값이 어디에 들어 있는가"로 정리된다. 값 형식 변수에는 값 자체가 들어 있다. 지역 변수라면 스택(stack)에, 클래스의 필드라면 그 객체 안에, 배열의 요소라면 배열 안에 있다. 참조 형식 변수에는 힙(heap)에 있는 객체의 주소가 들어 있다.
그림처럼 PackageSizeValue[] 는 배열 하나만 할당하면 요소가 모두 들어 있다. PackageSizeRef[] 는 배열 하나에 더해 요소 수만큼 객체를 따로 할당해야 한다. 객체마다 헤더가 붙고, GC 는 각 객체를 따로 추적한다. 순회할 때도 값 형식은 연속된 메모리를 읽지만 참조 형식은 주소를 따라간다.
대입의 의미도 다르다. 값 형식은 대입할 때 값이 복사되고, 참조 형식은 같은 객체를 가리키게 된다. 완성 코드의 첫 구간이 이를 그대로 보여 준다.
| 구분 | 변수에 든 것 | 대입하면 | GC 가 추적하는 대상 |
|---|---|---|---|
| 값 형식(struct) | 값 자체 | 값이 복사된다 | 포함한 객체 하나 |
| 참조 형식(class) | 힙 객체의 참조 | 참조만 복사된다 | 객체마다 따로 |
값 형식이 항상 스택에 있는 것은 아니다. 클래스의 필드나 배열 요소인 값 형식은 힙에 있는 객체 안에 있다. "struct 는 스택, class 는 힙"이라는 외움 규칙은 지역 변수에만 들어맞는다.
박싱
값 형식을 object 나 인터페이스 형식으로 다루면 값이 힙에 새로 만든 상자에 복사된다. 이것이 박싱이다. 상자에서 꺼내는 언박싱(unboxing)은 값을 다시 복사한다. 상자는 원본과 별개의 복사본이므로 원본을 바꿔도 상자 안의 값은 그대로다.
박싱은 눈에 잘 띄지 않는다. List<object> 에 int 를 넣는 것처럼 명백한 경우도 있지만, object 매개변수를 받는 메서드에 값 형식을 넘기거나, 제약 없이 인터페이스로 받은 struct 를 호출하는 경우에도 생긴다. 제네릭 컬렉션과 제네릭 제약을 다룬 첫 장의 내용이 여기서 실용적 의미를 갖는다. List<int> 는 int 를 배열에 그대로 담으므로 박싱이 없다.
Span, Memory, ArrayPool
Span<T> 와 ReadOnlySpan<T>
Span<T> 는 연속된 메모리의 한 구간을 가리키는 값 형식이다. 배열, 문자열, 스택 메모리의 일부를 복사 없이 가리킬 수 있다. string.Substring 은 새 문자열을 만들지만 AsSpan 이나 범위 표현식 [a..b] 로 자른 ReadOnlySpan<char> 는 원본 위의 창일 뿐이다. 문자열은 바꿀 수 없으므로 읽기 전용 버전을 쓴다.
Span 은 ref struct 이므로 스택에만 있어야 한다. 그래서 클래스의 필드가 될 수 없고, 람다에 캡처할 수 없으며, async 메서드의 지역 변수나 매개변수로 쓸 수 없다. await 를 지나면 메서드 상태가 힙으로 옮겨지기 때문이다.
Memory<T>
Memory<T> 와 ReadOnlyMemory<T> 는 같은 구간을 힙에 둘 수 있는 일반 struct 로 표현한다. 필드에 저장하고 async 메서드에 넘길 수 있다. 실제로 읽거나 쓸 때는 .Span 속성으로 Span 을 꺼내 쓴다. 정리하면 짧게 쓰고 버리는 구간은 Span, 저장하거나 await 를 건너야 하면 Memory 다.
ArrayPool<T> 와 stackalloc
버퍼가 필요할 때마다 new byte[n] 을 하면 요청마다 할당이 생긴다. ArrayPool<T>.Shared 는 배열을 빌려주고 돌려받아 재사용한다. 두 가지를 기억해야 한다. 빌린 배열은 요청보다 길 수 있으므로 실제 사용 길이를 따로 들고 다녀야 한다. 그리고 Return 한 뒤에는 그 배열이 다른 곳에 빌려 갈 수 있으므로 더 이상 만지면 안 된다. 예외가 나도 돌려주도록 try/finally 로 감싼다.
아주 작은 임시 버퍼는 stackalloc 으로 스택에 잡고 Span<T> 로 받으면 GC 와 상관이 없다. 크기가 큰 버퍼를 스택에 잡으면 스택이 넘칠 수 있으므로 수백 바이트 안팎의 고정된 크기에만 쓴다.
| 방법 | 메모리 위치 | 적합한 크기 | 주의점 |
|---|---|---|---|
| new T[n] | 힙 | 수명이 길거나 크기가 제각각일 때 | 할당마다 GC 부담 |
| ArrayPool | 힙(재사용) | 중간 크기 이상, 반복 호출 | Return 필수, 길이가 더 클 수 있음 |
| stackalloc | 스택 | 작고 고정된 크기 | 큰 크기는 스택 초과 위험 |
GC 세대
.NET 의 GC 는 객체를 0, 1, 2세대로 나누어 관리한다. 새 객체는 0세대에 만들어진다. 수집에서 살아남으면 한 세대씩 승격한다. 대부분의 객체는 금방 죽는다는 관찰에 기대어, 자주 수집하는 0세대는 작고 싸게 유지하고 오래 사는 객체가 모인 2세대는 드물게 수집한다.
약 85,000바이트 이상인 객체는 큰 객체 힙(large object heap)에 놓이며 2세대로 취급된다. 큰 배열을 반복해서 만들면 2세대 수집이 자주 일어나므로 비싸다. 이런 배열이 ArrayPool 의 주된 사용처다. 한편 GC.Collect() 는 세대 구조를 관찰하려는 이 장의 실험에서만 부른다. 운영 코드에서 수동으로 호출하면 GC 의 판단을 방해하는 경우가 대부분이다.
완성 코드
입고 목록을 Split 으로 읽는 방식과 Span 으로 읽는 방식을 비교하고, ArrayPool·Memory·stackalloc 을 함께 쓰며, 마지막에 세대를 관찰한다. Program.cs 하나에 담았다.
using System.Buffers;
using System.Globalization;
const string manifest =
"1001,SEL,3500\n1002,BSN,12250\n1003,DJN,800\n1004,SEL,4100\nbad line\n1005,BSN,990";
Console.WriteLine("[1] 값 형식과 참조 형식의 복사");
var sizeA = new PackageSizeValue { Width = 10 };
var sizeB = sizeA;
sizeB.Width = 99;
Console.WriteLine($"값 형식: A={sizeA.Width}, B={sizeB.Width}");
var refA = new PackageSizeRef { Width = 10 };
var refB = refA;
refB.Width = 99;
Console.WriteLine($"참조 형식: A={refA.Width}, B={refB.Width}");
Console.WriteLine("[2] 박싱");
int weight = 5;
object boxed = weight;
weight = 9;
int unboxed = (int)boxed;
Console.WriteLine($"박싱 시점의 값={boxed}, 원본={weight}, 언박싱={unboxed}");
var ints = new List<int>(1000);
var objs = new List<object>(1000);
long plainBytes = Probe.Measure(() => { for (int i = 0; i < 1000; i++) ints.Add(i); });
long boxedBytes = Probe.Measure(() => { for (int i = 0; i < 1000; i++) objs.Add(i); });
Console.WriteLine($"박싱 경로가 더 많이 할당: {boxedBytes > plainBytes}");
Console.WriteLine("[3] 문자열 분할과 Span 파싱");
Baseline.SumWithSplit(manifest);
ManifestReader.SumWeights(manifest);
Console.WriteLine($"합계(Split)={Baseline.SumWithSplit(manifest)}");
Console.WriteLine($"합계(Span)={ManifestReader.SumWeights(manifest)}");
long splitBytes = Probe.Measure(() => { for (int i = 0; i < 100; i++) Baseline.SumWithSplit(manifest); });
long spanBytes = Probe.Measure(() => { for (int i = 0; i < 100; i++) ManifestReader.SumWeights(manifest); });
Console.WriteLine($"Span 경로가 더 적게 할당: {spanBytes < splitBytes}");
Console.WriteLine("[4] ArrayPool·Memory·stackalloc");
char[] buffer = ArrayPool<char>.Shared.Rent(manifest.Length);
try
{
manifest.CopyTo(buffer);
var batch = new PendingBatch(buffer.AsMemory(0, manifest.Length));
Console.WriteLine($"버퍼 길이가 요청 이상: {buffer.Length >= manifest.Length}");
Console.WriteLine(batch.Summarize());
}
finally
{
ArrayPool<char>.Shared.Return(buffer);
}
Console.WriteLine("[5] GC 세대");
var label = new ShippingLabel("1001");
int newGen = GC.GetGeneration(label);
GC.Collect();
int afterOne = GC.GetGeneration(label);
GC.Collect();
int afterTwo = GC.GetGeneration(label);
byte[] large = new byte[90_000];
Console.WriteLine($"새 객체 {newGen}, 수집 1회 뒤 {afterOne}, 수집 2회 뒤 {afterTwo}, 큰 배열 {GC.GetGeneration(large)}, 최대 세대 {GC.MaxGeneration}");
GC.KeepAlive(label);
GC.KeepAlive(large);
enum Hub { Seoul = 0, Busan = 1, Daejeon = 2 }
readonly record struct ParcelRecord(int Id, Hub Hub, int WeightGram);
struct PackageSizeValue
{
public int Width;
}
sealed class PackageSizeRef
{
public int Width;
}
sealed class ShippingLabel(string code)
{
public string Code { get; } = code;
}
static class Probe
{
public static long Measure(Action action)
{
long before = GC.GetAllocatedBytesForCurrentThread();
action();
return GC.GetAllocatedBytesForCurrentThread() - before;
}
}
static class Baseline
{
public static long SumWithSplit(string text)
{
long total = 0;
foreach (string line in text.Split('\n'))
{
string[] parts = line.Split(',');
if (parts.Length != 3) continue;
if (int.TryParse(parts[2], out int gram)) total += gram;
}
return total;
}
}
static class ManifestReader
{
public static long SumWeights(string text)
{
long total = 0;
foreach (ReadOnlySpan<char> line in text.AsSpan().EnumerateLines())
{
if (TryParseLine(line, out ParcelRecord record)) total += record.WeightGram;
}
return total;
}
public static bool TryParseLine(ReadOnlySpan<char> line, out ParcelRecord record)
{
record = default;
int first = line.IndexOf(',');
if (first < 0) return false;
ReadOnlySpan<char> rest = line[(first + 1)..];
int second = rest.IndexOf(',');
if (second < 0) return false;
if (!int.TryParse(line[..first], NumberStyles.None, CultureInfo.InvariantCulture, out int id)) return false;
if (!TryParseHub(rest[..second], out Hub hub)) return false;
if (!int.TryParse(rest[(second + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out int gram)) return false;
record = new ParcelRecord(id, hub, gram);
return true;
}
static bool TryParseHub(ReadOnlySpan<char> code, out Hub hub)
{
if (code.SequenceEqual("SEL")) { hub = Hub.Seoul; return true; }
if (code.SequenceEqual("BSN")) { hub = Hub.Busan; return true; }
if (code.SequenceEqual("DJN")) { hub = Hub.Daejeon; return true; }
hub = default;
return false;
}
}
sealed class PendingBatch(ReadOnlyMemory<char> text)
{
public ReadOnlyMemory<char> Text { get; } = text;
public string Summarize()
{
Span<int> perHub = stackalloc int[3];
int invalid = 0;
foreach (ReadOnlySpan<char> line in Text.Span.EnumerateLines())
{
if (ManifestReader.TryParseLine(line, out ParcelRecord record)) perHub[(int)record.Hub]++;
else invalid++;
}
return $"서울 {perHub[0]}, 부산 {perHub[1]}, 대전 {perHub[2]}, 불량 {invalid}";
}
}
줄별 해설
[1] 복사 의미
var sizeB = sizeA 는 구조체 값을 통째로 복사한다. 그래서 sizeB.Width 를 바꿔도 sizeA 는 10 으로 남는다. 클래스 쪽은 refB 와 refA 가 같은 객체를 가리키므로 둘 다 99 가 된다. 필드를 공개한 변경 가능한 struct 는 이 대비를 보이려는 예시일 뿐이며, 실무에서는 struct 를 읽기 전용으로 설계하는 편이 안전하다.
[2] 박싱
object boxed = weight 에서 힙에 상자가 만들어지고 5 가 복사된다. 뒤이어 weight = 9 로 바꿔도 상자 안은 5 다. 아래 두 측정은 같은 1000번 반복이지만, List<int> 는 미리 용량을 잡아 두었으므로 할당이 거의 없고 List<object> 는 반복마다 상자를 하나씩 만든다. Probe.Measure 는 현재 스레드가 지금까지 할당한 바이트 수의 차이를 돌려준다. 정확한 바이트 수는 런타임 버전에 따라 달라지므로 출력에는 대소 비교의 결과만 싣는다.
[3] 파싱 비교
Baseline.SumWithSplit 은 줄 배열, 줄 문자열, 필드 배열, 필드 문자열을 모두 새로 만든다. ManifestReader.SumWeights 는 EnumerateLines 가 돌려주는 ReadOnlySpan<char> 를 그대로 받아 IndexOf 와 범위 표현식으로 자르고 숫자로 바꾼다. 허브 코드는 문자열로 만들지 않고 SequenceEqual 로 비교해 열거형으로 바꾼다. 결과가 ParcelRecord 라는 readonly record struct 이므로 반환값도 힙에 올라가지 않는다. 측정 전에 한 번씩 먼저 호출해 두는 것은 첫 호출의 JIT 컴파일 영향을 빼기 위해서다.
줄 형식이 틀린 bad line 은 쉼표가 없으므로 TryParseLine 이 false 를 돌려주고 합계에서 빠진다. 유효한 다섯 줄의 무게를 더하면 3500 + 12250 + 800 + 4100 + 990 = 21640 이다.
[4] 풀, Memory, stackalloc
Rent 로 빌린 배열에 입고 목록을 복사하고, 실제 쓰는 앞부분만 AsMemory(0, manifest.Length) 로 잘라 PendingBatch 에 넘긴다. 배열 전체가 아니라 사용 길이만 넘기는 것이 핵심이다. PendingBatch 는 클래스이므로 Span 을 필드로 가질 수 없어 ReadOnlyMemory<char> 를 저장하고, 읽을 때만 Text.Span 으로 꺼낸다. Summarize 안의 perHub 는 stackalloc 으로 만든 int 세 칸이며 허브 열거형의 정수 값을 인덱스로 쓴다. 마지막 finally 에서 배열을 돌려준다.
[5] 세대
label 은 변수로 계속 참조되므로 수집에서 살아남는다. 첫 GC.Collect() 뒤에 1세대, 두 번째 뒤에 2세대가 된다. 90,000바이트 배열은 큰 객체 힙 기준을 넘으므로 만들자마자 2세대다. 마지막의 GC.KeepAlive 는 두 객체가 그 지점까지 살아 있음을 분명히 하려는 호출이다.
실행 결과
$ dotnet run
[1] 값 형식과 참조 형식의 복사
값 형식: A=10, B=99
참조 형식: A=99, B=99
[2] 박싱
박싱 시점의 값=5, 원본=9, 언박싱=5
박싱 경로가 더 많이 할당: True
[3] 문자열 분할과 Span 파싱
합계(Split)=21640
합계(Span)=21640
Span 경로가 더 적게 할당: True
[4] ArrayPool·Memory·stackalloc
버퍼 길이가 요청 이상: True
서울 2, 부산 2, 대전 1, 불량 1
[5] GC 세대
새 객체 0, 수집 1회 뒤 1, 수집 2회 뒤 2, 큰 배열 2, 최대 세대 2
실무에서 자주 틀리는 것
async 메서드에서 Span 을 받는다
틀린 코드는 컴파일되지 않는다. await 를 지나서 살아 있어야 하는 값은 스택에만 있어야 하는 형식이 될 수 없기 때문이다.
static async Task SaveAsync(ReadOnlySpan<char> line) // 오류
{
await Task.Delay(1);
Console.WriteLine(line.Length);
}
Memory 로 받고, 실제로 읽는 동기 구간에서만 .Span 을 꺼낸다.
static async Task SaveAsync(ReadOnlyMemory<char> line)
{
await Task.Delay(1);
Console.WriteLine(line.Span.Length);
}
ArrayPool 배열의 길이를 요청 길이로 착각한다
Rent 는 요청보다 긴 배열을 줄 수 있다. 배열 전체를 처리하면 이전 사용자가 남긴 내용까지 읽게 된다.
char[] buf = ArrayPool<char>.Shared.Rent(100);
Console.WriteLine(new string(buf)); // 길이가 128 일 수 있고 쓰레기 값이 섞인다
ArrayPool<char>.Shared.Return(buf);
Console.WriteLine(buf[0]); // 반납한 뒤에 계속 사용
사용한 길이를 따로 기억하고, 그 범위만 다루고, 반납 뒤에는 만지지 않는다. 민감한 데이터를 담았다면 Return(buf, clearArray: true) 로 지운다.
char[] buf = ArrayPool<char>.Shared.Rent(100);
try
{
int used = FillBuffer(buf);
Console.WriteLine(new string(buf, 0, used));
}
finally
{
ArrayPool<char>.Shared.Return(buf);
}
로그 호출에서 숨은 박싱을 놓친다
object 를 받는 인자에 값 형식을 넘기면 호출마다 박싱된다. 반복문 안에서 하면 할당이 누적된다.
for (int i = 0; i < parcels.Length; i++)
Log("weight", parcels[i].WeightGram); // void Log(string key, object value)
제네릭 메서드로 바꾸면 값 형식이 그대로 전달된다. 값을 꼭 문자열로 바꿔야 한다면 그 시점에 한 번만 바꾼다.
static void Log<T>(string key, T value) where T : notnull
{
// value 를 T 로 유지한 채 사용
}
GC.Collect 로 메모리를 관리하려 한다
메모리가 늘어난 것을 보고 처리 루프마다 GC.Collect() 를 부르면 살아 있는 객체까지 매번 승격시키고 멈춤 시간을 늘린다.
foreach (var file in files)
{
Process(file);
GC.Collect(); // 수집 시점을 사람이 정한다
}
수동 호출을 지우고 원인이 되는 할당, 즉 반복마다 만드는 버퍼와 문자열을 줄인다. 관찰이 목적일 때만 호출하고, 그 경우에도 측정 코드에 한정한다.
char[] buf = ArrayPool<char>.Shared.Rent(4096);
try
{
foreach (var file in files) Process(file, buf);
}
finally
{
ArrayPool<char>.Shared.Return(buf);
}
한눈에 보기
| 주제 | 핵심 | 쓰는 곳 | 주의 |
|---|---|---|---|
| 값/참조 배치 | 값 형식은 값을 그 자리에 담는다 | 작고 불변인 데이터 | 큰 struct 는 복사 비용이 든다 |
| 박싱 | 값 형식을 object 로 바꾸면 힙 복사 | 제네릭으로 피한다 | object 매개변수, 제약 없는 인터페이스 호출 |
| Span·ReadOnlySpan | 복사 없이 구간을 본다 | 동기 파싱, 슬라이스 | 필드·람다·async 에서 쓸 수 없다 |
| Memory | 힙에 둘 수 있는 구간 | 필드, async 경계 | 읽을 때 .Span 으로 꺼낸다 |
| ArrayPool | 배열을 빌리고 돌려준다 | 반복되는 중간·큰 버퍼 | 길이가 더 클 수 있다, Return 후 사용 금지 |
| GC 세대 | 0, 1, 2세대와 큰 객체 힙 | 할당 패턴 이해 | 수동 Collect 는 관찰용 |
연습 문제
- 완성 코드의
sizeB.Width = 99앞 줄을var sizeB = sizeA대신var sizeB = refA로 바꾸면 컴파일이 되는지, 된다면 결과가 어떻게 되는지 답하라. 되지 않는다면 이유를 적어라. object boxed = weight이후weight = 9로 바꾸고boxed를 출력하면 왜 5 가 나오는지 박싱의 정의로 설명하라. 또List<int>가 박싱을 피하는 이유를 한 문장으로 적어라.ManifestReader.TryParseLine을 참고해"KR,SEL,700"처럼 송장 번호 자리에 문자가 들어간 줄이 어떻게 처리되는지 적고, 유효한 줄로 취급하려면 무엇을 바꿔야 하는지 답하라.ArrayPool<char>.Shared.Rent(manifest.Length)로 받은 배열을PendingBatch에buffer.AsMemory()로 넘기면 어떤 버그가 생기는지 설명하고, 완성 코드에서는 어떻게 막았는지 적어라.
정답과 해설
- 컴파일되지 않는다.
var sizeB = refA의 형식은PackageSizeRef이고, 이 형식에는PackageSizeValue로 바꾸는 변환이 없으므로 이후 코드가 성립하지 않는다. 그 아래 줄을sizeB.Width = 99처럼 그대로 두면 문제는 없으나sizeB는 이제refA와 같은 객체를 가리키게 되어 이 경우 A 와 B 가 모두 99 가 된다. 요점은 변수가 형식을 결정하고, 그 형식이 대입의 의미를 결정한다는 것이다. - 박싱은 값을 힙에 새로 만든 상자로 복사하는 동작이다. 이후 원본 변수를 바꿔도 상자는 별개의 복사본이므로 5 가 유지된다.
List<int>는 내부 배열이int[]이므로 값을 상자에 넣지 않고 배열 요소로 바로 저장한다. - 송장 번호 자리
KR은NumberStyles.None으로 정수로 바꿀 수 없으므로int.TryParse가 false 를 돌려주고, 이 줄은 불량으로 셈한다. 유효한 줄로 취급하려면ParcelRecord의Id를 문자열이나 다른 형식으로 바꿔야 하는데, 문자열로 만들면 줄마다 할당이 생기므로ReadOnlyMemory<char>로 원본 줄의 구간을 기억하는 방식을 함께 고려한다. - Rent 가 요청보다 긴 배열을 줄 수 있어서,
AsMemory()는 배열 전체를 가리키고 뒤쪽에 남은 이전 내용까지 줄로 읽는다. 그 결과 불량 줄이 늘거나 이전 사용자의 데이터가 섞여 들어온다. 완성 코드는buffer.AsMemory(0, manifest.Length)로 실제로 쓴 길이만 잘라서 넘겼다.