반응형

[PART7.클래스와 객체 입문(9/21)] 객체 초기화자 — 생성과 설정을 한 표현식으로

new Player { Name = "A", Age = 10 } / 임시 변수 패턴 / dup로 압축되는 IL / init·required·with 와의 관계


1. [문제 제기] 객체 5개 만들고 12줄을 쓴 코드

Unity에서 보스 데이터를 스크립트로 채우는 일을 떠올려 봅니다. 기획자가 보내준 엑셀에서 추출한 5명의 보스를 메모리에 올려야 합니다.

C#
// Unity에서 보스 데이터를 코드로 만들어야 한다고 가정
Boss b1 = new Boss();
b1.Name = "고블린 왕";
b1.Hp = 1200;

Boss b2 = new Boss();
b2.Name = "슬라임 군주";
b2.Hp = 800;

Boss b3 = new Boss();
b3.Name = "흑화한 마법사";
b3.Hp = 2400;
// ...

생성자 호출 한 줄, 프로퍼티 대입 N줄. 변수 b1, b2, b3 가 매 줄마다 반복됩니다. 사람의 눈은 "어떤 보스가 어떤 값을 갖는지"를 파악하는 대신, "변수 이름이 일치하는지"부터 검증하느라 시간을 씁니다.

게다가 이 패턴은 또 다른 문제를 만듭니다.

C#
// 위험: 부분적으로 채워진 객체가 잠시 노출된다
List<Boss> bosses = new List<Boss>();
Boss b = new Boss();   // ← 이 시점에는 Name=null, Hp=0
b.Name = "고블린 왕";  // ← 이 시점에는 Hp=0
b.Hp = 1200;
bosses.Add(b);

// 만약 b.Hp = 1200 줄에서 예외가 던져지면?
// bosses 리스트에는 절반만 채워진 b 가 들어 있을 수 있다

객체가 완성되기 에 외부에 노출됩니다. 다중 스레드 환경, LINQ 파이프라인, 컬렉션 추가 시점이 끼어들면 "절반만 채워진 객체"가 시스템에 흘러들어갑니다.

C# 3.0(2007년)부터 도입된 객체 초기화자(Object Initializer) 는 이 두 가지 문제를 한 번에 해결합니다. 이 글에서는 객체 초기화자가 무엇을 어떻게 바꾸는지, 컴파일러는 IL 수준에서 어떻게 변환하는지, 그리고 Unity 환경에서 언제 써야 안전한지를 다룹니다.


2. [개념 정의] 생성과 설정을 한 표현식으로 묶는 문법

2.1 비유 — 종이주문서 vs 카운터에서 부르는 메뉴

생성자 호출 후 프로퍼티를 연속 대입하는 방식은 카페 카운터에서 한 줄씩 메뉴를 부르는 것과 같습니다.

"아메리카노 한 잔이요. 그리고 시럽 추가요. 아 얼음도 빼주세요."

말이 끝날 때마다 점원은 잠깐씩 멈춥니다. 중간에 누군가 끼어들면 주문이 뒤섞입니다.

객체 초기화자는 종이주문서를 한 장 내미는 것과 같습니다. 음료 한 잔에 들어갈 모든 옵션이 적힌 종이를 점원에게 건네면, 점원은 처음부터 끝까지 그 종이만 보고 한 번에 만듭니다.

두 방식의 표현 단위 차이

2.2 기본 문법

C#
public class Boss
{
    public string Name { get; set; }
    public int Hp { get; set; }
}

// 객체 초기화자: new + { 프로퍼티 = 값, ... }
Boss b = new Boss { Name = "고블린 왕", Hp = 1200 };

// 매개변수 없는 생성자라면 () 도 생략 가능
Boss b2 = new Boss { Name = "슬라임 군주", Hp = 800 };

// 매개변수 있는 생성자와도 결합 가능
Boss b3 = new Boss("고블린 왕", 1200) { ExtraTag = "Elite" };
{ Prop = Value, ... } — 객체 초기화자 (Object Initializer) new 표현식 뒤에 중괄호로 프로퍼티·필드를 한 번에 대입하는 문법. 호출 가능한 생성자가 끝난 직후, 중괄호 안 순서대로 각 setter 가 호출된다. 결과는 하나의 표현식 이라서 메서드 인자, LINQ Select, 컬렉션 Add 어디든 그대로 끼워 넣을 수 있다.
예시: var p = new Player { Name = "A" }; 빈 생성자로 Player 를 만든 뒤 Name 프로퍼티 setter 가 호출됨

2.3 표면 동작 — 컴파일러는 사실상 두 단계로 푼다

객체 초기화자가 마법처럼 "한 번에 객체를 만든다"고 보일 수 있지만, 컴파일러 입장에서는 단순한 두 단계 변환입니다.

C#
// 작성한 코드
Boss b = new Boss { Name = "고블린 왕", Hp = 1200 };

// 컴파일러가 의미적으로 펼친 코드 (실제 IL 은 더 압축됨 — 4절 참고)
Boss _temp = new Boss();   // 1) 생성자 호출
_temp.Name = "고블린 왕"; // 2-1) setter 호출
_temp.Hp = 1200;           // 2-2) setter 호출
b = _temp;                 // 3) 임시 변수를 변수 b 에 대입

핵심 포인트는 두 가지입니다.

  1. 호출되는 생성자는 정해져 있다. 객체 초기화자가 새로운 생성자를 만들거나 우회하지 않습니다. 작성자가 호출 형식을 보고 결정한 그 생성자가 호출되고, 그 직후에 프로퍼티들이 차례로 대입됩니다.
  2. 임시 변수에 채워진 뒤 마지막에만 b 에 대입된다. 도중에 예외가 나면 b 에는 부분 객체가 들어가지 않습니다(자세한 분석은 5장).

이 부분은 객체 초기화자의 의미론(semantics)을 이해하는 핵심이며, 면접에서 자주 나오는 "객체 초기화자가 생성자보다 먼저 실행되나요?" 라는 질문의 정답이기도 합니다. 생성자가 먼저, 초기화자(=setter 호출) 이후입니다.


3. [내부 동작] IL 로 본 객체 초기화자

C# 코드는 컴파일러를 거쳐 IL(Intermediate Language, .NET 가상 머신이 실행하는 중간 언어) 로 변환됩니다. 표면 의미론과 실제 컴파일 결과는 미묘하게 다를 때가 있어, 객체 초기화자처럼 "어떻게 동작하는지" 가 중요한 문법은 IL 을 직접 보는 것이 가장 정확합니다. /il-analysis 로 객체 초기화자가 컴파일된 결과를 직접 확인합니다.

3.1 두 코드를 같이 컴파일한다

C#
public class Player
{
    public string Name { get; set; }
    public int Age { get; set; }
}

public class App
{
    public static void Main()
    {
        // (가) 객체 초기화자
        Player a = new Player { Name = "A", Age = 10 };

        // (나) 전통 방식
        Player b = new Player();
        b.Name = "B";
        b.Age = 20;
    }
}

3.2 IL 결과 — 거의 동일하다

위 코드를 .NET 8 Release 빌드로 컴파일한 IL입니다.

IL
.method public hidebysig static void Main () cil managed
{
    // (가) Player a = new Player { Name = "A", Age = 10 };
    IL_0000: newobj   instance void Player::.ctor()      // 새 Player 인스턴스 생성, 참조를 스택에 push
    IL_0005: dup                                          // 스택 맨 위 참조를 복제 (한 번 더 push)
    IL_0006: ldstr    "A"                                 // 문자열 상수 "A" push
    IL_000b: callvirt instance void Player::set_Name(string) // set_Name 호출 (참조와 "A" 소비)
    IL_0010: dup                                          // 다시 참조 복제 — 다음 setter 용
    IL_0011: ldc.i4.s 10                                  // 정수 10 push
    IL_0013: callvirt instance void Player::set_Age(int32)// set_Age 호출
    IL_0018: pop                                          // 마지막에 남은 참조를 버림 (변수 a 가 지역 슬롯이라면 stloc)

    // (나) Player b = new Player(); b.Name = "B"; b.Age = 20;
    IL_0019: newobj   instance void Player::.ctor()
    IL_001e: dup
    IL_001f: ldstr    "B"
    IL_0024: callvirt instance void Player::set_Name(string)
    IL_0029: ldc.i4.s 20
    IL_002b: callvirt instance void Player::set_Age(int32)
    IL_0030: ret
}

3.3 해설 — "임시 변수 패턴"의 진실

C# 자료들은 객체 초기화자가 "임시 지역 변수에 객체를 담은 뒤 setter 들을 호출하고 마지막에 변수에 대입한다"고 설명합니다. 의미론적으로는 정확합니다. 그러나 실제 IL 은 그보다 한 단계 더 최적화되어 있습니다.

dup — IL 명령어 (Duplicate top of stack) 평가 스택 맨 위 값을 복제해 한 번 더 push 한다. 하나의 객체 참조에 대해 여러 setter 를 연속 호출할 때, 매번 지역 변수에서 다시 로드하지 않고 스택에서 직접 재사용하기 위한 명령이다.

(가)(나) 의 IL 을 줄별로 비교하면 차이는 단 한 줄입니다.

항목 (가) 객체 초기화자 (나) 전통 방식
마지막 줄 pop (스택에 남은 참조 버림) (없음 — stloc 으로 즉시 지역에 저장)
그 외 newobjdup → setter → dup → setter 동일

즉, 런타임 비용은 두 방식이 사실상 같습니다. 객체 초기화자가 전통 방식보다 빠른 마법은 없습니다. setter 호출 횟수, 가상 호출(callvirt) 횟수, 메모리 할당 모두 동일합니다. 다만 컴파일러가 두 방식을 동일한 IL 로 압축하기 때문에, 가독성·안전성에서 더 유리한 객체 초기화자 쪽이 항상 우위입니다.

또 하나의 발견은 IL 에 임시 지역 변수가 등장하지 않는다는 점입니다. C# 자료들이 "임시 변수 패턴"이라 부르는 것은 언어 명세 수준의 의미론 이고, Roslyn 컴파일러는 이를 dup 만으로 풀어냅니다. 즉:

  • 언어 명세 (의미론): 임시 변수 _temp 를 만들고, 모든 setter 가 끝난 뒤 변수에 대입한다.
  • 실제 IL (구현): 평가 스택 위에 참조를 두고 dup 로 복제하면서 setter 들을 호출한다. 임시 지역 변수는 만들지 않는다.

이 차이가 의미를 갖는 순간은 부분 초기화 노출 안전성입니다(5장에서 다룹니다). 의미론적으로 중간 객체가 외부에 노출되지 않는다는 보장은 IL 수준에서도 그대로 유지됩니다 — 스택 위 참조는 다른 지역 변수에서 절대 접근할 수 없기 때문입니다.

3.4 SVG — 한 객체에 여러 setter 가 호출되는 흐름

IL 평가 스택의 흐름 — newobj → dup → setter → dup → setter

이 모델은 단순히 IL 학습용이 아닙니다. "setter 가 끝나기 전엔 다른 코드가 이 객체에 접근할 수 없다"는 안전성 이 실제 어떻게 보장되는지를 그림 한 장으로 설명합니다.


4. [실전 적용] 언제 객체 초기화자를 쓰는가

4.1 패턴 1 — DTO·설정 객체 (가장 강력한 케이스)

데이터를 담아 어딘가에 전달만 하는 객체는 객체 초기화자의 황금 사용처입니다.

C#
// ❌ 전통 방식 — 변수 이름 반복, 의도가 흩어짐
HttpRequest req = new HttpRequest();
req.Url = "https://api.example.com/users";
req.Method = "GET";
req.TimeoutMs = 5000;
req.UserAgent = "MyGame/1.0";
SendAsync(req);

// ✅ 객체 초기화자 — "이런 형태의 요청을 보낸다"는 의도가 한 표현식에 모임
SendAsync(new HttpRequest
{
    Url = "https://api.example.com/users",
    Method = "GET",
    TimeoutMs = 5000,
    UserAgent = "MyGame/1.0",
});

표현식이라 임시 변수가 사라지고, 호출과 데이터가 한 곳에 묶입니다. 코드 리뷰어는 "어떤 요청이 어디로 가는가"를 한 눈에 파악합니다.

4.2 패턴 2 — LINQ 프로젝션

LINQ Select 안에서는 거의 강제됩니다. 람다는 하나의 표현식만 반환할 수 있기 때문입니다.

C#
// 보스 데이터 → UI 모델로 변환
var viewModels = bosses.Select(b => new BossViewModel
{
    DisplayName = b.Name,
    HpRatio     = (float)b.Hp / b.MaxHp,
    IsBoss      = b.Tier >= BossTier.Elite,
}).ToList();

객체 초기화자가 없다면 람다 본문이 { ... return ...; } 블록으로 길어지고, 가독성이 무너집니다.

4.3 패턴 3 — Unity ScriptableObject 인스턴스 데이터

Unity에서 ScriptableObject 자체는 new 로 만들 수 없습니다(ScriptableObject.CreateInstance<T>() 가 필요). 그러나 그 안에 들어가는 일반 데이터 클래스에는 객체 초기화자가 그대로 사용됩니다.

C#
[CreateAssetMenu]
public class StageConfig : ScriptableObject
{
    public List<WaveData> Waves;
}

[Serializable]
public class WaveData
{
    public int   StartSec;
    public int   EnemyCount;
    public float SpawnIntervalSec;
}

// 에디터 스크립트에서 데이터를 코드로 채울 때
public static class StageBuilder
{
    public static StageConfig BuildTutorialStage()
    {
        var cfg = ScriptableObject.CreateInstance<StageConfig>();
        cfg.Waves = new List<WaveData>
        {
            new WaveData { StartSec =  0, EnemyCount = 3, SpawnIntervalSec = 1.0f },
            new WaveData { StartSec = 10, EnemyCount = 6, SpawnIntervalSec = 0.8f },
            new WaveData { StartSec = 25, EnemyCount = 9, SpawnIntervalSec = 0.5f },
        };
        return cfg;
    }
}

여러 웨이브를 코드로 표현해야 할 때, 전통 방식이라면 변수 w1, w2, w3 를 만들고 각각 4줄씩 12줄을 써야 합니다. 객체 초기화자는 이를 데이터 표 처럼 만듭니다.

4.4 패턴 4 — Unity MonoBehaviour 는 못 쓴다

MonoBehaviournew 로 생성할 수 없으므로 객체 초기화자도 직접 사용 불가입니다.

C#
// ❌ 컴파일은 되어도 런타임에 경고: "MonoBehaviour 는 new 로 만들 수 없습니다"
EnemyController e = new EnemyController { Hp = 100 };

// ✅ AddComponent 후 별도 초기화 메서드 호출
public class EnemyController : MonoBehaviour
{
    public int Hp { get; private set; }
    public void Initialize(int hp) { Hp = hp; }
}

var go = new GameObject("Enemy");
var ec = go.AddComponent<EnemyController>();
ec.Initialize(100);

대신 AddComponentInitialize 메서드를 호출하는 패턴을 씁니다. 이 메서드의 매개변수가 많아지면, 매개변수를 객체 초기화자로 받는 보조 데이터 클래스를 만드는 방법도 자주 사용됩니다.

C#
public struct EnemySpawnInfo
{
    public int    Hp;
    public float  Speed;
    public string Tag;
}

public class EnemyController : MonoBehaviour
{
    public void Initialize(EnemySpawnInfo info) { /* ... */ }
}

// 객체 초기화자로 깔끔하게
ec.Initialize(new EnemySpawnInfo { Hp = 100, Speed = 3.5f, Tag = "Elite" });

4.5 IL 로 본 LINQ + 객체 초기화자

LINQ 안에서 객체 초기화자를 쓰면 컴파일러가 어떻게 변환하는지 IL 로 확인합니다. 람다 자체는 컴파일러 생성 메서드가 되고, 그 내부 IL 은 우리가 3장에서 본 패턴 그대로입니다.

C#
// 작성한 코드
var names = bosses.Select(b => new BossViewModel
{
    DisplayName = b.Name,
}).ToList();

람다 본문이 컴파일된 IL은 다음과 같은 모양입니다.

IL
// 컴파일러가 생성한 람다 메서드 본문
.method static class BossViewModel '<Method>b__0_0' (Boss b)
{
    IL_0000: newobj   instance void BossViewModel::.ctor()
    IL_0005: dup
    IL_0006: ldarg.0                                   // 람다 매개변수 b 로드
    IL_0007: callvirt instance string Boss::get_Name() // b.Name 읽기
    IL_000c: callvirt instance void BossViewModel::set_DisplayName(string)
    IL_0011: ret
}

3장의 평범한 객체 초기화자 IL 과 동일합니다. LINQ 라서 추가 비용이 발생하지는 않으며, 흔히 우려하는 LINQ 비용은 객체 초기화자 자체가 아니라 Select / ToList 가 만드는 이터레이터·박스 할당 등 다른 곳에서 옵니다.


5. [함정과 주의사항] 신입이 자주 마주치는 실수

5.1 ❌ 부분 초기화 객체가 흘러 다닌다

객체 초기화자는 하나의 표현식 안에서만 안전합니다. 표현식 바깥에서 분리해 쓰는 순간 보호가 사라집니다.

C#
// ❌ 잘못된 패턴 — 객체 초기화자처럼 보이지만 사실은 분리된 문장
Boss b = new Boss();          // 빈 객체
b.Name = "고블린 왕";         // 부분 객체 (Hp=0)
RegisterToCache(b);           // ← 여기서 b 가 캐시에 들어감 (Hp 가 아직 0!)
b.Hp = 1200;                  // 너무 늦음 — 캐시는 Hp=0 으로 본다

실제 IL 을 보면 RegisterToCache 호출이 모든 setter 보다 앞에 있습니다. 객체가 완성되기 전에 외부로 빠져나가는 것이 IL 로도 명백히 드러납니다.

C#
// ✅ 올바른 패턴 — 단일 표현식
RegisterToCache(new Boss
{
    Name = "고블린 왕",
    Hp   = 1200,
});

이렇게 작성하면 컴파일된 IL 에서 RegisterToCache 호출은 setter 호출들 뒤에 위치합니다. 부분 객체가 절대 외부에 노출되지 않습니다.

5.2 ❌ 생성자에서 검증한 값을 객체 초기화자가 덮어쓴다

생성자가 매개변수에 대해 유효성 검증을 했더라도, 객체 초기화자는 그 뒤에 set 을 호출합니다.

C#
public class HpStat
{
    public int Current { get; set; }
    public int Max     { get; set; }

    public HpStat(int max)
    {
        if (max <= 0) throw new ArgumentException();
        Max = max;
        Current = max;        // ← 생성자에서 보장
    }
}

// ❌ 의도와 다른 결과 — 생성자가 보장한 Current=Max 가 깨진다
var hp = new HpStat(100) { Current = 999, Max = 50 };
// 호출 순서: ctor(100) → set_Current(999) → set_Max(50)
// 결과: Current=999, Max=50 (Current > Max!)

IL 로 확인해 보면 ctor 호출 직후에 두 setter 가 차례로 호출됩니다. 생성자의 무결성 검증이 무력화됩니다.

C#
// ✅ 해결: 위험한 프로퍼티는 init / 또는 setter 가 검증을 수행
public class HpStat
{
    public int Current { get; private set; }
    public int Max     { get; init; }       // init: 초기화자에서만 set 가능, 그 후엔 불변

    public HpStat(int max)
    {
        if (max <= 0) throw new ArgumentException();
        Max = max;
        Current = max;
    }

    public void Damage(int dmg) => Current = Math.Max(0, Current - dmg);
}

5.3 ❌ 컬렉션 프로퍼티가 null 일 때

객체 초기화자는 새 컬렉션을 만들지 않습니다. 기존 컬렉션 프로퍼티가 null 이면 그 안에 항목을 추가할 수 없습니다.

C#
public class Bag
{
    public List<int> Items { get; set; }   // 기본값 null
}

// ❌ NullReferenceException — Items 가 null 인 상태에서 Add 호출 시도
var bag = new Bag { Items = { 1, 2, 3 } };

// ✅ 컬렉션 자체를 새로 만들면서 채우기
var bag2 = new Bag { Items = new List<int> { 1, 2, 3 } };

// ✅ 또는 클래스 측에서 기본 인스턴스를 보장
public class Bag2
{
    public List<int> Items { get; } = new();   // 읽기 전용 + 기본 인스턴스
}
var bag3 = new Bag2 { Items = { 1, 2, 3 } };   // 이제 작동

{ Items = { 1, 2, 3 } } 형태는 사실 객체 초기화자가 아니라 컬렉션 초기화자(Collection Initializer) 의 중첩 사용이며, Items getter 가 반환한 객체에 대해 Add(1); Add(2); Add(3); 을 호출합니다. getter 가 null 을 반환하면 그대로 NRE 입니다.

5.4 ❌ 같은 프로퍼티를 두 번 쓰면 마지막 값만 남는다

C#
// ❌ Hp=100 이 아니라 Hp=200 — 컴파일러는 경고도 안 한다
var b = new Boss { Hp = 100, Hp = 200 };

복사·붙여넣기 후 수정하다가 흔히 발생합니다. IL 수준에서 setter 가 두 번 호출되며 두 번째 값이 최종값입니다. Roslyn 분석기 일부는 이를 잡아주지만 기본 컴파일러는 통과시킵니다.

5.5 ❌ Unity 핫패스에서 매 프레임 객체 초기화자 사용

객체 초기화자 자체에는 추가 비용이 없지만, 참조 타입을 매 프레임 새로 만드는 것 자체가 GC 스파이크의 원인이 됩니다.

GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) — Unity 의 기본 GC는 Boehm GC 입니다. 한 번에 멈춰서 회수하는 stop-the-world 방식이라 모바일에서 프레임 드랍의 주범입니다.
C#
// ❌ Update 마다 새 객체 — 60FPS 면 초당 60번 힙 할당
void Update()
{
    var info = new EnemySpawnInfo { Hp = 100, Speed = 3.5f };
    enemy.Initialize(info);
}

// ✅ struct 로 만들거나, 재사용 가능한 필드로 캐싱
private EnemySpawnInfo _cachedInfo = new EnemySpawnInfo { Hp = 100, Speed = 3.5f };

void Update()
{
    enemy.Initialize(_cachedInfo);   // 값 복사만 — 힙 할당 없음
}

EnemySpawnInfostruct 라면 객체 초기화자도 스택 위에서 동작하므로 GC 압박이 없습니다. 클래스라면 매 프레임 새로 만드는 패턴 자체를 피해야 합니다.


6. [C# 버전별 변화] 객체 초기화자가 진화한 길

객체 초기화자 문법 자체는 C# 3.0 이후 거의 변하지 않았습니다. 그러나 함께 작동하는 주변 기능들이 매 버전 추가되면서, 객체 초기화자의 안전성과 표현력이 크게 확장되었습니다.

6.1 C# 3.0 (2007) — 도입

LINQ 와 함께 도입되었습니다. 무명 형식(new { ... }) 의 기반 문법이기도 합니다.

C#
// C# 3.0 시점의 한계: get/set 만 가능 — 불변 객체는 만들 수 없음
public class Cfg { public string Url { get; set; } }
var c = new Cfg { Url = "https://api" };
c.Url = "https://other";   // 외부에서 언제든 변경 가능

6.2 C# 6.0 (2015) — 인덱스 초기화자 추가

Add 가 아닌 인덱서를 통해 채울 수 있게 되었습니다.

C#
// Before (C# 3.0~5.0): Add 만 가능 — 키 중복 시 예외
var d = new Dictionary<string, int> { { "A", 1 }, { "B", 2 } };

// After (C# 6.0+): 인덱서 초기화 — 같은 키는 마지막 값으로 덮어쓰기
var d2 = new Dictionary<string, int>
{
    ["A"] = 1,
    ["B"] = 2,
    ["A"] = 99,   // 덮어쓰기 OK
};

6.3 C# 9 (2020) — init 접근자

객체 초기화자의 가장 큰 도약입니다. 객체 초기화 시점에만 set 을 허용하는 setter 가 추가되었습니다.

C#
// After (C# 9+)
public class Cfg
{
    public string Url     { get; init; }
    public int    Timeout { get; init; }
}

var c = new Cfg { Url = "https://api", Timeout = 30 };
c.Url = "https://other";   // ❌ 컴파일 에러: init-only setter
init — 초기화 전용 접근자 (init-only accessor) set 자리에 쓰며, 객체 초기화자 안에서만 값을 할당할 수 있다. 객체가 만들어진 뒤에는 외부 코드에서 다시 대입할 수 없다. 불변(immutable) 데이터 모델을 객체 초기화자 문법으로 자연스럽게 만들기 위해 도입됐다.
예시: public string Name { get; init; } 생성/초기화 시점 1회만 대입 가능

IL 수준에서 init setter 는 일반 set 과 거의 같지만, 시그니처에 modreq IsExternalInit 이 붙습니다.

C#
public class Cfg
{
    public string Url { get; init; }
    public int Timeout { get; init; }
}

public static void Main()
{
    var c = new Cfg { Url = "https://api", Timeout = 30 };
}
IL
// init-only 프로퍼티의 setter 호출 (.NET 8 Release)
IL_0000: newobj   instance void Cfg::.ctor()
IL_0005: dup
IL_0006: ldstr    "https://api"
IL_000b: callvirt instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) Cfg::set_Url(string)
IL_0010: dup
IL_0011: ldc.i4.s 30
IL_0013: callvirt instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) Cfg::set_Timeout(int32)
IL_0018: pop
IL_0019: ret
modreq IsExternalInit — 필수 수정자 (Required Modifier) modreq 는 메서드 시그니처에 붙는 메타데이터 표시. IsExternalInit 가 붙은 setter 는 "객체 초기화자 안에서만 호출 가능" 이라는 규칙을 컴파일러가 강제하는 마커이다. 런타임은 이 표시를 보지 않으며, 순수하게 컴파일 타임 검사용이다.

호출 자체는 일반 callvirt 와 동일합니다. init 의 강제력은 컴파일러에서만 작동하며, 리플렉션이나 IL 직접 작성으로는 우회할 수 있습니다.

6.4 C# 9 (2020) — with 표현식

record 와 함께 도입된 with 는 객체 초기화자 문법을 "기존 객체의 일부만 바꾼 사본 만들기" 로 확장합니다.

C#
public record GameSettings(string Lang, int FrameRate, bool Fullscreen);

var defaults = new GameSettings("ko", 60, false);

// ✅ defaults 는 그대로, FrameRate 만 30 으로 바꾼 새 객체
var lowEndPreset = defaults with { FrameRate = 30 };

// ❌ 일반 클래스에는 with 불가 (record 또는 record struct, struct 만 지원)
public class GameSettingsClass { /* ... */ }
var s = new GameSettingsClass();
var s2 = s with { /* ... */ };   // 컴파일 에러

with 의 동작은 "런타임 클론 + 객체 초기화자 적용" 으로 이해하면 정확합니다. record 는 자동으로 Clone 메서드(정확히는 <Clone>$)를 생성하며, with 는 이를 호출한 뒤 객체 초기화자를 적용합니다.

6.5 C# 11 (2022) — required 키워드

객체 초기화자의 가장 큰 약점인 필수 프로퍼티 누락 위험 을 해결했습니다.

C#
// Before (C# 10 이전): 필수 프로퍼티를 표현할 방법 없음
public class Boss
{
    public string Name { get; init; }   // 사실은 필수인데 강제 못 함
    public int    Hp   { get; init; }
}
var b = new Boss { /* Name 누락 */ Hp = 100 };   // 컴파일 통과! 런타임에 Name 이 null

// After (C# 11+): required 로 컴파일 타임 강제
public class Boss
{
    public required string Name { get; init; }
    public required int    Hp   { get; init; }
}
var b2 = new Boss { Hp = 100 };
// ❌ 컴파일 에러: Required member 'Name' must be set
required — 필수 멤버 한정자 (Required modifier) 프로퍼티 또는 필드에 붙이며, 객체 초기화자에서 반드시 값을 할당해야 한다. 누락하면 컴파일 에러. C# 11 + .NET 7 이상에서 사용 가능하며, 같은 PART 의 required 멤버 (C# 11) 글에서 자세히 다룬다.

객체 초기화자가 진정으로 생성자를 대체할 수 있게 된 시점이 바로 C# 11 입니다. required + init 조합은 모던 C# 의 표준 데이터 클래스 설계 패턴입니다.


7. [정리]

이 글의 핵심을 7개 항목으로 압축합니다.

  • 객체 초기화자는 new + { Prop = Value } 형태로 객체 생성과 프로퍼티 대입을 한 표현식으로 묶는 문법이다. 생성자 호출이 먼저, 그 다음에 setter 들이 작성 순서대로 호출된다.
  • 언어 명세상은 "임시 변수 패턴"이지만, 실제 IL 은 dup 만으로 더 압축된다. Release 빌드에서 임시 지역 변수는 등장하지 않으며, 평가 스택 위에서 dup → setter → dup → setter 가 반복된다. 단일 변수 대입의 경우 전통 방식과 IL 이 사실상 동일하므로 런타임 비용 차이는 없다.
  • 부분 초기화 객체가 외부에 노출되지 않도록 보호한다 — 단, 단일 표현식 안에서만. 표현식 바깥에서 b.Foo = ... 식으로 분해하면 보호가 사라진다.
  • DTO·LINQ Select·테스트 데이터 구성에서 가독성이 가장 크게 좋아진다. 변수 이름 반복이 사라지고 코드가 데이터 표 처럼 읽힌다.
  • 모던 C# 에서는 init + required 조합이 객체 초기화자의 표준 동반자이다. 불변성과 필수 멤버 강제를 컴파일 타임에 모두 보장한다.
  • with 표현식은 객체 초기화자 문법을 비파괴적 변경(non-destructive mutation)으로 확장한 것이다. record 와 함께 사용하며, 내부적으로 클론 + 객체 초기화자가 적용된다.
  • Unity 에서 MonoBehaviournew 가 금지되므로 객체 초기화자도 못 쓴다. 대신 AddComponentInitialize(spawnInfo) 패턴이 표준이며, 이때 spawnInfo 같은 보조 데이터 객체에서 객체 초기화자가 빛난다. 핫패스에서는 매 프레임 새 객체 생성을 피하고, struct 또는 캐싱으로 GC 스파이크를 방지한다.
반응형

+ Recent posts