EveryDay.DevUp

[PART5.구조체와 레코드(3/4)] record — 불변 데이터를 다루는 새로운 방법 본문

C# 심화

[PART5.구조체와 레코드(3/4)] record — 불변 데이터를 다루는 새로운 방법

EveryDay.DevUp 2026. 4. 5. 19:32

record — 불변 데이터를 다루는 새로운 방법

Unity 게임에서 무기 능력치, 플레이어 스탯, 이벤트 데이터를 안전하게 다루는 핵심 도구. record가 컴파일러에게 얼마나 많은 일을 시키는지, 그리고 Unity에서 어떻게 써야 GC 스파이크를 피하는지 알아본다.


문제 제기

Unity 모바일 게임을 만들다 보면 "데이터를 담는 객체"가 끝없이 필요하다. 무기 능력치, 플레이어 스탯, 네트워크 패킷, 이벤트 메시지 — 이런 객체들은 동작(메서드)보다 데이터 자체가 중요하다.

문제는, 이런 데이터 객체를 class로 만들 때마다 반복되는 코드가 쏟아진다는 것이다.

C#
// 무기 데이터를 class로 만들면 이렇게 된다
public class WeaponData : IEquatable<WeaponData>
{
    public string Name { get; }
    public int Damage { get; }
    
    public WeaponData(string name, int damage)
    {
        Name = name;
        Damage = damage;
    }
    
    public bool Equals(WeaponData? other)
    {
        if (other is null) return false;
        return Name == other.Name && Damage == other.Damage;
    }
    
    public override bool Equals(object? obj) => Equals(obj as WeaponData);
    public override int GetHashCode() => HashCode.Combine(Name, Damage);
    public override string ToString() => $"WeaponData {{ Name = {Name}, Damage = {Damage} }}";
}

속성이 2개인데 코드가 20줄이다. 속성이 5개면? 10개면? 그리고 이 코드에는 더 근본적인 위험이 숨어 있다.

  • Equals에서 속성 하나를 빼먹으면 같은 무기인데 다르다고 판정된다
  • GetHashCodeEquals가 불일치하면 Dictionary에서 키를 찾을 수 없다
  • 누군가 Name 속성에 set을 추가하면 외부에서 무기 이름이 바뀐다

record는 이 모든 문제를 한 줄로 해결한다.


개념 정의

비유: 주민등록증 vs 명함

사람을 식별하는 두 가지 방식이 있다.

  • 주민등록증: 고유 번호로 식별한다. 이름이 같아도 번호가 다르면 다른 사람이다. → 이것이 class참조 기반 동등성이다. 메모리 주소가 같아야 같은 객체다.
  • 명함: 적힌 내용이 같으면 같은 사람이다. 이름, 직함, 연락처가 모두 같으면 동일하게 취급한다. → 이것이 record값 기반 동등성이다. 모든 속성의 값이 같으면 같은 객체다.

record는 "이 타입은 명함이다 — 내용으로 비교해라"라고 컴파일러에게 선언하는 것이다.

record — 레코드 (C# 9.0) 값 기반 동등성, 불변성, 비파괴적 복사를 컴파일러가 자동으로 구현해주는 데이터 중심 타입 선언 키워드. record class(참조 타입)와 record struct(값 타입)가 있다.
예시: public record WeaponData(string Name, int Damage); 이 한 줄로 Equals, GetHashCode, ToString, Deconstruct, 복사 생성자까지 자동 생성된다.
class vs record — 동등성 비교 방식

한 줄로 선언하기

record의 핵심은 위치 기반 선언(positional syntax)이다. 괄호 안에 속성을 나열하면 끝이다.

C#
// record 한 줄 선언 — 위치 기반(positional) 문법
public record WeaponData(string Name, int Damage);

이 한 줄을 컴파일러가 받아서 다음을 전부 자동 생성한다:

  • public string Name { get; init; }, public int Damage { get; init; } — 초기화 전용 속성
  • 주 생성자(primary constructor)
  • IEquatable<WeaponData> 구현 — Equals, GetHashCode
  • ==, != 연산자 오버로딩
  • ToString()"WeaponData { Name = Sword, Damage = 50 }" 형식
  • Deconstruct 메서드 — 구조 분해
  • 숨겨진 <Clone>$ 메서드 — with 식용 복사
init — 초기화 전용 접근자 (C# 9.0) set과 비슷하지만, 객체 생성 시점에서만 값을 할당할 수 있다. 생성 후에는 수정 불가. record의 불변성을 보장하는 핵심 메커니즘이다.
예시: public string Name { get; init; }new WeaponData { Name = "Sword" } 가능, 이후 weapon.Name = "Axe" 불가

실제로 컴파일러가 무엇을 만드는지 IL(Intermediate Language, C# 코드가 컴파일된 중간 언어)로 확인해보자.

C#
public record PlayerStats(string Name, int Level, float Hp);

public class RecordBasicDemo
{
    public static void Main()
    {
        var stats1 = new PlayerStats("Hero", 5, 100f);
        var stats2 = new PlayerStats("Hero", 5, 100f);
        
        bool areEqual = stats1 == stats2;
        bool sameRef = ReferenceEquals(stats1, stats2);
        
        Console.WriteLine(areEqual);   // True — 값이 같으므로
        Console.WriteLine(sameRef);    // False — 다른 객체
        Console.WriteLine(stats1);     // PlayerStats { Name = Hero, Level = 5, Hp = 100 }
    }
}
IL
// record PlayerStats의 IL — 컴파일러가 자동 생성한 구조

// 1. 클래스 선언: Object를 상속하고 IEquatable<PlayerStats>를 구현
.class public auto ansi beforefieldinit PlayerStats
    extends [System.Runtime]System.Object
    implements class [System.Runtime]System.IEquatable`1<class PlayerStats>

// 2. initonly 필드 — 생성 후 변경 불가
.field private initonly string '<Name>k__BackingField'
.field private initonly int32 '<Level>k__BackingField'
.field private initonly float32 '<Hp>k__BackingField'

// 3. set 접근자에 IsExternalInit 제약 — init 전용임을 표시
.method public hidebysig specialname 
    instance void modreq(IsExternalInit) set_Name(string 'value')

// 4. == 연산자: 참조가 같으면 바로 true, 아니면 Equals 호출
.method public hidebysig specialname static 
    bool op_Equality(class PlayerStats left, class PlayerStats right)
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: beq.s IL_0011        // 같은 참조면 → true로 점프
    IL_0007: ldarg.0
    IL_0008: ldarg.1
    IL_0009: callvirt instance bool PlayerStats::Equals(class PlayerStats)
    IL_000e: ret
    IL_0011: ldc.i4.1             // true 반환
    IL_0012: ret
}

// 5. Equals: EqualityContract + 모든 필드를 EqualityComparer로 비교
.method public hidebysig newslot virtual 
    instance bool Equals(class PlayerStats other)
{
    // EqualityContract 비교 (상속 시 타입 안전성)
    IL_0008: callvirt instance class System.Type PlayerStats::get_EqualityContract()
    IL_0013: call bool System.Type::op_Equality(...)
    IL_0018: brfalse.s IL_0061    // 타입이 다르면 → false
    
    // Name 비교
    IL_001a: call class EqualityComparer`1<!0> EqualityComparer`1<string>::get_Default()
    IL_0020: ldfld string PlayerStats::'<Name>k__BackingField'
    IL_002b: callvirt instance bool EqualityComparer`1<string>::Equals(!0, !0)
    IL_0030: brfalse.s IL_0061    // 다르면 → false
    
    // Level 비교
    IL_0032: call class EqualityComparer`1<!0> EqualityComparer`1<int32>::get_Default()
    IL_0038: ldfld int32 PlayerStats::'<Level>k__BackingField'
    IL_0043: callvirt instance bool EqualityComparer`1<int32>::Equals(!0, !0)
    IL_0048: brfalse.s IL_0061    // 다르면 → false
    
    // Hp 비교 — 마지막 필드의 결과가 곧 최종 결과
    IL_004a: call class EqualityComparer`1<!0> EqualityComparer`1<float32>::get_Default()
    IL_0050: ldfld float32 PlayerStats::'<Hp>k__BackingField'
    IL_005b: callvirt instance bool EqualityComparer`1<float32>::Equals(!0, !0)
    IL_0060: ret
}

IL에서 핵심적인 부분을 짚어보자:

  • initonly 필드: 생성자에서만 값을 설정할 수 있다. 이것이 record의 불변성을 CLR(Common Language Runtime, .NET의 실행 환경) 수준에서 보장하는 메커니즘이다.
  • modreq(IsExternalInit): set 접근자가 존재하지만 init 전용이라는 표식이다. 이 제약 덕분에 생성 이후 속성을 바꾸려 하면 컴파일 오류가 난다.
  • EqualityContract: 상속 계층에서 정확한 타입까지 비교하기 위한 가상 속성이다. 부모 record와 자식 record가 같은 값을 가져도 타입이 다르면 false를 반환한다.
  • EqualityComparer<T>.Default: 각 필드를 박싱(boxing) 없이 비교한다. 직접 Equals를 구현할 때 빠뜨리기 쉬운 null 처리와 타입 안전성이 자동으로 보장된다.

쉽게 말해, record 한 줄이 컴파일러를 시켜서 "실수 없는 완벽한 데이터 클래스"를 대신 만들게 하는 것이다.


내부 동작

with 식 — 비파괴적 변형의 메커니즘

불변 객체는 한 번 만들면 바꿀 수 없다. 그러면 "무기를 강화해서 데미지를 올리고 싶다"면 어떻게 할까? 원본을 복사한 뒤 원하는 속성만 바꾼 새 객체를 만든다. 이것이 with 식이다.

with — 비파괴적 변형 식 (Non-destructive mutation, C# 9.0) 기존 record의 복사본을 만들면서 지정한 속성만 변경한다. 원본은 그대로 유지된다.
예시: var upgraded = weapon with { Damage = 75 }; weapon의 모든 속성을 복사하되, Damage만 75로 변경한 새 객체를 생성
with 식 — 비파괴적 변형 흐름 원본 (baseSword) Name = "Iron Sword" Damage = 50 CritRate = 0.1f 1. Clone$ 복사본 (임시) Name = "Iron Sword" Damage = 50 → 75 CritRate = 0.1f 2. set 새 객체 (upgraded) Name = "Iron Sword" Damage = 75 CritRate = 0.1f 원본은 변하지 않는다 record class: $ → newobj (힙 할당 발생)record struct: 스택 복사 → set (힙 할당 없음)
C#
// with 식: 원본을 복사하고 특정 속성만 변경한 새 객체를 생성
public record BuffData(string Name, float Multiplier);

public class WithExpressionDemo
{
    public static void Main()
    {
        var baseBuff = new BuffData("Attack", 1.5f);
        var strongBuff = baseBuff with { Multiplier = 2.0f };
        
        Console.WriteLine(baseBuff);     // BuffData { Name = Attack, Multiplier = 1.5 }
        Console.WriteLine(strongBuff);   // BuffData { Name = Attack, Multiplier = 2 }
        Console.WriteLine(baseBuff == strongBuff); // False — Multiplier가 다르다
    }
}
IL
// with 식의 IL — record class일 때

// baseBuff 생성
IL_0000: ldstr "Attack"
IL_0005: ldc.r4 1.5
IL_000a: newobj instance void BuffData::.ctor(string, float32)  // 힙에 원본 할당

// with { Multiplier = 2.0f } 실행
IL_000f: callvirt instance class BuffData BuffData::'<Clone>$'()  // ← 복사본 생성 (힙에 새 객체)
IL_0014: dup
IL_0015: ldc.r4 2
IL_001a: callvirt instance void modreq(IsExternalInit) BuffData::set_Multiplier(float32)  // 복사본의 속성 변경

<Clone>$ 메서드는 컴파일러가 자동 생성한 숨겨진 메서드다. 내부적으로 복사 생성자(copy constructor)를 호출하여 모든 필드를 그대로 옮긴 새 객체를 힙에 만든다.

IL
// <Clone>$ 메서드의 IL
.method public hidebysig newslot virtual 
    instance class BuffData '<Clone>$'()
{
    IL_0000: ldarg.0
    IL_0001: newobj instance void BuffData::.ctor(class BuffData)  // ← newobj: 힙에 새 객체 할당
    IL_0006: ret
}

// 복사 생성자의 IL — 모든 필드를 하나씩 복사
.method family hidebysig specialname rtspecialname 
    instance void .ctor(class BuffData original)
{
    IL_0006: ldarg.0
    IL_0007: ldarg.1
    IL_0008: ldfld string BuffData::'<Name>k__BackingField'     // 원본의 Name 읽기
    IL_000d: stfld string BuffData::'<Name>k__BackingField'     // 복사본에 쓰기
    IL_0012: ldarg.0
    IL_0013: ldarg.1
    IL_0014: ldfld float32 BuffData::'<Multiplier>k__BackingField'
    IL_0019: stfld float32 BuffData::'<Multiplier>k__BackingField'
}

핵심은 newobj 명령어다. record classwith 식은 매번 힙에 새 객체를 할당한다. Unity의 Update() 같은 핫패스(hot path, 매 프레임 반복 실행되는 코드 경로)에서 with를 남용하면 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크의 원인이 된다.

record struct — 힙 할당 없는 대안

C# 10.0에서 추가된 record struct는 값 타입이다. with 식을 써도 스택에서 구조체를 복사할 뿐, 힙 할당이 없다.

C#
// record struct: 값 타입 + record의 편의 기능
public readonly record struct DamageEvent(int TargetId, float Amount);

public class RecordStructDemo
{
    public static void Main()
    {
        var evt1 = new DamageEvent(1, 50.5f);
        var evt2 = new DamageEvent(1, 50.5f);
        var evt3 = evt1 with { Amount = 75.0f };
        
        Console.WriteLine(evt1 == evt2); // True — 값 기반 비교
        Console.WriteLine(evt3);         // DamageEvent { TargetId = 1, Amount = 75 }
    }
}
IL
// record struct의 IL — DamageEvent

// 클래스가 아닌 ValueType을 상속 (값 타입)
.class public sequential ansi sealed beforefieldinit DamageEvent
    extends [System.Runtime]System.ValueType
    implements class [System.Runtime]System.IEquatable`1<valuetype DamageEvent>

// IsReadOnlyAttribute — readonly record struct임을 표시
.custom instance void IsReadOnlyAttribute::.ctor()

// initonly 필드
.field private initonly int32 '<TargetId>k__BackingField'
.field private initonly float32 '<Amount>k__BackingField'
IL
// record struct의 with 식 IL — Main 메서드

// evt1 생성 — 스택에 직접 저장
IL_0000: ldc.i4.1
IL_0001: ldc.r4 50.5
IL_0006: newobj instance void DamageEvent::.ctor(int32, float32)

// evt1 with { Amount = 75.0f } — <Clone>$ 호출 없이 구조체 복사
IL_0018: dup                  // 스택에서 구조체를 복사 (힙 할당 아님)
IL_0019: stloc.2              // 복사본을 로컬 변수에 저장
IL_001a: ldloca.s 2           // 복사본의 주소를 로드
IL_001c: ldc.r4 75
IL_0021: call instance void modreq(IsExternalInit) DamageEvent::set_Amount(float32)  // 속성 변경

// 비교 — op_Equality가 값 비교 수행
IL_0028: ldloc.0
IL_0029: call bool DamageEvent::op_Equality(valuetype DamageEvent, valuetype DamageEvent)

record class와의 결정적 차이:

  • extends System.ValueType: 값 타입이므로 스택에 저장된다
  • <Clone>$ 메서드가 없다: 구조체는 대입만으로 복사되므로 별도의 복제 메서드가 필요 없다
  • newobj로 힙 할당 없음: with 식에서 dup 명령어로 스택 복사만 수행한다
  • EqualityContract가 없다: 구조체는 상속이 불가능하므로 타입 비교가 불필요하다
readonly record struct — 읽기 전용 레코드 구조체 (C# 10.0) record structreadonly를 붙이면 모든 필드가 변경 불가능하다. readonly를 생략하면 속성에 set이 생성되어 가변 구조체가 되므로, 불변성을 원한다면 반드시 readonly를 붙여야 한다.

실전 적용

record class vs record struct — 언제 무엇을 쓰는가

record class vs record struct — 선택 기준

Before: Update()에서 record class로 이벤트 발행

C#
// ❌ record class를 매 프레임 생성하면 GC 스파이크 위험
public record DamageInfo(int TargetId, float Amount, string DamageType);

public class CombatSystem : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // 매번 힙에 새 객체가 할당된다
            var dmg = new DamageInfo(targetId, 10f, "Physical");
            ProcessDamage(dmg);
        }
    }
    
    private void ProcessDamage(DamageInfo info)
    {
        // with 식도 힙에 새 객체를 만든다
        var critDmg = info with { Amount = info.Amount * 2f };
        ApplyDamage(critDmg);
    }
}

After: readonly record struct로 GC 부담 제거

C#
// ✅ readonly record struct — 스택 할당으로 GC 부담 없음
public readonly record struct DamageInfo(int TargetId, float Amount, string DamageType);

public class CombatSystem : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // 스택에 할당 — GC 대상이 아니다
            var dmg = new DamageInfo(targetId, 10f, "Physical");
            ProcessDamage(dmg);
        }
    }
    
    private void ProcessDamage(DamageInfo info)
    {
        // with 식도 스택 복사 — 힙 할당 없음
        var critDmg = info with { Amount = info.Amount * 2f };
        ApplyDamage(critDmg);
    }
}
IL
// record class의 with 식 IL — 힙 할당 발생
IL_000f: callvirt instance class BuffData BuffData::'<Clone>$'()  // Clone → newobj (힙 할당)
IL_001a: callvirt instance void modreq(IsExternalInit) BuffData::set_Multiplier(float32)

// record struct의 with 식 IL — 스택 복사만
IL_0018: dup          // 스택에서 구조체를 복사 (힙 할당 아님)
IL_0019: stloc.2
IL_001a: ldloca.s 2
IL_0021: call instance void modreq(IsExternalInit) BuffDataStruct::set_Multiplier(float32)

IL을 비교하면 차이가 명확하다:

  • record class<Clone>$을 호출하고, 그 안에서 newobj힙에 새 객체를 할당한다. 이 객체는 이후 GC가 수거해야 한다.
  • record structdup 명령어로 스택에서 값을 복사할 뿐이다. newobj<Clone>$도 없다. GC는 이 데이터의 존재조차 모른다.

판단 기준: Update() 루프, 물리 연산, 이벤트 시스템처럼 매 프레임 실행되는 코드에서는 readonly record struct를 사용한다. 게임 설정, 저장 데이터, API 응답처럼 생성 빈도가 낮은 데이터에는 record class가 적합하다.

패턴 매칭과의 시너지

record는 C#의 패턴 매칭과 결합하면 강력한 커맨드/이벤트 시스템을 만들 수 있다.

C#
// record 상속으로 게임 커맨드 계층 정의
public abstract record GameCommand;
public record MoveCommand(Vector3 Direction, float Speed) : GameCommand;
public record AttackCommand(int TargetId, float Damage) : GameCommand;
public record UseItemCommand(int ItemId) : GameCommand;

public class CommandProcessor : MonoBehaviour
{
    public void ProcessCommand(GameCommand cmd)
    {
        // 패턴 매칭으로 타입별 분기 — switch + 구조 분해
        var result = cmd switch
        {
            MoveCommand(var dir, var spd) => $"이동: {dir}, 속도: {spd}",
            AttackCommand(var id, var dmg) => $"공격: 대상 {id}, 데미지 {dmg}",
            UseItemCommand(var itemId)     => $"아이템 사용: {itemId}",
            _ => "알 수 없는 명령"
        };
        
        Debug.Log(result);
    }
}

recordDeconstruct 자동 생성 덕분에 switch 식에서 바로 속성을 변수로 꺼낼 수 있다. if-else 체인과 캐스팅이 사라지고, 컴파일러가 모든 케이스를 검증한다. (IL 수준에서는 일반적인 가상 메서드 디스패치와 동일하므로 별도 분석을 생략한다.)


함정과 주의사항

함정 1: 가변 컬렉션을 record에 넣으면 불변성이 깨진다

record의 불변성은 속성의 재할당을 막는 것이지, 속성이 가리키는 객체의 내부 변경까지 막지 않는다.

C#
// ❌ 가변 컬렉션을 record에 넣으면 불변성이 깨진다
public record InventoryData(string PlayerName, List<string> Items);

public class MutableFieldTrap
{
    public static void Main()
    {
        var items = new List<string> { "Sword", "Shield" };
        var inv = new InventoryData("Hero", items);
        
        // record의 "불변성"이 깨진다 — 외부에서 리스트를 수정
        items.Add("Potion");
        Console.WriteLine(inv.Items.Count); // 3 — "불변" 객체의 데이터가 바뀌었다!
        
        // 동등성 비교도 문제 — List는 참조 비교
        var inv2 = new InventoryData("Hero", new List<string> { "Sword", "Shield", "Potion" });
        Console.WriteLine(inv == inv2); // False — 같은 내용이지만 다른 List 참조
    }
}
IL
// record의 Equals가 List<string>을 비교하는 IL
// EqualityComparer<List<string>>.Default.Equals()를 호출한다
IL_001a: call class EqualityComparer`1<!0> EqualityComparer`1<class List`1<string>>::get_Default()
IL_0020: ldfld class List`1<string> InventoryData::'<Items>k__BackingField'
IL_002b: callvirt instance bool EqualityComparer`1<class List`1<string>>::Equals(!0, !0)
// ↑ List<T>의 기본 Equals는 참조 비교 — 내용이 같아도 다른 참조면 false

EqualityComparer<List<string>>.DefaultList<T>Equals를 호출하는데, List<T>IEquatable을 구현하지 않으므로 참조 비교로 빠진다. 같은 아이템 목록이어도 다른 List 인스턴스면 false가 된다.

C#
// ✅ 불변 컬렉션을 사용하면 문제가 해결된다
using System.Collections.Immutable;

public record InventoryData(string PlayerName, ImmutableArray<string> Items);

public class ImmutableFieldFix
{
    public static void Main()
    {
        var items = ImmutableArray.Create("Sword", "Shield");
        var inv = new InventoryData("Hero", items);
        
        // ImmutableArray는 수정 불가 — 새 배열을 반환
        var newItems = items.Add("Potion"); // inv의 Items는 변하지 않는다
        Console.WriteLine(inv.Items.Length); // 2 — 원본 유지
    }
}

함정 2: Unity 직렬화와의 호환 문제

recordinit 속성은 Unity의 [SerializeField] 시스템과 호환되지 않는다. Unity 직렬화기는 리플렉션(reflection)으로 필드에 값을 설정하는데, init 접근자는 생성 시점 이후의 설정을 거부한다.

C#
// ❌ record를 MonoBehaviour 필드로 직접 사용할 수 없다
[Serializable]
public record EnemyConfig(string Name, int Hp, float Speed);

public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private EnemyConfig config; // Inspector에 표시되지 않는다
}
C#
// ✅ ScriptableObject나 일반 class로 직렬화하고, record로 변환
[CreateAssetMenu]
public class EnemyConfigAsset : ScriptableObject
{
    public string enemyName;
    public int hp;
    public float speed;
    
    // 불변 record로 변환하는 메서드
    public EnemyConfig ToRecord() => new EnemyConfig(enemyName, hp, speed);
}

// 게임 로직에서는 불변 record를 사용
public record EnemyConfig(string Name, int Hp, float Speed);

함정 3: IsExternalInit 누락 오류 (Unity 2021 이전)

Unity의 C# 런타임 버전에 따라 record를 선언할 때 IsExternalInit is not defined 컴파일 오류가 발생할 수 있다. init 접근자가 필요로 하는 내부 클래스가 Unity의 런타임에 포함되어 있지 않기 때문이다.

C#
// ❌ Unity 2021 이전에서 record 선언 시 컴파일 오류
public record PlayerData(string Name, int Level);
// error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined
C#
// ✅ 프로젝트 내 아무 스크립트에 이 코드를 추가하면 해결
namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

이 빈 클래스가 컴파일러에게 "이 런타임에도 init 접근자를 쓸 수 있다"고 알려주는 역할을 한다. Unity 2022 이후 버전에서는 이 문제가 해결되었다. (함정 2와 3은 Unity 환경의 호환성 문제이므로 IL 분석을 생략한다.)


C# 버전별 변화

C# 9.0 (2020) — record class 도입

record 키워드가 처음 도입되었다. 기본적으로 참조 타입(record class)이며, 위치 기반 선언, 값 기반 동등성, with 식, Deconstruct 등 핵심 기능이 모두 포함되었다.

C#
// C# 9.0: record (= record class)
public record PlayerStats(string Name, int Level, float Hp);

var stats = new PlayerStats("Hero", 5, 100f);
var levelUp = stats with { Level = 6 };  // 비파괴적 변형
var (name, level, hp) = stats;           // 구조 분해

C# 10.0 (2021) — record struct 추가

값 타입 record가 추가되어 GC 부담 없이 record의 편의 기능을 사용할 수 있게 되었다. record class라는 명시적 구문도 도입되어 두 종류를 명확히 구분할 수 있다.

C#
// Before (C# 9.0): record class만 사용 가능 — 힙 할당 불가피
public record DamageInfo(int TargetId, float Amount);
// with 식마다 힙 할당 발생

// After (C# 10.0): record struct 추가 — 스택 할당으로 GC 부담 제거
public readonly record struct DamageInfo(int TargetId, float Amount);
// with 식에서 스택 복사만 수행
IL
// C# 9.0 record class의 with 식 IL
IL_000f: callvirt instance class DamageInfo DamageInfo::'<Clone>$'()  // newobj → 힙 할당

// C# 10.0 record struct의 with 식 IL
IL_0018: dup          // 스택 복사 — 힙 할당 없음
IL_0019: stloc.2
IL_001c: ldc.r4 75
IL_0021: call instance void DamageInfo::set_Amount(float32)

C# 12.0 (2023) — Primary Constructor와의 관계

C# 12.0에서 일반 classstruct에도 주 생성자(primary constructor)가 추가되었다. 하지만 일반 class의 주 생성자는 record와 달리 속성을 자동 생성하지 않고, Equals/GetHashCode/ToString도 자동 생성하지 않는다.

C#
// C# 12.0 class 주 생성자 — 속성이 자동 생성되지 않는다
public class EnemyData(string Name, int Hp);  // Name, Hp는 매개변수일 뿐 속성이 아님

// record — 속성, Equals, GetHashCode, ToString 전부 자동 생성
public record EnemyRecord(string Name, int Hp);  // Name, Hp가 public 속성

record만의 고유한 가치는 여전히 유효하다: 값 기반 동등성 + 불변성 + with 식 + 자동 속성 생성. 일반 class의 주 생성자는 이 중 어느 것도 제공하지 않는다.


정리

  • record는 데이터 중심 타입이다class는 "행동"이 중요하고, record는 "내용"이 중요한 객체를 만들 때 쓴다
  • 한 줄 선언에 모든 것이 포함된다Equals, GetHashCode, ToString, Deconstruct, with 식 지원이 컴파일러에 의해 자동 생성된다
  • 값 기반 동등성이 기본이다 — 모든 속성 값이 같으면 같은 객체로 취급한다. Dictionary 키로 안전하게 사용할 수 있다
  • with 식으로 불변성을 유지하면서 변형한다 — 원본을 복사하고 원하는 속성만 바꾼 새 객체를 만든다
  • Unity 핫패스에서는 readonly record struct를 사용한다record classwith는 매번 힙 할당이 발생하지만, record struct는 스택 복사만 수행한다
  • 가변 컬렉션을 record에 넣지 않는다List<T> 같은 가변 참조 타입은 record의 불변성과 동등성을 모두 깨뜨린다. ImmutableArray<T> 등을 사용한다
  • Unity 직렬화와 직접 호환되지 않는다 — ScriptableObject로 직렬화하고, 게임 로직에서 record로 변환하는 패턴을 사용한다