← C# 강의 목록으로
모던 C#
모던 C# · 선수: 클래스/프로퍼티

22. record 와 init

C# 9 의 record 와 init 접근자는 '바뀌지 않는 값 객체' 를 짧고 안전하게 만드는 도구입니다. with 식·값 동등성·record struct 까지 살펴봅니다.

C#.NET 8recordinit
소요 시간
약 1~1.5시간
난이도
📊 중급
선수 조건
🎯 클래스와 프로퍼티
결과물
C# 9 의 record 와 init 접근자는 '바뀌지 않는 값 객체' 를 짧고 안전하게 만드는 도구입니다. with 식·값 동등성·record struct 까지 살펴봅니다.

이 강의에서 배우는 것

  • 1`record` 의 자동 생성 멤버(생성자, init 프로퍼티, Equals, ToString, Deconstruct) 를 안다
  • 2`record class` 와 `record struct` 의 차이를 안다
  • 3`with` 식으로 **비파괴 복사(non-destructive mutation)** 를 한다
  • 4`init` only setter 의 의미와 사용처를 안다
  • 5**값 동등성(value equality)** 이 무엇이고 왜 유용한지 안다

소개

`record` 는 **"값 자체가 의미인" 데이터 타입을 한 줄로** 만들어 주는 키워드입니다. `init` only setter, value equality, `with` 식 같은 모던 기능을 묶어, DTO·이벤트·결과 객체 작성을 비약적으로 줄여 줍니다.

핵심 개념

1) `record` 한 줄의 위력

csharp
record Person(string Name, int Age);

이 한 줄이 자동으로 만들어 주는 것:

  • `public Person(string name, int age)` 생성자
  • `public string Name { get; init; }`, `public int Age { get; init; }` 프로퍼티 (둘 다 init only)
  • `Equals`, `GetHashCode` — **값 기반**
  • `ToString` — `Person { Name = Alice, Age = 30 }` 형식
  • `==`, `!=` 연산자 — 값 비교
  • `Deconstruct` — `var (n, a) = p;`

2) `record class` vs `record struct`

`record class` (기본)`record struct`
카테고리참조 타입 (힙)값 타입 (스택/인라인)
복사참조 복사값 복사
동등성자동 값 동등성자동 값 동등성
크기큰 객체 OK작은 데이터 (16~24 B) 권장
DTO, 메시지`Point`, `Vector`

`record struct` 는 일반적으로 `readonly record struct` 로 선언해 불변성을 강제합니다.

csharp
record class    Person(string Name, int Age);
readonly record struct Point(int X, int Y);

3) `with` 식 — 비파괴 복사

`with` 는 "원본은 그대로 두고, 일부 프로퍼티만 바꾼 새 인스턴스" 를 만듭니다.

csharp
Person p1 = new("Alice", 30);
Person p2 = p1 with { Age = 31 };   // p1 은 그대로, p2 가 새 객체

`record` 가 컴파일러에 의해 자동 생성한 복제 메서드(`<Clone>$`) 를 사용합니다. 일반 `class` 에서는 `with` 를 못 씁니다.

4) `init` only setter

csharp
class Person
{
    public string Name { get; init; } = "";
}
  • **객체 초기화 시점에만** 대입 가능 (`new Person { Name = "Alice" }`)
  • 객체가 만들어진 후엔 readonly
  • record 가 아닌 일반 class 에서도 사용 가능

5) 값 동등성과 컬렉션

`record` 의 `Equals`/`GetHashCode` 가 자동으로 값 기반이라, **`HashSet<Person>` / `Dictionary<Person, …>` 키** 로 그대로 사용할 수 있습니다.

csharp
HashSet<Coord> set = [new(1,2), new(1,2)];   // Count == 1

`class` 였다면 두 객체가 다른 참조라 둘 다 들어갑니다.

핵심 예제

예제 1 — `RecordBasics` : record 한 줄의 위력

csharp
Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Console.WriteLine($"p1 == p2 ? {p1 == p2}");   // True
Console.WriteLine(p1);                          // ToString 자동
var (name, age) = p1;                           // 분해 자동

**실행 결과**

text
p1 == p2 ? True
p1 == p3 ? False
Person { Name = Alice, Age = 30 }
name=Alice, age=30

**메모:** 한 줄 선언으로 5가지 자동 멤버가 생깁니다. DTO/메시지 객체 작성이 진짜 짧아집니다.

예제 2 — `WithExpression` : 비파괴 복사

csharp
Person original = new("Alice", 30, "Seoul");
Person aged     = original with { Age = 31 };
Person moved    = original with { City = "Busan", Age = 31 };

**실행 결과**

text
original: Person { Name = Alice, Age = 30, City = Seoul }
aged    : Person { Name = Alice, Age = 31, City = Seoul }
moved   : Person { Name = Alice, Age = 31, City = Busan }

original 은 여전히 30세, Seoul

**메모:** 함수형 스타일의 "상태 변경 = 새 객체 생성" 패턴. 동시성 코드와 LINQ 파이프라인에서 특히 빛납니다.

예제 3 — `RecordVsClass` : class 와 struct

csharp
internal sealed record PointClass(int X, int Y);
internal readonly record struct PointStruct(int X, int Y);

Console.WriteLine($"record class:  c1 == c2 ? {c1 == c2}  (참조? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");

**실행 결과**

text
record class:  c1 == c2 ? True  (참조? False)
record struct: s1 == s2 ? True
s1 = PointStruct { X = 1, Y = 2 }
shifted = PointStruct { X = 99, Y = 2 }, s1 = PointStruct { X = 1, Y = 2 }

**메모:** 둘 다 값 동등성이라 `==` 결과는 같습니다. 차이는 메모리 배치 — `struct` 는 스택/인라인에 통째 저장되므로 작은 데이터에 적합.

예제 4 — `InitOnly` : init only setter

csharp
internal sealed class Person
{
    public string Name { get; init; } = "";
    public int Age { get; init; }
}

Person p = new() { Name = "Alice", Age = 30 };
// p.Name = "Bob";  // 컴파일 에러 — init 은 객체 초기화 후 변경 불가

**실행 결과**

text
Alice, 30세
C# 입문 (320p)

**메모:** `init` 은 record 의 전용 기능이 아닙니다. 일반 class 에서도 "생성 후 불변" 을 만들 때 쓸 수 있습니다. `required` 와 결합하면 "반드시 지정" 도 강제됩니다.

예제 5 — `ValueEquality` : 값 동등성 + 컬렉션

csharp
internal sealed record Coord(int X, int Y);

Coord a = new(1, 2);
Coord b = new(1, 2);
HashSet<Coord> set = [a, b, new(3, 4)];   // a, b 는 같으니 중복 제거

**실행 결과**

text
a.Equals(b) = True
set.Count = 2
ToString: Coord { X = 1, Y = 2 }
a == b : True
a == c : False

**메모:** `Dictionary<Coord, ...>` 의 키로 써도 정확히 동작합니다. `class` 였다면 `Equals`/`GetHashCode` 를 직접 오버라이드해야 했을 일입니다.

전체 예제 코드 (src/)

src/InitOnly/InitOnly.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/InitOnly/Program.cs

csharp
// init only setter — 객체 초기화 시점에만 대입 가능, 이후엔 readonly.
// 일반 class 에도 적용 가능 (record 전용 아님).

Person p = new()
{
    Name = "Alice",      // 객체 초기화 구문 안에서는 init setter 호출 가능
    Age = 30,
};

Console.WriteLine($"{p.Name}, {p.Age}세");

// p.Name = "Bob";        // 컴파일 에러: init 은 객체 생성 후 변경 불가

// required (07편) 와 결합하면 "반드시 지정" 을 강제할 수 있다.
Book b = new() { Title = "C# 입문", Pages = 320 };
Console.WriteLine(b);

internal sealed class Person
{
    public string Name { get; init; } = "";
    public int Age { get; init; }
}

internal sealed class Book
{
    public required string Title { get; init; }   // required → 초기화 시 필수
    public int Pages { get; init; }
    public override string ToString() => $"{Title} ({Pages}p)";
}

src/RecordBasics/Program.cs

csharp
// record 한 줄이면 — 생성자, 프로퍼티(init only), Equals, GetHashCode, ToString 까지 자동 생성.
Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Person p3 = new("Bob", 25);

// 값 동등성 — 안에 든 값이 같으면 동일하다고 본다 (class 였다면 참조 비교라 false).
Console.WriteLine($"p1 == p2 ? {p1 == p2}");   // True
Console.WriteLine($"p1 == p3 ? {p1 == p3}");   // False

// ToString 자동 생성 — 디버깅이 즐겁다.
Console.WriteLine(p1);

// 분해(deconstruction) 도 자동.
var (name, age) = p1;
Console.WriteLine($"name={name}, age={age}");

internal sealed record Person(string Name, int Age);

src/RecordBasics/RecordBasics.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/RecordVsClass/Program.cs

csharp
// record class (기본) : 참조 타입 + 값 동등성
// record struct          : 값 타입 + 값 동등성

PointClass  c1 = new(1, 2);
PointClass  c2 = new(1, 2);
PointStruct s1 = new(1, 2);
PointStruct s2 = new(1, 2);

Console.WriteLine($"record class:  c1 == c2 ? {c1 == c2}  (참조? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");

// struct 는 값 복사 → 함수에 넘기면 통째 복제.
void Shift(PointStruct p) { /* p.X = 99; (readonly 라 불가) */ }
Shift(s1);
Console.WriteLine($"s1 = {s1}");

// 두 record 모두 ToString / Equals / GetHashCode 자동 생성, with 식도 동일하게 사용 가능.
PointStruct shifted = s1 with { X = 99 };
Console.WriteLine($"shifted = {shifted}, s1 = {s1}");

internal sealed record PointClass(int X, int Y);
internal readonly record struct PointStruct(int X, int Y);

src/RecordVsClass/RecordVsClass.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/ValueEquality/Program.cs

csharp
// record 가 자동 생성해 주는 것들 : Equals, GetHashCode, ToString, ==, !=, Deconstruct.
Coord a = new(1, 2);
Coord b = new(1, 2);
Coord c = new(3, 4);

// Equals — 값 비교
Console.WriteLine($"a.Equals(b) = {a.Equals(b)}");

// GetHashCode — 같은 값이면 같은 해시 → HashSet, Dictionary 키로 그대로 사용 가능
HashSet<Coord> set = [a, b, c];   // b 는 a 와 같다고 보아 중복 제거
Console.WriteLine($"set.Count = {set.Count}");

// ToString — Coord { X = 1, Y = 2 } 형태로 자동 출력
Console.WriteLine($"ToString: {a}");

// == 연산자도 값 비교
Console.WriteLine($"a == b : {a == b}");
Console.WriteLine($"a == c : {a == c}");

internal sealed record Coord(int X, int Y);

src/ValueEquality/ValueEquality.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/WithExpression/Program.cs

csharp
// `with` 식 — record 의 일부만 바꿔서 "새 인스턴스" 를 만든다 (비파괴 복사).
Person original = new("Alice", 30, "Seoul");
Person aged     = original with { Age = 31 };
Person moved    = original with { City = "Busan", Age = 31 };

Console.WriteLine($"original: {original}");
Console.WriteLine($"aged    : {aged}");
Console.WriteLine($"moved   : {moved}");

// 원본은 그대로다.
Console.WriteLine($"\noriginal 은 여전히 {original.Age}세, {original.City}");

internal sealed record Person(string Name, int Age, string City);

src/WithExpression/WithExpression.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

자주 하는 실수

  1. record 의 프로퍼티는 기본이 `init` only 라는 사실을 잊고, 만들고 나서 `p.Name = ...` 으로 바꾸려 함 — 컴파일 에러.
  2. `with` 식을 일반 `class` 에 시도 — record 전용입니다.
  3. `record struct` 를 `readonly` 없이 선언 — 변경 가능 struct 는 미묘한 버그의 원인. 기본적으로 `readonly record struct` 권장.
  4. 큰 객체를 `record struct` 로 — 매번 통째 복사라 오히려 느려집니다. 보통 16바이트 이하만 struct.
  5. record 안에 가변 컬렉션(`List<T>`) 을 그대로 두기 — 안의 리스트는 여전히 변경 가능. 정말 불변이 필요하면 `IReadOnlyList<T>` 노출.

정리

  • `record` 한 줄로 생성자/프로퍼티/Equals/ToString/Deconstruct 가 자동 생성
  • 값 동등성 덕분에 HashSet/Dictionary 키로 그대로 쓸 수 있음
  • `with` 식으로 비파괴 복사 — 함수형 스타일에 적합
  • `init` 은 record 전용이 아니라 일반 class 에서도 사용 가능
  • 작고 불변인 값 묶음은 `readonly record struct`, 그 외는 `record class`

과제

**과제 - 22. Record 와 init**

문제 1 — Book 레코드

  • 프로젝트 폴더: `Homework01/`
  • 핵심 개념: `record` 선언, `with` 식, 값 동등성

요구사항

  • `record Book(string Title, string Author, int Pages);` 를 정의한다.
  • `Book original = new("CLR via C#", "Jeffrey Richter", 1100);` 을 만든다.
  • `with` 식을 사용해 다음을 만든다.
  • `revised` : `original` 에서 `Pages` 만 1200 으로 바꾼 새 인스턴스.
  • `translated` : `original` 에서 `Title` 을 "CLR via C# (한국어판)" 으로 바꾼 새 인스턴스.
  • 세 인스턴스를 모두 출력한다 (record 의 자동 `ToString` 사용).
  • `original == revised`, `original == new("CLR via C#", "Jeffrey Richter", 1100)` 두 값을 출력한다 (각각 False, True 가 나와야 함).

예상 출력

text
original  : Book { Title = CLR via C#, Author = Jeffrey Richter, Pages = 1100 }
revised   : Book { Title = CLR via C#, Author = Jeffrey Richter, Pages = 1200 }
translated: Book { Title = CLR via C# (한국어판), Author = Jeffrey Richter, Pages = 1100 }

original == revised        ? False
original == 같은 값의 새 객체 ? True

힌트

  • `record Book(string Title, string Author, int Pages);` 만으로 충분.
  • `var revised = original with { Pages = 1200 };`
  • 두 record 인스턴스의 `==` 는 값 비교.

문제 2 — Vector 레코드 구조체

  • 프로젝트 폴더: `Homework02/`
  • 핵심 개념: `readonly record struct`, 메서드 추가, 연산자 오버로드는 선택

요구사항

  • `readonly record struct Vector(double X, double Y)` 를 정의한다.
  • 같은 record 안에 인스턴스 메서드 `Vector Add(Vector other)` 를 추가한다 — 두 벡터를 더한 새 `Vector` 반환.
  • 같은 record 안에 인스턴스 메서드 `double Length()` 를 추가한다 — `sqrt(X*X + Y*Y)`.
  • 다음을 출력한다.
  • `v1 = (3, 4)`, `v2 = (1, 2)`.
  • `v1.Add(v2)` 결과.
  • `v1.Length()` 값 (5).
  • `v1 == new Vector(3, 4)` (True).

예상 출력

text
v1 = Vector { X = 3, Y = 4 }
v2 = Vector { X = 1, Y = 2 }
v1 + v2 = Vector { X = 4, Y = 6 }
|v1| = 5
v1 == new Vector(3, 4) ? True

힌트

  • record 본문은 `record struct Vector(double X, double Y) { ... 메서드 ... }` 처럼 중괄호로 확장 가능.
  • `Math.Sqrt`.
  • `readonly record struct` 이면 메서드도 자동으로 readonly 컨텍스트.

정답 확인

직접 풀어 본 후 [`answer/`](./answer/) 폴더의 정답과 비교해 보세요.

정답 (answer/)

homework/answer/Homework01/Homework01.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

homework/answer/Homework01/Program.cs

csharp
Book original = new("CLR via C#", "Jeffrey Richter", 1100);

// with 식 — 일부만 바꾼 새 인스턴스 생성. 원본은 변경되지 않는다.
Book revised    = original with { Pages = 1200 };
Book translated = original with { Title = "CLR via C# (한국어판)" };

Console.WriteLine($"original  : {original}");
Console.WriteLine($"revised   : {revised}");
Console.WriteLine($"translated: {translated}");

Console.WriteLine();
Console.WriteLine($"original == revised        ? {original == revised}");
Console.WriteLine($"original == 같은 값의 새 객체 ? {original == new Book("CLR via C#", "Jeffrey Richter", 1100)}");

internal sealed record Book(string Title, string Author, int Pages);

homework/answer/Homework02/Homework02.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

homework/answer/Homework02/Program.cs

csharp
Vector v1 = new(3, 4);
Vector v2 = new(1, 2);

Console.WriteLine($"v1 = {v1}");
Console.WriteLine($"v2 = {v2}");
Console.WriteLine($"v1 + v2 = {v1.Add(v2)}");
Console.WriteLine($"|v1| = {v1.Length()}");
Console.WriteLine($"v1 == new Vector(3, 4) ? {v1 == new Vector(3, 4)}");

// readonly record struct — 작은 값 타입 + 자동 값 동등성 + 불변
internal readonly record struct Vector(double X, double Y)
{
    public Vector Add(Vector other) => new(X + other.X, Y + other.Y);
    public double Length() => Math.Sqrt(X * X + Y * Y);
}

직접 해 보기

bash
cd src/RecordBasics && dotnet run
cd ../WithExpression && dotnet run
cd ../RecordVsClass && dotnet run
cd ../InitOnly && dotnet run
cd ../ValueEquality && dotnet run

다음 단원

축하합니다 — **C# 입문 22편 트랙 완주**입니다.

이제 언어와 표준 라이브러리는 충분히 익혔으니, 만들고 싶은 분야에 따라 후속 트랙으로 이어가세요.

트랙추천 시작점
**ASP.NET Core 8**웹 API · MVC · Minimal API
**Entity Framework Core 8**DB 매핑과 LINQ-to-SQL
**Unity** (C# 게임 개발)2D/3D 게임, MonoBehaviour 라이프사이클
**.NET MAUI**모바일/데스크톱 크로스플랫폼 UI
**Blazor**C# 으로 작성하는 웹 프론트엔드

[← 강의 메인으로 돌아가기](../../README.md)

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗