반응형

[PART7.클래스와 객체 입문(16/21)] ref 필드 — ref struct 안에서 참조를 필드로 보관 (C# 11)

왜 Span<T>가 5년 동안 "런타임 마법"이었는가 / = ref 문법이 따로 있는 이유 / 컴파일러가 수명을 추적하는 방법


문제 제기 — 왜 Span<T>는 5년 동안 "정상 문법"이 아니었을까

지난 글에서 다룬 ref struct는 "스택에서만 살 수 있는 구조체"였습니다. 그 대표 사례가 바로 Span<T> 였죠. 그런데 신기한 사실이 하나 있습니다.

C# 7.2(2017년)에서 Span<T>가 도입된 뒤로, 약 5년 동안 Span<T>의 내부 구현은 일반 C# 개발자가 흉내 낼 수 없는 모양을 하고 있었습니다. 마이크로소프트가 만든 Span<T>는 동작했지만, 똑같은 자료구조를 우리가 라이브러리로 만들려고 하면 컴파일러가 "그 문법은 허용되지 않습니다"라며 거부했습니다.

C#
// 우리가 만들려고 했던 것 — C# 11 이전엔 컴파일 에러
public ref struct MySpan<T>
{
    private ref T _reference; // CS9059: ref 필드는 ref 구조체에서만...
    private int _length;       // (사실은 C# 11 이전엔 ref struct에서도 금지)
}

마이크로소프트는 ByReference<T>라는 비공개 런타임 전용 타입을 만들어 우회했습니다. 이 타입은 일반 개발자가 사용할 수 없었고, 컴파일러와 JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환하는 컴파일러)가 특별 취급해 주는 "내부 마법"이었습니다. 즉, 언어 명세에는 없는 기능을 런타임 팀만 몰래 쓰고 있었던 셈입니다.

C# 11이 풀어낸 문제가 바로 이것입니다. ref struct 안에서 ref T·ref readonly T 필드를 정식 문법으로 선언할 수 있게 되었고, .NET 7부터는 Span<T>·ReadOnlySpan<T> 자신도 이 정식 문법 위에서 다시 구현되었습니다. 이번 글의 주인공인 ref 필드는 입문자가 직접 선언할 일은 거의 없지만, Span<T>·Memory<T>가 어떻게 GC 없이 메모리를 공유하는가를 이해하려면 반드시 알아야 하는 마지막 조각입니다.

ref 필드 — 참조형 필드 (Reference field) ref struct 내부에 선언하는 특별한 필드로, 값을 담는 대신 다른 변수가 있는 메모리 주소를 직접 가리킨다. C/C++의 포인터와 비슷하지만 컴파일러가 수명을 추적해 댕글링 참조(dangling reference, 이미 사라진 메모리를 가리키는 참조)를 원천 차단한다.
예시: ref struct Slice { private ref int _start; private int _length; } _start 필드는 int 값이 아니라 다른 곳에 있는 int 변수의 주소를 보관한다.

개념 정의 — "값을 복사하지 않고 가리키기만 하는 필드"

비유: 책상 위 메모지 vs 도서관 청구기호

평소 우리가 쓰는 일반 필드를 떠올려 보겠습니다. int x = 10; 같은 코드는 책상 위에 "10"이라고 적힌 메모지를 직접 올려놓는 것과 같습니다. 옆 책상이 그 값을 보려면 메모지를 베껴 적어야 합니다(복사).

ref 필드는 다릅니다. 책상 위에 직접 값을 적는 게 아니라, 도서관에 있는 책의 청구기호를 적어 둡니다. 책 자체는 도서관에 있고, 우리는 청구기호로 그 책을 찾아갑니다. 청구기호를 통해 책의 내용을 읽거나 고치면, 도서관에 있는 원본이 그대로 바뀝니다.

이 비유에서 중요한 점이 하나 있습니다. 청구기호의 책이 폐기되면(원본 메모리가 해제되면) 청구기호는 유령 주소가 됩니다. C/C++에서 댕글링 포인터로 유명한 그 위험입니다. C#의 ref 필드는 컴파일러가 "청구기호의 수명이 책의 수명보다 길지 않다"는 것을 정적 분석으로 보장합니다.

시각화: 일반 필드 vs ref 필드의 메모리 모양

일반 필드 vs ref 필드 — 메모리 안에서 무엇이 다른가

가장 단순한 ref 필드

ref 필드를 직접 선언해 보겠습니다.

C#
using System.Runtime.CompilerServices;

// ref struct 안에서만 ref 필드를 둘 수 있다
public ref struct SpanLike<T>
{
    private ref T _reference;  // ref 필드 — T 변수의 주소를 보관
    private int _length;        // 일반 필드 — 정수 값을 직접 보관

    public SpanLike(ref T start, int length)
    {
        _reference = ref start; // ref 대입 — 주소를 연결
        _length = length;        // 일반 대입 — 값을 복사
    }

    public ref T this[int i] =>
        ref Unsafe.Add(ref _reference, i);

    public int Length => _length;
}

public class Program
{
    public static void Main()
    {
        int[] arr = { 10, 20, 30, 40 };
        var span = new SpanLike<int>(ref arr[0], arr.Length);

        span[1] = 99; // ref 필드를 통해 원본 배열에 직접 쓰기

        System.Console.WriteLine($"arr[1]={arr[1]}");
        // 출력: arr[1]=99 — 복사가 아니라 같은 메모리를 공유했다
    }
}

SpanLike<T>Span<T>의 핵심 골격을 그대로 흉내 낸 것입니다. 두 개의 필드 — 시작 주소(ref T _reference)와 길이(int _length) — 만 가지고 배열의 일부를 가리키는 자료구조죠. 인덱서 this[int i]Unsafe.Add로 시작 주소에서 i칸만큼 떨어진 위치를 계산해 그 자리의 참조를 돌려줍니다. span[1] = 99 라고 쓰면 원본 배열 arr[1]의 메모리에 직접 99가 기록됩니다 — 복사 없이 같은 주소를 공유하고 있기 때문입니다.

이 코드의 IL을 보면 ref 필드의 정체를 한눈에 확인할 수 있습니다.

IL
.class public sequential ansi sealed beforefieldinit SpanLike`1<T>
    extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor() = (...)
    // ↑ ref struct임을 표시 (스택 전용)

    [module: RefSafetyRules(11)]
    // ↑ C# 11 ref-safety 분석 규칙을 따른다는 표시

    // Fields
    .field private !T& _reference  // ← T& 가 핵심: 'T 변수의 주소'를 담는 필드
    .field private int32 _length

    // 생성자 (ref 대입)
    .method public hidebysig specialname rtspecialname instance void .ctor (
            !T& start, int32 length) cil managed
    {
        IL_0001: ldarg.0
        IL_0002: ldarg.1
        IL_0003: stfld !0& valuetype SpanLike`1<!T>::_reference
        // ↑ 'T&'(주소)를 필드에 그대로 저장 — ref 대입
        IL_0008: ldarg.0
        IL_0009: ldarg.2
        IL_000a: stfld int32 valuetype SpanLike`1<!T>::_length
        // ↑ int 값을 필드에 복사 — 일반 대입
    }

    // 인덱서 (Unsafe.Add로 i번째 주소 계산)
    .method instance !T& get_Item (int32 i) cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldfld !0& valuetype SpanLike`1<!T>::_reference
        IL_0006: ldarg.1
        IL_0007: call !!0& [System.Runtime]System.Runtime.CompilerServices.Unsafe::Add<!T>(!!0&, int32)
        // ↑ '_reference + i*sizeof(T)' 위치의 주소를 그대로 반환
        IL_000c: ret
    }
}

세 가지 IL 흔적이 ref 필드의 정체를 말해 줍니다.

  • .field private !T& _reference — IL에서 &는 "managed reference" 즉 관리되는 포인터를 뜻합니다. int 같은 일반 값 타입은 int32로 표기되지만, ref 필드는 int32&처럼 & 가 붙습니다. 필드 타입 자체가 "주소" 라는 뜻입니다.
  • IsByRefLikeAttribute — 이 타입이 ref struct임을 런타임에 알리는 표식입니다. ref 필드를 담을 수 있는 컨테이너는 오직 이 표식이 붙은 타입뿐입니다.
  • RefSafetyRules(11) — 이 어셈블리가 C# 11의 ref-safety 분석 규칙을 따른다는 표시. 컴파일러가 "이 ref 필드의 수명이 ref struct 인스턴스의 수명을 넘지 않는지" 확인할 때 이 버전 정보를 봅니다.

핵심은 ref 필드가 IL 수준에서 일반 필드와 다른 별도 개념(T&, managed reference) 으로 표현된다는 점입니다. 단순한 문법 설탕이 아니라 CLR(Common Language Runtime, .NET 코드를 실제로 실행하는 가상 머신) 차원의 1급 시민 기능입니다.


내부 동작 — = ref= 는 IL에서 어떻게 다른가

두 종류의 대입이 필요한 이유

ref 필드를 다룰 때 가장 많이 헷갈리는 부분이 두 종류의 대입입니다. 일반 변수와 달리 ref 필드는 두 가지를 따로 표현해야 합니다.

C#
holder.R = 99;        // ① 가리키는 곳의 '값'을 99로
holder.R = ref other; // ② 가리키는 '대상 자체'를 other로 변경 (re-seating)

만약 두 동작 모두 똑같이 = 로 표기한다면 컴파일러는 "값을 바꾸려는 건지, 대상을 바꾸려는 건지" 구분할 수 없습니다. 그래서 대상 변경(② 재바인딩)에는 반드시 = ref 를 써야 한다는 규칙이 만들어졌습니다. C# 7.0의 ref 지역 변수에서 도입된 문법을 그대로 계승한 것입니다.

= ref — ref 대입 연산 (Ref reassignment) 일반 = 는 ref가 가리키는 곳의 값을 바꾸지만, = ref 는 ref 자체가 가리키는 대상을 다른 변수로 바꾼다. ref 필드·ref 지역 변수에서만 쓰는 별도 문법.
예시: _reference = ref start; _reference 필드가 이제 start 라는 변수를 가리키도록 연결한다. start 의 값은 바뀌지 않는다.

시각화: ref 대입 vs 일반 대입의 IL 흐름

두 가지 대입의 IL 명령어 차이

코드와 IL 비교

C#
public ref struct RefHolder
{
    public ref int R;

    // ref 대입 — 가리키는 대상을 처음 연결
    public RefHolder(ref int target)
    {
        R = ref target;
    }

    // 일반 대입 — 가리키는 곳의 값만 변경
    public void Write(int value)
    {
        R = value;
    }
}

public class Program
{
    public static void Main()
    {
        int a = 10;
        var holder = new RefHolder(ref a);
        holder.Write(99);
        System.Console.WriteLine($"a={a}");
        // 출력: a=99 — holder.R이 a를 가리키고 있었으므로 a 자체가 바뀜
    }
}

이 코드의 IL을 비교해 보겠습니다.

IL
// ref 대입: R = ref target
.method public instance void .ctor (int32& target)
{
    IL_0001: ldarg.0
    IL_0002: ldarg.1   // target의 '주소'(int32&)
    IL_0003: stfld int32& RefHolder::R   // 주소를 필드에 저장
    IL_0008: ret
}

// 일반 대입: R = value
.method public instance void Write (int32 'value')
{
    IL_0001: ldarg.0
    IL_0002: ldfld int32& RefHolder::R   // 필드에서 '주소'를 꺼내고
    IL_0007: ldarg.1                       // 새 값(99)을 스택에 올린 뒤
    IL_0008: stind.i4                      // 그 주소에 값을 저장
    IL_0009: ret
}

두 메서드의 마지막 명령이 결정적으로 다릅니다.

  • ref 대입의 마지막 명령은 stfld — store field. 필드 자체에 새 값(여기서는 주소)을 저장합니다. 필드 슬롯의 내용을 바꾸는 것이죠.
  • 일반 대입의 마지막 명령은 stind.i4 — store indirect. 스택 맨 위에 있는 주소가 가리키는 곳에 값을 저장합니다. 필드 슬롯 자체는 건드리지 않습니다.

stind.i4는 C/C++의 *ptr = 99 와 정확히 같은 동작입니다. ref 필드는 IL 레벨에서 이미 포인터처럼 다뤄지고 있다는 증거죠.

Span<T>는 .NET 7부터 어떻게 다시 구현되었는가

Span<T> 내부 구현의 변천

Span<T>ByReference<T> 필드는 IL 차원에서는 ref 필드와 똑같이 T& (managed reference)로 표현됐습니다. 차이는 누가 사용할 수 있는가였습니다. ByReference<T> 는 corelib(System.Private.CoreLib)의 internal 타입이라 외부에서 보이지 않았고, 컴파일러는 이 타입이 필드로 쓰일 때만 특별히 봐주는 우회로를 제공했습니다.

C# 11 + .NET 7에서 이 우회로가 사라지고, 누구나 ref T _reference 라고 쓸 수 있게 되면서 Span<T> 자신도 평범한 라이브러리 타입으로 재작성됐습니다. 외부에서 보이는 동작은 똑같지만 내부 구현은 더 이상 마법이 아닙니다.


실전 적용 — 라이브러리 저자 관점에서 언제 쓰는가

결론부터 말하자면

C# 입문자는 물론이고 일반 응용 프로그램·게임 개발자도 ref 필드를 직접 선언할 일은 거의 없습니다. 대부분의 경우 마이크로소프트가 만들어 둔 Span<T>·ReadOnlySpan<T>·Memory<T> 를 쓰면 끝납니다.

ref 필드가 진가를 발휘하는 건 라이브러리 저자가 다음과 같은 자료구조를 만들 때입니다.

패턴 어디에 쓰이는가
커스텀 슬라이스/뷰 배열·stackalloc 버퍼의 일부를 GC 없이 다루는 자료구조
스트리밍 파서 byte 배열 안의 현재 위치를 기억하는 파서 상태 (예: Utf8JsonReader)
빌더 패턴 스택에 할당된 임시 버퍼에 점진적으로 데이터를 쌓는 객체
시야·범위 계산 매 프레임 일부 메모리만 가리키는 임시 윈도우

Before/After — 매 프레임 GC 압박이 있는 토큰 파서

Unity 모바일에서 매 프레임 들어오는 패킷 헤더 문자열("k=v;k=v;k=v")에서 키-값 쌍을 잘라내야 한다고 가정해 보겠습니다. C# 신입이 가장 먼저 떠올리는 코드는 string.Split 입니다.

C#
// ❌ Before — 매 프레임 string[] 배열 + N개의 string 인스턴스 할당
public class PacketReader
{
    public string GetValue(string header, string key)
    {
        string[] pairs = header.Split(';');     // 새 배열
        foreach (string pair in pairs)
        {
            string[] kv = pair.Split('=');      // 또 새 배열
            if (kv[0] == key) return kv[1];     // 또 새 string
        }
        return null;
    }
}

이 코드는 매 호출마다 최소 4개의 힙 객체(string[] 둘 + 잘려나온 string 둘)를 만듭니다. 60FPS 환경에서 패킷 처리가 1초에 몇 차례만 일어나도 GC 스파이크의 원인이 됩니다.

ref 필드를 활용해서 만든 ReadOnlySpan<char> 기반 파서는 할당이 0 입니다.

C#
using System;

// ✅ After — Span<T>(내부적으로 ref 필드 사용)으로 새 string 없이 슬라이싱
public ref struct PacketScanner
{
    private ReadOnlySpan<char> _remaining; // ref 필드로 원본 char 배열의 일부를 가리킴

    public PacketScanner(ReadOnlySpan<char> header)
    {
        _remaining = header;
    }

    public bool TryGetNext(out ReadOnlySpan<char> key, out ReadOnlySpan<char> value)
    {
        key = default; value = default;
        if (_remaining.IsEmpty) return false;

        int semicolon = _remaining.IndexOf(';');
        ReadOnlySpan<char> pair = semicolon < 0 ? _remaining : _remaining[..semicolon];

        int equals = pair.IndexOf('=');
        if (equals < 0) return false;

        key = pair[..equals];
        value = pair[(equals + 1)..];
        _remaining = semicolon < 0 ? default : _remaining[(semicolon + 1)..];
        return true;
    }
}

public class Program
{
    public static void Main()
    {
        ReadOnlySpan<char> header = "id=42;hp=100;mp=50".AsSpan();
        var scanner = new PacketScanner(header);

        while (scanner.TryGetNext(out var k, out var v))
        {
            // k, v는 원본 문자열의 일부를 가리킬 뿐 — 새 string 할당 없음
            System.Console.WriteLine($"{k.ToString()}={v.ToString()}");
        }
    }
}

PacketScanner 자신도 ref struct 이고, 그 안의 _remaining 필드는 ReadOnlySpan<char> — 즉 내부에 ref 필드를 가진 또 다른 ref struct 입니다. 결국 ReadOnlySpan<char>ref T _reference 필드가 원본 string 의 char 배열 어딘가를 가리키고, 슬라이싱은 그 시작 주소와 길이만 새로 계산할 뿐 메모리 할당은 일어나지 않습니다.

AsSpan() 호출도 새 객체를 만들지 않습니다 — string 의 첫 글자 주소를 ref 필드에 채워 넣은 ReadOnlySpan<char> 구조체를 스택에 만들 뿐입니다. 모든 슬라이싱(pair[..equals])도 같은 원리로 새로운 ref T _reference + length 쌍을 스택에서 만드는 것에 불과합니다.

Unity 핫패스에서의 의의

Unity의 GC는 Boehm GC(Boehm-Demers-Weiser conservative garbage collector, Unity가 IL2CPP·Mono 양쪽에서 기본으로 쓰는 GC 구현)로, 한 번 발동되면 메인 스레드를 잠시 멈추는 Stop-The-World 방식입니다. 모바일 환경에서 16.6ms 안에 한 프레임을 그려야 하는데, GC가 5ms를 잡아먹으면 그 프레임은 드랍됩니다.

ref 필드(혹은 그것을 활용한 Span<T>·ReadOnlySpan<T>)는 핫패스에서 다음과 같이 활약합니다.

  • NetworkBehaviour.OnReceived 콜백에서 받은 byte 배열을 Span<byte> 로 슬라이싱하며 읽기 — byte[].AsSpan() 으로 시작
  • Animator·ECS 의 컴포넌트 배열을 일부만 잘라 처리 — array.AsSpan(start, length)
  • JSON·CSV 파서ref struct 로 작성해 매 호출마다 0 할당 — Utf8JsonReader 가 이 패턴
  • 고정 크기 임시 버퍼Span<int> tmp = stackalloc int[64]; 로 스택에 할당

이 기능들 모두 내부적으로 ref 필드 위에서 동작합니다. 즉, ref 필드를 직접 선언할 일이 없어도 "내가 쓰는 Span<T> 가 어떻게 GC 없이 동작하는지" 를 이해하려면 ref 필드의 존재를 알아야 합니다.


함정과 주의사항

함정 1 — 일반 struct에 ref 필드를 두면 컴파일 거부

❌ 가장 먼저 마주치는 에러입니다.

C#
// ❌ 일반 struct는 ref 필드를 담을 수 없다
public struct BadHolder
{
    public ref int R;
    // CS9059: ref 필드는 ref 구조체에서만 선언할 수 있습니다.
}
C#
// ✅ ref struct로 바꾸면 통과
public ref struct GoodHolder
{
    public ref int R;
}

왜 막았는가: 일반 struct 는 클래스 필드·박싱·배열 요소로 힙에 들어갈 수 있습니다. 힙에 있는 객체가 스택의 지역 변수 주소를 들고 있다가 그 메서드가 종료되면, 가리키는 메모리는 사라지는데 주소만 남게 됩니다(댕글링 참조). ref struct 는 힙으로 가는 모든 경로가 차단되어 있으므로 ref 필드의 안전한 컨테이너 역할을 합니다. IL 레벨에서는 IsByRefLikeAttribute 가 붙은 타입만 ref 필드를 가질 수 있다고 검증됩니다.

함정 2 — ref 필드가 가리키는 대상의 수명이 짧으면 거부

❌ ref-safety 분석에서 가장 많이 마주치는 에러입니다.

C#
public ref struct Holder
{
    public ref int R;
    public Holder(ref int target) { R = ref target; }
}

// ❌ 지역 변수의 주소를 ref struct에 담아 반환
public Holder MakeHolder()
{
    int local = 42;
    return new Holder(ref local);
    // CS8347: 'Holder.Holder(ref int)'을(를) 사용한 식 결과를
    // 둘러싸는 범위 외부에 노출할 수 없습니다...
}

localMakeHolder 가 끝나면 사라지는데, 반환된 Holder 가 그 주소를 들고 밖으로 나가려 하면 컴파일러가 즉시 막습니다. ref 필드의 핵심 보장 — "ref 필드가 가리키는 대상의 수명 ≥ ref struct 인스턴스의 수명" — 을 위반했기 때문입니다.

C#
// ✅ 호출자가 넘긴 변수의 수명에 묶이도록 — 호출자가 책임짐
public Holder MakeHolder(ref int target)
{
    return new Holder(ref target); // target의 수명이 호출자 영역에 있음
}

함정 3 — = ref= 를 헷갈리지 말 것

❌ 주소를 바꾸려다 값을 바꾸거나, 그 반대도 흔합니다.

C#
public ref struct Holder
{
    public ref int R;
    public Holder(ref int target) { R = ref target; }
}

int a = 10;
int b = 20;
var holder = new Holder(ref a);

holder.R = b;        // ❌ 의도: "이제 b를 가리켜라"  실제: a의 값을 20으로 바꿈
holder.R = ref b;    // ✅ 의도대로: holder.R이 이제 b를 가리킴

대상 자체를 바꿀 때는 반드시 = ref 라고 명시해야 합니다. C# 컴파일러는 == ref 를 다른 연산으로 취급하므로 의도에 맞는 쪽을 골라야 합니다.

함정 4 — async 메서드 안에서 ref struct 로컬 못 쓰는 이유

ref 필드를 가진 ref structasync 메서드 안에서 await 경계를 넘을 수 없습니다.

C#
// ❌ async 메서드에서 await 사이에 ref struct 로컬을 살려두면
public async Task BadAsync(byte[] data)
{
    Span<byte> span = data.AsSpan(); // ref struct 로컬
    await Task.Delay(100);            // CS4007: 'await' 이전에...
    Console.WriteLine(span.Length);   // 사용 시도
}

async 메서드는 컴파일러가 상태 머신 클래스로 변환하면서 await 시점의 지역 변수를 힙에 저장할 수 있습니다. ref struct 가 힙으로 옮겨지면 ref 필드의 수명 보장이 깨지므로 컴파일러가 원천 차단합니다. 해결책은 await 이전에 ref struct 사용을 끝내는 것입니다.

함정 5 — readonly 의 위치에 따라 의미가 달라진다

ref 필드에는 readonly 가 두 자리에 들어갈 수 있고, 위치마다 의미가 다릅니다.

C#
public ref struct Variants
{
    public ref int A;                     // 일반 — 대상도, 값도 변경 가능
    public ref readonly int B;            // 가리키는 '값'을 읽기만 — 재바인딩은 가능
    public readonly ref int C;            // '필드 자체'가 readonly — 값은 바꾸지만 재바인딩 불가
    public readonly ref readonly int D;   // 둘 다 — 가장 강한 제약
}

이름이 같은 readonly 가 두 자리에 들어가다 보니 입문자에게 가장 헷갈리는 부분입니다. 앞쪽 readonly(필드 한정자)는 "이 필드가 가리키는 대상을 다시 바꿀 수 없다", 뒤쪽 readonly(타입 한정자)는 "이 ref를 통해 대상의 값을 수정할 수 없다" 로 기억하면 됩니다. ReadOnlySpan<T> 의 내부 필드는 두 번째(ref readonly T _reference)에 해당합니다.


C# 버전별 변화 — Span<T> 의 5년 여정

C# 7.2 (2017) — Span<T> 도입, ref 필드는 미공개

Span<T> 가 처음 등장했지만 ref 필드는 정식 문법이 아니었습니다. 마이크로소프트는 ByReference<T> 라는 비공개 런타임 타입으로 우회했고, 라이브러리 저자는 같은 패턴을 흉내 낼 수 없었습니다.

C#
// ❌ C# 7.2 ~ 10 — 컴파일 에러
public ref struct MySpan<T>
{
    private ref T _start;  // CS8331/CS9059: ref 필드는 허용되지 않음
    private int _length;
}

마이크로소프트의 Span<T> 만 동작했던 이유는 corelib 안에서만 쓸 수 있는 ByReference<T> 를 쓸 수 있었기 때문이었습니다.

C# 11 (2022) + .NET 7 — ref 필드 정식화

ref struct 안에서 ref T·ref readonly T 필드를 정식 선언할 수 있게 됐습니다.

C#
// ✅ C# 11 — 누구나 작성 가능
public ref struct MySpan<T>
{
    private ref T _start;   // 정식 ref 필드
    private int _length;

    public MySpan(ref T start, int length)
    {
        _start = ref start; // ref 대입 문법
        _length = length;
    }
}

같은 시기에 Span<T>·ReadOnlySpan<T> 자신도 ByReference<T> 마법을 버리고 정식 ref T _reference 필드 위에서 다시 구현되었습니다.

IL 시그니처의 차이

IL
// C# 11 이전 — Span<T>의 _pointer 필드
.field private valuetype System.ByReference`1<!T> _pointer
// ↑ ByReference<T>라는 별도 internal 타입을 감쌌다

// C# 11 이후 — 새 Span<T>의 _reference 필드
.field private !T& _reference
// ↑ 'T&' 직접. 컴파일러가 RefSafetyRules(11) 분석으로 검증

[module: RefSafetyRules(11)]
// ↑ 어셈블리 메타데이터에 분석 버전이 새겨진다

C# 13 (2024) — ref struct가 인터페이스 구현 가능

C# 13에서는 ref struct 가 인터페이스를 구현할 수 있게 되었고, 제네릭 제약 where T : allows ref struct 로 ref 필드를 가진 타입을 제네릭 알고리즘에 넘길 수 있습니다. 단 인터페이스 타입으로 박싱되는 변환은 여전히 금지입니다 — ref 필드의 수명 보장이 깨지지 않도록.

이 글의 주제는 "ref 필드 자체"이므로 자세한 내용은 별도 주제(ref struct C# 13 확장)에서 다룹니다.


정리

이 글에서 다룬 내용을 한눈에 정리합니다.

  • ☑️ ref 필드는 ref struct 안에서만 선언할 수 있는 특별한 필드. 값 대신 다른 변수의 주소를 보관한다.
  • ☑️ C# 7.2의 Span<T>ByReference<T> 런타임 마법으로 우회 구현됐고, C# 11에서 정식 문법으로 승격되어 .NET 7부터 Span<T> 자신도 ref 필드 위에서 재구현되었다.
  • ☑️ ref 필드 대입은 두 종류 — = ref 는 가리키는 대상을 바꾸고, = 는 그 자리의 값을 바꾼다. IL 레벨에서 stfld(필드에 주소 저장) vs stind(주소가 가리키는 곳에 값 저장)로 명확히 구분된다.
  • ☑️ 컴파일러는 "ref 필드가 가리키는 대상의 수명 ≥ ref struct 인스턴스의 수명" 을 정적으로 보장한다(ref-safety 분석). 위반 시 CS8347/CS9079 등의 에러로 차단된다.
  • ☑️ 일반 개발자는 직접 선언할 일이 거의 없다. Span<T>·Memory<T>·Utf8JsonReader 같은 기존 타입의 내부 구현을 이해할 때 만나는 기능이며, 라이브러리 저자가 GC 0 할당 자료구조를 만들 때 쓴다.
  • ☑️ Unity 핫패스에서는 ref 필드 그 자체가 아니라 그것을 활용한 Span<T>·ReadOnlySpan<T> 가 GC 스파이크 회피의 핵심 도구다. byte[].AsSpan()·stackalloc·Utf8JsonReader 가 모두 이 기능 위에 서 있다.
  • ☑️ readonly 위치에 따라 의미가 다르다 — 앞쪽 readonly 는 "재바인딩 금지", 뒤쪽 readonly 는 "값 수정 금지". ReadOnlySpan<T> 의 내부 필드는 ref readonly T _reference(값 수정 금지)다.

ref 필드는 우리가 매일 쓰는 Span<T> 의 심장부입니다. 직접 선언할 일이 드물다고 해서 외면하면 어느 순간 Span<T> 가 왜 클래스 필드에 못 들어가는지, 왜 async 메서드에서 await 경계를 못 넘는지 이해할 수 없게 됩니다. ref 필드의 정체와 수명 규칙을 알아 두면, Span<T> 가 던지는 컴파일 에러가 외계어가 아닌 일관된 안전 규칙의 결과로 읽히기 시작할 것입니다.

반응형

+ Recent posts