레코드(record) — 데이터를 담는 타입에 값 동등성 같은 기본 기능을 얹어 주는 수식어
레코드(record) — 데이터를 담는 타입에 값 동등성 같은 기본 기능을 얹어 주는 수식어
C#에서 데이터 중심의 타입을 만들 때, record 수식어 하나를 붙이면 컴파일러가 '값 기반 동등성' 같은 유용한 동작을 통째로 얹어 줘요. 불변 데이터 모델을 깔끔하게 표현하려는 의도로 만들어졌지만, 값은 얼마든지 바꿀 수 있게 쓸 수도 있어요. 이번 글에서는 record가 정확히 뭘 해 주는지, 클래스와 구조체에서 어떻게 달라지는지 차근차근 풀어 볼게요.
출처: Microsoft Learn
본문
record 수식어는 데이터를 캡슐화하는 데 쓰는 기본 기능을 제공해요. record class와 record 문법은 참조 타입(reference type)을, record struct 문법은 값 타입(value type)을 정의해요.
[!INCLUDE]csharp-version-note
여기서 중요한 포인트가 나와요. 레코드에 기본 생성자(primary constructor)를 선언하면, 컴파일러가 그 기본 생성자 매개변수에 대한 public 프로퍼티를 자동으로 만들어 줘요. 이때 기본 생성자 매개변수를 위치 매개변수(positional parameter) 라고 하고, 그에 맞춰 생성되는 프로퍼티를 위치 프로퍼티(positional property) 라고 불러요. 반대로 record 수식어가 없는 타입에서는 기본 생성자 매개변수에 프로퍼티를 자동 생성하지 않아요. 즉 "레코드만 되는 특권"이라는 거죠.
다음 두 예시는 record(즉 record class) 참조 타입을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalRecord"::: :::code language="csharp" source="snippets/shared/RecordType.cs" id="ImmutableRecord":::
다음 두 예시는 record struct 값 타입을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalRecordStruct"::: :::code language="csharp" source="snippets/shared/RecordType.cs" id="ImmutableRecordStruct":::
프로퍼티와 필드를 바꿀 수 있는(mutable) 레코드도 만들 수 있어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="MutableRecord":::
레코드 구조체도 mutable일 수 있어요. 위치 매개변수가 있는 record struct든, 위치 매개변수가 전혀 없는 record struct든 말이죠.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="MutablePositionalRecordStruct"::: :::code language="csharp" source="snippets/shared/RecordType.cs" id="MutableRecordStruct":::
레코드는 mutable로 쓸 수 있긴 한데, 본래 목적은 불변 데이터 모델을 지원하는 것이에요. 레코드 타입이 제공하는 기능을 한 번 정리해 볼게요.
- 불변 프로퍼티를 가진 참조 타입을 만드는 간결한 문법
- 데이터 중심 참조 타입에 유용한 기본 동작:
- 상속 계층 지원
여기서 레코드가 참조 타입일 때와 값 타입일 때의 차이를 짚고 넘어가야 해요.
record또는record class는 참조 타입을 선언해요.class키워드는 생략 가능하지만, 읽는 사람에게 명확성을 더해 줘요.record struct는 값 타입을 선언하죠.- 위치 프로퍼티는
record class와readonly record struct에서는 불변(immutable) 이고,record struct에서는 변경 가능(mutable) 이에요.
이 글의 나머지에서는 record class와 record struct 둘 다를 다루되, 차이가 있는 부분은 각 절에서 따로 자세히 설명할게요. record class와 record struct 중 어떤 걸 고를지는, 그냥 class와 struct 중 고르는 것과 같은 기준으로 생각하면 돼요. 그리고 record라는 용어는 모든 레코드 타입에 적용되는 동작을 가리키고, struct만의 동작은 record struct, class만의 동작은 record class로 표현해요.
위치 문법으로 프로퍼티·필드 정의하기 (Positional syntax for property and field definition)
위치 매개변수를 쓰면 레코드의 프로퍼티를 선언하거나, 프로퍼티·필드 값을 초기화할 수 있어요. 다음 예시는 위치 프로퍼티 두 개를 가진 레코드를 만들어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="InstantiatePositional":::
프로퍼티 정의에 위치 문법을 쓰면 컴파일러가 다음을 만들어 줘요.
- 레코드 선언에 넣은 각 위치 매개변수마다 public 자동 구현 프로퍼티 하나씩.
record타입과readonly record struct타입에는 init-only 프로퍼티.record struct타입에는 읽기-쓰기(read-write) 프로퍼티.
- 레코드 선언의 위치 매개변수와 일치하는 매개변수를 가진 기본 생성자 하나.
record struct타입의 경우, 각 필드를 기본값으로 설정하는 매개변수 없는 생성자.- 레코드 선언에 넣은 각 위치 매개변수마다
out매개변수를 하나씩 가진Deconstruct메서드. 이 메서드는 위치 문법으로 정의한 프로퍼티만 분해하고, 표준 프로퍼티 문법으로 정의한 프로퍼티는 무시해요.
컴파일러가 레코드 정의로 생성하는 요소들에는 애트리뷰트(attribute)를 붙일 수도 있어요. 위치 레코드의 프로퍼티에 적용하는 애트리뷰트라면 대상(target) 을 지정할 수 있는데, 다음 예시는 Person 레코드의 각 프로퍼티에 xref:System.Text.Json.Serialization.JsonPropertyNameAttribute?displayProperty=nameWithType를 적용해요. property: 대상은 애트리뷰트가 컴파일러 생성 프로퍼티에 붙는다는 뜻이고, 그 외에 필드에는 field:, 매개변수에는 param:을 쓰면 돼요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalAttributes":::
위 예시는 레코드용 XML 문서 주석을 어떻게 만드는지도 보여 줘요. 기본 생성자 매개변수에 대한 문서를 만들려면 <param> 태그를 추가하면 돼요.
컴파일러가 만들어 주는 자동 구현 프로퍼티가 마음에 안 들면, 같은 이름의 프로퍼티나 필드를 직접 정의하면 돼요. 예를 들어 접근 수준(accessibility)이나 가변성(mutability)을 바꾸고 싶거나, get/set 접근자에 직접 구현을 넣고 싶을 때죠. 소스에 멤버를 직접 선언하면 반드시 레코드의 위치 매개변수에서 초기화해야 해요. 자동 구현 프로퍼티라면 그 프로퍼티를, 백킹 필드를 추가했다면 그 백킹 필드를 초기화해야 하죠. 생성된 디컨스트럭터(deconstructor)는 여러분이 정의한 프로퍼티나 필드를 사용해요. 예를 들어 다음 예시는 위치 레코드의 FirstName, LastName 프로퍼티를 public으로 선언하면서, Id 위치 매개변수는 internal로 제한해요. 이 문법은 record 타입과 record struct 타입 모두에 쓸 수 있어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalWithManualProperty":::
프로퍼티 대신 필드를 만들고 싶다면, 다음 예시처럼 위치 매개변수를 필드에 할당해 주면 돼요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalWithManualField":::
레코드 타입이 위치 프로퍼티를 반드시 가져야 하는 건 아니에요. 위치 프로퍼티 없이 선언하고 다른 필드·프로퍼티만 둘 수도 있어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="MixedSyntax":::
위치 매개변수에서 컴파일러가 생성하는 프로퍼티는 public이에요. 직접 선언하는 프로퍼티의 접근 수준은 여러분이 정하면 되고요.
불변성 (Immutability)
위치 record class와 위치 readonly record struct는 init-only 프로퍼티를 선언해요. 위치 record struct는 읽기-쓰기 프로퍼티를 선언하죠. 이 기본값들은 앞 절에서 본 것처럼 얼마든지 오버라이드할 수 있어요.
불변성이 유용한 경우는, 데이터 중심 타입이 스레드 안전(thread-safe)해야 하거나, 해시 테이블에서 해시 코드가 그대로 유지되는 데 의존할 때예요. 다만 모든 데이터 시나리오에 불변이 적합한 건 아니에요. 예를 들어 Entity Framework Core는 불변 엔티티 타입으로는 업데이트를 지원하지 않아요.
init-only 프로퍼티는 (위치 매개변수에서 만들어진 record class·readonly record struct든 init 접근자를 지정했든) 얕은 불변성(shallow immutability) 을 가져요. 초기화가 끝난 뒤에는 값 타입 프로퍼티의 값을 바꾸거나 참조 타입 프로퍼티의 참조를 바꿀 수 없어요. 그런데 참조 타입 프로퍼티가 가리키는 데이터 자체는 바뀔 수 있어요. 다음 예시는 참조 타입의 불변 프로퍼티(여기서는 배열)의 내용이 mutable임을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="ShallowImmutability":::
레코드 타입에만 있는 기능들은 컴파일러가 합성한(synthesized) 메서드로 구현되며, 그 어떤 메서드도 객체 상태를 바꿔 불변성을 깨뜨리지 않아요. 별도로 명시되지 않는 한, 합성 메서드는 record, record struct, readonly record struct 선언 모두에 대해 생성돼요.
값 동등성 (Value equality)
동등성 메서드를 오버라이드하거나 대체하지 않으면, 선언한 타입이 동등성을 어떻게 정의할지 결정해요.
class타입: 두 객체가 메모리상 같은 객체를 가리키면 같아요.struct타입: 두 객체가 같은 타입이고 같은 값을 저장하고 있으면 같아요.record수식어가 있는 타입(record class,record struct,readonly record struct): 두 객체가 같은 타입이고 같은 값을 저장하고 있으면 같아요.
record struct의 동등성 정의는 struct와 같아요. 차이는 구현 방식에 있어요. struct는 xref:System.ValueType.Equals(System.Object)?displayProperty=nameWithType에 구현이 있고 리플렉션(reflection)에 의존하는 반면, 레코드는 컴파일러가 합성한 구현이 선언된 데이터 멤버들을 직접 사용해요.
어떤 데이터 모델에서는 참조 동등성(reference equality)이 필수예요. 예를 들어 Entity Framework Core는 개념적으로 하나인 엔티티에 대해 엔티티 타입 인스턴스를 하나만 쓰도록 보장하기 위해 참조 동등성에 의존해요. 이런 이유로 레코드와 레코드 구조체는 Entity Framework Core의 엔티티 타입으로 쓰기엔 적합하지 않아요.
다음 예시는 레코드 타입의 값 동등성을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="Equality":::
값 동등성을 구현하기 위해 컴파일러는 여러 메서드를 합성해요.
- xref:System.Object.Equals(System.Object)?displayProperty=nameWithType의 오버라이드. 이걸 직접 선언하면 오류예요.
- 이 메서드는 두 매개변수가 모두 null이 아닐 때 xref:System.Object.Equals(System.Object,System.Object)?displayProperty=nameWithType 정적 메서드의 기반이 돼요.
virtual또는sealed인Equals(R? other)(여기서R은 레코드 타입). 이 메서드는 xref:System.IEquatable`1을 구현해요. 직접 선언할 수 있어요.- 레코드 타입이 베이스 레코드 타입
Base에서 파생됐다면Equals(Base? other). 이걸 직접 선언하면 오류예요.Equals(R? other)를 직접 구현했다면GetHashCode도 함께 구현하는 게 좋아요. - xref:System.Object.GetHashCode?displayProperty=nameWithType의 오버라이드. 직접 선언할 수 있어요.
- 연산자
==와 연산자!=의 오버라이드. 이 연산자들을 직접 선언하면 오류예요. - 레코드 타입이 베이스 레코드 타입에서 파생됐다면
protected override Type EqualityContract { get; };. 직접 선언할 수 있어요. 자세한 내용은 상속 계층에서의 동등성을 참고하세요.
레코드 타입이 합성 메서드의 시그니처와 일치하는 메서드를 갖고 있고, 그 메서드가 명시적으로 선언해도 되는 경우라면 컴파일러는 그 메서드를 합성하지 않아요.
비파괴적 변경 (Nondestructive mutation)
인스턴스 일부만 바꾼 복사본이 필요할 때는 with 식을 써서 비파괴적 변경을 하면 돼요. with 식은 기존 레코드 인스턴스의 복사본이되, 지정한 프로퍼티와 필드만 수정된 새 레코드 인스턴스를 만들어요. 바꿀 값을 지정할 때는 개체 이니셜라이저(object initializer) 문법을 사용해요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WithExpressions":::
with 식은 위치 프로퍼티든 표준 프로퍼티 문법으로 만든 프로퍼티든 설정할 수 있어요. 단, 명시적으로 선언한 프로퍼티는 with 식으로 바꾸려면 init이나 set 접근자가 있어야 해요.
with 식의 결과는 얕은 복사(shallow copy) 예요. 참조 프로퍼티라면 그 프로퍼티가 가리키는 참조(주소)만 복사돼서, 원본 레코드와 복사본이 같은 인스턴스를 가리키게 돼요.
record class 타입에서 이 기능을 구현하기 위해 컴파일러는 clone 메서드와 복사 생성자(copy constructor)를 합성해요. virtual clone 메서드는 복사 생성자로 초기화된 새 레코드를 반환하죠. with 식을 쓰면 컴파일러가 clone 메서드를 호출한 뒤, with 식에 포함된 프로퍼티만 설정하는 코드를 만들어요.
[!IMPORTANT] 레코드에 기본 생성자나 사용자 정의 생성자가 없으면, 컴파일러는 public 매개변수 없는 생성자도 합성해요. 이 생성자는 모든 필드를 기본값으로 초기화해요. 이 합성 생성자가 없다면 public 생성자가 전혀 없게 되는 셈이에요.
복사 동작을 다르게 하고 싶다면 record class에 직접 복사 생성자를 작성하면 돼요. 그러면 컴파일러는 복사 생성자를 합성하지 않아요. 레코드가 sealed라면 생성자를 private으로, 아니라면 protected로 만들어야 해요. 컴파일러는 record struct 타입에는 복사 생성자를 합성하지 않아요. 직접 작성할 수는 있지만, with 식에서 그 생성자를 호출하도록 코드를 생성하지는 않아요. record struct의 값은 대입 시점에 복사되거든요.
clone 메서드는 오버라이드할 수 없고, 어떤 레코드 타입에서도 Clone이라는 이름의 멤버를 만들 수 없어요. 실제 clone 메서드 이름은 컴파일러가 정해요.
[!IMPORTANT] 앞의 예시들에서는 모든 프로퍼티가 서로 독립적이에요. 어떤 프로퍼티도 다른 프로퍼티 값에서 계산되지 않죠.
with식은 먼저 기존 레코드 인스턴스를 복사한 뒤,with식에 포함된 프로퍼티·필드만 수정해요. 그러니 계산 프로퍼티(computed property)는 인스턴스 생성 시 초기화하지 말고 접근할 때 계산하도록 만들어야 해요. 만약 생성 시점에 값을 고정해 버리면, 수정된 복사본이 아니라 원본을 기준으로 계산된 값을 돌려줄 수 있거든요.
계산 프로퍼티의 정확성을 보장하려면 다음 선언처럼 접근할 때 값을 계산하면 돼요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WitherComputed":::
위 레코드 타입은 Distance를 접근할 때 계산해요. 다음 예시처럼 말이죠.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WitherComputedUsage":::
이번엔 반대로, Distance 프로퍼티를 새 인스턴스 초기화의 일부로 계산해서 캐시하는 선언을 볼게요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WitherInit":::
Distance가 초기화의 일부로 계산되기 때문에, with 식이 복사본의 Y 값을 바꾸기 전에 값이 계산·캐시돼요. 그 결과 거리 값이 틀리게 나와요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WitherInitUsage":::
Distance 계산은 접근할 때마다 해도 부담이 크지 않아요. 그런데 일부 계산 프로퍼티는 더 많은 데이터에 접근하거나 더 무거운 계산이 필요할 수 있어요. 그런 경우에는 레코드 대신 class 타입을 쓰고, 구성 요소 값 중 하나가 바뀔 때 캐시 값을 계산하도록 하는 게 낫죠.
표시용 기본 포맷팅 (Built-in formatting for display)
레코드 타입에는 컴파일러가 생성한 xref:System.Object.ToString* 메서드가 있어서, public 프로퍼티와 필드의 이름과 값을 보여 줘요. ToString 메서드는 다음 형식으로 문자열을 반환해요.
\
{ \ = \ , \ = \ , ...}
<value> 자리에 찍히는 문자열은 그 프로퍼티 타입의 xref:System.Object.ToString이 반환하는 값이에요. 다음 예시에서 ChildNames는 xref:System.Array?displayProperty=nameWithType라서 ToString이 System.String[]을 반환해요.
Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }
이 기능을 구현하기 위해 record class 타입에서는 컴파일러가 virtual PrintMembers 메서드와 xref:System.Object.ToString* 오버라이드를 합성해요. record struct 타입에서는 이 멤버가 private이에요. ToString 오버라이드는 타입 이름에 여는 중괄호를 붙인 xref:System.Text.StringBuilder 객체를 만들고, PrintMembers를 호출해 프로퍼티 이름과 값을 추가한 뒤 닫는 중괄호를 붙여요. 다음 예시는 합성 오버라이드가 담고 있는 코드와 비슷한 내용이에요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="ToStringOverrideDefault":::
PrintMembers나 ToString 오버라이드를 직접 구현할 수도 있어요. 예시는 이 글 뒤쪽의 '파생 레코드에서의 PrintMembers 포맷팅' 절에 있어요. ToString 구현에 sealed 수식어를 붙이면 컴파일러가 파생 레코드를 위한 ToString 구현을 합성하지 않아요. 이렇게 하면 레코드 타입 계층 전체에서 일관된 문자열 표현을 만들 수 있죠. (파생 레코드에는 여전히 모든 파생 프로퍼티에 대한 PrintMembers 메서드가 생성돼요.)
상속 (Inheritance)
이 절은 record class 타입에만 적용돼요.
레코드는 다른 레코드로부터 상속받을 수 있어요. 하지만 레코드는 클래스에서, 클래스는 레코드에서 상속받을 수 없어요.
C# 15부터 record class는 closed 수식어를 써서 직접 파생을 자신이 선언된 어셈블리로 제한할 수 있어요. 이렇게 하면 소비자들이 default 절 없이 직접 하위 타입들에 대해 완전 열거(exhaustive) switch 식을 작성할 수 있어요. 자세한 내용은 Closed 계층 패턴을 참고하세요. closed 수식어는 record struct 타입에는 적용되지 않는데, 구조체 레코드는 abstract가 될 수 없기 때문이에요.
파생 레코드 타입에서의 위치 매개변수
파생 레코드는 베이스 레코드 기본 생성자의 모든 매개변수에 대해 위치 매개변수를 선언해요. 베이스 레코드가 그 프로퍼티들을 선언·초기화하죠. 파생 레코드는 그것들을 숨기지 않고, 베이스 레코드에 선언되지 않은 매개변수에 대해서만 프로퍼티를 만들고 초기화해요.
다음 예시는 위치 프로퍼티 문법으로 상속을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PositionalInheritance":::
상속 계층에서의 동등성 (Equality in inheritance hierarchies)
이 절은 record class 타입에만 적용되고, record struct 타입에는 적용되지 않아요. 두 record 변수가 같으려면 런타임 타입이 같아야 해요. 변수들이 담긴 타입(컴파일 타임 타입)은 달라도 괜찮아요. 상속된 동등성 비교는 다음 코드 예시에서 확인할 수 있어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="InheritanceEquality":::
이 예시에서는 인스턴스가 Student나 Teacher 같은 파생 타입이더라도 모든 변수가 Person으로 선언돼 있어요. 인스턴스들은 같은 프로퍼티와 같은 프로퍼티 값을 갖고 있죠. 그런데 student == teacher는 두 변수 모두 Person 타입인데도 False를 반환하고, student == student2는 하나는 Person 변수, 하나는 Student 변수인데도 True를 반환해요. 즉 동등성 비교는 변수의 선언 타입이 아니라 실제 객체의 런타임 타입에 의존해요.
이 동작을 구현하기 위해 컴파일러는 레코드 타입과 일치하는 xref:System.Type 객체를 반환하는 EqualityContract 프로퍼티를 합성해요. EqualityContract는 동등성 메서드가 객체의 런타임 타입을 비교할 수 있게 해 줘요. 레코드의 베이스 타입이 object라면 이 프로퍼티는 virtual이고, 베이스 타입이 다른 레코드 타입이라면 override예요. 레코드 타입이 sealed라면 타입 자체가 sealed이므로 이 프로퍼티는 사실상 sealed가 돼요.
코드가 파생 타입의 인스턴스 두 개를 비교하면, 합성된 동등성 메서드는 베이스 타입과 파생 타입의 모든 데이터 멤버가 같은지 확인해요. 합성된 GetHashCode 메서드는 베이스 타입과 파생 레코드 타입에 선언된 모든 데이터 멤버의 GetHashCode 메서드를 사용해요. 레코드의 데이터 멤버에는 선언된 모든 필드와, 자동 구현 프로퍼티를 위한 컴파일러 합성 백킹 필드가 포함돼요.
파생 레코드에서의 with 식
with 식의 결과는 피연산자의 런타임 타입과 같은 타입이에요. 런타임 타입의 모든 프로퍼티가 복사되지만, 설정할 수 있는 건 컴파일 타임 타입의 프로퍼티뿐이에요. 다음 예시에서 확인할 수 있어요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="WithExpressionInheritance":::
파생 레코드에서의 PrintMembers 포맷팅
파생 레코드 타입의 합성 PrintMembers 메서드는 베이스 구현을 호출해요. 그 결과 ToString 출력에 파생 타입과 베이스 타입의 모든 public 프로퍼티와 필드가 포함돼요. 다음 예시를 볼게요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="ToStringInheritance":::
PrintMembers 메서드를 직접 구현할 수도 있어요. 그럴 때는 다음 시그니처를 사용해야 해요.
object에서 파생된(베이스 레코드를 선언하지 않은)sealed레코드:private bool PrintMembers(StringBuilder builder);- 다른 레코드에서 파생된
sealed레코드(둘러싼 타입이sealed라 사실상sealed):protected override bool PrintMembers(StringBuilder builder); sealed가 아니고object에서 파생된 레코드:protected virtual bool PrintMembers(StringBuilder builder);sealed가 아니고 다른 레코드에서 파생된 레코드:protected override bool PrintMembers(StringBuilder builder);
합성 PrintMembers 메서드를 대체하는 코드 예시를 볼게요. 하나는 object에서 파생된 레코드용, 하나는 다른 레코드에서 파생된 레코드용이에요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="PrintMembersImplementation":::
[!NOTE] 베이스 레코드가
ToString메서드를 sealed했더라도, 컴파일러는 파생 레코드에PrintMembers를 합성해요.PrintMembers를 직접 구현하는 것도 가능하고요.
파생 레코드에서의 디컨스트럭터 동작
파생 레코드의 Deconstruct 메서드는 컴파일 타임 타입의 모든 위치 프로퍼티 값을 반환해요. 변수 타입이 베이스 레코드라면, 객체를 파생 타입으로 캐스팅하지 않는 한 디컨스트럭션은 베이스 레코드의 프로퍼티만 반환해요. 다음 예시는 파생 레코드에서 디컨스트럭터를 호출하는 모습을 보여 줘요.
:::code language="csharp" source="snippets/shared/RecordType.cs" id="DeconstructorInheritance":::
제네릭 제약 (Generic constraints)
record 키워드는 class나 struct 타입을 위한 수식어예요. record 수식어를 붙이면 앞서 설명한 동작이 포함돼요. 타입이 레코드여야 한다는 제네릭 제약은 없어요. record class는 class 제약을, record struct는 struct 제약을 만족해요. 자세한 내용은 타입 매개변수에 대한 제약을 참고하세요.
C# 언어 사양 (C# language specification)
더 자세한 내용은 C# 언어 사양의 클래스 절을 참고하세요.
이번 기능들에 대한 자세한 내용은 다음 기능 제안 노트를 확인해 보세요.