[PART6.배열과 문자열 기본(12/14)] Span<T> · ReadOnlySpan<T> — 배열을 복사 없이 잘라 보는 뷰
시작 포인터 + 길이만 든 가벼운 struct / arr.AsSpan(1..3)은 zero-alloc 슬라이스 / string도 ReadOnlySpan<char>으로 부분 처리 / ref struct라 클래스 필드·async 경계 제한 / stackalloc과 짝 / C# 14 신규 — T[] ↔ Span<T> ↔ ReadOnlySpan<T> 암시적 변환 / Unity Physics 버퍼 슬라이싱·BurstCompile 친화
목차
1. 왜 새 타입이 필요한가
다음 두 줄짜리 함수의 차이를 짚어 봅니다.
// ❌ 매 호출 새 배열 + 복사
public int SumMiddle(int[] data)
{
int[] mid = data[1..^1]; // ← 새 배열!
int s = 0;
for (int i = 0; i < mid.Length; i++) s += mid[i];
return s;
}
// ✅ 같은 결과, 새 객체 0
public int SumMiddle(int[] data)
{
ReadOnlySpan<int> mid = data.AsSpan(1..^1); // ← 원본 위의 뷰
int s = 0;
for (int i = 0; i < mid.Length; i++) s += mid[i];
return s;
}
같은 동작이지만 첫 번째는 매 호출 새 배열을 만들고 두 번째는 만들지 않습니다. PART 6 #4·#6에서 반복적으로 만났던 AsSpan(...) 패턴의 정체가 바로 이 글의 주제입니다 — Span<T>/ReadOnlySpan<T>라는, 시작 포인터 + 길이만 든 가벼운 struct.
이 도구가 등장하기 전에는 부분 배열을 다루려면 Array.Copy 또는 인덱스+길이를 매번 손으로 넘기는 두 가지 선택뿐이었습니다. Span<T>은 "원본을 그대로 가리키되 시작·길이만 다른 참조"라는 새로운 추상을 도입해, alloc 없는 슬라이싱을 가능하게 만들었습니다.
2. Span<T>는 무엇인가
비유 — 종이 한 장 위의 형광펜 영역
배열이 책의 한 페이지라면 Span<T>은 그 페이지에 형광펜으로 표시한 영역입니다. 형광펜은 종이를 새로 만들지 않습니다 — 시작 위치와 길이만 가지고 원본 종이의 일부를 가리킵니다. 형광펜의 영역 안 글씨를 고치면 원본 종이도 같이 고쳐집니다 — Span<T>도 원본을 그대로 변경합니다.
내부 구조

Span<T>은 (시작 포인터, 길이) 두 필드를 가진 작은 struct입니다. 메서드 호출 비용 정도의 가벼운 객체라 스택에 올라가고, GC가 관여하지 않습니다.
무엇을 Span으로 만들 수 있는가
int[] arr = { 1, 2, 3, 4, 5 };
Span<int> s1 = arr; // 배열 전체
Span<int> s2 = arr.AsSpan(1, 3); // 부분 배열
Span<int> s3 = stackalloc int[10]; // 스택 메모리
ReadOnlySpan<char> s4 = "Hello"; // 문자열 — 읽기 전용
// C# 14 신규 — T[] ↔ Span<T> 암시적 변환이 강화됨
ReadOnlySpan<int> s5 = arr; // .AsSpan() 없이도 OK
배열·stackalloc 결과·문자열까지 모두 같은 Span 인터페이스로 다룰 수 있습니다. 메서드를 Span<int> 또는 ReadOnlySpan<int>로 받도록 만들면, 호출자가 어떤 형태로 데이터를 들고 있든 같은 코드로 처리할 수 있습니다.
3. zero-alloc 슬라이싱 — IL이 증명한다
배열 슬라이스(alloc) vs Span 슬라이스(zero-alloc)
public static int[] CopyArr(int[] xs) => xs[1..^1]; // 새 배열
public static ReadOnlySpan<int> ViewSpan(int[] xs) => xs.AsSpan(1..^1); // 뷰만
// CopyArr — 새 배열 alloc
IL_000e: newobj instance void System.Range::.ctor(Index, Index)
IL_0013: call !!0[] RuntimeHelpers::GetSubArray<int32>(!!0[], Range) // ← 새 int[]!
// ViewSpan — 새 객체 0
IL_000e: newobj instance void System.Range::.ctor(Index, Index)
IL_0013: call valuetype Span`1<!!0> MemoryExtensions::AsSpan<int32>(!!0[], Range)
// ← Span struct만
IL_0018: call valuetype ReadOnlySpan`1<!0> Span`1<int32>::op_Implicit(...)
// Span → ReadOnlySpan 암시적 변환
같은 슬라이스 표기가 받는 변수 타입에 따라 IL이 달라집니다. int[]로 받으면 GetSubArray(새 배열), ReadOnlySpan<int>로 받으면 AsSpan(뷰만). PART 6 #4 "범위·인덱스 연산자"에서 본 그 차이가 정확히 이 IL 한 줄 차이입니다.
Span을 받는 메서드의 IL
public static int Sum(ReadOnlySpan<int> xs)
{
int s = 0;
for (int i = 0; i < xs.Length; i++) s += xs[i];
return s;
}
IL_000b: call instance !0& ReadOnlySpan`1<int32>::get_Item(int32) // xs[i]
IL_001a: call instance int32 ReadOnlySpan`1<int32>::get_Length() // xs.Length
Span<T>/ReadOnlySpan<T>의 인덱서·Length는 IL에서 메서드 호출이지만, JIT가 이 정형 패턴을 인식해 BCE(Bounds Check Elimination — 범위 검사 제거)와 SIMD(Single Instruction Multiple Data — 한 명령으로 여러 데이터 처리)를 적극 적용합니다. 결과적으로 일반 배열과 거의 같은 성능을 냅니다.
4. ReadOnlySpan<T> — 인자 받는 자리의 표준
같은 메서드가 배열·Span·문자열을 모두 받는다
public static int Sum(ReadOnlySpan<int> data)
{
int s = 0;
for (int i = 0; i < data.Length; i++) s += data[i];
return s;
}
// 호출 측
int[] arr = { 1, 2, 3 };
Sum(arr); // 배열 → 암시적 변환
Sum(arr.AsSpan(1, 2)); // 부분 배열
Span<int> stackBuf = stackalloc int[3] { 4, 5, 6 };
Sum(stackBuf); // 스택 버퍼
// 문자열 처리 메서드라면
public static int CountVowels(ReadOnlySpan<char> s) { ... }
CountVowels("Hello"); // string → 암시적 변환
CountVowels("Hello".AsSpan(1, 3));
ReadOnlySpan<T> 인자 한 시그니처로 배열·부분 배열·스택 버퍼·문자열·stackalloc을 모두 받습니다. API 설계자가 가장 유연한 형태를 만드는 핵심 도구입니다.
string 부분 파싱 — Substring 대신 AsSpan
// ❌ 새 string + 파싱
int n = int.Parse(s.Substring(2, 3));
// ✅ alloc 0
int n = int.Parse(s.AsSpan(2, 3));
int.Parse·int.TryParse·double.Parse·DateTime.Parse 등 .NET 6+ 파싱 API의 대부분이 ReadOnlySpan<char> 오버로드를 제공합니다. Substring이 보일 때마다 AsSpan으로 옮길 수 있는지 검토해 보면 좋습니다.
5. ref struct — 강력한 제약
Span<T>이 zero-alloc일 수 있는 이유는 ref struct로 선언되어 매우 엄격한 규칙이 적용되기 때문입니다. 이 규칙들은 메모리 안전을 위해 필요하지만, 사용 자리를 좁힙니다.
제약 목록
// ❌ 1. 클래스/struct 필드 불가
public class Game
{
private Span<int> _data; // 컴파일 오류
}
// ❌ 2. 박싱 불가
object o = (Span<int>)stackalloc int[3]; // 컴파일 오류
// ❌ 3. 제네릭 타입 인자로 사용 불가
List<Span<int>> list; // 컴파일 오류
// ❌ 4. 비동기 메서드 await 사이 불가
async Task M()
{
Span<int> s = stackalloc int[3];
await Task.Delay(1); // 컴파일 오류 — Span을 await 너머로 못 들고 감
s[0] = 1;
}
// ❌ 5. 반복기에서 yield 불가
IEnumerable<int> M(Span<int> s) // 컴파일 오류
{
yield return s[0];
}
이유는 모두 같습니다 — Span<T>은 스택의 어떤 메모리를 가리키고 있을 수 있는데, 그 메모리의 수명이 끝난 뒤에 Span이 살아 있으면 안 됩니다. 클래스 필드(힙)·async 경계(스택 단절)·박싱(힙)은 모두 그 수명 보장을 깨는 자리입니다.
C# 13 일부 완화
C# 13부터 Span<T>이 await/yield 경계를 넘지 않는 한도 내에서는 비동기 메서드의 지역 변수로 선언할 수 있게 되었습니다. 즉 await 직전에 Span을 만들어 쓰고 await 직후에는 사용을 끝내는 식이라면 컴파일이 됩니다 — 단, await 양쪽에서 같은 Span 변수를 사용하면 여전히 오류입니다.
우회 방법
Span<T>을 클래스 필드에 캐시하고 싶다면 Memory<T>를 씁니다. Memory<T>는 힙에 살 수 있는 비-ref-struct 래퍼고, 필요할 때 .Span 프로퍼티로 Span<T>을 꺼낼 수 있습니다 — 다만 stackalloc 결과는 Memory<T>로 변환할 수 없습니다(스택 메모리는 공유 불가).
6. stackalloc과 짝을 이룬다
Span<T>의 가장 강력한 사용처 중 하나는 stackalloc과의 결합입니다.
public static int CountVowels(string s)
{
ReadOnlySpan<char> chars = s; // string → ReadOnlySpan<char>
Span<int> counters = stackalloc int[5]; // 스택에 5칸 — alloc 0
for (int i = 0; i < chars.Length; i++)
{
int idx = "aeiou".IndexOf(chars[i]);
if (idx >= 0) counters[idx]++;
}
int total = 0;
for (int i = 0; i < counters.Length; i++) total += counters[i];
return total;
}
stackalloc int[5]는 5칸짜리 임시 버퍼를 스택에 직접 잡아 줍니다. 그 결과를 Span<int>으로 받아 일반 배열처럼 인덱싱·for·foreach로 다룹니다. 메서드 진입부터 종료까지 힙 alloc 0 — 임시 작업 버퍼의 표준 패턴입니다.
자세한 stackalloc 사용법은 PART 6 #14에서 따로 다룹니다.
7. C# 14 신규 — 암시적 변환 확대
C# 14는 T[]/Span<T>/ReadOnlySpan<T> 사이의 암시적 변환을 언어 차원에서 더 확장했습니다.
// C# 14 이전 — .AsSpan() 명시 필요
void DoSomething(ReadOnlySpan<int> data) { ... }
int[] arr = { 1, 2, 3 };
DoSomething(arr); // 이전부터 OK (암시적 변환 있음)
DoSomething(arr.AsSpan(1, 2)); // 부분은 명시 필요
// C# 14 — 더 자연스러운 호출
public static int First<T>(ReadOnlySpan<T> data) where T : struct => data.IsEmpty ? default : data[0];
int[] arr = { 1, 2, 3 };
int x = First(arr); // OK — 제네릭 타입 추론도 자연스러움
확장 메서드 수신자 타입과 제네릭 추론이 자연스러워져, API 시그니처를 Span<T>/ReadOnlySpan<T>로 만들기가 훨씬 편해졌습니다. 새 코드라면 인자 타입을 T[]보다 ReadOnlySpan<T>로 잡는 편이 더 유연합니다.
8. 실전 적용 — Unity 핫패스
패턴 1 — Physics 버퍼 슬라이싱
private RaycastHit[] _hits = new RaycastHit[16];
void DetectEnemies()
{
int count = Physics.RaycastNonAlloc(origin, direction, _hits);
ReadOnlySpan<RaycastHit> valid = _hits.AsSpan(0, count); // 유효한 영역만 잘라서
for (int i = 0; i < valid.Length; i++)
ProcessHit(valid[i]);
}
private void ProcessHit(in RaycastHit hit) { ... }
RaycastNonAlloc이 채워 준 결과 중 count개만 의미가 있습니다. AsSpan(0, count)로 슬라이싱하면 불필요한 영역에 접근하지 않으면서, 새 배열을 만들지 않고 처리할 수 있습니다.
패턴 2 — 문자열 파싱 zero-alloc
// 채팅 메시지에서 특정 영역만 검사
public bool IsAdminCommand(string msg)
{
if (msg.Length < 1 || msg[0] != '/') return false;
ReadOnlySpan<char> cmd = msg.AsSpan(1);
return cmd.StartsWith("admin", StringComparison.OrdinalIgnoreCase);
}
Substring(1) 대신 AsSpan(1). string.StartsWith도 ReadOnlySpan<char> 오버로드가 있어 그대로 zero-alloc.
패턴 3 — NativeArray ↔ Span 변환
Unity의 NativeArray<T>는 Job System용 unmanaged 메모리 컨테이너입니다. 표준 C# 알고리즘(Sort, BinarySearch 등)에 넘기려면 AsSpan()으로 변환합니다.
NativeArray<int> data = new(100, Allocator.TempJob);
// ...
Span<int> view = data.AsSpan();
view.Sort(); // System.MemoryExtensions.Sort — alloc 0
BurstCompile은 Span 기반 코드를 잘 인식해 SIMD 최적화를 적용합니다. NativeArray 위에서 표준 C# 코드를 그대로 쓸 수 있는 다리 역할을 Span이 합니다.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라, 임시 배열 한 번이 곧 5~15ms 프레임 스파이크입니다. AsSpan + 메서드 시그니처 ReadOnlySpan<T> 표준화가 이 부담을 가장 직접적으로 줄이는 도구입니다.
9. 함정과 주의사항
함정 1 — 원본 변경/소멸 위험
List<int> list = new() { 1, 2, 3 };
Span<int> view = CollectionsMarshal.AsSpan(list);
list.Add(4); // ❌ 위험! 내부 배열이 새로 할당될 수 있음
view[0] = 99; // 옛 배열을 가리키고 있을 수도, 새 배열일 수도 — 정의되지 않은 동작
Span은 자체 데이터를 가지지 않습니다 — 가리키는 원본이 바뀌거나 사라지면 위험합니다. 일반 컬렉션처럼 "수정 중 예외"를 던져 주지도 않습니다. Span을 들고 있는 동안 원본 컬렉션의 길이를 바꾸지 마세요.
함정 2 — 클래스 필드 캐시 불가
public class Renderer
{
private Span<int> _buffer = stackalloc int[16]; // ❌ 두 가지 이유로 오류
}
Span<T>은 클래스 필드 불가. stackalloc은 메서드 지역 변수에서만 사용 가능. 두 가지 제약이 동시에 위반됩니다. 대안: int[] 필드로 캐시한 뒤 사용 시점에 AsSpan()으로 변환.
함정 3 — await 양쪽에서 같은 Span 사용
async Task Process()
{
ReadOnlySpan<char> name = "Hero".AsSpan();
int hash = name.GetHashCode();
await Task.Delay(100); // ❌ name을 await 이후에도 쓰려면 컴파일 오류
Use(name);
}
await 이전에만 또는 이후에만 쓰는 건 OK(C# 13+). 양쪽에서 같은 변수를 쓰는 건 여전히 불가.
함정 4 — ReadOnlySpan<T>을 람다에서 캡처
public void Foo(ReadOnlySpan<int> data)
{
Action a = () => Console.WriteLine(data[0]); // ❌ 컴파일 오류 — ref struct는 람다 캡처 불가
}
람다 클로저는 힙 객체로 만들어지는데 Span<T>은 힙에 못 가서 캡처 불가. 람다 안에서 사용해야 한다면 데이터를 배열로 복사해 넘기거나, 람다 대신 일반 메서드를 사용.
함정 5 — Span의 Length가 음수처럼 보이지 않게
Span<int> s = stackalloc int[5];
int len = s.Length; // 5 — 정상
기본 사용에서는 문제 없지만, 메모리 안전 규칙을 넘는 우회 코드(예: 포인터에서 직접 Span 만들기)에서는 길이를 잘못 잡으면 OOR로 이어집니다. 일반 사용에서는 신경 쓸 일 없음.
10. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 7.2 | Span<T>/ReadOnlySpan<T> 도입, stackalloc → Span 안전 사용 |
기본 |
| 7.2 | string.AsSpan / Array.AsSpan 도입 |
zero-alloc 부분 처리 |
| 8.0 | ^·.. 인덱스/범위 연산자 — arr.AsSpan(1..3) |
PART 6 #4 |
| 11 | "abc"u8 UTF-8 리터럴 → ReadOnlySpan<byte> |
PART 6 #11 |
| 11 | 인라인 배열([InlineArray(N)]) — Span으로 노출 |
PART 6 #13 |
| 12 | 컬렉션 식 Span<int> = [1,2,3] — 인라인 배열 자동 생성 |
PART 6 #5 |
| 13 | ref struct이 await/yield 경계를 넘지 않는 한 일부 사용 허용 | |
| 14 | T[] ↔ Span<T> ↔ ReadOnlySpan<T> 암시적 변환 확대 |
제네릭·확장 친화 |
C# 14의 암시적 변환 확대로 API 시그니처를 ReadOnlySpan<T>로 만들기가 훨씬 편해졌습니다. 신규 코드라면 인자 타입을 적극 ReadOnlySpan<T>로 잡는 게 권장됩니다.
11. 정리
- [ ]
Span<T>= 시작 포인터 + 길이의 가벼운 struct. 새 객체 alloc 없이 원본 메모리의 일부를 가리킨다. - [ ]
arr.AsSpan(1..3)은 zero-alloc 슬라이스.arr[1..3]은int[]로 받으면 새 배열,Span<int>으로 받으면 뷰. - [ ]
ReadOnlySpan<T>는 인자 타입의 표준 — 배열·부분 배열·스택 버퍼·문자열을 한 시그니처로. - [ ]
string은ReadOnlySpan<char>으로 부분 파싱(int.Parse(s.AsSpan(2, 3))) —Substring대체. - [ ]
ref struct제약: 클래스 필드 X, 박싱 X, 제네릭 인자 X, asyncawait/yield경계 X (C# 13에서 일부 완화). - [ ] 클래스 필드 캐시는
int[]+ 사용 시AsSpan(). 비동기 컨테이너는Memory<T>. - [ ]
stackalloc int[N]은 스택 버퍼 →Span<int>으로 받아 일반 배열처럼 사용 — alloc 0. - [ ] JIT가
Span<T>정형 루프에 BCE·SIMD 적용 — 일반 배열과 거의 같은 성능. - [ ] C# 14부터
T[]↔Span<T>↔ReadOnlySpan<T>암시적 변환이 더 자연스러워짐 — 신규 API는ReadOnlySpan<T>로. - [ ] Unity:
NativeArray.AsSpan()으로 표준 C# 알고리즘 호환, BurstCompile에서 SIMD 최적화 시너지. - [ ] Span을 들고 있는 동안 원본 컬렉션의 길이를 바꾸지 말 것 — 가리키는 메모리가 깨지면 정의되지 않은 동작.
- [ ]
stackalloc의 더 깊은 사용법은 PART 6 #14, 인라인 배열은 PART 6 #13.