콘텐츠로 이동

C# Records & Value Semantics

C# 9에서 도입된 record는 **값 동등성(Value Equality)**과 **불변성(Immutability)**을 기본으로 갖는 참조 타입입니다. 기존 class로 동등성 비교 코드를 수동으로 작성하던 반복 작업을 제거하고, 데이터 중심 타입을 간결하게 표현합니다.


// 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); // true
Console.WriteLine(c.Equals(d)); // true
Console.WriteLine(c.GetHashCode() == d.GetHashCode()); // true

생성자 파라미터를 선언하면 컴파일러가 속성, 생성자, 분해(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 }

불변 레코드의 일부 속성을 변경한 새 복사본을 만듭니다. 원본은 변경되지 않습니다.

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=Level1
Console.WriteLine(afterScore); // Score=100, Lives=3, CurrentLevel=Level1

이 패턴은 게임 상태를 불변 체인으로 관리하는 함수형 상태 관리에 적합합니다.


값 타입(스택 할당)의 레코드가 필요하면 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);

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 — 타입이 다름
// 상속 레코드의 with
var boss = new Enemy(99, "Boss", 200);
var weakBoss = boss with { Damage = 50 };

위치 레코드에 추가 메서드, 속성, 유효성 검사를 삽입할 수 있습니다.

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); // 음수 방지
}
}

// API 응답 DTO
public 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}");

타입적합한 경우
class가변 상태, 동일성(Identity)이 중요한 경우
record불변 DTO, API 응답, 이벤트, 쿼리 결과
struct소형 값 타입, 성능 민감 (스택 할당)
record struct소형 불변 값 타입 (Vector2, Color 등)

  • record는 값 동등성, 불변성(init), ToString, with 표현식을 컴파일러가 자동 생성한다.
  • with 표현식은 원본을 변경하지 않고 일부 속성을 바꾼 새 복사본을 반환한다.
  • record struct는 값 타입이므로 힙 할당 없이 사용하며, readonly record struct로 완전한 불변성을 강제한다.
  • 레코드 상속 시 동등성 비교는 런타임 타입까지 검사하므로, 부모와 자식 레코드는 같은 속성값을 가져도 같지 않다.