반응형

[PART7.클래스와 객체 입문(15/21)] ref struct — 스택에서만 살 수 있는 구조체

왜 컴파일러가 막아주는지 / Span<T>·ReadOnlySpan<T>가 ref struct인 이유 / Unity에서 substring 없이 문자열 자르기


문제 제기 — 왜 어떤 구조체는 "스택에만 살아야" 하는가

Unity에서 매 프레임 텍스트를 처리하는 코드를 떠올려 보겠습니다. 플레이어 정보 로그가 매 프레임 들어오고, 그중 ID 부분만 잘라내야 합니다.

C#
void Update()
{
    string raw = "PlayerID:12345-Level:99";
    string id = raw.Substring(9, 5); // 매 프레임 새 string 생성
    uiText.text = id;
}

겉보기에는 평범한 코드입니다. 그런데 60FPS 환경이라면 1초에 60번, 1분에 3,600번 새 string 객체가 힙(Heap, 동적으로 객체를 할당하는 메모리 영역)에 쌓입니다. Unity의 GC(Garbage Collector, 더 이상 쓰이지 않는 힙 객체를 자동 회수하는 런타임 구성요소)가 이걸 정리할 때마다 프레임 드랍 — 흔히 말하는 GC 스파이크 — 가 발생합니다.

해결책은 두 가지입니다. 하나는 "잘라낸 결과"를 새로 복사하지 않고 원본 메모리의 일부를 그대로 가리키기만 하는 타입을 쓰는 것이고, 다른 하나는 그 타입이 메서드를 빠져나가서 힙에 저장되는 것을 컴파일러가 막아주는 것입니다. 후자가 없으면 "원본은 사라졌는데 가리키는 손가락만 남는" 댕글링 참조(dangling reference) 문제가 생기기 때문입니다.

C# 7.2가 도입한 ref struct가 바로 이 두 번째 역할을 담당합니다. Span<T>·ReadOnlySpan<T>·Utf8JsonReader 같은 고성능 타입을 안전하게 쓰기 위해 반드시 알아야 하는 키워드입니다.

ref struct — 참조형 구조체 (Reference-like struct) 일반 struct 앞에 ref 한 단어를 붙인 선언으로, 컴파일러에게 "이 구조체 인스턴스는 절대 힙으로 가서는 안 된다"라고 알려준다. 클래스의 필드, 박싱(boxing) 변환, 배열 요소, 람다 캡처 등 힙으로 새는 모든 경로를 컴파일 에러로 차단한다.
예시: ref struct Buffer { public Span<byte> Data; } Buffer는 메서드 안에서만 쓸 수 있고 클래스 필드로는 못 담는다.

개념 정의 — "스택 전용 출입증을 가진 구조체"

비유: 보안 구역 출입증

회사 보안 구역을 떠올려 보겠습니다.

  • 일반 struct: 일반 사원증. 사무실(스택)이 기본 자리지만 회의실(클래스 필드)이나 외부 업체(힙)로도 외출 가능합니다.
  • ref struct: 빨간 출입증. 사무실 안에서만 활동 가능, 문 밖으로 나가려는 순간 경비원(컴파일러)이 막아 세웁니다.

이 출입증은 불편해 보이지만, 이 출입증을 가진 사람이 보안 구역 내부의 비밀 자료를 손에 들고 있기 때문에 외부로 나가면 안 되는 것입니다. ref struct도 마찬가지로 스택 메모리의 특정 위치를 가리키는 참조를 안에 들고 있습니다. 메서드가 끝나면 그 위치는 사라지므로, 인스턴스가 메서드 밖으로 나가면 잘못된 메모리를 가리키게 됩니다.

시각화: 일반 struct vs ref struct

일반 struct vs ref struct — 어디까지 갈 수 있는가

가장 단순한 ref struct

ref struct를 직접 정의해 보겠습니다.

C#
// 키워드 두 개: ref + struct
public ref struct Buffer
{
    public Span<byte> Data; // 스택 전용 참조를 담는 필드
}

public class Program
{
    public static void Main()
    {
        // stackalloc: 스택에 byte 16개를 할당
        Span<byte> raw = stackalloc byte[16];

        // ref struct 인스턴스도 당연히 스택에 산다
        var buf = new Buffer { Data = raw };
        buf.Data[0] = 42;

        Console.WriteLine(buf.Data[0]); // 42
    } // ← 이 시점에 raw·buf 모두 스택에서 함께 사라진다
}
stackalloc — 스택 할당 표현식 배열을 힙이 아닌 스택에 할당한다. GC가 관리하지 않으며, 메서드가 끝나면 자동으로 사라진다. 결과 타입은 Span<T> 또는 T*(unsafe)다.
예시: Span<byte> tmp = stackalloc byte[16]; 16바이트짜리 임시 버퍼를 스택에 만든다. 작은 크기에만 사용 (보통 1KB 이하).

Buffer는 안에 Span<byte>를 담고 있고, Span<byte> 자체가 스택 위치를 가리키는 참조이기 때문에 Buffer도 스택 밖으로 나갈 수 없어야 합니다. ref 키워드 한 단어가 이 모든 규칙을 컴파일러에게 통보합니다.

IL로 본 ref struct의 정체

위 코드의 Buffer 정의가 IL로 어떻게 컴파일되는지 봅시다.

IL
.class public sequential ansi sealed beforefieldinit Buffer
    extends [System.Runtime]System.ValueType
{
    // ① ref struct 표식 — IsByRefLikeAttribute
    .custom instance void [System.Runtime]System.Runtime.CompilerServices
        .IsByRefLikeAttribute::.ctor() = ( 01 00 00 00 )

    // ② 호환성 안내용 ObsoleteAttribute (구버전 컴파일러가 이 타입을 보면 거부)
    .custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool)

    // ③ 필드: Span<byte>
    .field public valuetype [System.Runtime]System.Span`1<uint8> Data
}

핵심은 ①번 줄의 IsByRefLikeAttribute입니다. C# 컴파일러는 ref struct 키워드를 보면 단순히 struct로 만든 뒤 이 어트리뷰트(attribute, 타입에 메타데이터를 부착하는 표식)를 붙입니다. CLR(Common Language Runtime, .NET 코드를 실행하는 런타임)과 컴파일러는 이 어트리뷰트가 붙은 타입을 만나면 위에서 말한 모든 제약을 강제 검사합니다. 즉 ref struct는 "런타임에 별도로 존재하는 종류"가 아니라 "컴파일 타임 검사가 추가된 일반 struct" 입니다.


내부 동작 — 컴파일러는 어떻게 "스택을 못 벗어나게" 강제하는가

메모리 그림: 댕글링 참조가 왜 위험한가

ref struct가 막아주려는 위험은 결국 "내가 가리키던 메모리가 사라졌는데 나는 살아있는" 상태입니다.

컴파일러가 막지 않으면 일어날 일 (가상)

Span<T>는 내부에 "어떤 메모리의 어떤 위치"를 가리키는 참조를 담고 있습니다. 그 메모리가 스택일 수도, 힙 위의 배열일 수도, 네이티브 메모리일 수도 있습니다. 그런데 만약 Span<T>가 클래스의 필드로 담겨 힙으로 올라가면, 가리키던 스택 메모리가 메서드 종료와 함께 사라진 뒤에도 힙 객체는 GC가 회수할 때까지 그 자리를 유지합니다 — 이미 사라진 자리를 가리키는 댕글링 참조가 됩니다.

C# 컴파일러는 ref struct로 표시된 타입에 대해 다음 규칙을 컴파일 타임에 강제합니다.

차단되는 동작 에러 코드 차단 이유
클래스의 필드로 선언 CS8345 힙으로 승격됨
object·dynamic·인터페이스 변수에 대입 CS0029 박싱이 일어나 힙으로 감
배열 요소로 선언 CS0611 배열은 힙에 생성됨
람다·로컬 함수에서 캡처 CS8175 클로저가 힙 객체로 만들어짐
async 메서드의 await 경계를 넘는 지역 변수 CS4012 상태 머신이 힙에 생성됨
제네릭 타입 인자로 사용 (C# 12 이하) CS0306 T가 어디 갈지 알 수 없으니 보수적으로 차단

이 모든 차단은 메모리 안전성을 컴파일 타임에 증명하기 위한 것입니다. 결과적으로 ref struct를 쓰는 코드는 런타임에 댕글링 참조가 발생할 수 없습니다.

IL 비교: Substring vs Span.Slice

문자열 자르기를 두 방식으로 한 코드의 IL을 비교해 보면, "왜 Span 방식이 빠른가"가 명확해집니다.

C#
string longText = "PlayerID:12345-Level:99";

// [방식 1] string.Substring — 새 string 객체 생성
string playerID = longText.Substring(9, 5);

// [방식 2] ReadOnlySpan<char>.Slice — 할당 없이 위치만 가리킴
ReadOnlySpan<char> span = longText.AsSpan();
ReadOnlySpan<char> idSpan = span.Slice(9, 5);
IL
// [방식 1] Substring
IL_0000: ldstr      "PlayerID:12345-Level:99"
IL_0006: ldc.i4.s   9
IL_0008: ldc.i4.5
IL_0009: callvirt   instance string System.String::Substring(int32, int32)
//                  ↑ 여기서 새 string 객체가 힙에 생성된다 (GC 압박)

// [방식 2] AsSpan + Slice
IL_0013: call       valuetype System.ReadOnlySpan`1<char>
                    System.MemoryExtensions::AsSpan(string)
//                  ↑ ReadOnlySpan<char>는 스택의 지역 변수로 만들어짐
IL_0018: stloc.0
IL_0019: ldloca.s   0          // ← 지역 변수 0번지의 주소를 적재
IL_001b: ldc.i4.s   9
IL_001d: ldc.i4.5
IL_001e: call       instance valuetype System.ReadOnlySpan`1<char>
                    System.ReadOnlySpan`1<char>::Slice(int32, int32)
//                  ↑ Slice 결과도 스택. 힙 할당이 단 한 번도 없다.

핵심은 두 가지입니다.

  1. Substringcallvirt로 새 string을 반환합니다. string은 참조 타입이므로 결과는 힙에 생성됩니다.
  2. AsSpan·Slicevaluetype 반환값을 스택의 지역 변수에 저장합니다 (stloc.0). ReadOnlySpan<char>는 ref struct이므로 스택에서 태어나서 스택에서 죽습니다. 힙 할당 0회.

방식 2의 ReadOnlySpan<char>가 자유롭게 힙으로 올라갈 수 있다면 안전성이 깨지지만, ref struct 제약 덕분에 컴파일러가 안전성을 보장하면서도 런타임 비용은 거의 0입니다.


실전 적용 — Unity에서 ref struct를 만나는 순간들

Before: string.Substring으로 매 프레임 GC 쌓기

UI 텍스트나 디버그 로그를 매 프레임 잘라야 하는 시나리오를 봅시다.

C#
public class PlayerInfoLogger : MonoBehaviour
{
    [SerializeField] TMP_Text idText;
    string raw = "PlayerID:12345-Level:99";

    void Update()
    {
        // ❌ 매 프레임 새 string 할당 → GC 스파이크
        string id = raw.Substring(9, 5);
        idText.text = id;
    }
}
  • 60FPS × 60초 = 3,600개의 임시 string이 매분 힙에 쌓입니다.
  • Unity의 Boehm GC(Boehm-Demers-Weiser GC, Unity가 기본 사용하는 보수적 GC)는 정리 시 짧은 스파이크를 일으켜 프레임 드랍의 원인이 됩니다.

After: ReadOnlySpan<char>로 0 할당 자르기

C#
public class PlayerInfoLogger : MonoBehaviour
{
    [SerializeField] TMP_Text idText;
    string raw = "PlayerID:12345-Level:99";

    void Update()
    {
        // ✅ 원본의 일부를 가리키기만 한다 — 힙 할당 0
        ReadOnlySpan<char> idSpan = raw.AsSpan(9, 5);

        // 가능하면 Span 상태로 끝까지 처리한다.
        // TMP_Text.SetText는 ReadOnlySpan<char>를 직접 받는 오버로드가 있다.
        idText.SetText(idSpan);
    }
}
.AsSpan(start, length) — 부분 스팬 생성 string·T[]에서 복사 없이 일부 영역만 가리키는 ReadOnlySpan<T>를 반환한다. ref struct이므로 메서드 안에서만 살아있다.
예시: ReadOnlySpan<char> name = "Player_42".AsSpan(0, 6); "Player"를 새 string 만들지 않고 가리킨다.
  • 할당 0: 자르기·범위 검사·파싱 모두 스택에서 처리됩니다.
  • SetText(ReadOnlySpan<char>): TextMeshPro·StringBuilder.Append·int.Parse 등 .NET / Unity 표준 API에는 ReadOnlySpan<char> 오버로드가 점점 늘어나고 있습니다. 가능하면 그 오버로드를 우선 선택합니다.

함정: Span을 String으로 다시 바꾸면 무의미

C#
ReadOnlySpan<char> idSpan = raw.AsSpan(9, 5);
idText.text = idSpan.ToString(); // ← 여기서 다시 string 새로 생성!

.ToString()은 결국 new string(span)을 호출해 힙에 새 string을 만듭니다. ReadOnlySpan<char>로 시작했으면 끝까지 Span 상태로 처리해야 의미가 있습니다. 받는 쪽 API가 Span을 못 받는다면, 그 API를 쓰는 한 ref struct의 이점은 사라집니다.

Utf8JsonReader: 같은 원리로 동작하는 표준 라이브러리

System.Text.JsonUtf8JsonReader도 ref struct입니다. 네트워크에서 받은 UTF-8 바이트 버퍼 위를 직접 훑으면서 파싱하므로, JSON을 string으로 변환하는 비용 없이 토큰을 읽어냅니다.

C#
public static int ReadLevel(ReadOnlySpan<byte> jsonBytes)
{
    var reader = new Utf8JsonReader(jsonBytes); // ref struct, 스택에서 산다
    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.PropertyName
            && reader.ValueTextEquals("level"))
        {
            reader.Read();
            return reader.GetInt32(); // 바이트 그대로 정수로 파싱, 중간 string 0개
        }
    }
    return 0;
}

이 메서드는 JSON 한 덩어리를 처리하는 동안 단 한 번도 힙에 객체를 만들지 않습니다. Unity 런타임에서 서버 응답을 자주 파싱한다면, JsonUtility·JObject 대신 Utf8JsonReader를 쓰는 것만으로도 핫패스(hot path, 자주 실행되는 성능 결정 코드)의 GC 부담을 크게 줄일 수 있습니다.


함정과 주의사항 — 신입이 자주 만나는 컴파일 에러

함정 1: 클래스의 필드로 담아버린다

가장 흔한 실수입니다.

C#
// ❌ CS8345: 필드는 ref struct 인스턴스 멤버가 아니면 'Span<int>' 타입일 수 없다
public class GameContext
{
    public Span<int> Scores; // 컴파일 에러
}

Span<T>는 자체가 ref struct입니다. 이걸 클래스 필드로 담으려는 순간 컴파일러가 막습니다. 해결책은 두 가지입니다.

C#
// ✅ 해결 1: 컨테이너도 ref struct로 만든다
public ref struct GameContext
{
    public Span<int> Scores;
}
// 단, 이 GameContext도 클래스에 못 담긴다 — 메서드 안에서만 쓸 수 있다.

// ✅ 해결 2: 일반 배열로 보관한다 (힙 할당 발생, 트레이드오프)
public class GameContext
{
    public int[] Scores; // 그냥 배열
}

"Span을 필드로 들고 다니고 싶다"는 욕구가 생기면, 그 자체가 "이 데이터의 수명이 메서드 한 번을 넘어선다" 는 신호입니다. 이 경우엔 ref struct가 아닌 다른 자료구조(배열·Memory<T>·ArrayPool<T>)를 검토해야 합니다.

함정 2: object 변수에 담거나 인터페이스로 캐스트한다

C#
Span<int> s = stackalloc int[4];

// ❌ CS0029: 'Span<int>'을(를) 'object'(으)로 변환할 수 없다
object o = s;

// ❌ CS0030: 'Span<int>'에 대한 IEnumerable로의 변환을 정의할 수 없다
IEnumerable e = s;

박싱(boxing, 값 타입을 object 참조 타입으로 변환하는 동작)은 값을 힙으로 복사합니다. ref struct는 박싱 자체가 금지됩니다. C# 13에서 ref struct가 인터페이스를 구현할 수는 있게 됐지만, 인터페이스 타입 변수에 대입하는 변환은 여전히 금지입니다 (그 변환이 곧 박싱이기 때문입니다). 인터페이스를 통한 호출은 뒤에서 다룰 where T : allows ref struct 제약을 통해서만 가능합니다.

함정 3: async 메서드 안에서 지역 변수로 들고 있다

C#
// ❌ CS4012: 비동기 메서드의 매개 변수 또는 지역 변수의 형식이 'Span<byte>'일 수 없다
async Task ProcessAsync(byte[] data)
{
    Span<byte> buf = stackalloc byte[16];
    await Task.Delay(100); // ← 여기서 컴파일러가 변수를 상태 머신에 보관해야 함
    buf[0] = 42;           // 그러나 상태 머신은 클래스(힙)
}

async 메서드는 컴파일러가 내부적으로 클래스(상태 머신)로 변환하기 때문에, await를 가로지르는 지역 변수는 그 클래스의 필드가 됩니다. ref struct가 클래스 필드가 될 수 없으니 당연히 차단됩니다.

C#
// ✅ 해결: ref struct 사용은 동기 헬퍼 메서드로 격리
async Task ProcessAsync(byte[] data)
{
    var result = ProcessSync(data);
    await Task.Delay(100);
}

static int ProcessSync(byte[] data)
{
    Span<byte> buf = stackalloc byte[16];
    // ... 동기적으로 처리
    return buf[0];
}

C# 13에서는 await 경계를 넘지 않는 범위라면 async 메서드 안에서도 ref struct 지역 변수를 쓸 수 있도록 완화됐지만, await를 가로지르는 순간은 여전히 금지입니다.

함정 4: 람다·LINQ에서 캡처한다

C#
Span<int> nums = stackalloc int[] { 1, 2, 3 };

// ❌ CS8175: 'ref' 변수는 익명 메서드, 람다 식, 쿼리 식에서 사용할 수 없다
var first = new Func<int>(() => nums[0]);

람다는 캡처한 변수를 힙의 클로저(closure, 람다가 외부 변수를 함께 들고 다니는 객체) 객체에 넣습니다. ref struct는 그 객체에 들어갈 수 없습니다. LINQ도 내부적으로 람다를 쓰므로 마찬가지로 막힙니다 — Span<T>에는 Where·Select가 동작하지 않습니다.


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

ref struct는 등장 이후 매 버전 조금씩 제약이 풀려왔습니다. 이 변천사를 알면 현재 코드베이스의 C# 버전에서 무엇을 할 수 있고 무엇이 막히는지 판단할 수 있습니다.

시각화: 버전별 완화 흐름

ref struct 변천사 (C# 7.2 → 13)

C# 7.2 — ref struct의 시작

Span<T>·ReadOnlySpan<T> 도입과 함께 등장했습니다. 키워드 자체와 위에서 설명한 모든 핵심 제약이 이 버전에서 정의됐습니다.

C#
// C# 7.2 ~
ref struct Buffer { public Span<byte> Data; }

이 시점에는 ref struct가 인터페이스 구현·async 사용·제네릭 인자 등 거의 모든 "고급 기능"에서 배제됐습니다.

C# 8 — readonly ref struct

readonly 한정자를 ref struct에도 붙일 수 있게 됐습니다. 인스턴스의 필드를 수정하지 않겠다는 약속을 컴파일러에게 알리는 표시입니다.

C#
// C# 8 ~
public readonly ref struct ImmutableView
{
    public readonly Span<int> Data;
    public ImmutableView(Span<int> data) => Data = data;
}

readonly 표시가 있으면 컴파일러는 메서드 호출 시 방어 복사(defensive copy)를 생략할 수 있어 약간의 성능 향상도 따라옵니다. (방어 복사 자체에 대한 자세한 내용은 14번 토픽 "readonly 구조체 멤버" 글 참조.)

C# 9 — using 패턴 지원

ref struct는 인터페이스 구현이 안 되니 IDisposable을 구현할 수 없었지만, C# 8부터 이미 패턴 기반 using이 도입돼 있었고, C# 9 시점에 ref struct에서도 안정적으로 활용됩니다.

C#
// 패턴 기반 Dispose — IDisposable 구현 없이 using 사용 가능
public ref struct PooledBuffer
{
    public Span<byte> Data;
    public void Dispose() { /* 풀 반환 */ }
}

void Use()
{
    using var buf = new PooledBuffer(); // OK
}

C# 11 — ref 필드와 scoped 키워드

ref struct 안에 ref 필드(다른 변수의 위치를 직접 가리키는 필드)를 명시적으로 둘 수 있게 됐습니다. 이전까지 Span<T>는 컴파일러 내부에서 특별 처리되던 것이었는데, 이제 일반 사용자도 같은 패턴을 만들 수 있습니다 (자세한 내용은 16번 토픽 "ref 필드" 글 참조).

C#
// C# 11 ~
public ref struct MyRef
{
    public ref int Value; // 다른 int 변수를 직접 가리키는 필드
}

함께 도입된 scoped 키워드는 ref struct 매개변수의 수명 범위를 더 좁게 제한해, 더 안전한 API 설계를 가능하게 합니다.

C# 13 — 인터페이스 구현 허용 + allows ref struct

가장 큰 변화입니다.

[변화 1] ref struct가 인터페이스를 구현할 수 있게 됐습니다.

C#
// C# 13 ~
public interface ICounter
{
    int Count { get; }
    void Increment();
}

public ref struct StackCounter : ICounter // OK
{
    public int Count { get; private set; }
    public void Increment() => Count++;
}

단, 이전과 동일하게 인터페이스 타입 변수에 대입하는 박싱 변환은 여전히 금지입니다.

C#
StackCounter c = default;
ICounter i = c; // ❌ CS9244: ref struct를 인터페이스로 변환 불가

이 인터페이스 구현이 의미를 가지려면 두 번째 변화와 짝을 이뤄야 합니다.

[변화 2] where T : allows ref struct 제네릭 제약

기존에는 제네릭 타입 인자 T로 ref struct를 넘길 수 없었습니다. 그래서 Span<T>를 받는 알고리즘과 일반 배열을 받는 알고리즘을 따로 작성해야 했습니다.

C#
// C# 12 이하: T가 ref struct가 될 가능성 자체가 차단됨
static void Sum<T>(T values) { ... } // ❌ T = Span<int> 불가

// C# 13 ~: anti-constraint로 명시적으로 허용
static int Sum<T>(T counter) where T : ICounter, allows ref struct
{
    counter.Increment();
    return counter.Count;
}

StackCounter c = default;
int result = Sum(c); // OK — ref struct여도 제네릭 인자로 전달 가능
                     //      그리고 ICounter 메서드 호출도 박싱 없이 동작

allows ref struct는 일반적인 제약과 반대 방향입니다. 보통 where T : ICounter는 "T의 능력을 좁힌다"이지만, allows ref struct는 "T가 가질 수 있는 형태를 넓힌다"입니다 (anti-constraint). 그래서 메서드 본문 안에서는 T가 박싱될 가능성이 있는 모든 동작 — (object)counter·null 비교·is 패턴 — 을 컴파일러가 추가로 검사합니다.

이 두 변화가 합쳐져서 마침내 Span<T> 같은 ref struct를 제네릭 알고리즘에 안전하게 넘길 수 있게 됐습니다. .NET 9 이후 BCL의 LINQ-like 확장들이 점진적으로 이 제약을 채택하면서, 입문 개발자도 이 변화의 혜택을 자연스럽게 받게 됩니다.


정리 — 이것만 기억하세요

ref struct는 "성능을 위해 안전성을 컴파일러가 직접 증명하는 도구"입니다. Unity 신입 개발자 입장에서 외워야 할 핵심은 다음 다섯 가지입니다.

  • [ ] ref struct는 무조건 스택에만 산다. 클래스 필드·박싱·배열 요소·async 가로지르기는 모두 컴파일 에러로 막힌다. 이 막힘은 버그가 아니라 안전 보장이다.
  • [ ] 대표 예시는 Span<T>·ReadOnlySpan<T>·Utf8JsonReader다. 이들이 ref struct이기 때문에 힙 할당 없이 메모리를 다룰 수 있다.
  • [ ] Unity 핫패스에서 string.Substring 대신 ReadOnlySpan<char> + AsSpan + Slice를 쓴다. .ToString()으로 다시 string 만들면 의미가 사라지므로 끝까지 Span 상태로 처리한다.
  • [ ] stackalloc과 함께 쓸 때는 크기를 작게 (보통 1KB 이하). 스택 오버플로 위험이 있다.
  • [ ] C# 13부터 인터페이스 구현·allows ref struct 제네릭 제약이 가능해졌다. 다만 박싱(인터페이스 변수 대입)은 여전히 금지다. 라이브러리 작성자가 아니라면 이 변화는 "이제 BCL의 더 많은 API가 Span을 받는다" 정도로 이해하면 충분하다.

ref struct는 어디서나 쓰는 도구가 아닙니다. 일반 게임 로직에는 일반 class·struct가 적합하고, 매 프레임 실행되는 핫패스에서 GC 스파이크를 줄여야 할 때 ref struct 기반 API들을 꺼내 쓰면 됩니다. 컴파일러가 막아준다는 사실만 기억하면, 잘못 써서 메모리 사고를 낼 위험은 거의 없습니다.

반응형

+ Recent posts