C# Records & Value Semantics
C# 9에서 도입된 record는 **값 동등성(Value Equality)**과 **불변성(Immutability)**을 기본으로 갖는 참조 타입입니다. 기존 class로 동등성 비교 코드를 수동으로 작성하던 반복 작업을 제거하고, 데이터 중심 타입을 간결하게 표현합니다.
1. class vs record 동등성 비교
섹션 제목: “1. class vs record 동등성 비교”// class: 참조 동등성 (같은 객체인지)public class PointClass{ public int X { get; init; } public int Y { get; init; }}
var a = new PointClass { X = 1, Y = 2 };var b = new PointClass { X = 1, Y = 2 };Console.WriteLine(a == b); // false (다른 객체)
// record: 값 동등성 (모든 속성이 같으면 같음)public record PointRecord(int X, int Y);
var c = new PointRecord(1, 2);var d = new PointRecord(1, 2);Console.WriteLine(c == d); // trueConsole.WriteLine(c.Equals(d)); // trueConsole.WriteLine(c.GetHashCode() == d.GetHashCode()); // true2. 위치 레코드 (Positional Record)
섹션 제목: “2. 위치 레코드 (Positional Record)”생성자 파라미터를 선언하면 컴파일러가 속성, 생성자, 분해(Deconstruct)를 자동 생성합니다.
// 한 줄 선언public record Player(string Name, int Level, float Health);
var player = new Player("Alice", 10, 100f);
// 분해 (Deconstruct 자동 생성)var (name, level, health) = player;Console.WriteLine($"{name}: Lv{level}, HP{health}");
// ToString 자동 생성Console.WriteLine(player);// Player { Name = Alice, Level = 10, Health = 100 }3. with 표현식 — 비파괴 수정
섹션 제목: “3. with 표현식 — 비파괴 수정”불변 레코드의 일부 속성을 변경한 새 복사본을 만듭니다. 원본은 변경되지 않습니다.
public record GameState( int Score, int Lives, string CurrentLevel, bool IsPaused);
var initial = new GameState(0, 3, "Level1", false);
// 점수만 변경한 새 상태 생성var afterScore = initial with { Score = 100 };
// 레벨 변경 + 점수 리셋var nextLevel = initial with { CurrentLevel = "Level2", Score = 0 };
Console.WriteLine(initial); // Score=0, Lives=3, CurrentLevel=Level1Console.WriteLine(afterScore); // Score=100, Lives=3, CurrentLevel=Level1이 패턴은 게임 상태를 불변 체인으로 관리하는 함수형 상태 관리에 적합합니다.
4. record struct (C# 10)
섹션 제목: “4. record struct (C# 10)”값 타입(스택 할당)의 레코드가 필요하면 record struct를 사용합니다.
// record struct: 값 타입 + 값 동등성public record struct Vector2(float X, float Y){ public float Magnitude => MathF.Sqrt(X * X + Y * Y);
public static Vector2 operator +(Vector2 a, Vector2 b) => new(a.X + b.X, a.Y + b.Y);}
var v1 = new Vector2(3f, 4f);var v2 = new Vector2(3f, 4f);Console.WriteLine(v1 == v2); // true (값 동등성)Console.WriteLine(v1.Magnitude); // 5
// readonly record struct: 완전한 불변 보장public readonly record struct Point3D(float X, float Y, float Z);5. 상속과 레코드
섹션 제목: “5. 상속과 레코드”record는 다른 record를 상속할 수 있습니다. 동등성 비교는 런타임 타입까지 확인합니다.
public record Entity(int Id, string Name);public record Enemy(int Id, string Name, int Damage) : Entity(Id, Name);
var e1 = new Entity(1, "Object");var e2 = new Enemy(1, "Object", 50);
Console.WriteLine(e1 == e2); // false — 타입이 다름
// 상속 레코드의 withvar boss = new Enemy(99, "Boss", 200);var weakBoss = boss with { Damage = 50 };6. 커스텀 멤버 추가
섹션 제목: “6. 커스텀 멤버 추가”위치 레코드에 추가 메서드, 속성, 유효성 검사를 삽입할 수 있습니다.
public record PlayerStats(string Name, int Level, int Experience){ // 파생 속성 public int ExperienceToNextLevel => Level * 100 - Experience; public bool IsMaxLevel => Level >= 99;
// 유효성 검사 (생성자 이후 실행) public PlayerStats { if (string.IsNullOrWhiteSpace(Name)) throw new ArgumentException("Name cannot be empty", nameof(Name)); if (Level < 1 || Level > 99) throw new ArgumentOutOfRangeException(nameof(Level), "Level must be 1-99"); Experience = Math.Max(0, Experience); // 음수 방지 }}7. DTO 패턴 — API 응답 모델링
섹션 제목: “7. DTO 패턴 — API 응답 모델링”// API 응답 DTOpublic record ApiResponse<T>(bool Success, T? Data, string? ErrorMessage){ public static ApiResponse<T> Ok(T data) => new(true, data, null);
public static ApiResponse<T> Fail(string message) => new(false, default, message);}
// 사용var response = ApiResponse<PlayerStats>.Ok( new PlayerStats("Alice", 10, 500));
if (response.Success && response.Data is { } stats) Console.WriteLine($"{stats.Name}: Lv{stats.Level}");8. 언제 record를 쓸까
섹션 제목: “8. 언제 record를 쓸까”| 타입 | 적합한 경우 |
|---|---|
class | 가변 상태, 동일성(Identity)이 중요한 경우 |
record | 불변 DTO, API 응답, 이벤트, 쿼리 결과 |
struct | 소형 값 타입, 성능 민감 (스택 할당) |
record struct | 소형 불변 값 타입 (Vector2, Color 등) |
record는 값 동등성, 불변성(init),ToString,with표현식을 컴파일러가 자동 생성한다.with표현식은 원본을 변경하지 않고 일부 속성을 바꾼 새 복사본을 반환한다.record struct는 값 타입이므로 힙 할당 없이 사용하며,readonly record struct로 완전한 불변성을 강제한다.- 레코드 상속 시 동등성 비교는 런타임 타입까지 검사하므로, 부모와 자식 레코드는 같은 속성값을 가져도 같지 않다.