[PART7.클래스와 객체 입문(5/21)] init 접근자 — 객체 초기화자의 편의성과 불변성을 동시에 (C# 9)
C# 9 이전엔 객체 초기화자(new T { X = 1 })와 불변성을 동시에 못 가졌다 / init은 set과 본문이 같지만 시그니처에 modreq([IsExternalInit]) 한정자가 박혀 컴파일러가 호출 자리를 객체 초기화자·생성자·with 식으로만 제한 / 백킹 필드는 private initonly (readonly와 동의) / JIT는 일반 setter처럼 인라이닝 / record의 모든 위치 매개변수는 자동으로 init 접근자 / required(C# 11)와 결합하면 누락 방지까지 / .NET 5 이상에서 사용 가능
목차
1. 옛 패턴의 두 가지 한계
C# 8까지 불변 객체를 만들면 두 가지 선택뿐이었습니다 — 둘 다 한계가 있었습니다.
한계 1 — { get; } + 생성자: 매개변수가 많아지면 가독성 폭망
public class Order
{
public string Id { get; }
public string CustomerName { get; }
public DateTime CreatedAt { get; }
public decimal Total { get; }
public string ShippingAddress { get; }
public Order(string id, string customerName, DateTime createdAt, decimal total, string shippingAddress)
{
Id = id; CustomerName = customerName; CreatedAt = createdAt;
Total = total; ShippingAddress = shippingAddress;
}
}
// 호출 — 매개변수 순서를 외워야 한다
var order = new Order("ABC", "Alice", DateTime.UtcNow, 99.99m, "Seoul");
// ↑ 이게 ID? 이름? 주소? 헷갈림
매개변수가 5개 넘어가면 순서를 외우기 어렵고 같은 타입 매개변수를 바꿔 넣어도 컴파일러가 못 잡습니다. 이름 있는 인자(id: "ABC")로 완화할 수 있지만 호출 측 책임.
한계 2 — { get; private set; } + 객체 초기화자: 진짜 불변성 X
public class Order
{
public string Id { get; private set; }
public string CustomerName { get; private set; }
// ...
}
var order = new Order { Id = "ABC", CustomerName = "Alice" }; // ✅ 가독성 좋음
order.Id = "X"; // ❌ 외부에서는 막힘 (private set)
public class Service
{
void DoSomething(Order o)
{
// 같은 어셈블리 안의 다른 코드는 못 막음 — Order 클래스 외부니까
}
}
// 하지만 Order 클래스 내부 다른 메서드는?
public class Order
{
public void Reset() { Id = "0"; } // ⚠️ 클래스 내부에서는 변경 가능
}
{ get; private set; }은 외부 코드는 막지만 클래스 내부 메서드에서는 자유롭게 변경 가능합니다. 진정한 불변성이 아니죠.
C# 9의 해결 — init 접근자
public class Order
{
public string Id { get; init; }
public string CustomerName { get; init; }
public DateTime CreatedAt { get; init; }
public decimal Total { get; init; }
public string ShippingAddress { get; init; }
}
var order = new Order
{
Id = "ABC",
CustomerName = "Alice",
CreatedAt = DateTime.UtcNow,
Total = 99.99m,
ShippingAddress = "Seoul"
}; // ✅ 가독성 좋음 + 명시적 매핑
order.Id = "X"; // ❌ 컴파일 오류
객체 초기화자의 편의성과 진정한 불변성을 동시에 얻었습니다. 이 글은 init이 IL 레벨에서 어떻게 이걸 가능하게 했는지, record·required와 어떻게 결합하는지를 다룹니다.
2. init 접근자란 무엇인가
비유 — 한 번만 쓸 수 있는 펜
set 접근자는 무한 잉크 볼펜입니다. 언제든 다시 써서 값을 바꿀 수 있죠. init 접근자는 한 번만 쓸 수 있는 인쇄 도장입니다 — 객체가 만들어지는 순간(생성자 또는 객체 초기화자) 한 번 도장을 찍고 나면, 그 후로는 다시 찍을 수 없습니다.
사용 자리 4곳
init setter는 다음 네 자리에서만 호출 가능합니다:
- 객체 초기화자:
new Order { Id = "ABC" } - 생성자: 인스턴스 자신의
.ctor본문 - 다른
init접근자: 같은 인스턴스의 다른initsetter 안에서 with식:record·record struct의 비파괴적 복사 (PART 7 #18)
다른 메서드, 외부 코드, 또는 객체가 만들어진 후에는 호출 불가 — 컴파일 오류가 납니다.
옛 옵션과의 비교
| 패턴 | 객체 초기화자 사용 | 외부 변경 | 클래스 내부 다른 메서드에서 변경 |
|---|---|---|---|
{ get; set; } |
✅ | ✅ | ✅ |
{ get; private set; } |
✅ | ❌ | ✅ |
{ get; } + 생성자 |
❌ (긴 매개변수) | ❌ | ❌ (생성자 안만) |
{ get; init; } |
✅ | ❌ | ❌ |
init이 객체 초기화자 사용 가능 + 진짜 불변성 두 마리 토끼를 잡습니다.
3. IL로 본 init — modreq([IsExternalInit]) 시그니처 트릭
public class Order
{
public string Id { get; init; } = "";
public string Name { get; set; } = "";
}
// ================ 백킹 필드 ================
.field private initonly string '<Id>k__BackingField' // ← initonly (= readonly)
.field private string '<Name>k__BackingField' // ← 일반 (initonly 없음)
// ================ get_Id — 일반 getter (init과 set 차이 없음) ================
.method public hidebysig specialname instance string get_Id()
{
IL_0000: ldarg.0
IL_0001: ldfld string Order::'<Id>k__BackingField'
IL_0006: ret
}
// ================ set_Id — init setter ================
.method public hidebysig specialname
instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit)
set_Id(string 'value') cil managed
// ^^^^^^^ 핵심! 시그니처에 modreq 한정자 박힘
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string Order::'<Id>k__BackingField' // ← 본문은 일반 set과 100% 동일
IL_0007: ret
}
// ================ set_Name — 일반 setter (modreq 없음) ================
.method public hidebysig specialname
instance void set_Name(string 'value') cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string Order::'<Name>k__BackingField'
IL_0007: ret
}
핵심 세 가지:
- 백킹 필드가
initonly— IL의initonly는 C# 필드의readonly와 동의. 생성자/필드 이니셜라이저에서만 쓰기 가능. - setter 시그니처에
modreq([IsExternalInit])한정자가 박힘 — 본문은 일반 setter와 100% 동일하지만, 시그니처가 다른 메서드처럼 보입니다. C# 컴파일러는 이 메서드를 호출하는 자리가 객체 초기화자/생성자/with식이 아니면 컴파일 오류를 발생시킵니다. - modreq의 의미 — "이 메서드를 호출하려는 컴파일러는 반드시
IsExternalInit한정자의 의미를 이해해야 한다"는 표식.modreq한정자를 모르는 컴파일러는 메서드를 호출조차 할 수 없습니다.
modreq(Required Modifier)란? CLR이 메서드 시그니처에 추가 의미를 부여하는 메커니즘. C# 컴파일러는modreq([IsExternalInit])한정자를 발견하면 호출 가능한 자리를 객체 초기화자·생성자·with식으로만 제한한다.
예시:init외에도in매개변수(modreq([In]))나unmanaged제약 등이 같은 메커니즘을 사용한다. C# 언어 자체에 새 키워드를 추가하지 않고 시그니처 트릭으로 새 의미를 만든 것.
IsExternalInit은 System.Runtime.CompilerServices 네임스페이스
IsExternalInit 클래스는 빈 마커 클래스입니다. .NET 5 이상에서 BCL이 제공하지만 .NET Framework·.NET Core 3.x 같은 옛 런타임에는 없습니다. 그래서 옛 런타임에서 init을 쓰려면 직접 정의해야 했죠.
// 옛 런타임에서 init을 쓰기 위한 워크어라운드
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}
Unity 2021.3 LTS는 init 사용 시 이 워크어라운드가 필요했고, Unity 2022 이상은 BCL에서 직접 제공합니다.
4. 호출 자리에서의 IL — 객체 초기화자 = 생성자 + 연속 setter 호출
var order = new Order { Id = "ABC", Name = "Alice" };
IL_0001: newobj instance void Order::.ctor() // 1) 생성자 호출
IL_0006: dup // 2) 인스턴스 참조 복제
IL_0007: ldstr "ABC"
IL_000c: callvirt instance void Order::set_Id(string) // 3) init setter 호출
IL_0011: dup
IL_0012: ldstr "Alice"
IL_0017: callvirt instance void Order::set_Name(string) // 4) set setter 호출
IL_001c: stloc.0
객체 초기화자는 IL에서 생성자 호출 → 각 프로퍼티 setter 연속 호출 시퀀스로 풀립니다. init setter도 일반 메서드처럼 callvirt로 호출되지만, 컴파일러가 이 호출 자리를 객체 초기화자 안에서만 허용하기 때문에 다른 자리에서는 사용할 수 없는 것이죠.
객체가 만들어진 후 order.Id = "X"라고 쓰면:
오류 CS8852: Init-only property or indexer 'Order.Id' can only be assigned in
an object initializer, or on 'this' or 'base' in an instance constructor
or an 'init' accessor.
C# 컴파일러가 호출 자리가 객체 초기화자/생성자/init 접근자가 아님을 보고 오류를 발생시킵니다.
5. record와의 관계 — 모든 위치 매개변수가 자동 init
public record Person(string FirstName, string LastName);
// 컴파일러가 자동 생성하는 코드와 동등
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
// + ToString, Equals, GetHashCode, Deconstruct, with 등
// ...
}
var p1 = new Person("Alice", "Kim");
var p2 = p1 with { FirstName = "Bob" }; // ✅ with 식도 init setter 호출
// p1.FirstName = "X"; // ❌ 불변
record의 위치 매개변수(positional parameter)는 자동으로 { get; init; } 프로퍼티로 변환됩니다. with 식은 객체를 복사한 뒤 init setter로 일부 값을 변경하는 비파괴적 복사 패턴 — 내부적으로 컴파일러가 임시 복사본을 만들고 init setter를 호출합니다.
자세한 record 내용은 PART 7 #18에서 다룹니다.
6. required와의 결합 (C# 11) — 누락 방지
public class Order
{
public required string Id { get; init; }
public required string CustomerName { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
var o1 = new Order { Id = "ABC", CustomerName = "Alice" }; // ✅
var o2 = new Order { Id = "ABC" }; // ❌ CustomerName 누락
// 오류 CS9035
init만 쓰면 사용자가 객체 초기화자에서 누락한 프로퍼티는 기본값(null/0)이 들어갑니다. string이라면 null, 검증을 통과 못하는 잘못된 상태로 객체가 만들어질 수 있죠.
required 한정자(C# 11)는 누락 시 컴파일 오류를 발생시켜 이 함정을 막습니다. init + required 조합이 진정한 불변 객체 초기화의 표준 패턴입니다.
자세한 required 내용은 PART 7 #6에서 다룹니다.
7. 실전 적용 — Unity DTO·이벤트 메시지·설정 객체
패턴 1 — DTO (Data Transfer Object)
public class GameSaveData
{
public required string PlayerName { get; init; }
public required int Level { get; init; }
public required DateTime SavedAt { get; init; }
public string? CharacterClass { get; init; }
public Dictionary<string, int>? Stats { get; init; }
}
// 사용
var save = new GameSaveData
{
PlayerName = "Hero",
Level = 10,
SavedAt = DateTime.UtcNow,
Stats = new() { ["HP"] = 100, ["MP"] = 50 }
};
DTO는 데이터를 한 번 만들고 변경 없이 전달하는 게 일반적입니다. init이 가장 적합한 자리. required로 필수 필드를 강제하면 객체가 항상 유효한 상태로 만들어집니다.
패턴 2 — 이벤트 메시지
public record DamageEvent
{
public required int Source { get; init; }
public required int Target { get; init; }
public required int Amount { get; init; }
public DamageType Type { get; init; } = DamageType.Physical;
}
// 발행
EventBus.Publish(new DamageEvent
{
Source = attacker.Id,
Target = victim.Id,
Amount = damage
});
이벤트 메시지는 불변이 강제 조건입니다. 여러 구독자가 같은 메시지를 받는데 한 명이 변경하면 다른 구독자가 깨지죠. init이 이걸 컴파일 타임에 보장.
패턴 3 — 설정 객체
public class GameSettings
{
public int FrameRate { get; init; } = 60;
public bool VSync { get; init; } = true;
public float MasterVolume { get; init; } = 1.0f;
public Resolution? ScreenResolution { get; init; }
}
// Unity 설정 로드
var settings = LoadFromJson(path) ?? new GameSettings { FrameRate = 30 };
ApplySettings(settings);
// settings.FrameRate = 144; // ❌ 한 번 만들면 불변 — 변경하려면 새 객체
설정 객체는 만든 후 변경하지 않는 게 안전합니다. 변경 시 일관성 없는 상태가 생기기 쉽거든요. init으로 불변성 강제 + 다음 변경은 새 객체를 만드는 패턴.
Unity 모바일 GC 특수성
init 접근자 자체는 GC와 무관 — IL 본문이 일반 setter와 같으니 비용도 같습니다. 다만 불변 객체 패턴이 새 객체 생성을 강제합니다 (변경하려면 새 인스턴스). 핫패스에서 매 프레임 새 DTO를 만들면 GC 부담이 누적되죠.
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터)의 조합이라 한 번의 GC가 5~15ms 프레임 스파이크입니다. 불변 객체는 캐시·풀과 함께 쓰는 게 좋고, 핫패스에서 자주 만들면 record struct(값 타입, PART 7 #18) 같은 zero-alloc 대안을 검토하세요.
8. 함정과 주의사항
함정 1 — init 단독으로는 누락 막지 못함
public class Order
{
public string Id { get; init; } // required 없음
}
var o = new Order(); // ✅ 컴파일 OK
Console.WriteLine(o.Id); // ⚠️ null
init만 쓰면 사용자가 객체 초기화자에서 빠뜨린 프로퍼티는 기본값(null/0)입니다. 항상 required와 함께 사용하시거나 nullable 명시(string?)로 의도를 표현하세요.
함정 2 — init 접근자 안에서 다른 init 호출 가능
public class Triangle
{
public int Side1 { get; init; }
public int Side2 { get; init; }
public int Side3
{
init
{
// 같은 인스턴스의 다른 init setter 호출 OK
field = value;
}
}
}
init 접근자 본문 안에서는 같은 인스턴스의 다른 init setter도 호출 가능합니다. 의존 관계가 있는 프로퍼티 묶음에 유용.
함정 3 — 옛 런타임(.NET Framework, .NET Core 3.x) 호환성
// 옛 런타임에서는 IsExternalInit을 직접 정의해야 함
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}
IsExternalInit은 .NET 5 이상에서 BCL이 제공합니다. 옛 런타임에서 init을 쓰려면 위 워크어라운드 코드를 직접 추가해야 합니다. Unity 2021.3 LTS도 해당. Unity 2022 이상은 자동 제공.
함정 4 — record의 init 매개변수 검증
public record Person(string FirstName, string LastName)
{
public string FirstName { get; init; } = FirstName ?? throw new ArgumentNullException();
// ^^^^^^^^^^ 위치 매개변수와 같은 이름
}
record 위치 매개변수는 자동으로 init 프로퍼티가 되지만, 검증 로직을 넣고 싶으면 명시적 프로퍼티 선언이 필요합니다. 위 코드는 record 위치 매개변수 FirstName을 명시 프로퍼티 FirstName { get; init; }로 덮어쓰면서 ?? 검증을 넣은 패턴.
함정 5 — Reflection을 통한 우회 가능
var order = new Order { Id = "ABC" };
typeof(Order)
.GetProperty("Id")!
.SetValue(order, "X"); // ⚠️ Reflection으로 우회 가능
init은 C# 컴파일러가 강제하는 규칙일 뿐, CLR 차원의 진정한 불변성은 아닙니다. Reflection이나 IL 직접 작성 같은 우회 경로로는 변경 가능합니다. 그래도 일반 코드에서는 충분한 보장.
9. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | { get; private set; } + 객체 초기화자 |
진짜 불변 X |
| 1.0 | { get; } + 생성자 매개변수 |
매개변수 많으면 가독성 X |
| 3.0 | 객체 초기화자 도입 | set 필수 |
| 6.0 | { get; } 자동 프로퍼티 (initonly 백킹 필드) |
진짜 불변 + 생성자 필요 |
| 9.0 | init 접근자 |
객체 초기화자 + 진짜 불변 동시 |
| 9.0 | record 클래스 (모든 위치 매개변수가 자동 init) |
PART 7 #18 |
| 10.0 | record struct |
값 타입 record |
| 10.0 | 일반 struct에서 with 식 사용 가능 |
|
| 11.0 | required 멤버 |
init과 결합으로 누락 방지 |
| 14.0 | field 키워드와 결합 |
init => field = ... 검증 |
C# 9의 init이 불변 객체 패턴의 게임 체인저였습니다. 11의 required까지 합쳐지면서 마침내 완전한 불변 초기화 패턴이 완성됐죠.
10. 정리
- [ ]
init접근자는 객체 초기화자/생성자/with식에서만 호출 가능한 setter — 그 후 변경 불가. - [ ] 객체 초기화자의 편의성 + 진정한 불변성을 동시에 가진 첫 도구. C# 9 신문법.
- [ ] IL 백킹 필드는
initonly(=readonly) — 생성자에서만 쓰기 가능. - [ ] setter 시그니처에
modreq([IsExternalInit])한정자가 박힘 — 본문은 일반 setter와 100% 동일. - [ ]
modreq메커니즘: 컴파일러가 호출 자리를 제한하는 시그니처 트릭. C# 언어에 새 키워드 추가 없이 새 의미 부여. - [ ] JIT 인라이닝으로 일반 setter와 비용 동일.
- [ ] 호출 가능한 자리 4곳: 객체 초기화자, 생성자, 다른
init접근자,with식. - [ ]
record는 모든 위치 매개변수가 자동init—with식의 비파괴적 복사도 init setter 호출. - [ ]
init단독으로는 누락 못 막음 — 항상required(C# 11)와 결합. - [ ] 옛 런타임(.NET Framework·.NET Core 3.x)에서는
IsExternalInit직접 정의 필요. .NET 5 이상은 BCL 제공. - [ ] Unity 2021.3 LTS는 워크어라운드 필요, Unity 2022 이상은 자동.
- [ ] DTO·이벤트 메시지·설정 객체가
init이 가장 빛나는 자리. - [ ] Reflection으로는 우회 가능 — C# 컴파일러 규칙이지 CLR 차원의 불변성은 아님.
- [ ] 더 깊은 신문법(
required·Primary Constructor·record·with)은 PART 7 #6·#11·#18·#20에서 다룬다.