블로그로 돌아가기
C#

C# record와 class의 차이 및 선택 기준

C#의 record와 class는 모두 참조 형식이지만 기본 동등성 비교와 복사 방식에서 차이가 있습니다. 데이터의 값이 같으면 같은 객체로 판단해야 할 때와 객체의 고유한 정체성이 필요할 때의 선택 기준을 설명합니다.

C#recordclass값 동등성with 표현식불변성
C# record와 class의 차이 및 선택 기준

C# record와 class의 차이 및 선택 기준

C#에서 recordclass는 모두 참조 형식을 선언할 수 있습니다. 두 형식의 핵심 차이는 객체가 저장되는 위치가 아니라 객체의 동등성을 판단하는 기본 방식입니다.

class는 별도로 동등성을 구현하지 않으면 두 변수가 같은 인스턴스를 참조하는지 비교합니다. record는 서로 다른 인스턴스라도 포함된 데이터가 같으면 같은 값으로 판단합니다. 이 차이 때문에 데이터 전달 객체와 값 객체에는 record가 잘 맞고, 수명과 상태가 있는 객체에는 class가 더 자연스럽습니다.

적용 범위

  • 언어: C#
  • 비교 대상: record classclass
  • 제외 대상: 값 형식인 record struct
  • 프레임워크 의존성: 없음
  • 버전 조건: 특정 C# 버전에 종속된 기능은 다루지 않습니다.
  • 검증 상태: 예제는 일반적인 C# 언어 규칙을 기준으로 작성했으며 실제 SDK 환경에서 실행 검증이 필요합니다.

record만 작성하면 record class를 선언한 것과 같습니다.

public record User(string Name);

다음 선언과 같은 종류의 참조 형식입니다.

public record class User(string Name);

반면 record struct는 값 형식이므로 이번 비교에서는 제외합니다.

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

record와 class의 가장 큰 차이

recordclass의 가장 큰 차이는 기본 동등성 비교입니다.

다음 class는 이름과 나이가 같은 두 인스턴스를 생성합니다.

public sealed class UserClass { public UserClass(string name, int age) { Name = name; Age = age; } public string Name { get; } public int Age { get; } }

두 객체에 같은 값을 전달해도 서로 다른 인스턴스입니다.

var userA = new UserClass("Kim", 30); var userB = new UserClass("Kim", 30); Console.WriteLine(userA == userB);

예상 결과는 False입니다.

False

UserClass==, Equals()GetHashCode()를 별도로 구현하지 않았기 때문에 참조를 비교합니다.

같은 구조를 record로 선언하면 결과가 달라집니다.

public sealed record UserRecord(string Name, int Age);
var userA = new UserRecord("Kim", 30); var userB = new UserRecord("Kim", 30); Console.WriteLine(userA == userB); Console.WriteLine(ReferenceEquals(userA, userB));

예상 결과는 다음과 같습니다.

True False

userAuserB는 서로 다른 인스턴스이므로 ReferenceEquals()False를 반환합니다. 하지만 두 레코드에 저장된 값이 같으므로 == 비교는 True를 반환합니다.

즉, record가 값 형식으로 바뀌는 것은 아닙니다. 참조 형식의 특성을 유지하면서 값 기반 동등성을 제공합니다.

컴파일러가 record에 생성하는 기능

다음과 같은 위치 기반 레코드 선언은 짧지만 여러 기능을 포함합니다.

public record Product(string Name, decimal Price);

컴파일러는 위치 매개변수를 바탕으로 생성자와 프로퍼티를 만듭니다. 값 기반 비교에 필요한 Equals(), GetHashCode(), ==, != 관련 구현도 생성합니다.

출력에 사용할 ToString()과 데이터를 분해하는 Deconstruct()도 사용할 수 있습니다.

var product = new Product("Keyboard", 120000m); Console.WriteLine(product); var (name, price) = product; Console.WriteLine(name); Console.WriteLine(price);

예상되는 ToString() 형식은 다음과 같습니다.

Product { Name = Keyboard, Price = 120000 }

일반 class에서도 같은 기능을 구현할 수 있습니다. 다만 생성자, 프로퍼티, 동등성 비교, 해시 코드와 출력 형식을 직접 작성해야 합니다.

record는 데이터 중심 형식에 반복해서 필요한 기능을 컴파일러가 생성한다는 점에서 차이가 있습니다.

with 표현식으로 기존 값을 복사하기

recordwith 표현식으로 기존 인스턴스의 값을 복사하고 일부 프로퍼티만 변경한 새 인스턴스를 만들 수 있습니다.

public record Product(string Name, decimal Price); var original = new Product("Keyboard", 120000m); var discounted = original with { Price = 99000m }; Console.WriteLine(original.Price); Console.WriteLine(discounted.Price); Console.WriteLine(ReferenceEquals(original, discounted));

예상 결과는 다음과 같습니다.

120000 99000 False

with 표현식은 original을 수정하지 않습니다. 기존 값을 바탕으로 새로운 Product 인스턴스를 만듭니다.

이 방식은 특정 시점의 데이터를 유지해야 하는 응답 모델, 설정 정보, 이벤트 메시지와 잘 맞습니다.

일반 class에도 직접 복사 생성자나 복사 메서드를 구현할 수 있습니다.

public sealed class Product { public Product(string name, decimal price) { Name = name; Price = price; } public string Name { get; } public decimal Price { get; } public Product WithPrice(decimal price) { return new Product(Name, price); } }

하지만 이 복사 방식은 클래스마다 직접 설계하고 유지해야 합니다.

record가 항상 불변인 것은 아닙니다

위치 기반 record class가 생성하는 프로퍼티는 기본적으로 init 접근자를 사용합니다.

public record User(string Name, int Age);

따라서 객체를 생성한 뒤에는 다음과 같이 값을 직접 변경할 수 없습니다.

var user = new User("Kim", 30); // 컴파일 오류 user.Age = 31;

하지만 record 문법 자체가 모든 상태 변경을 금지하는 것은 아닙니다. 프로퍼티에 set 접근자를 선언하면 값을 변경할 수 있습니다.

public record MutableUser { public string Name { get; set; } = string.Empty; public int Age { get; set; } }
var user = new MutableUser { Name = "Kim", Age = 30 }; user.Age = 31;

따라서 record는 항상 불변이다라고 설명하면 정확하지 않습니다. 위치 기반 레코드의 기본 프로퍼티가 init 전용일 뿐이며, 개발자가 변경 가능한 멤버를 선언할 수 있습니다.

값 동등성을 사용하는 객체의 프로퍼티를 변경하면 동등성 비교와 해시 코드 결과도 달라질 수 있습니다. DictionaryHashSet의 키로 사용하는 레코드는 동등성에 영향을 주는 값을 변경하지 않는 편이 안전합니다.

with 표현식은 깊은 복사가 아닙니다

with 표현식은 참조 형식 프로퍼티가 가리키는 객체까지 새로 만들지 않습니다. 내부 참조는 원본과 복사본이 공유할 수 있습니다.

public record CartSnapshot( string Owner, List<string> Items ); var original = new CartSnapshot( "Kim", new List<string> { "Keyboard" } ); var copied = original with { Owner = "Lee" }; copied.Items.Add("Mouse"); Console.WriteLine(original.Items.Count); Console.WriteLine(copied.Items.Count);

예상 결과는 두 객체 모두 2입니다.

2 2

originalcopied는 서로 다른 CartSnapshot 인스턴스입니다. 하지만 두 인스턴스의 Items는 같은 List<string> 객체를 참조합니다.

내부 데이터까지 독립적으로 복사해야 한다면 해당 컬렉션도 새로 만들어야 합니다.

var copied = original with { Owner = "Lee", Items = new List<string>(original.Items) };

recordwith를 사용했다는 이유만으로 깊은 불변성이 보장되지는 않습니다.

class가 적합한 경우

객체의 데이터보다 개별 인스턴스의 정체성과 수명이 중요하면 class가 적합합니다.

장바구니 객체는 항목을 추가하고 제거하면서 상태가 계속 변합니다.

public sealed class ShoppingCart { private readonly List<string> _items = new(); public IReadOnlyList<string> Items => _items; public void AddItem(string item) { _items.Add(item); } public bool RemoveItem(string item) { return _items.Remove(item); } }

내용이 같은 장바구니 두 개가 있다고 해서 같은 장바구니인 것은 아닙니다.

var cartA = new ShoppingCart(); var cartB = new ShoppingCart(); cartA.AddItem("Keyboard"); cartB.AddItem("Keyboard"); Console.WriteLine(cartA == cartB);

예상 결과는 False입니다.

False

두 장바구니에는 같은 항목이 들어 있지만 서로 다른 수명과 상태를 가진 객체입니다. 이런 객체에 값 기반 동등성을 자동으로 적용하면 오히려 의미가 불분명해질 수 있습니다.

다음 조건에서는 class를 우선 검토합니다.

  • 객체마다 고유한 정체성이 있습니다.
  • 객체 상태가 수명 동안 계속 변경됩니다.
  • 데이터 저장보다 메서드와 동작이 중심입니다.
  • 같은 데이터를 가진 두 객체도 서로 다르게 취급해야 합니다.
  • 동등성을 일부 프로퍼티만으로 직접 정의해야 합니다.
  • 일반 클래스 기반 상속 구조가 필요합니다.

record가 적합한 경우

데이터의 조합 자체가 객체의 의미라면 record가 적합합니다.

다음 Address는 데이터가 모두 같다면 같은 주소로 판단할 수 있습니다.

public sealed record Address( string PostalCode, string City, string Street );
var addressA = new Address( "12345", "Seoul", "Example Road" ); var addressB = new Address( "12345", "Seoul", "Example Road" ); Console.WriteLine(addressA == addressB);

예상 결과는 True입니다.

True

다음 조건에서는 record를 우선 검토합니다.

  • 데이터 저장과 전달이 형식의 주된 목적입니다.
  • 포함된 값이 같으면 같은 데이터로 판단해야 합니다.
  • 생성 후 값을 자주 변경하지 않습니다.
  • 일부 값만 변경한 복사본이 필요합니다.
  • 동등성 비교와 해시 코드를 직접 구현하고 싶지 않습니다.
  • 로그와 디버깅에서 프로퍼티 값이 표시되는 출력이 필요합니다.

DTO, API 응답 모델, 명령 메시지, 이벤트 데이터, 설정 스냅샷과 값 객체가 대표적인 후보입니다.

모든 DTO를 반드시 record로 선언해야 한다는 의미는 아닙니다. 직렬화 도구, 프레임워크의 생성 조건, 변경 가능한 프로퍼티 요구사항을 함께 확인해야 합니다.

record와 class의 상속 차이

record class는 다른 record class를 상속할 수 있습니다.

public abstract record User(string Name); public sealed record AdminUser( string Name, string Role ) : User(Name);

하지만 record가 일반 class를 상속하거나 일반 classrecord를 상속할 수는 없습니다.

다음 코드는 허용되지 않습니다.

public class UserBase { } // 컴파일 오류 public record AdminUser(string Name) : UserBase;

반대 방향도 허용되지 않습니다.

public record User(string Name); // 컴파일 오류 public class AdminUser : User;

레코드의 동등성 비교와 복사 동작은 상속 계층 전체에서 일관되어야 합니다. 이 때문에 일반 클래스와 레코드를 하나의 상속 계층에 섞을 수 없습니다.

인터페이스는 recordclass 모두 구현할 수 있습니다.

public interface IIdentifiable { string Id { get; } } public record UserRecord(string Id) : IIdentifiable; public class UserClass : IIdentifiable { public required string Id { get; init; } }

record와 class 비교

비교 항목record classclass
형식 종류참조 형식참조 형식
기본 동등성포함된 값을 기준으로 비교같은 인스턴스인지 비교
간결한 위치 문법지원일반 클래스 선언에서는 지원하지 않음
with 표현식기본 지원직접 복사 방식을 구현해야 함
기본 ToString()멤버 이름과 값을 출력일반적으로 형식 이름을 출력
불변성위치 프로퍼티는 기본적으로 init 전용프로퍼티 설계에 따라 결정
변경 가능한 멤버선언 가능선언 가능
상속다른 레코드만 상속 가능다른 클래스를 상속 가능
적합한 대상데이터, 값 객체, 스냅샷상태, 동작, 정체성이 있는 객체

피해야 하는 선택 방법

코드를 짧게 만들기 위해 모든 class를 record로 변경하기

record는 단순한 보일러플레이트 제거 문법이 아닙니다. 기본 동등성의 의미가 달라집니다.

기존 클래스가 참조 동등성을 전제로 컬렉션이나 캐시에서 사용되고 있다면 record로 변경한 뒤 예상하지 못한 중복 처리나 비교 결과가 발생할 수 있습니다.

record는 불변이라고 가정하기

set 프로퍼티나 변경 가능한 컬렉션을 포함한 레코드는 상태가 변경될 수 있습니다. init 접근자도 내부 참조 객체의 변경까지 막지는 않습니다.

불변성이 필요하다면 프로퍼티 접근자뿐 아니라 내부에 포함된 컬렉션과 객체의 변경 가능성도 확인해야 합니다.

with 표현식을 깊은 복사로 사용하기

with 표현식은 기본적으로 얕은 복사를 수행합니다. 배열, 리스트와 사용자 정의 참조 객체는 원본과 복사본이 공유할 수 있습니다.

독립된 복사본이 필요하면 내부 객체의 복사 방식도 직접 설계해야 합니다.

값이 같다는 이유만으로 같은 엔티티라고 판단하기

데이터베이스의 서로 다른 행이나 서로 다른 주문은 현재 프로퍼티가 우연히 같아도 별도의 정체성을 가질 수 있습니다.

인스턴스의 수명과 식별자가 중심인 모델에는 class가 더 명확합니다.

어떤 형식을 선택해야 하는가

선택 기준은 데이터가 같은가같은 객체인가 중 어떤 질문이 더 중요한지입니다.

두 인스턴스의 프로퍼티가 같으면 같은 값으로 취급해야 한다면 record를 선택합니다.

public sealed record Money( decimal Amount, string Currency );

같은 금액과 통화를 가진 두 Money 인스턴스는 같은 값으로 판단할 수 있습니다.

반대로 프로퍼티가 같아도 서로 다른 수명과 정체성을 가진다면 class를 선택합니다.

public sealed class BankAccount { public required string AccountId { get; init; } public decimal Balance { get; private set; } public void Deposit(decimal amount) { Balance += amount; } }

잔액이 같은 두 계좌는 같은 계좌가 아닙니다. 계좌는 데이터 조합보다 식별자와 상태 변화가 중요합니다.

다음 규칙으로 정리할 수 있습니다.

  1. 값의 조합이 객체의 의미라면 record를 사용합니다.
  2. 개별 인스턴스의 정체성이 중요하면 class를 사용합니다.
  3. 생성 후 데이터 변경이 드물고 복사본이 필요하면 record를 검토합니다.
  4. 상태 변경과 비즈니스 동작이 중심이면 class를 검토합니다.
  5. 레코드 내부에 변경 가능한 객체가 있다면 얕은 복사와 동등성 영향을 확인합니다.
  6. 기존 클래스를 레코드로 변경할 때는 컬렉션, 캐시와 테스트의 비교 동작을 다시 검증합니다.

동작 확인

다음 명령으로 콘솔 프로젝트를 생성합니다.

dotnet new console -n RecordClassSample cd RecordClassSample

Program.cs를 다음 코드로 교체합니다.

var classA = new UserClass("Kim", 30); var classB = new UserClass("Kim", 30); Console.WriteLine($"class ==: {classA == classB}"); var recordA = new UserRecord("Kim", 30); var recordB = new UserRecord("Kim", 30); var recordC = recordA with { Age = 31 }; Console.WriteLine($"record ==: {recordA == recordB}"); Console.WriteLine( $"record ReferenceEquals: {ReferenceEquals(recordA, recordB)}" ); Console.WriteLine($"original age: {recordA.Age}"); Console.WriteLine($"copied age: {recordC.Age}"); public sealed class UserClass { public UserClass(string name, int age) { Name = name; Age = age; } public string Name { get; } public int Age { get; } } public sealed record UserRecord(string Name, int Age);

다음 명령으로 실행합니다.

dotnet run

예상 결과는 다음과 같습니다.

class ==: False record ==: True record ReferenceEquals: False original age: 30 copied age: 31

현재 작성 환경에는 .NET SDK가 설치되어 있지 않아 예제 코드를 직접 실행하지 못했습니다. 발행 전에 실제 사용하는 SDK에서 dotnet builddotnet run으로 확인해야 합니다. [실행 검증 필요]

정리

recordclass는 모두 참조 형식이지만 객체를 비교하는 기본 기준이 다릅니다.

데이터가 같으면 같은 값으로 판단해야 하고 생성 후 변경이 적다면 record를 우선 검토합니다. 객체마다 고유한 정체성과 수명이 있고 상태와 동작이 계속 변한다면 class가 더 적합합니다.

record를 선택할 때는 변경 가능한 프로퍼티, 내부 참조 객체와 with 표현식의 얕은 복사를 확인해야 합니다. 기존 classrecord로 변경할 때는 동등성 비교가 달라진다는 점을 먼저 검증해야 합니다.