Devin.KR

C# · 심화

제네릭·비동기·성능 설계

메모리와 GC - 할당을 줄이는 코드

값/참조 형식 배치, 박싱, Span<T>·Memory<T>, ArrayPool, GC 세대

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 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세대는 드물게 수집한다.

새 객체는 0세대에서 시작해 수집에서 살아남을 때마다 승격하고, 큰 배열은 처음부터 큰 객체 힙에 놓인다.

약 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 는 관찰용

연습 문제

  1. 완성 코드의 sizeB.Width = 99 앞 줄을 var sizeB = sizeA 대신 var sizeB = refA 로 바꾸면 컴파일이 되는지, 된다면 결과가 어떻게 되는지 답하라. 되지 않는다면 이유를 적어라.
  2. object boxed = weight 이후 weight = 9 로 바꾸고 boxed 를 출력하면 왜 5 가 나오는지 박싱의 정의로 설명하라. 또 List<int> 가 박싱을 피하는 이유를 한 문장으로 적어라.
  3. ManifestReader.TryParseLine 을 참고해 "KR,SEL,700" 처럼 송장 번호 자리에 문자가 들어간 줄이 어떻게 처리되는지 적고, 유효한 줄로 취급하려면 무엇을 바꿔야 하는지 답하라.
  4. ArrayPool<char>.Shared.Rent(manifest.Length) 로 받은 배열을 PendingBatch 에 buffer.AsMemory() 로 넘기면 어떤 버그가 생기는지 설명하고, 완성 코드에서는 어떻게 막았는지 적어라.

정답과 해설

  1. 컴파일되지 않는다. var sizeB = refA 의 형식은 PackageSizeRef 이고, 이 형식에는 PackageSizeValue 로 바꾸는 변환이 없으므로 이후 코드가 성립하지 않는다. 그 아래 줄을 sizeB.Width = 99 처럼 그대로 두면 문제는 없으나 sizeB 는 이제 refA 와 같은 객체를 가리키게 되어 이 경우 A 와 B 가 모두 99 가 된다. 요점은 변수가 형식을 결정하고, 그 형식이 대입의 의미를 결정한다는 것이다.
  2. 박싱은 값을 힙에 새로 만든 상자로 복사하는 동작이다. 이후 원본 변수를 바꿔도 상자는 별개의 복사본이므로 5 가 유지된다. List<int> 는 내부 배열이 int[] 이므로 값을 상자에 넣지 않고 배열 요소로 바로 저장한다.
  3. 송장 번호 자리 KR 은 NumberStyles.None 으로 정수로 바꿀 수 없으므로 int.TryParse 가 false 를 돌려주고, 이 줄은 불량으로 셈한다. 유효한 줄로 취급하려면 ParcelRecord 의 Id 를 문자열이나 다른 형식으로 바꿔야 하는데, 문자열로 만들면 줄마다 할당이 생기므로 ReadOnlyMemory<char> 로 원본 줄의 구간을 기억하는 방식을 함께 고려한다.
  4. Rent 가 요청보다 긴 배열을 줄 수 있어서, AsMemory() 는 배열 전체를 가리키고 뒤쪽에 남은 이전 내용까지 줄로 읽는다. 그 결과 불량 줄이 늘거나 이전 사용자의 데이터가 섞여 들어온다. 완성 코드는 buffer.AsMemory(0, manifest.Length) 로 실제로 쓴 길이만 잘라서 넘겼다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.