반응형

[PART6.배열과 문자열 기본(12/14)] Span<T> · ReadOnlySpan<T> — 배열을 복사 없이 잘라 보는 뷰

시작 포인터 + 길이만 든 가벼운 struct / arr.AsSpan(1..3)은 zero-alloc 슬라이스 / stringReadOnlySpan<char>으로 부분 처리 / ref struct라 클래스 필드·async 경계 제한 / stackalloc과 짝 / C# 14 신규 — T[]Span<T>ReadOnlySpan<T> 암시적 변환 / Unity Physics 버퍼 슬라이싱·BurstCompile 친화


1. 왜 새 타입이 필요한가

다음 두 줄짜리 함수의 차이를 짚어 봅니다.

C#
// ❌ 매 호출 새 배열 + 복사
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>의 구조 — 포인터 + 길이

Span<T>(시작 포인터, 길이) 두 필드를 가진 작은 struct입니다. 메서드 호출 비용 정도의 가벼운 객체라 스택에 올라가고, GC가 관여하지 않습니다.

무엇을 Span으로 만들 수 있는가

C#
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)

C#
public static int[] CopyArr(int[] xs) => xs[1..^1];                        // 새 배열
public static ReadOnlySpan<int> ViewSpan(int[] xs) => xs.AsSpan(1..^1);    // 뷰만
IL
// 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

C#
public static int Sum(ReadOnlySpan<int> xs)
{
    int s = 0;
    for (int i = 0; i < xs.Length; i++) s += xs[i];
    return s;
}
IL
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·문자열을 모두 받는다

C#
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

C#
// ❌ 새 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로 선언되어 매우 엄격한 규칙이 적용되기 때문입니다. 이 규칙들은 메모리 안전을 위해 필요하지만, 사용 자리를 좁힙니다.

제약 목록

C#
// ❌ 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과의 결합입니다.

C#
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#
// 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 버퍼 슬라이싱

C#
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

C#
// 채팅 메시지에서 특정 영역만 검사
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.StartsWithReadOnlySpan<char> 오버로드가 있어 그대로 zero-alloc.

패턴 3 — NativeArraySpan 변환

Unity의 NativeArray<T>는 Job System용 unmanaged 메모리 컨테이너입니다. 표준 C# 알고리즘(Sort, BinarySearch 등)에 넘기려면 AsSpan()으로 변환합니다.

C#
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 — 원본 변경/소멸 위험

C#
List<int> list = new() { 1, 2, 3 };
Span<int> view = CollectionsMarshal.AsSpan(list);

list.Add(4);                       // ❌ 위험! 내부 배열이 새로 할당될 수 있음
view[0] = 99;                      // 옛 배열을 가리키고 있을 수도, 새 배열일 수도 — 정의되지 않은 동작

Span은 자체 데이터를 가지지 않습니다 — 가리키는 원본이 바뀌거나 사라지면 위험합니다. 일반 컬렉션처럼 "수정 중 예외"를 던져 주지도 않습니다. Span을 들고 있는 동안 원본 컬렉션의 길이를 바꾸지 마세요.

함정 2 — 클래스 필드 캐시 불가

C#
public class Renderer
{
    private Span<int> _buffer = stackalloc int[16];   // ❌ 두 가지 이유로 오류
}

Span<T>은 클래스 필드 불가. stackalloc은 메서드 지역 변수에서만 사용 가능. 두 가지 제약이 동시에 위반됩니다. 대안: int[] 필드로 캐시한 뒤 사용 시점에 AsSpan()으로 변환.

함정 3 — await 양쪽에서 같은 Span 사용

C#
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>을 람다에서 캡처

C#
public void Foo(ReadOnlySpan<int> data)
{
    Action a = () => Console.WriteLine(data[0]);    // ❌ 컴파일 오류 — ref struct는 람다 캡처 불가
}

람다 클로저는 힙 객체로 만들어지는데 Span<T>은 힙에 못 가서 캡처 불가. 람다 안에서 사용해야 한다면 데이터를 배열로 복사해 넘기거나, 람다 대신 일반 메서드를 사용.

함정 5 — Span의 Length가 음수처럼 보이지 않게

C#
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> 는 인자 타입의 표준 — 배열·부분 배열·스택 버퍼·문자열을 한 시그니처로.
  • [ ] stringReadOnlySpan<char> 으로 부분 파싱(int.Parse(s.AsSpan(2, 3))) — Substring 대체.
  • [ ] ref struct 제약: 클래스 필드 X, 박싱 X, 제네릭 인자 X, async await/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.
반응형

+ Recent posts