반응형

[PART12.제네릭·델리게이트·람다·LINQ(11/18)] iterator / async 메서드 안에서 ref·unsafe (C# 13)

C# 12까지는 yield return이나 await이 있는 메서드에서 Span<T>·ref 로컬을 한 줄도 쓸 수 없었다 / C# 13은 "경계를 넘지 않으면" 허용한다 — 그 "경계"가 무엇인지가 핵심이다


1. [문제 제기] — Unity 코루틴에서 Span<T>를 쓰려고 하면 왜 막혔는가

Unity 모바일 게임을 만들다 보면, 매 프레임 또는 일정 주기로 문자열을 파싱해야 할 때가 있습니다. 예를 들어 서버에서 받은 패킷을 코루틴으로 풀어주거나, 비동기로 다운로드한 JSON을 파싱하는 코드입니다. 신입 개발자가 Span<T> 같은 무할당(zero-allocation) 도구를 배우고 나면 자연스럽게 이런 코드를 작성합니다.

C#
// "패킷을 한 번씩 끊어 가공해 돌려주는 코루틴"이라는 흔한 시나리오
IEnumerator ParseRoutine(string raw)
{
    Span<char> buffer = stackalloc char[64]; // ❌ C# 12 컴파일 에러
    raw.AsSpan().CopyTo(buffer);
    yield return null;
}

그런데 이 코드는 C# 12까지는 한 줄도 통과하지 못했습니다. 컴파일러가 에러로 막아버립니다.

error CS9202: '비동기 및 반복기 메서드에서 ref 및 unsafe' 기능은 C# 12.0에서 사용할 수 없습니다.
              언어 버전 13.0 이상을 사용하세요.

await가 들어간 async 메서드도 마찬가지입니다.

C#
async Task<int> ParseAsync(byte[] data)
{
    Span<byte> view = data.AsSpan(0, 4); // ❌ C# 12 컴파일 에러 (CS9202)
    int v = view[0];
    await Task.Delay(1);
    return v;
}

Span<T>는 GC 부담을 덜기 위해 .NET이 마련해 준 가장 강력한 무기입니다. 그런데 정작 모바일 게임에서 GC를 가장 신경 써야 하는 두 영역 — 코루틴과 async/await — 에서는 쓸 수 없었던 것입니다. C# 13이 이 제약을 풀었습니다. 단, 무조건 풀어준 건 아니고 "안전 경계"를 도입했습니다. 이번 글은 "왜 막혀 있었고, 무엇이 풀렸으며, 어떤 경계가 남았는가"를 다룹니다.

참고: iterator(yield return)와 async/await의 동작 원리 자체는 PART 13에서 본격적으로 다룹니다. 이번 글에서는 두 메커니즘이 컴파일러에 의해 상태 머신 클래스로 변환된다는 사실까지만 전제로 사용합니다.

2. [개념 정의] — "상태 머신 변환"이라는 한 가지 사실에서 시작한다

비유: 식당 주문서에 옮겨 적는 메모

코루틴이나 async 메서드를 직원이 받아 적은 주문서라고 생각해 봅시다. 손님이 "스테이크 굽기 시작하고, 5분 뒤에 와인 따고, 다시 손님이 부르면 디저트 가져와"라고 길게 시켰습니다. 직원은 이 한 흐름을 처음부터 끝까지 한 자리에서 외우고 있을 수가 없습니다. 잠시 다른 손님도 응대해야 하니까요.

그래서 직원은 주문서(메모지) 에 변수들을 옮겨 적습니다. "현재 단계: 와인 따는 중", "고기 굽기 시작 시각: 7시 12분"처럼요. 메모지는 주방 한쪽 게시판(힙)에 핀으로 꽂아 두고, 직원은 잠시 자리를 비웠다가 돌아와 메모지를 읽으며 다음 단계를 이어갑니다.

C# 컴파일러도 똑같이 합니다. async/iterator 메서드의 지역 변수를 상태 머신 클래스의 필드로 옮겨 적습니다. 그래야 await/yield return에서 잠시 멈췄다가 돌아와도 변수 값을 다시 읽을 수 있기 때문입니다. 이걸 호이스팅(hoisting) 이라고 부릅니다.

yield return — 반복자에서 한 번에 한 값씩 돌려주고 멈춤 컴파일러가 메서드를 상태 머신 클래스로 바꿔, MoveNext()를 호출할 때마다 다음 값까지 진행하고 일시 정지한다.
예시: IEnumerable<int> Nums() { yield return 1; yield return 2; } — 한 번씩 끊어서 1, 2를 돌려준다.
await — 비동기 작업이 끝날 때까지 메서드를 일시 정지 마찬가지로 컴파일러가 메서드를 상태 머신으로 바꿔, await 지점에서 호출자에게 제어를 돌려주고 작업이 끝나면 이어 실행한다.
예시: int v = await Task.FromResult(7); — Task가 끝나면 7을 받는다.

시각화: 상태 머신과 호이스팅

컴파일러의 상태 머신 변환

코드와 IL: "필드가 될 수 없다"는 한 줄 규칙

Span<T>·ReadOnlySpan<T> 같은 타입을 ref struct 라고 부릅니다. 이 타입들은 .NET 런타임이 정한 단호한 규칙 하나가 있습니다.

stackalloc — 스택에 직접 메모리를 잡는 키워드 힙(GC 영역)이 아니라 현재 메서드의 스택 프레임에 메모리를 할당한다. 메서드가 끝나면 자동으로 해제된다 — GC 부담이 0이다. 보통 Span<T>와 함께 쓴다.
예시: Span<byte> buf = stackalloc byte[16]; — 스택에 16바이트를 잡고 Span<byte>로 보는 것.
ref struct 타입과 ref 로컬은 참조 타입(클래스)의 필드가 될 수 없다.

이유는 안전성입니다. Span<T>는 자기 안에 스택 메모리(stackalloc이 잡은 영역)를 가리키는 포인터를 들고 있을 수 있습니다. 만약 이걸 힙에 있는 클래스 필드로 옮겨 두면, 스택은 함수가 끝날 때 사라지는데 힙의 객체는 살아남아서 사라진 메모리를 가리키는 좀비 포인터가 됩니다. 그래서 ref struct는 무조건 스택에서만 살도록 강제합니다.

이제 두 사실을 합치면 결론이 자동으로 나옵니다.

  1. async/iterator 메서드는 지역 변수를 클래스 필드로 옮긴다(호이스팅).
  2. ref struct는 클래스 필드가 될 수 없다.

→ 따라서 async/iterator 메서드 안에서 Span<T>ref 로컬을 선언하는 것 자체가 C# 12까지는 일률적으로 금지 되었습니다.

C#
// C# 12 — 메서드 어디든 선언만 해도 컴파일 실패
async Task<int> ParseAsync()
{
    await Task.Delay(1);
    Span<byte> buf = stackalloc byte[4]; // ❌ CS9202
    return buf[0];
}

실제 빌드 시 컴파일러가 출력하는 에러는 다음과 같습니다.

error CS9202: '비동기 및 반복기 메서드에서 ref 및 unsafe' 기능은 C# 12.0에서 사용할 수 없습니다.
              언어 버전 13.0 이상을 사용하세요.

이 에러 메시지는 단서 그 자체입니다. "비동기 및 반복기 메서드에서 ref 및 unsafe" — 이게 곧 C# 13에서 풀린 기능의 정식 이름입니다.


3. [내부 동작] — C# 13이 푼 것은 "선언 금지"가 아니라 "분석 능력"

시각화: 안전 범위 분석

컴파일러의

컴파일러가 실제로 보는 두 가지 패턴

C# 13의 변경은 한마디로 이렇게 정리됩니다.

무조건 금지를 풀고, "awaityield return 경계를 가로지르며 살아남아야 하는가" 만 검사하기로 했다.

경계를 넘으면 호이스팅이 필요한데 ref struct는 호이스팅이 불가능하니 여전히 에러. 경계를 넘지 않으면 호이스팅이 필요 없으니 그냥 MoveNext() 메서드의 지역 변수로 두면 됩니다. 이걸 직접 두 가지 코드로 확인해 봅니다.

C#
// ✅ C# 13 — 안전 범위 (await 이전에 buf 사용 종료)
async Task<int> SafeAsync()
{
    await Task.Delay(1);             // 먼저 멈춘다
    Span<byte> buf = stackalloc byte[4];
    buf[0] = 7;
    int v = buf[0];                  // buf 사용 끝
    return v;                        // buf를 다시 안 쓴다
}

이 코드는 C# 13에서 깔끔히 컴파일됩니다. 같은 코드를 ilspycmd로 디컴파일하여 컴파일러가 만든 <SafeAsync>d__0 상태 머신 struct의 필드 목록을 들여다보면 결정적인 사실이 보입니다.

IL
.class nested private value sealed beforefieldinit '<SafeAsync>d__0'
    extends [System.Runtime]System.ValueType
    implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
    .field public int32 '<>1__state'                                    // 상태 번호
    .field public valuetype AsyncTaskMethodBuilder`1<int32> '<>t__builder' // 결과 빌더
    .field private valuetype TaskAwaiter '<>u__1'                        // 대기 중인 awaiter
    // ← 끝. Span<byte>는 어디에도 없다.
}

상태 머신의 필드는 단 3개입니다 — 상태 번호, 결과 빌더, 그리고 await가 기다리는 awaiter. Span<byte>는 필드 목록에 한 줄도 들어가 있지 않습니다. 그러면 Span은 어디에 살까요? MoveNext() 안의 진짜 지역 변수입니다.

IL
.method private hidebysig instance void MoveNext () cil managed
{
    .locals init (
        [0] int32,
        [1] valuetype TaskAwaiter,
        [2] int32,
        [3] valuetype System.Span`1<uint8>   // ← 여기, MoveNext의 지역 변수로 살아 있다
    )
    // ... 상태 분기 ...
    IL_xxxx: ldc.i4.4
    IL_xxxx: conv.u
    IL_xxxx: localloc                         // ← stackalloc — 진짜 스택에 4바이트 잡는다
    IL_xxxx: ldc.i4.4
    IL_xxxx: newobj instance void System.Span`1<uint8>::.ctor(void*, int32)
    IL_xxxx: stloc.3                          // ← Span을 지역 변수 슬롯 3에 저장
    // ... 사용 ...
}

여기서 핵심은 두 가지입니다.

  • locallocMoveNext() 메서드 자체의 스택 프레임에 진짜 스택 메모리를 잡는다 — Span이 가리키는 메모리가 실제로 MoveNext 호출 동안만 살아 있다.
  • Span<byte>.locals init의 슬롯 3 — 클래스 필드가 아니라 메서드 지역. 호이스팅이 일어나지 않았다.

컴파일러가 "await 너머로 살아 있을 필요가 없네 → 굳이 필드로 옮길 필요가 없네 → 그러면 Span이라도 OK"라는 분석을 했기 때문에 가능한 결과입니다.

경계를 넘으면 어떻게 되는가

같은 패턴을 일부러 어겨 봅니다.

C#
// ❌ C# 13 — 위반: buf가 await 경계를 넘어 살아남아야 함
async Task<int> CrossAwait()
{
    Span<byte> buf = stackalloc byte[4];
    buf[0] = 7;
    await Task.Delay(1);     // ← 중단 지점
    return buf[0];           // ← await 이후에도 buf 사용
}

빌드하면 다음 에러가 납니다.

error CS4007: 'await' 또는 'yield' 경계를 넘어 'System.Span<byte>' 형식의
              인스턴스를 보존할 수 없습니다.

이 에러 메시지가 C# 12의 CS9202와 결정적으로 다른 점은 변수 단위·문장 단위의 정밀한 위반 보고라는 점입니다. C# 12는 "async 메서드 안에 Span<T> 못 써"였지만, C# 13은 "이 bufawait 너머로 보존하려 해서 안 돼"라고 콕 집어 알려줍니다.

CS9202 vs CS4007 — 두 에러 메시지의 의미 차이 CS9202는 "기능 자체가 미지원" 신호 — 언어 버전을 올리라는 안내. CS4007은 "기능은 되는데 이 사용은 안전 규칙 위반" 신호 — 코드 패턴을 바꿔야 한다.
예시: C# 12에서 Span 선언만 해도 CS9202. C# 13에서는 같은 선언은 통과하고, await 너머로 Span을 살리려 할 때만 CS4007.

4. [실전 적용] — Unity 신입 개발자가 실제로 얻는 것

패턴 1: async 메서드에서 무할당 파싱

서버 응답을 받아 정수 4개를 파싱한 뒤 다시 다른 비동기 호출을 이어가는 흔한 시나리오입니다. C# 12까지는 이 한 메서드에서 Span<T>로 파싱하는 게 아예 불가능했고, 별도 동기 메서드로 빼야 했습니다.

C#
// ❌ Before (C# 12): Span을 쓰고 싶으면 동기 메서드로 분리해야 했다
async Task<HeaderInfo> LoadHeaderAsync(Stream s)
{
    var bytes = new byte[16];
    await s.ReadExactlyAsync(bytes);
    return ParseHeader(bytes);   // 별도 동기 메서드로 던진다
}
HeaderInfo ParseHeader(byte[] data) { /* 여기서만 Span 사용 가능 */ }
C#
// ✅ After (C# 13): 같은 메서드에서 바로 Span 파싱
async Task<HeaderInfo> LoadHeaderAsync(Stream s)
{
    var bytes = new byte[16];
    await s.ReadExactlyAsync(bytes);              // ← await 끝

    ReadOnlySpan<byte> view = bytes.AsSpan();     // ← 안전 범위 시작
    int magic   = BinaryPrimitives.ReadInt32LittleEndian(view);
    int version = BinaryPrimitives.ReadInt32LittleEndian(view.Slice(4));
    int width   = BinaryPrimitives.ReadInt32LittleEndian(view.Slice(8));
    int height  = BinaryPrimitives.ReadInt32LittleEndian(view.Slice(12));
    var info = new HeaderInfo(magic, version, width, height); // ← view 사용 끝
    // ← 안전 범위 끝

    await ReportLoadedAsync(info);  // 다음 await — 이때 view는 이미 죽었음
    return info;
}

After 패턴의 가치는 단순합니다. 메서드 분리 없이 한 곳에서 흐름을 따라갈 수 있게 되었다. Before에서는 동기·비동기를 굳이 갈라서 메서드를 두 개 만들어야 했습니다. 코드 가독성·유지보수성이 즉시 올라갑니다.

IL에서도 결과는 명확합니다 — view(ReadOnlySpan<byte>)는 <LoadHeaderAsync>d__N 상태 머신의 필드 목록에 들어가지 않고, MoveNext() 안에서만 살았다가 사라집니다.

패턴 2: 코루틴/이터레이터에서 stackalloc 임시 버퍼

Unity 코루틴은 IEnumerator 위에서 동작합니다. 매 프레임 또는 매 WaitForSeconds 마다 임시 버퍼가 필요한데 new byte[N]을 매번 만들면 GC 스파이크가 납니다. C# 13에서는 stackalloc이 가능합니다.

C#
// ✅ C# 13 — iterator 안에서 stackalloc
static IEnumerable<int> GenerateChecksums(IEnumerable<string> lines)
{
    foreach (string line in lines)
    {
        Span<byte> buf = stackalloc byte[64];        // ← 매 반복마다 스택에 잡고
        int written = Encoding.UTF8.GetBytes(line, buf);
        int checksum = SimpleHash(buf.Slice(0, written));
        // ← buf 사용 종료
        yield return checksum;                       // checksum 만 돌려준다 — int는 그냥 값
    }
}

이 코드의 결정적 증거를 보기 위해 컴파일러가 만든 <GenerateChecksums>d__0 클래스의 필드 목록을 보면 다음과 같습니다.

IL
.class nested private auto ansi sealed beforefieldinit '<IterStack>d__0'
    extends [System.Runtime]System.Object
    implements ... IEnumerable`1<int32>, IEnumerator`1<int32> ...
{
    .field private int32 '<>1__state'              // 상태 번호
    .field private int32 '<>2__current'            // 현재 yield 값 (Current)
    .field private int32 '<>l__initialThreadId'    // 스레드 ID (재진입 검사)
    // ← 끝. Span<int> 도, IEnumerator 도 필드에 없다.
}

.method private hidebysig instance bool MoveNext () cil managed
{
    .locals init (
        [0] int32,
        [1] valuetype System.Span`1<int32>,         // ← Span은 MoveNext의 지역 변수
        [2] int32
    )
    // ...
    IL_0017: ldc.i4.s 12          // 4 * 3 = 12 바이트
    IL_0019: conv.u
    IL_001a: localloc             // ← MoveNext 호출 동안만 사는 스택 메모리
    IL_001c: ldc.i4.3
    IL_001d: newobj instance void System.Span`1<int32>::.ctor(void*, int32)
    IL_0022: stloc.1              // ← Span을 지역 슬롯 1에 저장
    // ... yield return ...
}

여기서 신입 개발자가 짚어 둘 두 가지 사실:

  1. iterator 클래스의 필드는 <>1__state, <>2__current(현재 값), <>l__initialThreadId딱 3개. Span<int>는 그림자도 없습니다.
  2. Span이 가리키는 스택 메모리는 MoveNext()가 호출되는 각 반복마다 다시 잡힙니다(localloc). MoveNext() 가 한 번 끝나면(=yield return이 한 번 일어나면) 그 스택 프레임은 사라지고, 다음 MoveNext() 호출 때 새로 잡힙니다.

→ 그래서 "한 yield return 사이클 내부"라는 안전 경계가 보장되는 것입니다.

패턴 3: Unity 코루틴에서의 적용 가능성과 단서

이론적으로 Unity의 MonoBehaviour.StartCoroutine 코루틴도 같은 혜택을 받을 수 있습니다. 코루틴은 IEnumerator를 반환하는 메서드이고, Unity 엔진은 이를 매 프레임 MoveNext() 호출하는 형태로 돌립니다. 따라서 C# 13 컴파일러가 만든 상태 머신 위에서라면 코루틴 안에서도 stackalloc/Span<T>을 쓸 수 있습니다.

다만 신입 개발자가 반드시 짚어야 할 단서가 있습니다.

Unity의 C# 13 지원은 Unity 에디터에 내장된 Roslyn 컴파일러 버전에 달려 있습니다.

Unity는 .NET 런타임은 자체적으로 IL2CPP 또는 Mono 위에서 돌리지만, C# 코드를 IL로 번역하는 건 Unity 에디터에 묶인 Roslyn 컴파일러가 합니다. C# 13 언어 기능을 쓰려면 C# 13을 지원하는 Roslyn이 들어 있는 Unity 버전을 써야 합니다. 본인이 쓰는 Unity 버전의 패치 노트에서 "C# language version" 항목을 확인하고, 프로젝트의 csc.rsp 또는 Unity의 API Compatibility Level 설정을 점검해야 합니다.

또 하나, Unity 코루틴은 .NET의 async/await와 다른 시스템입니다. StartCoroutine은 직접 IEnumerator를 받아 자기 스케줄러로 돌리지, Task 기반이 아닙니다. 그래서 이 글의 async 패턴은 코루틴이 아니라 Unity의 UniTaskAwaitable(Unity 2023+) 같은 비동기 API 에 적용된다고 이해해야 합니다.

IL2CPP — Unity의 C# → C++ → 네이티브 변환 빌드 모드 C# 코드를 일단 IL로 컴파일한 뒤, 다시 C++로 변환해 네이티브 코드로 빌드한다. iOS·Android 릴리즈에서 표준이며, JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환)가 없는 환경에서도 동작한다.
Roslyn — .NET 공식 C# 컴파일러 Microsoft가 만든 C# → IL 컴파일러. 언어 버전(C# 12, 13 등)이 결정되는 곳이며, Unity 에디터는 자체 빌드된 Roslyn을 내장한다.

5. [함정과 주의사항] — "안전 범위"는 직관과 어긋나는 순간이 있다

함정 1: 루프 안에서 Span을 만들고 yield return이 그 안에 있을 때

신입 개발자가 가장 자주 헷갈리는 패턴입니다.

C#
// ❌ 잘못된 직관 — "루프 안이니까 매 반복 새로 만들어지겠지"
IEnumerable<int> Bad()
{
    Span<int> data = stackalloc int[3];   // ← 루프 밖에서 한 번 선언
    data[0] = 1; data[1] = 2; data[2] = 3;
    foreach (var item in data)
    {
        yield return item;                 // ← 여기서 멈췄다 다시 옴
    }
    // 다음 반복에서 data를 또 봐야 함 → 경계 위반 → CS4007
}

이 코드의 함정은 data가 루프 밖에서 선언되었다는 점입니다. yield return 이후에도 foreach가 다음 항목을 읽기 위해 data를 다시 봐야 하므로 datayield 경계를 넘어야 합니다. 컴파일 에러로 막힙니다.

C#
// ✅ 올바른 패턴 — Span 자체가 한 yield 사이클 내에서만 살게 한다
IEnumerable<int> Good()
{
    int[] source = { 1, 2, 3 };           // 일반 배열로 보관
    foreach (var item in source)
    {
        Span<int> tmp = stackalloc int[1];  // ← 매 반복 안에서만 살아 있는 Span
        tmp[0] = item * 2;
        int doubled = tmp[0];               // ← tmp 사용 종료
        yield return doubled;               // ← int(값 타입)만 yield
    }
}

핵심 규칙은 한 줄입니다. Span<T>await/yield return 한 번을 가로지르지 않는 짧은 생애를 가져야 한다. 결과만 빼서 일반 값/객체로 변환한 뒤 yield/await 너머로 보냅니다.

함정 2: Span<T>를 인자로 다른 await 메서드에 넘기기

C#
// ❌ Span 자체가 await 호출에 인자로 들어가면 — 함수 호출 동안 살아 있어야 함
async Task BadCall()
{
    Span<byte> buf = stackalloc byte[16];
    await DoSomethingAsync(buf);   // ❌ Span이 비동기 호출 너머로 살아야 함
}

Span<T>async 메서드의 인자 타입이 될 수 없습니다(이건 C# 13 이전부터 그랬습니다). Span<T>를 받는 비동기 메서드 자체를 만들 수 없기 때문에, 이런 호출은 컴파일 단계에서 막힙니다.

C#
// ✅ 결과만 추출해서 일반 타입으로 보내거나, 동기 메서드에 위임
async Task GoodCall()
{
    Span<byte> buf = stackalloc byte[16];
    FillBuffer(buf);                       // 동기 메서드에 위임
    int v = BitConverter.ToInt32(buf);     // 결과만 추출
    await SaveAsync(v);                    // 일반 int만 await에 넘긴다
}
static void FillBuffer(Span<byte> b) { /* ... */ }

함정 3: unsafe 블록 안의 awaityield

C# 13은 iterator/async 안에서도 unsafe 블록을 허용합니다. 단, yield return/yield break 문장 자체는 반드시 safe 컨텍스트 에 있어야 합니다.

C#
// ❌ unsafe 블록 안에서 yield return
IEnumerable<int> BadUnsafe(byte[] data)
{
    unsafe
    {
        fixed (byte* p = data)
        {
            yield return p[0];   // ❌ unsafe 컨텍스트 안에서 yield 금지
        }
    }
}
C#
// ✅ unsafe로는 데이터만 뽑고, yield는 바깥 safe 영역에서
IEnumerable<int> GoodUnsafe(byte[] data)
{
    foreach (var _ in data)
    {
        int v;
        unsafe
        {
            fixed (byte* p = data) { v = p[0]; }  // ← unsafe는 여기까지만
        }
        yield return v;                            // ← safe 컨텍스트
    }
}

규칙을 한 문장으로 외우면 좋습니다. "중단 지점(yield/await)은 항상 안전한 곳에서."

함정 4: Unity 핫패스에서의 잘못된 기대

신입 개발자가 가장 쉽게 빠지는 함정 — "C# 13 덕분에 코루틴 안에서 Span을 쓰면 GC가 사라지겠다"는 단정입니다. 절반은 맞고 절반은 틀립니다.

  • Span<int> tmp = stackalloc int[64]; — 이 자체는 GC 할당이 0입니다. 맞습니다.
  • ❌ 그러나 코루틴 자체가 만드는 상태 머신 클래스(<MyRoutine>d__0)는 여전히 힙 객체입니다. 코루틴을 매 프레임 새로 시작하면 그 인스턴스만큼 힙 할당이 발생합니다.

→ "GC가 사라진다"가 아니라 "코루틴 본문 내부에서 임시 버퍼 할당이 사라진다"가 정확한 표현입니다. Unity Profiler의 GC Alloc 컬럼이 줄어들었는지 반드시 측정해야 합니다.


6. [C# 버전별 변화] — C# 12에서 13으로 한 줄이 바뀌었다

이 주제는 본질적으로 버전 변화 자체가 핵심인 주제입니다. 짧게 정리합니다.

버전 iterator/async 안의 ref·ref struct·unsafe
C# 11 이하 일률 금지 (선언만 해도 컴파일 에러)
C# 12 일률 금지 (CS9202 — "비동기 및 반복기 메서드에서 ref 및 unsafe 기능은 사용할 수 없습니다")
C# 13 안전 범위(await/yield 경계를 넘지 않음) 내에서 허용 — 위반 시 CS4007

C# 13의 변경은 언어 명세 변경이라기보다 컴파일러 분석 능력의 향상입니다. .NET 런타임의 "ref struct는 클래스 필드가 될 수 없다" 규칙은 그대로입니다. 컴파일러가 똑똑해져서 "어차피 필드가 될 필요가 없는 경우"를 판별하게 된 것뿐입니다.

C#
// 같은 코드 — 단지 LangVersion 만 바뀐다
async Task<int> Demo()
{
    await Task.Delay(1);
    Span<byte> buf = stackalloc byte[4];   // C# 12: ❌ CS9202 / C# 13: ✅
    return buf[0];
}

프로젝트 파일에서 단 한 줄을 바꾸면 됩니다.

<PropertyGroup>
  <LangVersion>13</LangVersion>      <!-- ← 12 → 13 -->
</PropertyGroup>

단, 앞서 "함정" 섹션에서 본 위반 패턴을 그대로 가지고 올라간 코드는 CS9202 대신 CS4007로 다시 막힐 것입니다. 에러 코드만 바뀔 뿐, 본질적인 안전 규칙은 동일하게 유지됩니다.


7. [정리] — 이것만 기억하라

다음 다섯 항목을 외우면 신입 개발자가 실수할 일이 없습니다.

  • [ ] 컴파일러는 async/iterator 메서드를 상태 머신 클래스로 변환한다. 지역 변수는 await/yield 너머로 살아남기 위해 클래스 필드로 호이스팅된다.
  • [ ] ref struct(Span<T> 등)와 ref 로컬은 클래스 필드가 될 수 없다. 이게 과거 일률 금지의 진짜 이유였다.
  • [ ] C# 13의 변경은 "선언 금지 해제"가 아니라 "안전 범위 분석 도입"이다. await/yield 경계를 넘지 않으면 호이스팅이 필요 없으니 허용한다.
  • [ ] 위반 시 에러는 CS4007 — "await 또는 yield 경계를 넘어 보존할 수 없습니다." 변수 단위로 콕 짚어 알려준다.
  • [ ] Unity 적용은 Roslyn 버전에 달려 있다. 본인 Unity 버전이 C# 13을 지원하는지 확인하고, 코루틴 자체의 힙 할당은 별개로 남는다는 사실을 기억한다.

핵심 메시지를 한 줄로 줄이면 이렇습니다.

C# 13의 새 규칙은 한 문장입니다 — "Span<T>await/yield return 한 번을 가로지르지 않는 짧은 생애를 가져야 한다." 그게 전부입니다.
반응형

+ Recent posts