Menu

C# 레코드: 값 같음, with 식, record struct

C# 레코드(C# 9 이상)란 무엇인가: 위치 구문, 컴파일러가 생성하는 멤버, 값 기반 같음 비교, with로 만드는 비파괴적 복사, 기본 제공 ToString, C# 10의 record struct, 레코드 간 상속, 그리고 직접 작성한 동등한 클래스를 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

레코드는 데이터를 담는 것이 주된 역할이고, 그 데이터로 같음이 정의되는 타입입니다. 레코드는 C# 9에서 추가되었습니다. 한 줄을 쓰면 컴파일러가 데이터 클래스에 필요한 멤버를 생성합니다. 속성, 생성자, 값 기반 Equals와 ==, GetHashCode, 읽기 좋은 ToString, Deconstruct, with 복사 지원입니다.

레코드는 C# 9 이상(.NET 5+)이 필요하므로, 이 페이지의 레코드 코드는 출력을 주석으로 단 일반 C#으로 보여 줍니다. 마지막 절은 같은 멤버를 C# 7로 직접 작성하며, 이는 실행할 수 있습니다.

위치 레코드

가장 짧은 형태는 이름 뒤 괄호 안에 속성을 나열합니다. 각 매개변수가 같은 이름의 public init 전용 속성이 됩니다:

public record Product(string Sku, string Name, decimal Price);

var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name);        // Mug
Console.WriteLine(mug);             // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m;                  // error CS8852: init-only property

var (sku, name, price) = mug;       // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50

그 한 줄로부터 컴파일러가 생성하는 것:

  • (string Sku, string Name, decimal Price)를 받는 생성자;
  • public ... { get; init; } 속성 세 개;
  • 세 속성을 비교하는 Equals(object), Equals(Product), GetHashCode(), ==와 != 연산자;
  • 타입 이름과 모든 public 속성을 출력하는 ToString();
  • Deconstruct(out string Sku, out string Name, out decimal Price);
  • 복사 생성자(protected, sealed 레코드라면 private)와 with가 쓰는 숨겨진 복제 메서드.

레코드는 일반 본문으로 작성할 수도 있으며, 속성에 기본값이나 검증이 필요할 때 편리합니다:

public record Customer
{
    public required string Email { get; init; }   // required: C# 11
    public string Name { get; init; } = "";
}

var c = new Customer { Email = "ana@example.com" };

두 형태를 합칠 수도 있습니다. 위치 매개변수에 중괄호 안의 추가 멤버를 더합니다.

public record Order(string Id, decimal Subtotal)
{
    public decimal Tax => Subtotal * 0.23m;
    public decimal Total => Subtotal + Tax;
}

값 같음

일반 클래스에서 ==는 "같은 객체인가?"를 묻습니다. 레코드에서는 "값이 같은가?"를 묻습니다:

var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);

Console.WriteLine(a == b);                  // True
Console.WriteLine(a.Equals(b));             // True
Console.WriteLine(ReferenceEquals(a, b));   // False: still two objects

GetHashCode도 이에 맞게 생성되므로, 레코드는 딕셔너리 키와 HashSet<T> 안에서 올바르게 동작합니다. 값이 같은 두 번째 레코드가 첫 번째의 항목을 찾습니다.

같음 비교는 각 필드(위치 레코드라면 각 속성 뒤의 필드)를 EqualityComparer<T>.Default로 비교하며, 이는 타입 자신의 Equals를 호출합니다. 컬렉션 속성의 경우 이것은 참조 같음이며, 사람들을 놀라게 합니다:

public record Basket(string Owner, List<string> Items);

var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y);   // False: two different List objects

레코드가 컬렉션을 담고 그 내용으로 비교해야 한다면 Equals(Basket other)와 GetHashCode()를 재정의하거나, 직접 값 의미론을 가진 불변 컬렉션을 쓰세요.

with 식: 비파괴적 변경

레코드는 보통 불변이므로, 수정된 복사본을 만들어 "바꿉니다". with는 모든 속성을 복사한 뒤 중괄호 안의 대입을 적용합니다:

var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };

Console.WriteLine(sale);        // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price);   // 8.50: the original is untouched

복사는 얕습니다. 참조 형식 속성은 참조로 복사되므로 두 레코드가 객체를 공유합니다:

public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }

var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");

Console.WriteLine(string.Join(",", c1.Tags));   // vip,newsletter

레코드 속성을 끝까지 불변으로 유지하거나(한 번 채운 IReadOnlyList<T>, 또는 ImmutableList<T>), with에서 새 리스트를 만드세요: c1 with { Tags = new List<string>(c1.Tags) }.

ToString

생성된 ToString은 타입 이름과 모든 public 속성을 출력하므로, 로그와 디버거에서 레코드를 보기 좋게 만듭니다:

Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }

컬렉션은 타입 이름(System.Collections.Generic.List`1[System.String])으로 출력되고, 중첩된 레코드는 재귀적으로 출력됩니다. ToString을 재정의하면 출력 전체를 바꿀 수 있습니다:

public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount:F2} {Currency}";
}

record struct(C# 10)

record만 쓰면 record class, 즉 참조 형식을 뜻합니다. C# 10은 같은 생성 멤버를 가진 값 형식인 record struct를 추가했습니다:

public readonly record struct Point(int X, int Y);

var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4));   // True
Console.WriteLine(q);                      // Point { X = 3, Y = 10 }

기본값의 차이는 기억해 둘 만합니다. 위치 record struct는 구조체가 보통 동작하는 방식에 맞게 변경 가능한 속성({ get; set; })을 갖고, readonly record struct와 record class는 init 전용 속성을 갖습니다. 구조체와 클래스 중에서 고르듯이 고르세요. 자유롭게 복사되는 작은 값에는 readonly record struct가, 그 밖의 모든 것에는 record가 어울립니다.

상속

레코드는 다른 레코드를 상속할 수 있습니다(클래스는 상속할 수 없고, 클래스도 레코드를 상속할 수 없습니다). 위치 매개변수는 생성자 인수처럼 기본 레코드에 넘깁니다:

public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);

Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);

Console.WriteLine(a == b);   // True
Console.WriteLine(a == c);   // False: different runtime types are never equal
Console.WriteLine(a);        // Circle { Color = red, Radius = 2 }

같음 비교는 생성된 EqualityContract 속성을 통해 실행 시점 타입을 포함합니다. 그래서 둘 다 Shape를 통해 비교하더라도 Color가 같은 Circle과 Square는 절대 같지 않고, 변수 타입이 기본 타입이어도 ToString과 with가 파생 타입에서 동작합니다.

C# 7에서의 같은 것: 값 같음을 가진 클래스

레코드는 직접 작성할 수 있는 코드를 생성하며, 그것을 보면 레코드의 동작이 이해됩니다. 다음은 public record Point(int X, int Y);에 해당하는 C# 7 클래스입니다. get 전용 속성, 생성자, Deconstruct, 값 같음, 이에 맞는 해시 코드, ==, ToString, 그리고 with 식을 대신하는 With 메서드입니다.

출력:

True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False

속성 두 개에 약 30줄이고, 속성을 새로 추가할 때마다 생성자, Deconstruct, Equals, GetHashCode, ToString을 다시 건드려야 합니다. 이 중 하나를 잊는 것은 전형적인 버그입니다(==로는 같은데 해시가 달라서 HashSet이 추적하지 못하는 두 점). 레코드가 없애 주는 것이 이 유지보수입니다.

클래스는 일부러 sealed입니다. 값 같음과 상속을 결합하려면 레코드가 EqualityContract로 생성하는 추가 타입 검사가 필요한데, sealed로 만들면 이 문제를 비켜 갑니다.

레코드를 언제 쓸까

레코드는 값으로 정의되고 만들어진 뒤 바뀌지 않는 데이터에 어울립니다:

  • 웹 API의 요청과 응답 모델;
  • 시스템의 부분 사이에 오가는 메시지, 명령, 이벤트;
  • 설정과 옵션 객체;
  • 복합 딕셔너리 키(record CacheKey(string Region, int Year));
  • 계산의 결과(record PriceQuote(decimal Net, decimal Tax)).

값보다 정체성이 중요한 곳에는 잘 맞지 않습니다. Entity Framework 엔터티는 이름이 바뀐 뒤에도 "고객 42"이고, EF Core의 변경 추적은 참조 정체성에 의존합니다. 거기서는 클래스를 쓰세요.

흔한 실수

  • 컬렉션에 깊은 같음을 기대하기. List<T> 속성은 참조로 비교합니다. 똑같아 보이는 리스트를 가진 두 레코드는 같지 않습니다.
  • with가 깊은 복사를 하리라 기대하기. 중첩 객체와 컬렉션은 원본과 복사본이 공유합니다.
  • 실수로 변경 가능한 위치 record struct 만들기. 설정 가능한 속성을 원하는 게 아니라면 readonly를 추가하세요.
  • 레코드를 EF Core 엔터티로 쓰기. 값 같음 비교와 복사가 변경 추적과 충돌합니다.
  • C# 8 프로젝트에 레코드 추가하기. 레코드는 C# 9(.NET 5 이상의 기본값)가 필요합니다. 이전 대상에서는 위에서 보인 것처럼 클래스를 직접 작성하세요.

자주 묻는 질문

C#에서 레코드란 무엇인가요?

레코드(C# 9)는 컴파일러가 값 기반 같음 비교, 읽기 좋은 ToString(), Deconstruct 메서드, with 복사 지원을 생성해 주는 클래스이며, record struct(C# 10)라면 구조체입니다. public record Product(string Sku, decimal Price);는 init 전용 속성 두 개를 가진 완전한 타입입니다. 속성 값이 같은 두 레코드는 서로 다른 객체여도 같습니다.

C#에서 레코드와 클래스의 차이는 무엇인가요?

record는 내부적으로 클래스이므로 참조 형식이고 다른 레코드를 상속할 수 있습니다. 차이는 생성되는 멤버입니다. 레코드는 값으로 비교하고(==와 Equals가 모든 필드를 검사), ToString()이 속성을 출력하며, with를 지원합니다. 일반 클래스는 그런 멤버를 직접 작성하지 않는 한 참조로 비교하고 타입 이름을 출력합니다.

C#에서 with 식은 무엇을 하나요?

var sale = product with { Price = 6.00m };는 product의 모든 속성을 복사한 뒤 나열한 속성을 설정한 새 레코드를 만듭니다. 원본은 바뀌지 않습니다. 복사는 얕습니다. List<T> 속성은 두 레코드가 공유하므로, 한쪽을 통해 추가하면 다른 쪽에서도 보입니다.

C#의 record struct란 무엇인가요?

record struct(C# 10)는 레코드 클래스와 같은 생성 멤버(값 같음 비교, ToString, Deconstruct, with)를 가진 값 형식입니다. 레코드 클래스와 달리 위치 속성은 기본적으로 변경 가능하며, 이를 init 전용으로 만들려면 readonly record struct로 선언하세요. 좌표나 금액 같은 작은 값에 쓰세요.

C#에서 레코드는 언제 써야 하나요?

정체성이 곧 값인 데이터에 쓰세요. DTO, API 요청과 응답 모델, 메시지와 이벤트, 설정, 딕셔너리 키 같은 것입니다. Entity Framework 엔터티처럼 시간에 따라 바뀌고 id로 식별되는 엔터티에는 레코드를 피하세요. 값 같음 비교와 with 복사가 변경 추적과 충돌합니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기