[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# 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 필드의 메모리 모양

가장 단순한 ref 필드
ref 필드를 직접 선언해 보겠습니다.
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 필드의 정체를 한눈에 확인할 수 있습니다.
.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 필드는 두 가지를 따로 표현해야 합니다.
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 비교
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을 비교해 보겠습니다.
// 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>의 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 입니다.
// ❌ 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 입니다.
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 필드를 두면 컴파일 거부
❌ 가장 먼저 마주치는 에러입니다.
// ❌ 일반 struct는 ref 필드를 담을 수 없다
public struct BadHolder
{
public ref int R;
// CS9059: ref 필드는 ref 구조체에서만 선언할 수 있습니다.
}
// ✅ ref struct로 바꾸면 통과
public ref struct GoodHolder
{
public ref int R;
}
왜 막았는가: 일반 struct 는 클래스 필드·박싱·배열 요소로 힙에 들어갈 수 있습니다. 힙에 있는 객체가 스택의 지역 변수 주소를 들고 있다가 그 메서드가 종료되면, 가리키는 메모리는 사라지는데 주소만 남게 됩니다(댕글링 참조). ref struct 는 힙으로 가는 모든 경로가 차단되어 있으므로 ref 필드의 안전한 컨테이너 역할을 합니다. IL 레벨에서는 IsByRefLikeAttribute 가 붙은 타입만 ref 필드를 가질 수 있다고 검증됩니다.
함정 2 — ref 필드가 가리키는 대상의 수명이 짧으면 거부
❌ ref-safety 분석에서 가장 많이 마주치는 에러입니다.
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)'을(를) 사용한 식 결과를
// 둘러싸는 범위 외부에 노출할 수 없습니다...
}
local 은 MakeHolder 가 끝나면 사라지는데, 반환된 Holder 가 그 주소를 들고 밖으로 나가려 하면 컴파일러가 즉시 막습니다. ref 필드의 핵심 보장 — "ref 필드가 가리키는 대상의 수명 ≥ ref struct 인스턴스의 수명" — 을 위반했기 때문입니다.
// ✅ 호출자가 넘긴 변수의 수명에 묶이도록 — 호출자가 책임짐
public Holder MakeHolder(ref int target)
{
return new Holder(ref target); // target의 수명이 호출자 영역에 있음
}
함정 3 — = ref 와 = 를 헷갈리지 말 것
❌ 주소를 바꾸려다 값을 바꾸거나, 그 반대도 흔합니다.
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 struct 는 async 메서드 안에서 await 경계를 넘을 수 없습니다.
// ❌ 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 가 두 자리에 들어갈 수 있고, 위치마다 의미가 다릅니다.
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# 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# 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 시그니처의 차이
// 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(필드에 주소 저장) vsstind(주소가 가리키는 곳에 값 저장)로 명확히 구분된다. - ☑️ 컴파일러는 "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> 가 던지는 컴파일 에러가 외계어가 아닌 일관된 안전 규칙의 결과로 읽히기 시작할 것입니다.
'C# 기초' 카테고리의 다른 글
| [PART8.상속과 인터페이스 사용법(2/11)] virtual · override · base — 부모를 갈아끼우는 세 개의 약속 (0) | 2026.05.03 |
|---|---|
| [PART8.상속과 인터페이스 사용법(1/11)] 상속 문법 — `:` (0) | 2026.05.03 |
| [PART7.클래스와 객체 입문(15/21)] ref struct — 스택에서만 살 수 있는 구조체 (0) | 2026.05.02 |
| [PART7.클래스와 객체 입문(14/21)] readonly 구조체 멤버 — 메서드·프로퍼티 단위 불변성 (C# 8) (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(13/21)] 구조체의 매개변수 없는 생성자와 필드 이니셜라이저 (C# 10) / 자동 기본 구조체 (C# 11 (0) | 2026.05.01 |