EveryDay.DevUp

struct 4형제 — struct, record struct, readonly struct, ref struct 본문

C# 심화

struct 4형제 — struct, record struct, readonly struct, ref struct

EveryDay.DevUp 2026. 4. 1. 23:56

struct 4형제 — struct, record struct, readonly struct, ref struct

Unity 모바일 게임 클라이언트 신입 개발자를 위한 C# struct 심층 가이드


왜 struct가 네 가지나 있는가 — 문제 제기

Unity에서 캐릭터의 위치를 나타내는 타입을 만들어야 한다. 보통 이렇게 시작한다.

C#
public struct Vector2Int
{
    public int X;
    public int Y;
}

그런데 실제 Unity 프로젝트를 보면 다음처럼 쓰기도 한다.

C#
public readonly struct ImmutableVector { ... }
public record struct HitResult(int Damage, bool IsCritical);

Span<T>이나 NativeArray<T>의 내부를 보면 또 다른 것이 등장한다.

C#
public ref struct Span<T> { ... }

같은 struct 키워드인데 왜 앞에 수식어가 붙는가? 단순히 "기능을 추가한 것"이라고 넘기기에는 각각이 근본적으로 다르게 동작한다. 방어 복사(defensive copy)가 조용히 발생해 성능을 갉아먹는 경우, boxing이 일어나지 않으리라 믿었는데 실제로는 힙 할당이 생기는 경우, ref struct를 List에 넣으려다 컴파일 오류가 나는 경우 — 이 모든 상황이 struct 4형제의 차이를 이해하지 못해서 생긴다.

이 글은 struct, readonly struct, record struct, ref struct가 IL(중간 언어) 수준에서 어떻게 다른지, 각각이 보장하는 것과 제약하는 것이 무엇인지, 그리고 Unity 실전에서 언제 무엇을 선택해야 하는지를 다룬다.


struct — 값 타입의 기본 형태

struct란 무엇인가

struct는 C#의 값 타입(value type)이다. 값 타입이란 변수가 데이터를 직접 보관하는 타입을 말한다. int, float, bool이 모두 값 타입이고, struct도 같은 범주에 속한다.

class(참조 타입)와의 핵심 차이는 메모리 위치와 복사 방식이다.

struct (값 타입) vs class (참조 타입) — 메모리 모델

기본 struct의 C# 코드와 IL

C#
// Unity에서 위치 좌표를 struct로 정의하는 일반적인 패턴
public struct Position
{
    public int X;
    public int Y;

    public Position(int x, int y) { X = x; Y = y; }
    public void Teleport(int newX, int newY) { X = newX; Y = newY; }
}

public class StructBasicDemo
{
    public static void Run()
    {
        var a = new Position(1, 2);
        var b = a;           // 값 복사 — b는 a의 독립적인 복사본
        b.Teleport(99, 99);  // b를 수정해도 a는 변하지 않음
        Console.WriteLine($"a.X={a.X}, b.X={b.X}"); // a.X=1, b.X=99
    }
}
IL
// IL 코드: Position struct
.class public sequential ansi sealed beforefieldinit Position
    extends [System.Runtime]System.ValueType
{
    // Fields
    .field public int32 X                         // 힙 헤더 없이 필드 직접 배치
    .field public int32 Y

    .method public hidebysig specialname rtspecialname
        instance void .ctor (int32 x, int32 y) cil managed
    {
        IL_0001: ldarg.0
        IL_0002: ldarg.1
        IL_0003: stfld int32 Position::X          // this.X = x
        IL_0008: ldarg.0
        IL_0009: ldarg.2
        IL_000a: stfld int32 Position::Y          // this.Y = y
        IL_000f: ret
    }

    .method public hidebysig instance void Teleport (int32 newX, int32 newY) cil managed
    {
        IL_0001: ldarg.0                          // this (struct 인스턴스 주소) 로드
        IL_0002: ldarg.1
        IL_0003: stfld int32 Position::X          // this.X = newX (직접 변이 가능)
        IL_0008: ldarg.0
        IL_0009: ldarg.2
        IL_000a: stfld int32 Position::Y          // this.Y = newY
        IL_000f: ret
    }
}

// Run() 내부
// .locals init ([0] valuetype Position, [1] valuetype Position)  ← 스택에 할당
// IL_0005: call instance void Position::.ctor(int32, int32)
// IL_000a: ldloc.0                                               ← a 값 로드
// IL_000b: stloc.1                                               ← b에 복사 (전체 8바이트 복사)
// IL_000c: ldloca.s 1                                            ← b의 주소
// IL_000e: call instance void Position::Teleport(int32, int32)   ← b만 변이

IL 분석 포인트:

  1. extends [System.Runtime]System.ValueType — 모든 struct는 ValueType을 상속한다. 이것이 값 타입임을 CLR(Common Language Runtime, .NET 실행 환경)에 알린다. ValueTypeObject를 상속하지만, 값 타입의 할당·복사 동작은 참조 타입과 근본적으로 다르다.
  2. .locals initvaluetype Position — 지역 변수 a, b가 스택에 직접 예약된다. newobj(힙 할당 지시어)가 없다.
  3. stloc.1(b에 복사)b = a는 단 하나의 IL 명령어로 처리된다. 내부적으로는 struct 크기만큼(여기서는 8바이트) 메모리를 통째로 복사한다. class라면 참조(포인터 주소)만 4~8바이트 복사하는 것과 대조적이다.
  4. ldloca.s vs ldloc — 메서드를 호출할 때 ldloca.s(주소 로드)를 사용한다. struct 메서드는 this가 값이 아니라 주소로 전달되기 때문이다. 이 덕분에 Teleport가 원본을 직접 변이할 수 있다.

기본 struct의 제약과 보장

보장:

  • 힙 할당 없음(스택, 또는 다른 타입의 필드로 inline 배치)
  • 값 의미론(value semantics) — 대입 시 독립적인 복사본 생성
  • null이 될 수 없음(Nullable<T>로 감싸지 않는 이상)

제약:

  • 상속 불가(sealed + ValueType 상속 구조)
  • 기본 생성자는 항상 모든 필드를 0으로 초기화 (C# 10 이전에는 커스텀 기본 생성자 불가)
  • 박싱(boxing) — object, 인터페이스, dynamic에 대입 시 힙 할당 발생

readonly struct — 불변성을 컴파일러가 강제한다

방어 복사 문제

일반 struct에 in 파라미터(값 복사 없이 읽기 전용 참조로 전달)를 쓰면 성능 최적화를 기대한다. 그런데 실제로는 컴파일러가 몰래 복사본을 만들 수 있다.

in — 읽기 전용 참조 파라미터 (In modifier) in 키워드가 붙은 파라미터는 참조로 전달되지만 수신자가 수정할 수 없다. 복사 비용 없이 큰 struct를 전달할 때 사용한다. ref와 달리 수신자에서 값을 변경할 수 없다.
예시: public static int Calculate(in LargeStruct s) => s.Value; s를 복사하지 않고 원본 주소를 직접 참조

문제는 메서드 내부에서 인스턴스 메서드를 호출할 때다. 컴파일러는 해당 메서드가 this를 수정할 수 있는지 알 수 없으므로, in 파라미터로 전달된 struct라도 메서드 호출 전에 복사본을 만든다. 이것이 방어 복사(defensive copy)다.

방어 복사 발생 여부 — 일반 struct vs readonly struct

방어 복사 IL 비교

C#
// 일반 struct + in 파라미터 → 방어 복사 발생
public struct MutablePoint
{
    public int X;
    public int Y;
    public MutablePoint(int x, int y) { X = x; Y = y; }
    public int Compute() => X * X + Y * Y; // 메서드가 this를 변경할 수도 있다
}

public static int TestMutable(in MutablePoint p)
{
    return p.Compute(); // 컴파일러가 방어 복사 삽입
}
IL
// TestMutable IL — 방어 복사 발생
.method public hidebysig static int32 TestMutable (
    [in] valuetype MutablePoint& p) cil managed
{
    .locals init (
        [0] valuetype MutablePoint,    // 방어 복사본을 위한 로컬 변수
        [1] int32
    )
    IL_0001: ldarg.0                   // in 파라미터(원본 주소) 로드
    IL_0002: ldobj MutablePoint        // ★ 방어 복사: 원본에서 값 전체를 로컬로 복사
    IL_0007: stloc.0                   // 복사본을 로컬 변수 [0]에 저장
    IL_0008: ldloca.s 0                // 복사본의 주소 로드
    IL_000a: call instance int32 MutablePoint::Compute()  // 복사본으로 호출
    IL_000f: stloc.1
    IL_0013: ret
}
C#
// readonly struct + in 파라미터 → 방어 복사 없음
public readonly struct ImmutablePoint
{
    public readonly int X;
    public readonly int Y;
    public ImmutablePoint(int x, int y) { X = x; Y = y; }
    public int Compute() => X * X + Y * Y; // readonly: this를 절대 변경 불가
}

public static int TestReadonly(in ImmutablePoint p)
{
    return p.Compute(); // 방어 복사 없이 직접 원본 주소 사용
}
IL
// TestReadonly IL — 방어 복사 없음
.method public hidebysig static int32 TestReadonly (
    [in] valuetype ImmutablePoint& p) cil managed
{
    .locals init ([0] int32)
    IL_0001: ldarg.0                   // in 파라미터(원본 주소) 직접 로드
    // ldobj 없음 — 방어 복사 생략!
    IL_0002: call instance int32 ImmutablePoint::Compute()  // 원본 주소로 직접 호출
    IL_0007: stloc.0
    IL_000b: ret
}

IL 분석 포인트:

  1. ldobj MutablePoint — 방어 복사의 증거다. ldobj는 관리되는 포인터가 가리키는 위치에서 값 타입을 복사해 평가 스택에 올린다. 즉, 원본 MutablePoint 전체(8바이트)를 스택 로컬 변수로 복사한다. Update 루프에서 이 연산이 매 프레임 발생하면 불필요한 스택 복사가 누적된다.
  2. ldobj 없음(readonly struct)IsReadOnlyAttribute가 붙은 struct는 in 파라미터에서 메서드를 호출해도 컴파일러가 방어 복사를 삽입하지 않는다. 원본 주소(ldarg.0)를 그대로 callthis로 쓴다.
  3. IsReadOnlyAttributereadonly struct를 컴파일하면 구조체 클래스 선언에 .custom instance void IsReadOnlyAttribute::.ctor() 어트리뷰트가 붙는다. 컴파일러는 이 어트리뷰트를 보고 방어 복사 생략 여부를 결정한다.

readonly struct의 규칙

readonly struct를 선언하면 컴파일러가 다음 규칙을 강제한다:

  1. 모든 인스턴스 필드는 readonly여야 한다.
  2. 자동 구현 프로퍼티는 get만 있어야 한다(setter 불가).
  3. 인스턴스 메서드는 암묵적으로 readonly로 취급된다 — this를 변이할 수 없다.
C#
public readonly struct Color
{
    public readonly byte R, G, B, A;

    public Color(byte r, byte g, byte b, byte a = 255)
    {
        R = r; G = g; B = b; A = a;
    }

    // 올바름: 새 Color 인스턴스를 반환 (this 불변)
    public Color WithAlpha(byte alpha) => new Color(R, G, B, alpha);

    // 컴파일 오류: readonly struct에서 this를 변이하려 함
    // public void SetAlpha(byte alpha) { A = alpha; } // ← CS0191
}

Unity 실전: readonly struct가 중요한 상황

C#
// Unity 핫패스 예시: 매 프레임 호출되는 물리 계산
public readonly struct PhysicsData
{
    public readonly Vector3 Velocity;
    public readonly float Mass;
    public readonly float Drag;

    public PhysicsData(Vector3 velocity, float mass, float drag)
    {
        Velocity = velocity;
        Mass = mass;
        Drag = drag;
    }

    // readonly struct이므로 in 파라미터에서 방어 복사 없이 호출 가능
    public Vector3 ApplyDrag(float deltaTime) =>
        Velocity * (1f - Drag * deltaTime);
}

// 물리 시스템 (매 프레임 수천 개 엔티티에 적용)
public class PhysicsSystem : MonoBehaviour
{
    private PhysicsData[] _entities = new PhysicsData[1000];

    // in 키워드 + readonly struct = 복사 없이 안전하게 계산
    private Vector3 ComputeNextPosition(in PhysicsData data, float dt)
    {
        return transform.position + data.ApplyDrag(dt) * dt;
        // readonly struct이므로 data.ApplyDrag() 호출 시 방어 복사 없음
    }

    private void Update()
    {
        float dt = Time.deltaTime;
        for (int i = 0; i < _entities.Length; i++)
        {
            ComputeNextPosition(in _entities[i], dt); // 방어 복사 없음
        }
    }
}

record struct — 값 의미론 + 자동 생성 멤버

record struct가 해결하는 문제

좌표나 이벤트 데이터처럼 "데이터 묶음"을 표현하는 struct를 만들면 Equals, GetHashCode, ToString을 매번 직접 구현해야 한다. 그냥 두면 기본 ValueType.Equals가 reflection 기반으로 동작해 성능이 떨어진다.

C#
// 문제 상황: struct의 동등성 비교를 직접 구현해야 한다
public struct HitResult
{
    public int Damage;
    public bool IsCritical;

    // 이게 없으면 == 연산자 사용 불가 + 기본 Equals는 reflection 사용 (느림)
    public bool Equals(HitResult other) => Damage == other.Damage && IsCritical == other.IsCritical;
    public override bool Equals(object obj) => obj is HitResult h && Equals(h);
    public override int GetHashCode() => HashCode.Combine(Damage, IsCritical);
    public static bool operator ==(HitResult a, HitResult b) => a.Equals(b);
    public static bool operator !=(HitResult a, HitResult b) => !a.Equals(b);
    public override string ToString() => $"HitResult {{ Damage = {Damage}, IsCritical = {IsCritical} }}";
    // Deconstruct도 필요하면 추가...
}

record struct는 이 모든 것을 컴파일러가 자동 생성한다.

C#
// record struct: 한 줄로 동등성, ToString, Deconstruct 자동 생성
public record struct HitResult(int Damage, bool IsCritical);

record struct가 자동 생성하는 멤버

public record struct HitResult(int Damage, bool IsCritical); 한 줄이 컴파일되면 다음 멤버들이 자동 생성된다:

C#
// 컴파일러가 생성하는 것들
public struct HitResult : IEquatable<HitResult>
{
    // 프로퍼티 (get + set)
    public int Damage { get; set; }
    public bool IsCritical { get; set; }

    // 생성자
    public HitResult(int Damage, bool IsCritical);

    // 동등성 (값 기반)
    public bool Equals(HitResult other);
    public override bool Equals(object obj);
    public override int GetHashCode();
    public static bool operator ==(HitResult left, HitResult right);
    public static bool operator !=(HitResult left, HitResult right);

    // 분해
    public void Deconstruct(out int Damage, out bool IsCritical);

    // 출력
    public override string ToString(); // "HitResult { Damage = 10, IsCritical = True }"
}

record struct의 IL — 자동 생성 코드 살펴보기

C#
public record struct RecordStruct(int X, int Y);
IL
// RecordStruct IL 핵심 발췌
.class public sequential ansi sealed beforefieldinit RecordStruct
    extends [System.Runtime]System.ValueType
    implements class [System.Runtime]System.IEquatable`1<valuetype RecordStruct>
{
    // 백킹 필드 — 프로퍼티가 사용하는 실제 저장소
    .field private int32 '<X>k__BackingField'   // X 프로퍼티의 백킹 필드
    .field private int32 '<Y>k__BackingField'   // Y 프로퍼티의 백킹 필드

    // set_X — 일반 record struct는 setter가 있음 (변이 가능)
    .method public hidebysig specialname instance void set_X(int32 'value') cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld int32 RecordStruct::'<X>k__BackingField'  // 백킹 필드 직접 설정
        IL_0007: ret
    }

    // Equals(RecordStruct other) — 값 기반 동등성 비교
    .method public final hidebysig newslot virtual instance bool Equals(
        valuetype RecordStruct other) cil managed
    {
        // EqualityComparer<int>.Default.Equals(this.X, other.X) &&
        // EqualityComparer<int>.Default.Equals(this.Y, other.Y)
        IL_0000: call class EqualityComparer`1<!0> EqualityComparer`1<int32>::get_Default()
        IL_0005: ldarg.0
        IL_0006: ldfld int32 RecordStruct::'<X>k__BackingField'
        IL_000b: ldarg.1
        IL_000c: ldfld int32 RecordStruct::'<X>k__BackingField'
        IL_0011: callvirt instance bool EqualityComparer`1<int32>::Equals(!0, !0)
        IL_0016: brfalse.s IL_0030           // X가 다르면 false 반환
        // Y 비교 이어서...
        IL_0031: ret
    }

    // op_Equality(==) — Equals 위임
    .method public hidebysig specialname static bool op_Equality(
        valuetype RecordStruct left, valuetype RecordStruct right) cil managed
    {
        IL_0000: ldarga.s left
        IL_0002: ldarg.1
        IL_0003: call instance bool RecordStruct::Equals(valuetype RecordStruct)  // call (가상 아님)
        IL_0008: ret
    }
}

IL 분석 포인트:

  1. IEquatable<RecordStruct> 구현 — record struct는 자동으로 IEquatable<T>를 구현한다. 이는 Dictionary, HashSet에서 boxing 없이 동등성 비교가 가능하게 한다.
  2. set_X에 일반 stfld — 일반 record struct의 setter는 IsExternalInit modifier가 없다. 즉 어디서나 set 가능하다. readonly record struct에서는 setter에 modreq(IsExternalInit)이 붙어 생성자에서만 설정 가능하다.
  3. callvirt vs callop_Equality에서 Equalscall로 호출한다(가상 디스패치 아님). struct 메서드는 call로 직접 호출되어 가상 테이블 조회 비용이 없다.

with 표현식 — 불변 업데이트 패턴

with — with 표현식 (With expression) 기존 인스턴스를 기반으로 일부 프로퍼티만 바꾼 새 인스턴스를 만든다. 원본은 변하지 않는다. record 타입(record class, record struct 모두)에서 사용 가능하다.
예시: var b = a with { X = 10 }; a는 그대로이고, X만 10으로 바뀐 새 인스턴스가 b에 대입된다.
C#
public record struct HitResult(int Damage, bool IsCritical, string Source);

// with 표현식으로 불변 업데이트
var original = new HitResult(100, false, "Sword");
var critical = original with { IsCritical = true, Damage = 150 };

Console.WriteLine(original); // HitResult { Damage = 100, IsCritical = False, Source = Sword }
Console.WriteLine(critical); // HitResult { Damage = 150, IsCritical = True, Source = Sword }

with 표현식의 IL은 다음과 같다(앞서 분석한 WithExpressionDemo.UseWith()):

IL
// var modified = original with { X = 10 }
IL_0001: ldloca.s 0          // original의 주소 로드
IL_0003: ldc.i4.1
IL_0004: ldc.i4.2
IL_0005: call instance void RecordStruct::.ctor(int32, int32)   // original = new RecordStruct(1,2)
IL_000a: ldloc.0             // original 값 로드 (전체 복사)
IL_000b: stloc.2             // 복사본 저장
IL_000c: ldloca.s 2          // 복사본의 주소
IL_000e: ldc.i4.s 10
IL_0010: call instance void RecordStruct::set_X(int32)          // 복사본.X = 10
IL_0015: nop
IL_0016: ldloc.2
IL_0017: stloc.1             // modified에 최종 복사본 저장

with 표현식은 내부적으로 전체 struct를 복사한 뒤 변경할 필드만 set한다. 복사 비용이 struct 크기에 비례하므로, 필드가 많고 빈번하게 with를 쓰면 성능에 주의해야 한다.

record struct vs record class

C# 9에서 record(record class)가 먼저 도입되었고, C# 10에서 record struct가 추가되었다.

특성 record class (C# 9+) record struct (C# 10+)
메모리 힙 할당 스택 할당
기본값 null 가능 null 불가
상속 가능 불가
기본 가변성 불변(init 프로퍼티) 가변(일반 set 프로퍼티)
boxing 없음(이미 참조) object에 대입 시 발생
용도 데이터 불변 모델, 도메인 객체 값 데이터 묶음, 이벤트 페이로드

Unity에서 record struct는 주로 이벤트 페이로드, 게임플레이 데이터 스냅숏 등 "전달되는 데이터 묶음"에 적합하다.


readonly record struct — 불변 레코드

readonly record structreadonly structrecord struct의 결합이다. 자동 생성 멤버(Equals, GetHashCode, ToString, Deconstruct, with)를 제공하면서, 동시에 불변성을 보장한다.

C#
public readonly record struct ImmutableHit(int Damage, bool IsCritical);
IL
// readonly record struct의 IL 핵심 차이
.class public sequential ansi sealed beforefieldinit ReadonlyRecordStruct
    extends [System.Runtime]System.ValueType
    implements class [System.Runtime]System.IEquatable`1<valuetype ReadonlyRecordStruct>
{
    .custom instance void IsReadOnlyAttribute::.ctor()  // readonly struct 마커

    // 백킹 필드: initonly — 생성자에서만 설정 가능
    .field private initonly int32 '<X>k__BackingField'
    .field private initonly int32 '<Y>k__BackingField'

    // set_X에 IsExternalInit — init-only setter
    .method public hidebysig specialname instance void
        modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) set_X(int32 'value')
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld int32 ReadonlyRecordStruct::'<X>k__BackingField'
        IL_0007: ret
    }
    // modreq(IsExternalInit) → 생성자와 with 표현식에서만 호출 가능
    // 일반 코드에서 .X = 값 시도 시 컴파일 오류
}

modreq(IsExternalInit) — 이것이 readonly record struct의 setter를 init-only로 만드는 CLR 수준 메커니즘이다. modreq(required modifier)는 이 modifier를 이해하지 못하는 런타임은 메서드 호출을 거부해야 한다는 계약이다. C# 컴파일러는 일반 대입(.X = 값)이 생성자/with 외부에서 발생하면 컴파일 오류로 막는다.

C#
var hit = new ImmutableHit(100, true);
// hit.Damage = 200; // ← CS8852: Init-only property — 컴파일 오류

// with 표현식으로만 "수정" 가능 (실제로는 새 인스턴스 생성)
var stronger = hit with { Damage = 200 };

4가지 struct 타입 비교 요약

struct 4형제 특성 비교

ref struct — 스택만 살고 힙에는 절대 가지 않는다

ref struct가 존재하는 이유

C# 7.2에서 ref struct가 도입된 배경은 Span<T>이다. Span<T>은 배열, 스택 메모리, 비관리 메모리를 가리지 않고 연속된 메모리 블록을 슬라이스(slice)로 표현하는 타입이다. 이 타입이 올바르게 동작하려면 절대 힙에 올라가서는 안 된다. 힙에 올라가면 GC가 메모리를 이동시킬 수 있고, 그러면 스택 메모리나 비관리 메모리를 가리키는 포인터가 무효가 된다.

ref struct는 이 요구사항을 CLR 수준에서 보장한다. ref struct 인스턴스는 항상 스택에 있다.

ref struct의 스택 전용 제약 — 왜 필요한가

ref struct의 IL과 컴파일러 어트리뷰트

C#
public ref struct SpanLike
{
    private int _value;
    public int Value => _value;
    public SpanLike(int value) { _value = value; }
}
IL
// ref struct IL
.class public sequential ansi sealed beforefieldinit SpanLike
    extends [System.Runtime]System.ValueType
{
    // ★ ref struct를 나타내는 세 가지 어트리뷰트
    .custom instance void IsByRefLikeAttribute::.ctor()  // ref struct 핵심 마커
    .custom instance void ObsoleteAttribute::.ctor(string, bool)
        // "Types with embedded references are not supported in this version of your compiler."
        // 이전 컴파일러가 실수로 힙에 올리는 것을 막는 안전망
    .custom instance void CompilerFeatureRequiredAttribute::.ctor(string)
        // = "RefStructs"

    .field private int32 _value    // 일반 필드와 동일하게 선언됨

    // 메서드도 일반 struct와 동일한 IL
    .method public hidebysig specialname rtspecialname
        instance void .ctor(int32 'value') cil managed
    {
        IL_0001: ldarg.0
        IL_0002: ldarg.1
        IL_0003: stfld int32 SpanLike::_value
        IL_0008: ret
    }
}

IL 분석 포인트:

  1. IsByRefLikeAttribute — 이 어트리뷰트가 ref struct를 일반 struct와 구분한다. CLR은 이 어트리뷰트를 보고 boxing, 힙 할당, 인터페이스 구현을 차단한다.
  2. ObsoleteAttribute — 오래된 컴파일러(ref struct를 모르는)가 이 타입을 만나면 경고/오류를 내도록 하는 호환성 안전망이다. 실제로 "deprecated"가 아니라 이전 버전 컴파일러 대상 방어 메커니즘이다.
  3. CompilerFeatureRequiredAttribute("RefStructs") — C# 11에서 추가된 메커니즘. 컴파일러가 "RefStructs" 기능을 지원하지 않으면 이 타입을 사용할 수 없게 한다.

ref struct의 제약 목록

C#
public ref struct MyRefStruct
{
    public int Value;
}

// ❌ class 필드로 사용 불가
public class MyClass
{
    private MyRefStruct _field; // CS8345: 컴파일 오류
}

// ❌ boxing 불가
MyRefStruct s = new MyRefStruct { Value = 1 };
object boxed = s; // CS0029: 컴파일 오류

// ❌ 인터페이스 구현 불가 (C# 13 이전)
// public ref struct MyRefStruct : IDisposable { } // CS9244 (C# 13 미만)

// ❌ async 메서드 내 지역 변수 불가
public async Task Broken()
{
    MyRefStruct s = new MyRefStruct(); // CS4012: 컴파일 오류
    await Task.Delay(1);
}

// ❌ 람다 캡처 불가
MyRefStruct captured = new MyRefStruct();
Action a = () => Console.WriteLine(captured.Value); // CS8175: 컴파일 오류

// ✅ 일반 메서드에서는 자유롭게 사용 가능
public static void Correct()
{
    MyRefStruct s = new MyRefStruct { Value = 42 };
    Process(s); // 값 전달 또는 ref 전달 모두 가능
}

ref struct의 주요 활용: Span<T>

C#
// Unity 실전: NativeArray → Span 변환으로 zero-copy 처리
using Unity.Collections;
using System;

public class SpanUsageInUnity : MonoBehaviour
{
    private NativeArray<float> _positions;

    private void Start()
    {
        _positions = new NativeArray<float>(1000, Allocator.Persistent);
    }

    private void Update()
    {
        // Span<T>으로 NativeArray를 래핑 — 복사 없이 슬라이싱
        // _positions.AsSpan()은 ref struct인 Span<float>를 반환
        Span<float> span = _positions.AsSpan();

        // 첫 100개만 처리 — 복사 없이 슬라이스
        Span<float> first100 = span.Slice(0, 100);

        for (int i = 0; i < first100.Length; i++)
        {
            first100[i] *= 1.1f; // 원본 NativeArray 직접 수정
        }

        // stackalloc으로 스택에 임시 버퍼 — GC 완전 없음
        Span<int> tempBuffer = stackalloc int[64];
        ProcessBatch(tempBuffer);
    }

    private static void ProcessBatch(Span<int> buffer)
    {
        for (int i = 0; i < buffer.Length; i++)
            buffer[i] = i * 2;
    }

    private void OnDestroy()
    {
        _positions.Dispose();
    }
}

C# 13의 ref struct 확장

C# 13(.NET 9)에서 ref struct에 인터페이스 구현과 제네릭 제약이 가능해졌다.

C#
// C# 13 이전: ref struct는 인터페이스 구현 불가
// C# 13 이후: IDisposable 등 인터페이스 구현 가능

// C# 13에서 허용
public ref struct ManagedBuffer : IDisposable
{
    private bool _disposed;

    public void Dispose()
    {
        if (!_disposed)
        {
            _disposed = true;
            // 정리 작업
        }
    }
}

// 제네릭 제약에서도 ref struct 허용 (allows ref struct)
public static void Process<T>(T value) where T : allows ref struct
{
    // ref struct를 포함한 모든 타입에 적용 가능한 제네릭 메서드
}

내부 동작 — IL 수준 차이 종합 정리

타입 선언부의 IL 차이

struct 4형제 — IL 타입 선언부 핵심 차이

실전 적용 — 언제 무엇을 선택하는가

선택 기준 흐름도

struct 4형제 선택 흐름도

Unity 실전 패턴

1. 게임플레이 이벤트 페이로드 → record struct

C#
// Unity 게임 이벤트: 데이터 묶음, 동등성 비교 필요
public record struct DamageEvent(
    int SourceId,
    int TargetId,
    float Amount,
    DamageType Type
);

// 사용
var ev = new DamageEvent(1, 2, 100f, DamageType.Fire);
var doubled = ev with { Amount = ev.Amount * 2 };

// 동등성 자동 비교 (중복 이벤트 필터링에 유용)
if (currentEvent == lastEvent) return;

2. 물리/계산 데이터 → readonly struct

C#
// 핫패스에서 in 파라미터와 함께 사용
public readonly struct RaycastInput
{
    public readonly Vector3 Origin;
    public readonly Vector3 Direction;
    public readonly float MaxDistance;
    public readonly LayerMask Mask;

    public RaycastInput(Vector3 origin, Vector3 direction, float maxDist, LayerMask mask)
    {
        Origin = origin;
        Direction = direction;
        MaxDistance = maxDist;
        Mask = mask;
    }
}

public class RaycastSystem : MonoBehaviour
{
    // in + readonly struct → 방어 복사 없음
    public static bool Cast(in RaycastInput input, out RaycastHit hit)
    {
        return Physics.Raycast(input.Origin, input.Direction, out hit, input.MaxDistance, input.Mask);
    }
}

3. 스택 임시 버퍼 → ref struct + Span<T>

C#
// Unity IL2CPP 환경에서도 동작하는 zero-allocation 패턴
public class MeshBuilder : MonoBehaviour
{
    public Mesh BuildMesh(int vertexCount)
    {
        // stackalloc: 힙 할당 없이 스택에 임시 버퍼
        Span<Vector3> vertices = stackalloc Vector3[vertexCount];
        Span<int> indices = stackalloc int[vertexCount * 3];

        FillVertices(vertices);
        FillIndices(indices);

        var mesh = new Mesh();
        mesh.SetVertices(vertices.ToArray()); // Mesh API가 Span 직접 지원
        mesh.SetIndices(indices.ToArray(), MeshTopology.Triangles, 0);
        return mesh;
    }

    private static void FillVertices(Span<Vector3> vertices)
    {
        for (int i = 0; i < vertices.Length; i++)
            vertices[i] = new Vector3(i * 0.1f, 0, 0);
    }

    private static void FillIndices(Span<int> indices)
    {
        for (int i = 0; i < indices.Length - 2; i += 3)
        {
            indices[i] = i;
            indices[i + 1] = i + 1;
            indices[i + 2] = i + 2;
        }
    }
}

함정과 주의사항

함정 1: 일반 struct + in 파라미터 → 방어 복사 함정

C#
// ❌ 잘못된 패턴: 성능 최적화를 기대했으나 실제로는 방어 복사 발생
public struct Enemy
{
    public int Health;
    public Vector3 Position;
    public float Speed;

    // in 파라미터로 전달받아도 이 메서드가 this를 바꿀 수 있으므로
    // 컴파일러가 방어 복사를 삽입한다
    public float DistanceTo(Vector3 target) => Vector3.Distance(Position, target);
}

public static float GetDistance(in Enemy enemy, Vector3 target)
{
    // ← 여기서 방어 복사 발생! Enemy 전체(최소 20바이트)가 복사된다
    return enemy.DistanceTo(target);
}
C#
// ✅ 올바른 패턴: readonly struct로 방어 복사 제거
public readonly struct Enemy
{
    public readonly int Health;
    public readonly Vector3 Position;
    public readonly float Speed;

    public Enemy(int health, Vector3 position, float speed)
    {
        Health = health;
        Position = position;
        Speed = speed;
    }

    // readonly struct이므로 in 파라미터에서 방어 복사 없음
    public float DistanceTo(Vector3 target) => Vector3.Distance(Position, target);
}

public static float GetDistance(in Enemy enemy, Vector3 target)
{
    return enemy.DistanceTo(target); // 방어 복사 없음
}

함정 2: record struct의 GetHashCode를 Dictionary 키로 사용하면서 변이

C#
// ❌ 잘못된 패턴: record struct를 Dictionary 키로 쓰면서 값을 변이
public record struct ItemSlot(int SlotIndex, string ItemId);

var inventory = new Dictionary<ItemSlot, int>();
var slot = new ItemSlot(0, "sword");
inventory[slot] = 1;

slot = slot with { ItemId = "shield" }; // ItemSlot은 값 타입 → 로컬 변수만 바뀜
// inventory는 변하지 않음 — 의도와 다른 동작

// ✅ 올바른 이해: record struct는 값 의미론
// Dictionary 키로 쓸 때는 변이하지 않거나, 키를 재설정해야 한다
inventory.Remove(slot with { ItemId = "sword" }); // 이전 키 제거
inventory[slot] = 1;                              // 새 키로 추가

함정 3: ref struct를 async 컨텍스트에서 사용하려 함

C#
// ❌ 잘못된 패턴: async 메서드에서 ref struct 사용 불가
public async Task<int> LoadDataAsync()
{
    Span<byte> buffer = stackalloc byte[256]; // ← CS4012: 컴파일 오류
    int read = await stream.ReadAsync(buffer.ToArray());
    return read;
}

// ✅ 올바른 패턴: ref struct 사용 부분을 동기 메서드로 분리
public static int ProcessBuffer(ReadOnlySpan<byte> buffer)
{
    // Span<T>는 여기서 사용
    return buffer.Length;
}

public async Task<int> LoadDataAsync()
{
    byte[] buffer = new byte[256];           // 힙 배열은 async에서 사용 가능
    int read = await stream.ReadAsync(buffer);
    return ProcessBuffer(buffer);            // 동기 메서드에서 Span으로 처리
}

함정 4: struct 크기가 크면 값 복사 비용이 class보다 크다

C#
// ❌ 위험 패턴: 필드가 많은 struct를 매번 값으로 전달
public struct LargeTransform
{
    public Vector3 Position;  // 12 bytes
    public Quaternion Rotation; // 16 bytes
    public Vector3 Scale;     // 12 bytes
    public Matrix4x4 LocalToWorld; // 64 bytes
    // 총 104 bytes
}

// 이렇게 쓰면 매 호출마다 104 bytes 복사
public static void Update(LargeTransform t) { /* ... */ }

// ✅ 올바른 패턴: in으로 참조 전달 + readonly struct로 방어 복사 제거
public readonly struct LargeTransform { /* 동일 */ }
public static void Update(in LargeTransform t) { /* ... */ }

일반적인 기준: struct의 총 크기가 16바이트를 초과하면 in 파라미터와 readonly struct 조합을 고려한다. Unity의 Vector3(12바이트)나 Quaternion(16바이트) 정도는 복사해도 무방하지만, 이를 포함하는 커스텀 struct는 쉽게 커진다.

함정 5: IL2CPP에서의 주의사항

Unity는 iOS/Android 빌드에서 IL을 네이티브 C++ 코드로 변환하는 IL2CPP(IL To C++ 변환기)를 사용한다. 일반적으로 struct의 동작은 동일하지만 몇 가지 주의사항이 있다.

C#
// IL2CPP에서 주의: reflection 기반 Equals는 IL2CPP에서 더 느리거나 스트리핑될 수 있음
public struct BadEquality
{
    public int X;
    // GetHashCode/Equals를 오버라이드하지 않으면
    // ValueType.Equals가 reflection을 사용 → IL2CPP에서 성능 저하 + 스트리핑 위험
}

// ✅ IL2CPP 안전: 직접 구현 또는 record struct 사용
public record struct GoodEquality(int X);
// → EqualityComparer<int>.Default.Equals() 사용 → reflection 없음

C# 버전별 변화

C# 7.2 — readonly struct와 ref struct 도입

C# 7.2(Unity 2020.x 이상에서 사용 가능)에서 readonly structref struct가 동시에 도입되었다. 이전에는 "방어 복사를 방지하려면 struct를 크게 만들지 말라"는 조언밖에 없었다.

C#
// C# 7.2 이전: in 파라미터는 있지만 방어 복사를 막을 방법이 없었음
// → 큰 struct를 in으로 전달해도 메서드 호출 시 방어 복사 발생
// C# 7.2 이후: readonly struct + in 파라미터로 방어 복사 완전 제거
public readonly struct Vector4D  // C# 7.2+
{
    public readonly float X, Y, Z, W;
    public Vector4D(float x, float y, float z, float w) { X=x; Y=y; Z=z; W=w; }
    public float Magnitude() => MathF.Sqrt(X*X + Y*Y + Z*Z + W*W);
}

C# 8.0 — readonly 인스턴스 멤버

C#
// C# 8.0 이전: 메서드 하나만 불변이어도 struct 전체를 readonly로 선언해야 함
// C# 8.0 이후: 개별 멤버에 readonly 수식어 가능
public struct PartiallyMutable
{
    public int X;
    public int Y;

    // C# 8.0: 이 메서드는 this를 변이하지 않음을 명시
    public readonly int Sum() => X + Y;  // ← 방어 복사 없이 in으로 호출 가능

    public void Increment() { X++; } // 이 메서드는 변이 가능
}
IL
// readonly 멤버의 IL
.method public hidebysig instance int32 Sum() cil managed
{
    .custom instance void IsReadOnlyAttribute::.ctor()  // 메서드에도 IsReadOnlyAttribute
    // 메서드 레벨 IsReadOnlyAttribute → 이 메서드를 in 파라미터에서 방어 복사 없이 호출 가능
    IL_0000: ldarg.0
    IL_0001: ldfld int32 PartiallyMutable::X
    IL_0006: ldarg.0
    IL_0007: ldfld int32 PartiallyMutable::Y
    IL_000c: add
    IL_000d: ret
}

C# 10 — record struct와 readonly record struct 도입

C#
// C# 9 이전: struct에서 동등성을 직접 구현해야 했음
public struct OldHit { /* Equals, GetHashCode 직접 구현 */ }

// C# 10 이후: record struct로 자동화
public record struct Hit(int Damage, bool IsCritical); // C# 10+
public readonly record struct ImmutableHit(int Damage, bool IsCritical); // C# 10+

C# 11 — ref struct 인터페이스 구현 허용 (부분)

C# 11에서 ISpanFormattable, ISpanParsable 등 일부 인터페이스를 ref struct에 구현할 수 있게 되었다. 단, 이 인터페이스들은 allows ref struct 제약을 가진다.

C# 13 — ref struct 제약 해제

C#
// C# 13(.NET 9) 이후: ref struct에 인터페이스 구현 전면 허용
public ref struct RefBuffer : IDisposable
{
    private bool _disposed;
    public void Dispose() { _disposed = true; }
}

// 제네릭 제약에서 ref struct 허용
public static T Process<T>(T value) where T : allows ref struct
{
    return value;
}

Unity 버전별 C# 지원

Unity 버전 C# 버전 지원되는 주요 기능
2019.4 LTS C# 7.3 readonly struct, ref struct, in 파라미터
2021.3 LTS C# 9 (일부) 기본 record class (record struct 미지원)
2022.3 LTS C# 10 (일부) record struct, readonly record struct
6000.x (Unity 6) C# 10+ record struct 완전 지원

Unity 2022.3 LTS 이상에서 record struct를 안전하게 사용할 수 있다. 이전 버전에서는 readonly struct와 수동 Equals/GetHashCode 구현으로 대체한다.


정리

핵심 선택 기준 체크리스트

  • struct: 값 의미론이 필요하고, 변이 가능하며, 동등성 자동 생성이 필요 없을 때. 가장 기본적인 선택. 크기가 16바이트를 초과하면 in 파라미터와 함께 사용을 고려한다.
  • readonly struct: struct인데 변이할 필요가 없고, in 파라미터로 자주 전달되는 경우. 방어 복사 제거가 핵심 이점. Unity 핫패스의 물리 데이터, 설정 데이터에 적합.
  • record struct: 이벤트 페이로드, 좌표, 상태 스냅숏처럼 "데이터 묶음"에 == 비교, with 표현식, ToString이 필요할 때. Unity 2022.3 LTS 이상에서 사용 가능.
  • readonly record struct: record struct의 불변 버전. 데이터 묶음인데 생성 후 변이하면 안 될 때. with 표현식으로만 "업데이트"한다.
  • ref struct: Span<T>, ReadOnlySpan<T>, stackalloc 버퍼 처리처럼 스택 전용 메모리를 안전하게 다룰 때. GC 완전 제로가 목표일 때. async 메서드, class 필드, boxing이 필요한 컨텍스트에서는 사용 불가.

IL 수준 핵심 마커 요약

struct 종류 IL 핵심 마커 의미
struct extends ValueType 기본 값 타입
readonly struct IsReadOnlyAttribute + initonly 필드 불변, 방어 복사 없음
record struct IEquatable<T> 구현 + <X>k__BackingField 자동 동등성 + backing field
readonly record struct IsReadOnlyAttribute + modreq(IsExternalInit) 불변 + init-only setter
ref struct IsByRefLikeAttribute + ObsoleteAttribute 스택 전용 보장

Unity 성능 관점 요약

패턴 IL 증거 Update 루프 영향
일반 struct + in + 메서드 호출 ldobj (방어 복사) struct 크기 × 호출 횟수만큼 스택 복사
readonly struct + in + 메서드 호출 ldarg.0 직접 call 복사 없음 — 성능 최적
struct → object 대입 box 힙 할당 + GC 부담
record struct == 비교 EqualityComparer.Equals reflection 없음 — 빠름
큰 struct 값 전달 ldloc + stloc 크기만큼 복사 — 주의
Span<T> 처리 IsByRefLikeAttribute GC 없음 — 최고 성능