| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 게임개발
- Job 시스템
- 패스트캠퍼스
- base64
- Unity Editor
- 최적화
- unity
- RSA
- 샘플
- 암호화
- Dots
- C#
- 오공완
- DotsTween
- Framework
- sha
- ui
- 프레임워크
- 2D Camera
- 패스트캠퍼스후기
- adfit
- Tween
- 직장인자기계발
- AES
- Custom Package
- 직장인공부
- TextMeshPro
- 가이드
- job
- 환급챌린지
- Today
- Total
EveryDay.DevUp
struct 4형제 — struct, record struct, readonly struct, ref struct 본문
struct 4형제 — struct, record struct, readonly struct, ref struct
EveryDay.DevUp 2026. 4. 1. 23:56struct 4형제 — struct, record struct, readonly struct, ref struct
Unity 모바일 게임 클라이언트 신입 개발자를 위한 C# struct 심층 가이드
목차
왜 struct가 네 가지나 있는가 — 문제 제기
Unity에서 캐릭터의 위치를 나타내는 타입을 만들어야 한다. 보통 이렇게 시작한다.
public struct Vector2Int
{
public int X;
public int Y;
}
그런데 실제 Unity 프로젝트를 보면 다음처럼 쓰기도 한다.
public readonly struct ImmutableVector { ... }
public record struct HitResult(int Damage, bool IsCritical);
Span<T>이나 NativeArray<T>의 내부를 보면 또 다른 것이 등장한다.
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의 C# 코드와 IL
// 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 코드: 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 분석 포인트:
extends [System.Runtime]System.ValueType— 모든 struct는ValueType을 상속한다. 이것이 값 타입임을 CLR(Common Language Runtime, .NET 실행 환경)에 알린다.ValueType은Object를 상속하지만, 값 타입의 할당·복사 동작은 참조 타입과 근본적으로 다르다..locals init에valuetype Position— 지역 변수a,b가 스택에 직접 예약된다.newobj(힙 할당 지시어)가 없다.stloc.1(b에 복사) —b = a는 단 하나의 IL 명령어로 처리된다. 내부적으로는 struct 크기만큼(여기서는 8바이트) 메모리를 통째로 복사한다. class라면 참조(포인터 주소)만 4~8바이트 복사하는 것과 대조적이다.ldloca.svsldloc— 메서드를 호출할 때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)다.
방어 복사 IL 비교
// 일반 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(); // 컴파일러가 방어 복사 삽입
}
// 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
}
// 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(); // 방어 복사 없이 직접 원본 주소 사용
}
// 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 분석 포인트:
ldobj MutablePoint— 방어 복사의 증거다.ldobj는 관리되는 포인터가 가리키는 위치에서 값 타입을 복사해 평가 스택에 올린다. 즉, 원본MutablePoint전체(8바이트)를 스택 로컬 변수로 복사한다. Update 루프에서 이 연산이 매 프레임 발생하면 불필요한 스택 복사가 누적된다.ldobj없음(readonly struct) —IsReadOnlyAttribute가 붙은 struct는in파라미터에서 메서드를 호출해도 컴파일러가 방어 복사를 삽입하지 않는다. 원본 주소(ldarg.0)를 그대로call의this로 쓴다.IsReadOnlyAttribute—readonly struct를 컴파일하면 구조체 클래스 선언에.custom instance void IsReadOnlyAttribute::.ctor()어트리뷰트가 붙는다. 컴파일러는 이 어트리뷰트를 보고 방어 복사 생략 여부를 결정한다.
readonly struct의 규칙
readonly struct를 선언하면 컴파일러가 다음 규칙을 강제한다:
- 모든 인스턴스 필드는
readonly여야 한다. - 자동 구현 프로퍼티는
get만 있어야 한다(setter 불가). - 인스턴스 메서드는 암묵적으로
readonly로 취급된다 —this를 변이할 수 없다.
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가 중요한 상황
// 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 기반으로 동작해 성능이 떨어진다.
// 문제 상황: 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는 이 모든 것을 컴파일러가 자동 생성한다.
// record struct: 한 줄로 동등성, ToString, Deconstruct 자동 생성
public record struct HitResult(int Damage, bool IsCritical);
record struct가 자동 생성하는 멤버
public record struct HitResult(int Damage, bool IsCritical); 한 줄이 컴파일되면 다음 멤버들이 자동 생성된다:
// 컴파일러가 생성하는 것들
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 — 자동 생성 코드 살펴보기
public record struct RecordStruct(int X, int Y);
// 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 분석 포인트:
IEquatable<RecordStruct>구현 — record struct는 자동으로IEquatable<T>를 구현한다. 이는 Dictionary, HashSet에서 boxing 없이 동등성 비교가 가능하게 한다.set_X에 일반stfld— 일반record struct의 setter는IsExternalInitmodifier가 없다. 즉 어디서나 set 가능하다.readonly record struct에서는 setter에modreq(IsExternalInit)이 붙어 생성자에서만 설정 가능하다.callvirtvscall—op_Equality에서Equals를call로 호출한다(가상 디스패치 아님). struct 메서드는call로 직접 호출되어 가상 테이블 조회 비용이 없다.
with 표현식 — 불변 업데이트 패턴
with— with 표현식 (With expression) 기존 인스턴스를 기반으로 일부 프로퍼티만 바꾼 새 인스턴스를 만든다. 원본은 변하지 않는다. record 타입(record class, record struct 모두)에서 사용 가능하다.
예시:var b = a with { X = 10 };a는 그대로이고, X만 10으로 바뀐 새 인스턴스가 b에 대입된다.
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()):
// 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 struct는 readonly struct와 record struct의 결합이다. 자동 생성 멤버(Equals, GetHashCode, ToString, Deconstruct, with)를 제공하면서, 동시에 불변성을 보장한다.
public readonly record struct ImmutableHit(int Damage, bool IsCritical);
// 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 외부에서 발생하면 컴파일 오류로 막는다.
var hit = new ImmutableHit(100, true);
// hit.Damage = 200; // ← CS8852: Init-only property — 컴파일 오류
// with 표현식으로만 "수정" 가능 (실제로는 새 인스턴스 생성)
var stronger = hit with { Damage = 200 };
4가지 struct 타입 비교 요약
ref struct — 스택만 살고 힙에는 절대 가지 않는다
ref struct가 존재하는 이유
C# 7.2에서 ref struct가 도입된 배경은 Span<T>이다. Span<T>은 배열, 스택 메모리, 비관리 메모리를 가리지 않고 연속된 메모리 블록을 슬라이스(slice)로 표현하는 타입이다. 이 타입이 올바르게 동작하려면 절대 힙에 올라가서는 안 된다. 힙에 올라가면 GC가 메모리를 이동시킬 수 있고, 그러면 스택 메모리나 비관리 메모리를 가리키는 포인터가 무효가 된다.
ref struct는 이 요구사항을 CLR 수준에서 보장한다. ref struct 인스턴스는 항상 스택에 있다.
ref struct의 IL과 컴파일러 어트리뷰트
public ref struct SpanLike
{
private int _value;
public int Value => _value;
public SpanLike(int value) { _value = value; }
}
// 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 분석 포인트:
IsByRefLikeAttribute— 이 어트리뷰트가 ref struct를 일반 struct와 구분한다. CLR은 이 어트리뷰트를 보고 boxing, 힙 할당, 인터페이스 구현을 차단한다.ObsoleteAttribute— 오래된 컴파일러(ref struct를 모르는)가 이 타입을 만나면 경고/오류를 내도록 하는 호환성 안전망이다. 실제로 "deprecated"가 아니라 이전 버전 컴파일러 대상 방어 메커니즘이다.CompilerFeatureRequiredAttribute("RefStructs")— C# 11에서 추가된 메커니즘. 컴파일러가 "RefStructs" 기능을 지원하지 않으면 이 타입을 사용할 수 없게 한다.
ref struct의 제약 목록
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>
// 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# 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 차이
실전 적용 — 언제 무엇을 선택하는가
선택 기준 흐름도
Unity 실전 패턴
1. 게임플레이 이벤트 페이로드 → record struct
// 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
// 핫패스에서 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>
// 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 파라미터 → 방어 복사 함정
// ❌ 잘못된 패턴: 성능 최적화를 기대했으나 실제로는 방어 복사 발생
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);
}
// ✅ 올바른 패턴: 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 키로 사용하면서 변이
// ❌ 잘못된 패턴: 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 컨텍스트에서 사용하려 함
// ❌ 잘못된 패턴: 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보다 크다
// ❌ 위험 패턴: 필드가 많은 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의 동작은 동일하지만 몇 가지 주의사항이 있다.
// 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 struct와 ref struct가 동시에 도입되었다. 이전에는 "방어 복사를 방지하려면 struct를 크게 만들지 말라"는 조언밖에 없었다.
// 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# 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++; } // 이 메서드는 변이 가능
}
// 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# 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# 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 없음 — 최고 성능 |
'C# 심화' 카테고리의 다른 글
| 프로퍼티 — 필드와 무엇이 다른가 (0) | 2026.04.02 |
|---|---|
| string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.01 |
| string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 (0) | 2026.03.31 |
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
