반응형

[PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법

배열 슬라이싱의 기존 문제 / 힙 없이 뷰만 만드는 원리 / ref struct가 강제하는 제약 / async 경계는 Memory<T>에 맡긴다


1. 문제 제기 — Substring 한 줄이 프레임을 먹는다

Unity 모바일 게임에서 서버로부터 받은 한 줄짜리 CSV 응답 "1001,42,HERO_A,25"를 파싱한다고 가정해 보겠습니다. 흔하게 쓰는 방법은 string.SplitSubstring입니다. 문제는 이 한 줄 파싱이 프레임당 수백 번 호출될 때 드러납니다.

C#
// Update 루프에서 매 프레임 호출되는 패킷 파서
void Update()
{
    string packet = NetworkBuffer.DequeueString();   // 한 프레임에 수십~수백 개
    string[] parts = packet.Split(',');              // 배열 1개 + 문자열 N개 힙 할당
    int id = int.Parse(parts[0]);
    int damage = int.Parse(parts[1]);
    // ...
}

Split새로운 string[] 배열을 힙에 만들고, 분리된 각 토큰마다 string 인스턴스를 힙에 만듭니다. 5개 필드짜리 패킷을 초당 100번 파싱하면 초당 600개의 작은 객체가 생성됩니다. 이 객체들은 곧 Gen0 수집 대상이 되고, Unity에서 GC가 깨어나면 프레임이 얼어붙습니다(GC 스파이크).

GC 스파이크(GC Spike) — 가비지 컬렉터가 일시적으로 관리 메모리를 정지시키고 수집을 수행하는 동안 발생하는 프레임 드랍. Unity의 Boehm GC는 세대 구분이 없어 수집 시간이 더 튀기 쉽습니다.

정말로 필요한 것은 "1001,42,HERO_A,25" 안에서 "1001" 부분만 가리키는 포인터와 길이입니다. 데이터는 이미 원본 문자열 안에 있는데, 왜 똑같은 바이트를 힙 어딘가에 다시 복사해야 할까요.

Span<T>Memory<T>는 바로 이 질문에 답합니다. "복사하지 말고 가리키기만 하자"는 것입니다. 이 글은 왜 둘을 굳이 나눴는지, ref struct가 만드는 제약은 어디서 오는지, 그리고 Unity 모바일 환경에서 이 도구가 어디까지 현실적으로 쓸 수 있는지를 IL 수준까지 내려가 설명합니다.


2. 개념 정의 — 메모리 위를 떠다니는 가벼운 창

2.1 Span<T>는 "포인터 + 길이"일 뿐

Span<T>는 연속된 메모리 영역을 가리키는 뷰(view) 입니다. 데이터를 소유하지 않고, 이미 존재하는 메모리의 어느 구간을 가리키는 "창(window)"만 제공합니다.

원본 메모리 (배열·문자열·stackalloc)

위 그림처럼 Span<T>는 내부적으로 (참조, 길이) 두 필드만 가진 값 타입입니다. 배열이든 문자열이든 stackalloc 버퍼든, 이 두 필드만 바꿔 끼우면 동일한 API로 접근할 수 있습니다.

C#
int[] array = { 0, 1, 2, 3, 4, 5 };
Span<int> spanFromArray = array;                  // 배열 → Span
Span<int> slice = spanFromArray.Slice(2, 3);      // 인덱스 2부터 길이 3
slice[0] = 99;
// slice는 원본 array를 가리키므로 array[2] == 99
ref struct — 참조형 구조체 (ref-like struct) 인스턴스가 반드시 스택에만 존재하도록 컴파일러가 강제하는 특수 값 타입. 힙 저장·박싱·async 상태 머신 필드화가 모두 금지됩니다.
예시: public readonly ref struct Span<T> { ... } 컴파일러가 힙으로 새어 나갈 수 있는 모든 경로를 금지합니다.

2.2 왜 이걸 "할당 없는 뷰"라고 부르는가

Span<T>스택에만 존재하는 값 타입입니다. 스택 할당은 함수 진입 시 프레임에 16바이트 정도 자리를 잡는 것이고, 함수가 끝나면 자동으로 사라집니다. 힙을 건드리지 않으므로 GC가 추적할 대상이 없습니다. Substring이 매번 string 객체를 힙에 새로 만드는 것과 정확히 반대입니다.

2.3 Memory<T>는 Span을 담을 수 있는 상자

Span<T>는 스택 전용이라 클래스 필드로도, async 메서드의 변수로도 저장할 수 없습니다. 이 제약을 우회하는 짝이 Memory<T>입니다.

구분 Span<T> Memory<T>
타입 종류 ref struct 일반 struct
저장 위치 스택 전용 스택 + 힙 모두 가능
클래스 필드
async 변수
데이터 접근 직접(인덱서) .Span 호출로 변환 후
JIT 최적화 최고 (ByRefLike) 일반 struct 수준
async — 비동기 메서드 키워드 메서드를 컴파일러가 상태 머신 클래스로 변환해 await 지점에서 중단·재개할 수 있도록 만드는 키워드. 지역 변수는 모두 그 상태 머신 클래스의 필드가 됩니다.
예시: async Task<int> ReadAsync(Memory<byte> buf) { ... } 상태 머신이 힙에 놓이므로 필드로 넣을 수 있는 것은 ref struct가 아닌 타입뿐입니다.

Memory<T>는 "저장은 Memory, 처리는 Span"이라는 한 줄 규칙으로 요약됩니다. 오래 보관해야 할 버퍼는 Memory<T>로 들고 다니다가, 실제로 데이터를 훑어야 하는 시점에 .Span 속성을 호출해 그 자리에서만 쓰고 버립니다.


3. 내부 동작 — ref struct와 상태 머신이 만나지 못하는 이유

3.1 Span이 스택에만 사는 이유

Span<T>의 구조는 개념적으로 다음과 같습니다.

C#
public readonly ref struct Span<T>
{
    internal readonly ref T _reference;   // 원본 메모리 첫 요소의 참조
    private readonly int _length;         // 가리키는 범위의 길이
}

핵심은 ref T _reference입니다. 이 내부 참조(interior reference) 는 배열 중간이든 스택 변수든 네이티브 메모리든 어디든 가리킬 수 있습니다. 만약 Span<T>가 힙 객체의 필드가 될 수 있다고 가정하면, 다음과 같은 상황이 가능해집니다.

C#
// 가정: Span이 힙에 저장될 수 있다면
class Container
{
    public Span<int> Held;   // (실제로는 컴파일 에러)
}

void Trap(Container c)
{
    Span<int> local = stackalloc int[4];  // 스택 프레임에 할당
    c.Held = local;                       // 힙 객체가 스택 주소를 붙잡음
}   // 여기서 local이 가리키던 스택 프레임은 사라짐

// c.Held는 이제 "이미 재사용된 스택 주소"를 가리킴 → 메모리 손상

C/C++에서 흔히 말하는 댕글링 포인터 문제입니다. C#은 이를 컴파일 타임에 차단하기 위해 Span<T>ref struct로 정의했고, 다음을 전부 금지합니다.

  • 클래스·일반 구조체의 필드로 선언 — ❌
  • 제네릭 타입 인자로 사용 (List<Span<int>>) — ❌
  • 박싱 (object obj = span) — ❌
  • async 메서드의 지역 변수 (상태 머신 필드화) — ❌
  • 반복자(yield return 포함 메서드)의 지역 변수 — ❌
  • 람다·지역 함수의 캡처 대상 — ❌

3.2 Substring은 힙에 새 문자열을 찍어낸다 — IL로 확인

/il-analysis 실행 결과로 Substring이 힙을 만지는지, Span.Slice가 스택에서 끝나는지 비교합니다.

C#
// Before — string.Substring: 매 호출마다 힙에 새 string 할당
public static int SumDigitsSubstring(string csv)
{
    int sum = 0;
    int start = 0;
    for (int i = 0; i <= csv.Length; i++)
    {
        if (i == csv.Length || csv[i] == ',')
        {
            string token = csv.Substring(start, i - start);  // 할당
            sum += int.Parse(token);
            start = i + 1;
        }
    }
    return sum;
}

// After — ReadOnlySpan<char>.Slice: 할당 없이 뷰만 조정
public static int SumDigitsSpan(string csv)
{
    int sum = 0;
    int start = 0;
    ReadOnlySpan<char> span = csv.AsSpan();
    for (int i = 0; i <= span.Length; i++)
    {
        if (i == span.Length || span[i] == ',')
        {
            ReadOnlySpan<char> token = span.Slice(start, i - start);  // 할당 없음
            sum += int.Parse(token);
            start = i + 1;
        }
    }
    return sum;
}

Before 쪽 IL의 핵심만 발췌합니다.

IL
// SumDigitsSubstring — 토큰 경계마다 String::Substring 호출
IL_0026: ldarg.0
IL_0027: ldloc.1
IL_0028: ldloc.2
IL_0029: ldloc.1
IL_002a: sub
IL_002b: callvirt instance string [System.Runtime]System.String::Substring(int32, int32)  // 힙 할당 발생
IL_0030: stloc.s 4
IL_0032: ldloc.0
IL_0033: ldloc.s 4
IL_0035: call int32 [System.Runtime]System.Int32::Parse(string)  // string 오버로드
IL_003a: add
IL_003b: stloc.0

After 쪽은 어떻게 바뀌는지 봅니다.

IL
// SumDigitsSpan — Slice는 call 한 번으로 새 Span 값만 반환
.locals init (
    [0] int32,
    [1] int32,
    [2] valuetype [System.Runtime]System.ReadOnlySpan`1<char>,  // 지역 0: 원본 Span (스택)
    [3] int32,
    [4] bool,
    [5] valuetype [System.Runtime]System.ReadOnlySpan`1<char>,  // 지역 5: 슬라이스 (스택)
    [6] bool,
    [7] int32
)

IL_0005: ldarg.0
IL_0006: call valuetype [System.Runtime]System.ReadOnlySpan`1<char>
         [System.Memory]System.MemoryExtensions::AsSpan(string)   // string → Span (할당 없음)
IL_000b: stloc.2

// ... 루프 내부 ...
IL_0032: ldloca.s 2                                                // Span의 주소
IL_0034: ldloc.1
IL_0035: ldloc.3
IL_0036: ldloc.1
IL_0037: sub
IL_0038: call instance valuetype [System.Runtime]System.ReadOnlySpan`1<!0>
         [System.Runtime]System.ReadOnlySpan`1<char>::Slice(int32, int32)  // 스택 구조체 반환
IL_003d: stloc.s 5

IL_003f: ldloc.0
IL_0040: ldloc.s 5
IL_0042: ldc.i4.7                                                  // NumberStyles.Integer
IL_0043: ldnull
IL_0044: call int32 [System.Runtime]System.Int32::Parse(
         valuetype [System.Runtime]System.ReadOnlySpan`1<char>,
         valuetype [System.Runtime]System.Globalization.NumberStyles,
         class [System.Runtime]System.IFormatProvider)             // Span 오버로드 — 내부에서도 할당 없음

IL 분석 포인트

  1. callvirt Substring vs call Slice — Before의 callvirt는 힙에 새 System.String 인스턴스를 만드는 라이브러리 메서드를 호출합니다. 토큰 5개면 프레임당 힙에 5개 객체가 찍힙니다. After의 call Slice값 타입 ReadOnlySpan<char>를 반환하므로 전부 스택 지역 변수([5])에 저장되고 함수 종료 시 자동 회수됩니다.
  2. locals init의 차이 — Before의 .locals에는 [4] string이 있어 "이 프레임에 문자열 참조 슬롯이 있다"는 신호를 줍니다. 반면 After의 .locals에는 ReadOnlySpan<char> 값 타입 슬롯이 두 개 들어 있고 참조 타입은 하나도 없습니다. 스택 위에 값만 놓고 쓰겠다는 뜻입니다.
  3. Unity Update 루프 관점 — Substring 버전이 초당 60프레임 × 5토큰 = 초당 300개의 string 힙 객체를 만듭니다. Span 버전은 0개입니다. Boehm GC 환경에서는 이 차이가 곧 스파이크 유무로 이어집니다.

3.3 Memory<T>는 async 상태 머신에 들어간다 — IL로 확인

C#
public static async Task<int> ReadHeadAsync(ReadOnlyMemory<byte> buffer)
{
    await Task.Yield();                       // await 경계
    ReadOnlySpan<byte> span = buffer.Span;    // 필요한 시점에만 변환
    int sum = 0;
    for (int i = 0; i < span.Length && i < 4; i++) sum += span[i];
    return sum;
}

컴파일러는 이 async 메서드를 <ReadHeadAsync>d__0 상태 머신 구조체로 변환합니다(첫 await에서 중단이 일어나면 이 구조체가 박싱되어 힙으로 올라갑니다). 핵심은 그 구조체의 필드 선언입니다.

IL
.class nested private auto ansi sealed beforefieldinit '<ReadHeadAsync>d__0'
    extends [System.Runtime]System.ValueType
    implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
    .field public int32 '<>1__state'
    .field public valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<int32> '<>t__builder'
    .field public valuetype [System.Runtime]System.ReadOnlyMemory`1<uint8> buffer   // ✅ Memory는 필드 OK
    // ...
}

// MoveNext 내부 — await 이후에 .Span을 호출해 그 시점에만 Span을 만든다
IL_0074: ldflda valuetype [System.Runtime]System.ReadOnlyMemory`1<uint8>
         Program/'<ReadHeadAsync>d__0'::buffer
IL_0079: call instance valuetype [System.Runtime]System.ReadOnlySpan`1<!0>
         valuetype [System.Runtime]System.ReadOnlyMemory`1<uint8>::get_Span()

IL 분석 포인트

  1. ReadOnlyMemory<uint8> buffer가 상태 머신 필드로 선언됨Memory<T>는 일반 struct라서 상태 머신 구조체의 필드가 될 수 있습니다. 만약 파라미터 타입이 ReadOnlySpan<byte>였다면 컴파일러는 CS4012("Parameters or locals of type 'ReadOnlySpan<byte>' cannot be declared in async methods or async lambda expressions") 오류를 내고 컴파일을 거부합니다.
  2. .Span 호출은 await 경계를 넘은 이후에MoveNext에서 await Task.Yield()를 처리한 다음 그 자리에서 get_Span()을 호출합니다. Span<T>는 이 MoveNext 프레임의 스택에만 존재하므로, await로 프레임이 교체되기 전에만 유효합니다. 이 패턴이 "저장은 Memory, 처리는 Span"의 IL 수준 구현입니다.
  3. Unity async 관점 — Unity에서 UniTask·Awaitable·async Task를 섞어 쓰는 네트워크 코드에서도 동일합니다. 수신 버퍼를 ReadOnlyMemory<byte>로 보관하다가 디스패치 직전에만 Span으로 훑으면 GC 압박이 크게 줄어듭니다.

4. 실전 적용 — 파싱·슬라이싱·버퍼 재사용

4.1 stackalloc + Span — 스택 버퍼를 안전하게 쓰는 법

Unity 클라이언트에서 한 번 쓰고 버리는 작은 바이트 배열(예: HMAC·해시 계산용)을 new byte[32]로 만들면 Gen0 쓰레기가 됩니다. stackalloc을 쓰면 스택에서 해결되지만, 전통적으로 unsafe + byte* 조합이 필요했습니다. Span<T>는 이 조합을 safe 코드로 바꿔 줍니다.

C#
// Before — new byte[]: 힙 할당, 계산이 자주 일어나면 GC 압박
public static int HashEightNumbersHeap()
{
    byte[] buffer = new byte[32];             // 힙 할당
    for (int i = 0; i < buffer.Length; i++) buffer[i] = (byte)(i * 3);
    int sum = 0;
    foreach (byte b in buffer) sum += b;
    return sum;
}

// After — stackalloc + Span: 스택만 사용, GC 부담 없음
public static int SumStackBuffer()
{
    Span<int> buffer = stackalloc int[8];     // 32바이트가 스택 프레임에 잡힘
    for (int i = 0; i < buffer.Length; i++) buffer[i] = i + 1;
    int sum = 0;
    foreach (int v in buffer) sum += v;
    return sum;
}

After의 IL을 보면 스택 할당이 어떻게 표현되는지 명확합니다.

IL
// SumStackBuffer — localloc으로 스택에 32바이트 잡고 Span으로 감싼다
.locals init (
    [0] valuetype [System.Runtime]System.Span`1<int32>,   // 스택 구조체
    ...
)

IL_0001: ldc.i4.s 32                                       // 8 × sizeof(int)
IL_0003: conv.u
IL_0004: localloc                                          // ⭐ 스택에 32바이트 확보
IL_0006: ldc.i4.8
IL_0007: newobj instance void valuetype
         [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)  // Span(포인터, 길이)
IL_000c: stloc.2

IL 분석 포인트

  1. localloc 명령어 — CIL의 localloc은 현재 스택 프레임에 N바이트를 동적으로 확보합니다. 함수가 리턴되면 자동 해제되므로 GC 추적 대상이 아닙니다. newobj가 보이지만 이것은 Span 값 타입의 생성자 호출일 뿐 힙 할당이 아닙니다(값 타입의 .ctor은 기존 스택 슬롯을 초기화합니다).
  2. 경계 검사 — 인덱서 get_Item이 호출될 때마다 _length와 비교하는 경계 검사가 들어갑니다. unsafe 포인터와 달리 버퍼 오버런이 즉시 IndexOutOfRangeException으로 드러나므로 안전합니다.
  3. Unity 관점stackalloc은 Unity에서도 Mono·IL2CPP 모두 지원됩니다. 단 스택 프레임 한계(플랫폼마다 다르지만 보통 1MB 이하)를 넘지 않도록 1KB 이하로 유지하는 것이 안전한 경험칙입니다.

4.2 Utf8Parser — string을 거치지 않는 숫자 파싱

Utf8ParserReadOnlySpan<byte>에서 직접 int·long·Guid·DateTime으로 파싱합니다. 중간 단계의 string 객체가 완전히 사라집니다.

C#
using System.Buffers.Text;
using System.Text;

// Before — byte[] → string → int.Parse: 중간 string 힙 할당
public static int ParseIntFromUtf8_Before(byte[] bytes, int start, int length)
{
    string s = Encoding.UTF8.GetString(bytes, start, length);   // 힙에 string 생성
    return int.Parse(s);
}

// After — Utf8Parser.TryParse(ReadOnlySpan<byte>): 할당 없음
public static int ParseIntFromUtf8_After(byte[] bytes, int start, int length)
{
    ReadOnlySpan<byte> span = bytes.AsSpan(start, length);
    Utf8Parser.TryParse(span, out int value, out _);
    return value;
}

Before의 IL은 예상대로 Encoding::GetString 호출이 들어갑니다.

IL
// Before — GetString이 새 string을 힙에 만든다
IL_0001: call class [System.Runtime]System.Text.Encoding
         [System.Runtime]System.Text.Encoding::get_UTF8()
IL_0009: callvirt instance string
         [System.Runtime]System.Text.Encoding::GetString(uint8[], int32, int32)   // 힙 할당
IL_0010: call int32 [System.Runtime]System.Int32::Parse(string)

After의 IL에는 GetString이 없습니다. AsSpanUtf8Parser::TryParse 두 번의 call로 끝납니다.

IL
// After — 문자열 경유 없이 바이트 스팬을 그대로 파싱
IL_0004: call valuetype [System.Runtime]System.Span`1<!!0>
         [System.Memory]System.MemoryExtensions::AsSpan<uint8>(!!0[], int32, int32)
IL_0009: call valuetype [System.Runtime]System.ReadOnlySpan`1<!0>
         [System.Runtime]System.Span`1<uint8>::op_Implicit(...)  // Span → ReadOnlySpan 암시 변환
IL_0015: call bool [System.Memory]System.Buffers.Text.Utf8Parser::TryParse(
         valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>,
         int32&, int32&, char)                                    // 결과는 out 파라미터로

IL 분석 포인트

  1. 힙 경로가 사라짐 — Before의 Encoding::GetString은 숫자 길이만큼의 char[] 내부 버퍼와 최종 string을 힙에 만듭니다. After에는 이 체인이 완전히 제거되고 Span 두 개와 int& 참조만 오갑니다.
  2. op_ImplicitSpan<T>ReadOnlySpan<T> 변환은 값 복사 없이 참조·길이만 재해석합니다. .NET의 제로 카피 전통이 그대로 드러납니다.
  3. Unity 네트워크 핫패스 관점 — 모바일 RPG의 초당 수백 개 패킷 디코딩에서 이 패턴이 수천 개 임시 string을 원천 차단합니다. 핫패스에서 Utf8JsonReader를 쓰는 이유도 같은 설계 철학입니다.

4.3 파일 I/O에서 Memory<byte>로 버퍼 재사용

Unity에서 저장 파일(Save file)·애셋 번들 조각을 비동기로 읽을 때, Stream.ReadAsync(byte[]) 대신 Stream.ReadAsync(Memory<byte>) 오버로드를 쓰면 ValueTask<int>를 받을 수 있어 동기 완료 경로에서 Task 인스턴스 할당까지 피할 수 있습니다.

C#
// Before — byte[] + Task: 매 호출마다 Task 객체 할당 가능
public async Task<int> ReadChunkAsync_Before(Stream s, byte[] buffer)
{
    return await s.ReadAsync(buffer, 0, buffer.Length);
}

// After — Memory<byte> + ValueTask: 동기 완료 시 할당 0
public async ValueTask<int> ReadChunkAsync_After(Stream s, Memory<byte> buffer)
{
    return await s.ReadAsync(buffer);   // .NET Core 2.1+ 오버로드
}

핵심 요약

  • Memory<byte>ArrayPool<byte>.Rent()로 빌려온 버퍼도 감쌀 수 있어 풀 재사용 + 할당 없는 I/O가 한 줄로 연결됩니다.
  • ValueTask<int>는 struct라서 동기적으로 완료되면 힙 할당 자체가 일어나지 않습니다. 이것이 Task 할당을 피하는 한 가지 현실적인 방법입니다.
  • Unity 2021.2+ 의 .NET Standard 2.1 모드에서 두 기능 모두 사용 가능합니다.

5. 함정과 주의사항 — 댕글링 참조와 Span.ToArray()

5.1 ❌ Span을 수명보다 오래 살리려는 유혹

컴파일러가 대부분을 막아 주지만, unsafe 조합이나 MemoryMarshal로 억지로 깨는 코드에서 댕글링이 발생할 수 있습니다. 다행히 평범하게 쓸 때는 컴파일 에러가 나서 사고를 막아 줍니다.

C#
// ❌ 잘못된 시도 — stackalloc한 Span을 클래스 필드에 저장
public class BadBuffer
{
    public Span<byte> Held;   // CS8345: Field or auto-implemented property
                              //         cannot be of type 'Span<byte>'
}

public static void Trap()
{
    Span<byte> local = stackalloc byte[16];
    // new BadBuffer { Held = local }; // 애초에 컴파일 자체가 안 됨
}

이 코드는 컴파일되지 않습니다. IL 수준에서 보면 Span<T>에는 [System.Runtime.CompilerServices.IsByRefLikeAttribute]가 붙어 있어 참조 필드로 쓰는 것을 CLR 검증기가 거부합니다.

C#
// ✅ 올바른 패턴 — 오래 들고 다녀야 하면 Memory<T>로 전환
public class GoodBuffer
{
    public ReadOnlyMemory<byte> Held;   // OK: 일반 struct

    public void Load(ReadOnlyMemory<byte> data)
    {
        Held = data;                    // stackalloc이 아니라 배열·풀 기반 Memory를 받는다
    }
}

5.2 ❌ Span.ToArray()로 조용히 할당을 되살리는 실수

Span<T>를 받아 열심히 할당을 줄였는데, 마지막 줄에서 .ToArray()를 호출해 다시 힙 배열을 만들어 버리는 경우가 흔합니다. 특히 Unity API 호환 때문에 자주 발생합니다.

C#
// ❌ 중간은 Span인데 결과에서 ToArray — 마지막에 할당 부활
public static byte[] DecodePayload_Bad(ReadOnlySpan<byte> raw)
{
    ReadOnlySpan<byte> body = raw.Slice(4);
    return body.ToArray();   // ⭐ 여기서 힙에 새 byte[] 할당
}

// 호출 측: Unity Mesh.SetColors(byte[])처럼 배열만 받는 API 때문에 어쩔 수 없다고 여길 때가 많음

IL에서는 Span<byte>::ToArray() 호출이 결국 newarr로 이어집니다. 반복 호출되는 핫패스라면 Span 최적화의 의미가 절반 이상 사라집니다.

C#
// ✅ 올바른 패턴 — 호출 측이 Span을 받도록 시그니처를 바꾸거나, ArrayPool로 재사용
public static void DecodePayload_Good(ReadOnlySpan<byte> raw, Span<byte> destination)
{
    ReadOnlySpan<byte> body = raw.Slice(4);
    body.CopyTo(destination);   // 호출자가 미리 준비한 버퍼에 복사, 할당 없음
}

Unity API가 byte[]만 받는 경우에도 ArrayPool<byte>.Shared.Rent()로 배열을 빌려 쓰고 Return으로 돌려주면 매 프레임 ToArray로 버리는 패턴을 피할 수 있습니다.

5.3 ❌ Memory<T>.Span을 루프 안에서 반복 호출

Memory<T>.Span은 내부에 저장된 참조를 재구성하는 작은 메서드 호출입니다. 값 자체는 저렴하지만, 루프 안에서 매번 호출하면 JIT가 루프 경계 검사 최적화를 포기할 수 있습니다.

C#
// ❌ 루프 안에서 반복 변환 — JIT 최적화를 방해
public static int SumSlow(ReadOnlyMemory<byte> buffer)
{
    int sum = 0;
    for (int i = 0; i < buffer.Length; i++)
    {
        sum += buffer.Span[i];   // 매 반복마다 get_Span 호출
    }
    return sum;
}

// ✅ 루프 밖에서 한 번만 — JIT가 경계 검사 생략 등 적극 최적화
public static int SumFast(ReadOnlyMemory<byte> buffer)
{
    ReadOnlySpan<byte> span = buffer.Span;   // 한 번만 변환
    int sum = 0;
    for (int i = 0; i < span.Length; i++)
    {
        sum += span[i];
    }
    return sum;
}

5.4 ❌ Unity IL2CPP에서 JIT 최적화를 기대하는 실수

.NET Core/8의 RyuJIT는 Span<T> 경계 검사를 루프 패턴에 따라 제거하거나, SIMD 벡터화까지 수행합니다. Unity의 IL2CPP(AOT)나 Mono(JIT)는 이 수준까지 따라오지 못합니다. Span 자체의 값 복사 비용은 작아 여전히 "할당 제거"라는 이득은 그대로지만, "Span을 쓰면 자동으로 10배 빨라진다"는 기대는 플랫폼마다 다릅니다.

  • Mono (Unity Editor·Android·iOS 일부): Span API는 쓸 수 있지만 런타임 내장 특수 처리가 없어 일반 struct 수준으로 처리됩니다.
  • IL2CPP (iOS·WebGL·Switch 등): C#을 C++로 번역합니다. 경계 검사 자체는 살아 있지만 Span 전용 SIMD 최적화는 없습니다.
  • 결론: Unity에서 Span의 진짜 이득은 GC 압박 감소이고, 속도는 덤입니다. 속도가 필요하면 NativeArray<T> + Job System + Burst 경로를 병행합니다.

6. C# 버전별 변화 — C# 7.2부터 C# 13까지

6.1 C# 7.2 — ref structSpan<T> 도입

C# 7.2에서 ref struct 키워드와 함께 System.Span<T>·System.Memory<T>가 공식 도입되었습니다. 이전에는 unsafe + 포인터로만 할 수 있었던 "할당 없는 메모리 뷰"가 safe 코드 세계로 내려왔습니다.

C#
// C# 7.2+
Span<int> buffer = stackalloc int[8];   // unsafe 블록 없이 사용 가능

6.2 C# 7.3 — Span<T> 기반 문자열 지원 확장

System.MemoryExtensions.AsSpan(), int.Parse(ReadOnlySpan<char>), string.Create 등 BCL에 Span 오버로드가 본격적으로 추가되었습니다.

6.3 C# 8 — 범위 연산자 ..와 Span의 결합

범위(Range) 연산자 ..가 Span·문자열 슬라이싱에 바로 연결됩니다. Slice 대신 훨씬 간결한 문법을 쓸 수 있습니다.

C#
ReadOnlySpan<char> span = "HELLO, WORLD".AsSpan();
ReadOnlySpan<char> hello = span[..5];      // "HELLO"
ReadOnlySpan<char> world = span[^5..];     // 끝에서 5자
.. — 범위 연산자 (Range operator) 시작·끝 인덱스로 부분 구간을 만드는 연산자. ^n은 끝에서 n번째 요소를 가리킵니다.
예시: span[2..5] — 인덱스 2(포함) ~ 5(제외). Slice(2, 3)과 같습니다.

6.4 C# 10 — scoped 한정자와 Span 흐름 분석 강화

C# 10에서 scoped 키워드가 들어와 "이 ref/Span 파라미터는 현재 메서드보다 오래 살지 않는다"는 계약을 명시할 수 있습니다. 라이브러리 작성자가 오남용을 컴파일 타임에 막기 더 쉬워졌습니다.

C#
// C# 10+
public static int ReadFirst(scoped ReadOnlySpan<byte> buffer)  // 메서드 밖으로 탈출 안 됨을 명시
{
    return buffer[0];
}

6.5 C# 12 — 컬렉션 식과 Span 초기화

C# 12 컬렉션 식([1, 2, 3])이 Span<T> 타겟을 자연스럽게 인식합니다. 컴파일러가 상황에 따라 stackalloc이나 배열을 선택해 할당을 줄입니다.

C#
// C# 12+
ReadOnlySpan<int> primes = [2, 3, 5, 7, 11];   // 컴파일러가 최적 저장 선택

6.6 C# 13 — ref struct에 인터페이스 구현 허용

C# 13은 ref struct가 인터페이스를 구현할 수 있게 허용했습니다(단, 박싱을 유발하는 상황은 여전히 제한). Span/Memory 생태계의 API 다형성이 확장되는 기반이 마련되었습니다.

Unity 이식 시점 요약

버전 Unity 지원 시점 비고
Span<T> API Unity 2018.1 (System.Memory 패키지) Span struct 자체만 제공
.NET Standard 2.1 Unity 2021.2 LTS string.AsSpan, Utf8Parser, Stream.ReadAsync(Memory<byte>) 등 BCL 통합
C# 범위 연산자 .. Unity 2020.3 LTS (C# 8) Span 인덱싱에 사용 가능
scoped 키워드 Unity 2022.2 LTS (C# 10) 라이브러리 작성 시 유용

7. 정리 — 이것만 기억하면 됩니다

항목 핵심
Span<T>는 뭘 하는가 배열·문자열·stackalloc·네이티브 메모리를 복사 없이 가리키는 값 타입 뷰
왜 ref struct인가 힙으로 새어 나가 댕글링 참조가 되는 경로를 컴파일 타임에 전부 차단하기 위해
Memory<T>는 언제 클래스 필드, async·반복자 등 Span이 못 사는 자리에서 버퍼를 들고 다닐 때
Span.Slice vs Substring Slice는 valuetype 반환(스택), Substring은 callvirt String::Substring(힙 할당)
할당 없는 파싱 Utf8Parser.TryParse(ReadOnlySpan<byte>)로 중간 string 경로 제거
Unity 실전 이득 GC 스파이크 감소가 주효과 — 속도 향상은 .NET 대비 제한적
자주 하는 실수 Span.ToArray()로 마지막에 할당 부활 / Memory.Span을 루프 안에서 반복 호출 / stackalloc을 1KB 이상 잡기
버전별 체크 C# 7.2 도입 → C# 8 범위 연산자 결합 → C# 10 scoped → Unity 2021.2부터 BCL Span 오버로드 전면 사용 가능

마지막으로 한 문장으로 요약하면 다음과 같습니다.

"복사하지 말고 가리켜라. 오래 들고 다닐 일이 생기면 Memory<T>로 바꾸고, 훑는 순간에만 Span<T>로 꺼내라."

이 규칙 하나로 Unity 모바일 게임의 네트워크·파싱·버퍼 처리 핫패스 대부분에서 Gen0 쓰레기를 걷어낼 수 있습니다.

반응형

+ Recent posts