1. 왜 record가 필요했을까?
C# (그리고 .NET 전체)에서는 오랫동안 class(class)와 struct(struct)가 기본 빌딩 블록이었어. 근데 이 둘 다 완벽하진 않아. 클래스는 참조 타입이고, 변경 가능하고, 비교는 거의 항상 참조로 해 (아주 드물게 예외 있음). 구조체는 값 타입(전달할 때 복사됨)이고, 기본적으로 바이트 단위로 비교되고, 대부분 변경 가능해 (나중에 readonly struct가 나오기 전까지는).
근데 만약 네가 간단하고 자연스러운 데이터 기록이 필요하고, 값으로 비교하기 편하고, 빠르게 복제할 수 있고, 누가 어디서 객체를 바꿨는지 신경 쓰고 싶지 않다면 — 직접 구현하거나 ValueTuple이나 System.Tuple 같은 라이브러리를 써야 했어. 근데 이런 것들은 그렇게 예쁘거나 직관적이지 않아.
그래서 C# 9에서 record가 나왔어 — 선언이 짧고, 불변성 보장도 쉽고, 값 비교에 최적화된 데이터 타입이지.
2. 네 가지 타입 한눈에 보기
| class | struct | record | record struct | |
|---|---|---|---|---|
| 카테고리 | 참조 타입 | 값 타입 | 참조 타입 | 값 타입 |
| 변경 가능성 | 기본적으로 가능 | 기본적으로 가능 | 기본적으로 불가 (init) | 기본적으로 불가 (init) |
| 비교 | 참조로 (==) | 값으로 (필드) | 값으로 (필드/프로퍼티) | 값으로 (필드) |
| 복제 | 직접 구현해야 함 | 직접 구현해야 함 | 내장 지원 (with) | 내장 지원 (with) |
| 상속 | 가능 | 불가 | 가능 | 불가 |
| 불변성 | 직접 구현해야 함 | 직접 구현해야 함 | 아주 쉽게 구현 가능 | 아주 쉽게 구현 가능 |
| 문법 | 가장 길다 | 짧다 | 가장 짧다 (포지셔널) | 꽤 짧다 |
| 컬렉션에서 사용 | 참조 | 복사 | 참조 | 복사 |
비주얼 다이어그램
+----------------+ +----------------+ +--------------------+
| class | | struct | | record |
+----------------+ +----------------+ +--------------------+
| Reference Type | | Value Type | | Reference Type |
| Mutable | | Mutable | | Immutable (init) |
| == : Reference | | == : By Fields | | == : By Value |
+----------------+ +----------------+ +--------------------+
3. record, class, struct의 본질적인 차이
메모리에서의 동작: 참조냐 값이냐?
- class와 record는 참조 타입이야. 함수에 넘기면 객체의 참조가 복사돼.
- struct와 record struct는 값 타입이야. 항상 바이트 단위로 복사돼 (명시적으로 참조로 넘기지 않는 한).
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; }
record PointRecord(int X, int Y);
record struct PointRecordStruct(int X, int Y);
void Demo()
{
var pc = new PointClass { X = 1, Y = 2 };
var ps = new PointStruct { X = 1, Y = 2 };
var pr = new PointRecord(1, 2);
var prs = new PointRecordStruct(1, 2);
ChangeY(pc); // pc.Y 바뀐다!
ChangeY(ps); // ps.Y 안 바뀜 — 복사!
ChangeY(pr); // pr.Y 바뀐다!
ChangeY(prs); // prs.Y 안 바뀜 — 복사!
}
void ChangeY(dynamic p) { p.Y = 99; }
여기서 dynamic이 마법처럼 보일 수도 있는데 — 그냥 예시를 위해 쓴 거야. struct로 작업할 때 값이 안 바뀌고, class나 record(참조 타입)로 작업할 때는 값이 바뀌는 걸 보여주려고.
비교: 두 객체가 같은지 어떻게 알지?
- class: 참조로 비교함 (== — 메모리에서 같은 객체일 때만 true), Equals를 오버라이드하지 않으면.
- struct: 모든 필드 값으로 비교함 (기본값).
- record: 주 생성자에 지정된 모든 필드/프로퍼티 값으로 비교함.
class Foo { public int A; public int B; }
record Bar(int A, int B);
var foo1 = new Foo { A = 42, B = 1 };
var foo2 = new Foo { A = 42, B = 1 };
var bar1 = new Bar(42, 1);
var bar2 = new Bar(42, 1);
Console.WriteLine(foo1 == foo2); // False! 다른 객체
Console.WriteLine(bar1 == bar2); // True! 값이 같다
재밌는 사실:
record struct는 더 멋져: struct처럼 "값으로" 비교하면서도 record의 문법과 기능을 쓸 수 있어.
4. 불변성: 누가 얼마나 보장해줄까?
객체의 안전성 비교:
- class: 기본적으로 쉽게 변경 가능, 모든 필드를 readonly로 안 하면.
- struct: 마찬가지지만 readonly struct로 선언하면 필드/프로퍼티 변경 불가.
- record: 보통 필드는 init으로 선언해서 생성자나 객체 이니셜라이저(with)에서만 값 설정 가능. 데이터 안전하게 전달할 때 편함.
- record struct: 마찬가지로 readonly record struct로 만들면 값 타입의 불변성과 record의 장점을 동시에 얻을 수 있음.
record Person(string Name, int Age);
var p1 = new Person("알렉세이", 23);
// p1.Age = 24; // 에러! init만 가능
var p2 = p1 with { Age = 24 }; // 가능! 새로운 값으로 복사본 생성
프로젝트가 크고 엔티티 객체가 수십 개 있을 때, record를 쓰면 "누가 어디서 필드 바꿔서 다 망가졌다" 같은 버그를 피할 수 있어.
5. 문법: 선언은 어떻게 생겼고, 헷갈리지 않으려면?
// class
public class Product
{
public int Id { get; init; }
public string Name { get; init; }
}
// struct
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
// record
public record Product(int Id, string Name);
// record struct
public record struct Point(int X, int Y);
보다시피 record 타입이 제일 짧아. 생성자, 분해자, 동등성, ToString() 등등을 "바로" 쓸 수 있어.
| 문법 | 자동으로 제공되는 기능 | 상속 가능 여부 |
|---|---|---|
| class | 거의 없음 | 가능 |
| struct | 거의 없음 | 불가 |
| record | ToString, Equals, Deconstruct, with | 가능 |
| record struct | ToString, Equals, Deconstruct, with | 불가 |
6. 복제와 with 연산자
record와 record struct만 with 연산자를 쓸 수 있어서, 일부 프로퍼티만 바꿔서 객체를 쉽게 복제할 수 있어.
record User(string Name, int Age);
var user1 = new User("이리나", 28);
var user2 = user1 with { Age = 29 }; // user2.Name == "이리나", user2.Age == 29
class(class)의 경우 복사 메서드를 직접 구현해야 하고, 필드 하나라도 빼먹으면 버그가 생길 수 있어.
7. 상속: 누가 누구를 상속할 수 있나
- class: 표준 상속 지원 (클래스 계층, 가상 메서드, 추상화 등).
- struct: 상속 불가 (인터페이스만 구현 가능).
- record: 상속 지원하지만 제한 있음 (record끼리만 상속 가능, class와는 불가).
- record struct: struct처럼 상속 불가.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // 가능!
class Vehicle { }
class Car : Vehicle { } // 가능!
// struct는 struct를 상속할 수 없음
상속에 대한 자세한 내용은 다음 레벨에서 :P
8. 실제로 어디에 어떤 타입을 쓸까?
- class: 동작이 많고, 수명 주기가 길고, 상태가 바뀌고, 계층 구조가 필요한 큰 객체 (예: 비즈니스 로직, UI 컴포넌트 등).
- struct: 작고 값만 중요한 객체, 빠른 복사, GC 없음, 오버헤드 최소화가 중요한 경우 (예: 좌표, 색상, 금액 등 빠르게 복제 가능한 것).
- record: DTO(Data Transfer Object), 파라미터 객체, 설정 파라미터, 불변 상태, 계산 결과 등 내용으로 비교하기 편한 객체.
- record struct: 값 타입이면서 불변성과 record의 동작이 필요한데, 힙 할당을 피하고 싶을 때.
9. 흔한 실수와 주의할 점
가끔 개발자들이 record가 그냥 "클래스 대체품"이라고 생각하는데, 그건 아니야! 객체가 살아있는 동안 상태가 바뀌어야 한다면 class를 써.
객체를 참조로 비교해야 한다면(예: 싱글턴 패턴이나 객체의 수명 주기가 중요할 때) — class를 써.
숫자나 좌표처럼 값처럼 동작해야 하는 객체라면 — struct 또는 record struct.
불변이고, 비교가 쉽고, 여러 계층에서 자주 전달되고, 컬렉션에 저장되고, 로깅/직렬화가 필요한 객체라면 — record가 딱이야.
또 한 가지: record 클래스를 읽기 전용 프로퍼티로 선언했더라도, 내부에 있는 객체가 변경 가능하면 그 필드는 여전히 바뀔 수 있어.
record Student(string Name, List<int> Grades);
var s1 = new Student("안톤", new List<int>() {5,5,5});
var s2 = s1 with { };
s2.Grades.Add(2); // 두 객체 모두 "2점"이 추가됨! s1.Grades == s2.Grades
GO TO FULL VERSION