반응형

[PART7.클래스와 객체 입문(5/21)] init 접근자 — 객체 초기화자의 편의성과 불변성을 동시에 (C# 9)

C# 9 이전엔 객체 초기화자(new T { X = 1 })와 불변성을 동시에 못 가졌다 / initset과 본문이 같지만 시그니처에 modreq([IsExternalInit]) 한정자가 박혀 컴파일러가 호출 자리를 객체 초기화자·생성자·with 식으로만 제한 / 백킹 필드는 private initonly (readonly와 동의) / JIT는 일반 setter처럼 인라이닝 / record의 모든 위치 매개변수는 자동으로 init 접근자 / required(C# 11)와 결합하면 누락 방지까지 / .NET 5 이상에서 사용 가능


1. 옛 패턴의 두 가지 한계

C# 8까지 불변 객체를 만들면 두 가지 선택뿐이었습니다 — 둘 다 한계가 있었습니다.

한계 1 — { get; } + 생성자: 매개변수가 많아지면 가독성 폭망

C#
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

C#
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 접근자

C#
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는 다음 네 자리에서만 호출 가능합니다:

  1. 객체 초기화자: new Order { Id = "ABC" }
  2. 생성자: 인스턴스 자신의 .ctor 본문
  3. 다른 init 접근자: 같은 인스턴스의 다른 init setter 안에서
  4. with: record·record struct의 비파괴적 복사 (PART 7 #18)

다른 메서드, 외부 코드, 또는 객체가 만들어진 후에는 호출 불가 — 컴파일 오류가 납니다.

옛 옵션과의 비교

패턴 객체 초기화자 사용 외부 변경 클래스 내부 다른 메서드에서 변경
{ get; set; }
{ get; private set; }
{ get; } + 생성자 ❌ (긴 매개변수) ❌ (생성자 안만)
{ get; init; }

init객체 초기화자 사용 가능 + 진짜 불변성 두 마리 토끼를 잡습니다.


3. IL로 본 initmodreq([IsExternalInit]) 시그니처 트릭

C#
public class Order
{
    public string Id { get; init; } = "";
    public string Name { get; set; } = "";
}
IL
// ================ 백킹 필드 ================
.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
}

핵심 세 가지:

  1. 백킹 필드가 initonly — IL의 initonly는 C# 필드의 readonly와 동의. 생성자/필드 이니셜라이저에서만 쓰기 가능.
  2. setter 시그니처에 modreq([IsExternalInit]) 한정자가 박힘 — 본문은 일반 setter와 100% 동일하지만, 시그니처가 다른 메서드처럼 보입니다. C# 컴파일러는 이 메서드를 호출하는 자리가 객체 초기화자/생성자/with 식이 아니면 컴파일 오류를 발생시킵니다.
  3. modreq의 의미 — "이 메서드를 호출하려는 컴파일러는 반드시 IsExternalInit 한정자의 의미를 이해해야 한다"는 표식. modreq 한정자를 모르는 컴파일러는 메서드를 호출조차 할 수 없습니다.
modreq (Required Modifier)란? CLR이 메서드 시그니처에 추가 의미를 부여하는 메커니즘. C# 컴파일러는 modreq([IsExternalInit]) 한정자를 발견하면 호출 가능한 자리를 객체 초기화자·생성자·with 식으로만 제한한다.
예시: init 외에도 in 매개변수(modreq([In]))나 unmanaged 제약 등이 같은 메커니즘을 사용한다. C# 언어 자체에 새 키워드를 추가하지 않고 시그니처 트릭으로 새 의미를 만든 것.

IsExternalInitSystem.Runtime.CompilerServices 네임스페이스

IsExternalInit 클래스는 빈 마커 클래스입니다. .NET 5 이상에서 BCL이 제공하지만 .NET Framework·.NET Core 3.x 같은 옛 런타임에는 없습니다. 그래서 옛 런타임에서 init을 쓰려면 직접 정의해야 했죠.

C#
// 옛 런타임에서 init을 쓰기 위한 워크어라운드
namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

Unity 2021.3 LTS는 init 사용 시 이 워크어라운드가 필요했고, Unity 2022 이상은 BCL에서 직접 제공합니다.


4. 호출 자리에서의 IL — 객체 초기화자 = 생성자 + 연속 setter 호출

C#
var order = new Order { Id = "ABC", Name = "Alice" };
IL
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

C#
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) — 누락 방지

C#
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)

C#
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 — 이벤트 메시지

C#
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 — 설정 객체

C#
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 단독으로는 누락 막지 못함

C#
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 호출 가능

C#
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) 호환성

C#
// 옛 런타임에서는 IsExternalInit을 직접 정의해야 함
namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

IsExternalInit은 .NET 5 이상에서 BCL이 제공합니다. 옛 런타임에서 init을 쓰려면 위 워크어라운드 코드를 직접 추가해야 합니다. Unity 2021.3 LTS도 해당. Unity 2022 이상은 자동 제공.

함정 4 — recordinit 매개변수 검증

C#
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을 통한 우회 가능

C#
var order = new Order { Id = "ABC" };
typeof(Order)
    .GetProperty("Id")!
    .SetValue(order, "X");                 // ⚠️ Reflection으로 우회 가능

initC# 컴파일러가 강제하는 규칙일 뿐, 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는 모든 위치 매개변수가 자동 initwith 식의 비파괴적 복사도 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에서 다룬다.
반응형

+ Recent posts