본문 바로가기

C#

C# ReadOnlyMemory<T>와 메모리 안전성 확보

ReadOnlyMemory<T>는 데이터를 복사하지 않고 읽기 전용 뷰를 제공해 성능과 메모리 안전성을 동시에 확보하는데 유용합니다. 특히 비동기 경계도 안전하게 넘길 수 있어 API 설계에서 ReadOnlySpan<T>보다 실용적입니다.

1. ReadOnlyMemory<T> 한눈에 보기

ReadOnlyMemory<T>는 힙에 저장 가능하며 필드로 보관, 비동기 메서드 반환 등이 가능합니다. 반면 ReadOnlySpan<T>는 ref struct로 스택 한정이며 필드 저장이나 async 경계를 넘길 수 없습니다. ReadOnlyMemory는 Slice로 부분 뷰를 만들고, 읽을 때는 .Span으로 ReadOnlySpan을 얻어 빠르게 접근합니다.

2. 기본 사용법과 복사 없는 슬라이싱

배열과 문자열에서 쉽게 읽기 전용 뷰를 만들고 복사 없이 부분을 다룰 수 있습니다.

using System;

class Demo
{
    static void Main()
    {
        var data = new byte[] { 1, 2, 3, 4, 5 };
        ReadOnlyMemory<byte> ro = data;                 // 전체 뷰
        ReadOnlyMemory<byte> slice = ro.Slice(1, 3);     // {2,3,4}
        Process(slice);

        string s = "Hello World";
        ReadOnlyMemory<char> mem = s.AsMemory(6, 5);     // "World"
        Console.WriteLine(mem.Span.ToString());          // ReadOnlySpan<char> 출력
    }

    static void Process(ReadOnlyMemory<byte> input)
    {
        // 읽을 때는 ReadOnlySpan으로 접근
        foreach (var b in input.Span)
            Console.WriteLine(b);
    }
}

3. 메모리 안전성 포인트

ReadOnlyMemory는 읽기 전용 뷰일 뿐, 원본 데이터의 불변을 보장하지 않습니다. 즉 내부에서 배열을 여전히 수정할 수 있으면 외부의 읽기 결과도 바뀝니다. 외부에 배열을 직접 노출하지 말고 ReadOnlyMemory로 공개해 의도치 않은 변경을 막습니다.

public sealed class Packet
{
    private readonly byte[] _buffer; // 내부에서만 보유
    public Packet(byte[] src)
    {
        // 외부 소유 배열이면 복사해 소유권 명확화
        _buffer = new byte[src.Length];
        Buffer.BlockCopy(src, 0, _buffer, 0, src.Length);
    }

    public ReadOnlyMemory<byte> Payload => _buffer; // 읽기 전용 뷰로만 노출
}

반대로, 소비자에게 데이터를 받는 API는 ReadOnlyMemory를 받아 불필요한 복사를 줄이고 호출자 버퍼를 안전하게 사용할 수 있습니다.

public static int Sum(ReadOnlyMemory<int> input)
{
    int sum = 0;
    foreach (var v in input.Span)
        sum += v;
    return sum;
}

4. Span과의 차이, 수명과 비동기

ReadOnlySpan은 성능 최전선에서 매우 빠르지만 스택 수명에 묶여 필드/박싱/async/iterator에 사용 불가합니다. ReadOnlyMemory는 힙에 저장 가능해 비동기 경계를 안전하게 넘기고, 필요 시 .Span으로 읽기 전용 접근을 수행합니다. 필드에 저장하거나 비동기 메서드에서 반환할 때는 ReadOnlyMemory를 선택합니다.

5. 인코딩과 버퍼 작업에서의 무복사 패턴

인코딩처럼 입력은 읽기 전용, 출력은 쓰기 가능인 시나리오에서 입력은 ReadOnlyMemory, 출력은 Memory/Span을 조합합니다.

using System;
using System.Buffers;
using System.Text;

public static class EncodingDemo
{
    public static ReadOnlyMemory<byte> EncodeUtf8(ReadOnlyMemory<char> input)
    {
        // 필요 바이트 계산
        int byteCount = Encoding.UTF8.GetByteCount(input.Span);
        var owner = MemoryPool<byte>.Shared.Rent(byteCount);
        int written = Encoding.UTF8.GetBytes(input.Span, owner.Memory.Span);
        // 소비자에게는 읽기 전용으로 노출하되, 소유권을 함께 관리해야 안전
        return owner.Memory.Slice(0, written);
    }
}

주의: 위처럼 풀에서 임대한 버퍼를 반환하면 임대 수명을 누가 책임지는지 명확히 해야 합니다. 호출자에게 IMemoryOwner<byte>를 함께 넘기거나, 소유 객체가 Dispose될 때까지 ReadOnlyMemory를 유효하게 유지해야 합니다.

6. 소유권(IMemoryOwner)과 수명 관리

풀 메모리를 외부에 노출할 때는 소유자와 함께 전달해 수명을 명확히 합니다.

using System;
using System.Buffers;

public sealed class OwnedBytes : IDisposable
{
    private IMemoryOwner<byte> _owner;
    public ReadOnlyMemory<byte> Memory => _owner?.Memory ?? ReadOnlyMemory<byte>.Empty;

    public OwnedBytes(IMemoryOwner<byte> owner, int length)
    {
        _owner = owner;
        // 필요한 구간만 노출
        _memory = _owner.Memory.Slice(0, length);
    }

    private readonly ReadOnlyMemory<byte> _memory;
    public void Dispose()
    {
        _owner?.Dispose();
        _owner = null;
    }
}

대안으로 TryWrite와 같은 패턴에서 IBufferWriter<byte>를 받아 호출자 버퍼로 직접 쓰는 방법도 효과적입니다.

7. 고정(pin)과 네이티브 연동

네이티브 호출에 포인터가 필요하면 ReadOnlyMemory.Pin으로 고정할 수 있습니다. 고정은 GC 이동을 막아 단편화를 유발할 수 있으므로 최소 범위, 최소 시간만 수행합니다.

using System;

unsafe static void CallNative(ReadOnlyMemory<byte> ro)
{
    using var handle = ro.Pin(); // MemoryHandle
    byte* ptr = (byte*)handle.Pointer;
    nuint len = (nuint)ro.Length;
    // NativeCall(ptr, len);
}

8. 흔한 실수와 예방

ReadOnlyMemory가 불변을 보장한다고 오해하지 않습니다. 원본 배열을 다른 코드가 수정하면 읽기 결과가 변합니다. 외부에 배열을 노출하지 말고 필요한 경우 복사해 소유권을 확립합니다.

Span/ReadOnlySpan을 필드에 저장하거나 async 경계를 넘기지 않습니다. 비동기 보관이 필요하면 ReadOnlyMemory를 사용합니다.

ReadOnlyMemory.ToArray는 즉시 복사를 유발합니다. 꼭 필요한 경우에만 사용하고, 가능한 한 뷰를 유지합니다.

풀에서 임대한 메모리를 반환한 뒤에도 참조를 유지하지 않습니다. 반환 이후의 ReadOnlyMemory/Span 사용은 정의되지 않은 동작을 야기합니다.

9. 적용 체크리스트

입력 매개변수는 ReadOnlyMemory로 받아 복사 비용을 줄입니다.

출력은 Memory/Span 또는 IBufferWriter로 받아 쓰고, 외부에는 ReadOnlyMemory로 노출합니다.

비동기 경계나 필드 보관이 필요하면 ReadOnlyMemory를, 단기 고성능 처리에는 ReadOnlySpan을 사용합니다.

풀과 소유권을 명확히 하여 수명 종료 시점에 Dispose를 보장합니다.

10. 마무리

ReadOnlyMemory<T>는 무복사 읽기 전용 뷰, 안전한 수명 관리, 비동기 친화성을 동시에 제공해 현대 .NET에서 기본 API 타입으로 적합합니다. 입력을 ReadOnlyMemory로 받고, 내부에서는 Span으로 빠르게 처리하며, 소유권과 수명을 분리해 설계하면 메모리 안전성과 성능을 모두 잡을 수 있습니다.