[PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법
배열 슬라이싱의 기존 문제 / 힙 없이 뷰만 만드는 원리 / ref struct가 강제하는 제약 / async 경계는 Memory<T>에 맡긴다
목차
1. 문제 제기 — Substring 한 줄이 프레임을 먹는다
Unity 모바일 게임에서 서버로부터 받은 한 줄짜리 CSV 응답 "1001,42,HERO_A,25"를 파싱한다고 가정해 보겠습니다. 흔하게 쓰는 방법은 string.Split과 Substring입니다. 문제는 이 한 줄 파싱이 프레임당 수백 번 호출될 때 드러납니다.
// 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)"만 제공합니다.

위 그림처럼 Span<T>는 내부적으로 (참조, 길이) 두 필드만 가진 값 타입입니다. 배열이든 문자열이든 stackalloc 버퍼든, 이 두 필드만 바꿔 끼우면 동일한 API로 접근할 수 있습니다.
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>의 구조는 개념적으로 다음과 같습니다.
public readonly ref struct Span<T>
{
internal readonly ref T _reference; // 원본 메모리 첫 요소의 참조
private readonly int _length; // 가리키는 범위의 길이
}
핵심은 ref T _reference입니다. 이 내부 참조(interior reference) 는 배열 중간이든 스택 변수든 네이티브 메모리든 어디든 가리킬 수 있습니다. 만약 Span<T>가 힙 객체의 필드가 될 수 있다고 가정하면, 다음과 같은 상황이 가능해집니다.
// 가정: 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가 스택에서 끝나는지 비교합니다.
// 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의 핵심만 발췌합니다.
// 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 쪽은 어떻게 바뀌는지 봅니다.
// 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 분석 포인트
callvirt Substringvscall Slice— Before의callvirt는 힙에 새System.String인스턴스를 만드는 라이브러리 메서드를 호출합니다. 토큰 5개면 프레임당 힙에 5개 객체가 찍힙니다. After의call Slice는 값 타입ReadOnlySpan<char>를 반환하므로 전부 스택 지역 변수([5])에 저장되고 함수 종료 시 자동 회수됩니다.locals init의 차이 — Before의.locals에는[4] string이 있어 "이 프레임에 문자열 참조 슬롯이 있다"는 신호를 줍니다. 반면 After의.locals에는ReadOnlySpan<char>값 타입 슬롯이 두 개 들어 있고 참조 타입은 하나도 없습니다. 스택 위에 값만 놓고 쓰겠다는 뜻입니다.- Unity Update 루프 관점 — Substring 버전이 초당 60프레임 × 5토큰 = 초당 300개의 string 힙 객체를 만듭니다. Span 버전은 0개입니다. Boehm GC 환경에서는 이 차이가 곧 스파이크 유무로 이어집니다.
3.3 Memory<T>는 async 상태 머신에 들어간다 — IL로 확인
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에서 중단이 일어나면 이 구조체가 박싱되어 힙으로 올라갑니다). 핵심은 그 구조체의 필드 선언입니다.
.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 분석 포인트
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") 오류를 내고 컴파일을 거부합니다..Span호출은 await 경계를 넘은 이후에 —MoveNext에서await Task.Yield()를 처리한 다음 그 자리에서get_Span()을 호출합니다.Span<T>는 이MoveNext프레임의 스택에만 존재하므로,await로 프레임이 교체되기 전에만 유효합니다. 이 패턴이 "저장은 Memory, 처리는 Span"의 IL 수준 구현입니다.- 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 코드로 바꿔 줍니다.
// 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을 보면 스택 할당이 어떻게 표현되는지 명확합니다.
// 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 분석 포인트
localloc명령어 — CIL의localloc은 현재 스택 프레임에 N바이트를 동적으로 확보합니다. 함수가 리턴되면 자동 해제되므로 GC 추적 대상이 아닙니다.newobj가 보이지만 이것은Span값 타입의 생성자 호출일 뿐 힙 할당이 아닙니다(값 타입의.ctor은 기존 스택 슬롯을 초기화합니다).- 경계 검사 — 인덱서
get_Item이 호출될 때마다_length와 비교하는 경계 검사가 들어갑니다.unsafe포인터와 달리 버퍼 오버런이 즉시IndexOutOfRangeException으로 드러나므로 안전합니다. - Unity 관점 —
stackalloc은 Unity에서도 Mono·IL2CPP 모두 지원됩니다. 단 스택 프레임 한계(플랫폼마다 다르지만 보통 1MB 이하)를 넘지 않도록 1KB 이하로 유지하는 것이 안전한 경험칙입니다.
4.2 Utf8Parser — string을 거치지 않는 숫자 파싱
Utf8Parser는 ReadOnlySpan<byte>에서 직접 int·long·Guid·DateTime으로 파싱합니다. 중간 단계의 string 객체가 완전히 사라집니다.
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 호출이 들어갑니다.
// 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이 없습니다. AsSpan → Utf8Parser::TryParse 두 번의 call로 끝납니다.
// 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 분석 포인트
- 힙 경로가 사라짐 — Before의
Encoding::GetString은 숫자 길이만큼의char[]내부 버퍼와 최종string을 힙에 만듭니다. After에는 이 체인이 완전히 제거되고Span두 개와int&참조만 오갑니다. op_Implicit—Span<T>→ReadOnlySpan<T>변환은 값 복사 없이 참조·길이만 재해석합니다..NET의 제로 카피 전통이 그대로 드러납니다.- Unity 네트워크 핫패스 관점 — 모바일 RPG의 초당 수백 개 패킷 디코딩에서 이 패턴이 수천 개 임시
string을 원천 차단합니다. 핫패스에서Utf8JsonReader를 쓰는 이유도 같은 설계 철학입니다.
4.3 파일 I/O에서 Memory<byte>로 버퍼 재사용
Unity에서 저장 파일(Save file)·애셋 번들 조각을 비동기로 읽을 때, Stream.ReadAsync(byte[]) 대신 Stream.ReadAsync(Memory<byte>) 오버로드를 쓰면 ValueTask<int>를 받을 수 있어 동기 완료 경로에서 Task 인스턴스 할당까지 피할 수 있습니다.
// 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로 억지로 깨는 코드에서 댕글링이 발생할 수 있습니다. 다행히 평범하게 쓸 때는 컴파일 에러가 나서 사고를 막아 줍니다.
// ❌ 잘못된 시도 — 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 검증기가 거부합니다.
// ✅ 올바른 패턴 — 오래 들고 다녀야 하면 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 호환 때문에 자주 발생합니다.
// ❌ 중간은 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 최적화의 의미가 절반 이상 사라집니다.
// ✅ 올바른 패턴 — 호출 측이 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가 루프 경계 검사 최적화를 포기할 수 있습니다.
// ❌ 루프 안에서 반복 변환 — 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 struct와 Span<T> 도입
C# 7.2에서 ref struct 키워드와 함께 System.Span<T>·System.Memory<T>가 공식 도입되었습니다. 이전에는 unsafe + 포인터로만 할 수 있었던 "할당 없는 메모리 뷰"가 safe 코드 세계로 내려왔습니다.
// 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 대신 훨씬 간결한 문법을 쓸 수 있습니다.
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# 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# 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 쓰레기를 걷어낼 수 있습니다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(9/10)] stackalloc — 스택에 직접 할당하는 이유 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(8/10)] ArrayPool<T> — 배열을 재사용하는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(4/10)] 메모리 누수가 발생하는 5가지 상황 (0) | 2026.04.14 |