EveryDay.DevUp

생성자 — 객체를 올바르게 초기화하는 방법 본문

C# 심화

생성자 — 객체를 올바르게 초기화하는 방법

EveryDay.DevUp 2026. 4. 2. 22:34

생성자 — 객체를 올바르게 초기화하는 방법

new Player("전사", 100)를 호출하는 순간, 내부에서는 무슨 일이 벌어질까? 생성자의 호출 순서 하나만 잘못 이해해도 null 참조 버그가 생긴다. 생성자 체이닝, 정적 생성자, 객체 초기화자, 그리고 C#12 기본 생성자까지 — 객체 초기화의 모든 것을 IL 수준에서 파헤친다.


인스턴스 생성자와 생성자 체이닝

문제 제기

Unity에서 캐릭터를 만들 때를 생각해 보자. 전사는 이름·체력·방어력이 필요하고, 마법사는 이름·체력·마나가 필요하다. 클래스마다 생성자를 여러 개 만들다 보면 같은 초기화 코드가 여기저기 복사된다. 하나를 수정하면 나머지를 빠뜨리고, 결국 "이 필드는 왜 초기화가 안 됐지?"라는 버그를 만난다.

생성자가 하는 일과 호출 순서를 정확히 알면 이 문제를 구조적으로 방지할 수 있다.

개념 정의

생성자는 건물의 착공식과 같다. 부지(메모리)는 이미 확보되어 있고, 착공식(생성자)에서 전기·수도·가스(필드)를 연결하는 것이다. 착공식을 건너뛰면 빈 껍데기만 남는다.

new Player(

CLR(Common Language Runtime, C# 코드를 실행하는 런타임 엔진)이 new 키워드를 만나면 4단계를 수행한다. 핵심은 생성자가 메모리를 할당하는 것이 아니라, 이미 할당된 메모리 위에서 초기화만 담당한다는 점이다.

.ctor — 인스턴스 생성자의 IL 이름 IL(Intermediate Language, C# 코드가 컴파일되면 변환되는 중간 언어)에서 인스턴스 생성자는 .ctor라는 특수 메서드로 표현된다. newobj 명령어가 메모리 할당과 .ctor 호출을 함께 수행한다.
예시: newobj instance void Player::.ctor(string, int32) IL에서 new Player("전사", 100)에 해당하는 명령어

생성자를 하나도 작성하지 않으면 컴파일러가 매개변수 없는 기본 생성자를 자동 생성한다. 하지만 매개변수가 있는 생성자를 하나라도 직접 정의하면 기본 생성자는 사라진다.

C#
class Enemy
{
    public int Hp;

    public Enemy(int hp)
    {
        Hp = hp;
    }
}

// var e = new Enemy(); // 컴파일 에러 — 기본 생성자가 없다
IL
.class private auto ansi beforefieldinit Enemy
    extends [System.Runtime]System.Object
{
    .field public int32 Hp

    // 매개변수 있는 생성자만 존재 — 기본 생성자(.ctor())는 없다
    .method public hidebysig specialname rtspecialname 
        instance void .ctor (int32 hp) cil managed 
    {
        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ldarg.0
        IL_0007: ldarg.1
        IL_0008: stfld int32 Enemy::Hp
        IL_000d: ret
    }
}

call instance void Object::.ctor() — 모든 클래스는 Object를 상속하므로, 생성자에서 부모 생성자를 반드시 호출한다. 컴파일러가 base()를 자동 삽입하는 것이다.

내부 동작

필드를 선언과 동시에 초기화하면(= 값), 그 코드는 생성자 본문이 아니라 생성자 앞부분에 삽입된다. 이 순서를 IL로 직접 확인해 보자.

C#
class Character
{
    private string _name = "기본";
    private int _hp = 100;

    public Character(string name, int hp)
    {
        _name = name;
        _hp = hp;
    }
}
IL
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name, int32 hp) cil managed 
{
    // ① 필드 이니셜라이저가 먼저 실행된다
    IL_0000: ldarg.0
    IL_0001: ldstr "기본"
    IL_0006: stfld string Character::_name       // _name = "기본"
    IL_000b: ldarg.0
    IL_000c: ldc.i4.s 100
    IL_000e: stfld int32 Character::_hp           // _hp = 100

    // ② base() 호출
    IL_0013: ldarg.0
    IL_0014: call instance void [System.Runtime]System.Object::.ctor()

    // ③ 생성자 본문 — 매개변수로 덮어쓰기
    IL_0019: ldarg.0
    IL_001a: ldarg.1
    IL_001b: stfld string Character::_name       // _name = name
    IL_0020: ldarg.0
    IL_0021: ldarg.2
    IL_0022: stfld int32 Character::_hp           // _hp = hp
    IL_0027: ret
}

IL을 보면 _name_hp두 번씩 대입된다. 필드 이니셜라이저에서 "기본"100을 넣고, 생성자 본문에서 매개변수 값으로 다시 덮어쓴다. 매개변수로 반드시 덮어쓸 필드에 이니셜라이저를 붙이면 불필요한 대입이 발생한다는 뜻이다.

필드 초기화 순서 정리: 필드 이니셜라이저 → base() 호출 → 생성자 본문

생성자 체이닝: this()

: this() — 같은 클래스의 다른 생성자 호출 생성자 선언 뒤에 : this(인자)를 붙이면, 해당 생성자의 본문 실행 전에 같은 클래스의 다른 생성자를 먼저 호출한다.
예시: public Player(string name) : this(name, 100, 1) { } 매개변수 1개짜리 생성자가 3개짜리 마스터 생성자에 위임

여러 생성자에서 같은 초기화를 반복하는 대신, 하나의 마스터 생성자에 초기화를 몰아넣고 나머지는 위임한다.

C#
class Player
{
    public string Name;
    public int Hp;
    public int Level;

    // 마스터 생성자 — 모든 초기화를 여기서 수행
    public Player(string name, int hp, int level)
    {
        Name = name;
        Hp = hp;
        Level = level;
    }

    // this()로 마스터 생성자에 위임
    public Player(string name, int hp) : this(name, hp, 1) { }

    public Player(string name) : this(name, 100, 1) { }
}
IL
// 마스터 생성자 — base() 호출 + 필드 초기화
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name, int32 hp, int32 level) cil managed 
{
    IL_0000: ldarg.0
    IL_0001: call instance void [System.Runtime]System.Object::.ctor()
    IL_0006: ldarg.0
    IL_0007: ldarg.1
    IL_0008: stfld string Player::Name
    IL_000d: ldarg.0
    IL_000e: ldarg.2
    IL_000f: stfld int32 Player::Hp
    IL_0014: ldarg.0
    IL_0015: ldarg.3
    IL_0016: stfld int32 Player::Level
    IL_001b: ret
}

// 위임 생성자 — this()로 마스터 생성자를 call
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name, int32 hp) cil managed 
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: ldarg.2
    IL_0003: ldc.i4.1                                          // 기본값 1
    IL_0004: call instance void Player::.ctor(string, int32, int32)  // this() 호출
    IL_0009: ret
}

// 위임 생성자 — 역시 마스터 생성자를 call
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name) cil managed 
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: ldc.i4.s 100                                      // 기본값 100
    IL_0004: ldc.i4.1                                          // 기본값 1
    IL_0005: call instance void Player::.ctor(string, int32, int32)
    IL_000a: ret
}

위임 생성자는 Object::.ctor()를 직접 호출하지 않는다. 마스터 생성자에서 한 번만 호출된다. this() 체이닝은 IL에서 같은 클래스의 다른 .ctorcall하는 코드로 변환된다.

생성자 체이닝: base()

: base() — 부모 클래스의 생성자 호출 파생 클래스 생성자에서 : base(인자)를 붙이면, 부모 클래스의 해당 생성자를 먼저 호출한다. 명시하지 않으면 컴파일러가 base()(매개변수 없는 버전)를 자동 삽입한다.
예시: public Warrior(string name, int hp, int armor) : base(name, hp) { } 자식 생성자가 부모 Character(string, int) 생성자를 먼저 호출
C#
class Character
{
    public string Name;
    public int Hp;

    public Character(string name, int hp)
    {
        Name = name;
        Hp = hp;
    }
}

class Warrior : Character
{
    public int Armor;

    public Warrior(string name, int hp, int armor) : base(name, hp)
    {
        Armor = armor;
    }
}
IL
// Warrior 생성자 — base()가 먼저, 자기 필드는 나중
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name, int32 hp, int32 armor) cil managed 
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1           // name
    IL_0002: ldarg.2           // hp
    IL_0003: call instance void Character::.ctor(string, int32)  // base(name, hp) 먼저!
    IL_0008: ldarg.0
    IL_0009: ldarg.3           // armor
    IL_000a: stfld int32 Warrior::Armor                          // 그 다음 Armor 설정
    IL_000f: ret
}

Character::.ctor()Warrior::Armor 대입보다 먼저 실행된다. 부모가 완전히 초기화된 후 자식이 초기화된다 — 이 순서는 절대 바뀌지 않는다.

base()를 생략하면 컴파일러가 부모의 매개변수 없는 생성자를 자동 호출한다. 부모에 기본 생성자가 없으면 컴파일 에러가 난다.

실전 적용

Before: 중복 초기화

C#
class BadPlayer
{
    private string _name;
    private int _hp;
    private List<string> _items = new();

    public BadPlayer(string name, int hp)
    {
        _name = name;
        _hp = hp;
    }

    public BadPlayer(string name)
    {
        _name = name;
        _hp = 100;
    }
}
IL
// BadPlayer — 생성자 2개 모두에 _items = new() 초기화 코드가 복사됨

// .ctor(string, int32) — 32바이트
IL_0000: ldarg.0
IL_0001: newobj instance void class List`1<string>::.ctor()
IL_0006: stfld class List`1<string> BadPlayer::_items   // ← _items 초기화
IL_000b: ldarg.0
IL_000c: call instance void Object::.ctor()
// ... 필드 대입 ...

// .ctor(string) — 33바이트
IL_0000: ldarg.0
IL_0001: newobj instance void class List`1<string>::.ctor()
IL_0006: stfld class List`1<string> BadPlayer::_items   // ← 동일한 코드 반복!
IL_000b: ldarg.0
IL_000c: call instance void Object::.ctor()
// ... 필드 대입 ...

필드 이니셜라이저 _items = new()두 생성자 모두에 복사되었다. 생성자가 늘어날수록 이 복사도 늘어나 IL 코드가 비대해진다.

After: this() 체이닝

C#
class GoodPlayer
{
    private string _name;
    private int _hp;
    private List<string> _items = new();

    public GoodPlayer(string name, int hp)
    {
        _name = name;
        _hp = hp;
    }

    public GoodPlayer(string name) : this(name, 100) { }
}
IL
// GoodPlayer — 마스터 생성자에만 _items 초기화 존재

// .ctor(string, int32) — 32바이트 (마스터)
IL_0000: ldarg.0
IL_0001: newobj instance void class List`1<string>::.ctor()
IL_0006: stfld class List`1<string> GoodPlayer::_items   // ← 여기 한 곳만!
IL_000b: ldarg.0
IL_000c: call instance void Object::.ctor()
// ... 필드 대입 ...

// .ctor(string) — 10바이트 (위임)
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldc.i4.s 100
IL_0004: call instance void GoodPlayer::.ctor(string, int32)  // this() 위임만!
IL_0009: ret

위임 생성자는 10바이트로, 마스터 생성자를 call하는 코드만 들어있다. 필드 이니셜라이저가 중복 복사되지 않는다. 생성자가 아무리 많아져도 초기화 로직은 마스터 생성자 한 곳에만 존재한다.

함정과 주의사항

virtual / override — 가상 메서드와 재정의 virtual은 파생 클래스에서 재정의할 수 있는 메서드를 선언한다. override는 부모의 virtual 메서드를 파생 클래스에서 다른 구현으로 교체한다. 런타임에 객체의 실제 타입에 따라 호출될 메서드가 결정된다(다형성).
예시: protected virtual void Initialize() { }protected override void Initialize() { } 부모가 선언한 Initialize를 자식이 재정의

함정 1: 생성자에서 가상 메서드 호출

C#
// ❌ 잘못된 패턴 — 생성자에서 virtual 메서드 호출
class Base
{
    public Base()
    {
        Initialize(); // virtual 메서드 호출!
    }

    protected virtual void Initialize()
    {
        System.Console.WriteLine("Base 초기화");
    }
}

class Derived : Base
{
    private readonly string _name;

    public Derived(string name)
    {
        _name = name; // 이 줄은 Base() 이후에 실행된다
    }

    protected override void Initialize()
    {
        // _name이 아직 null — Base 생성자에서 호출되므로
        System.Console.WriteLine($"Derived: {_name}");
    }
}
IL
// Base::.ctor — callvirt로 가상 메서드를 호출한다
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ldarg.0
IL_0007: callvirt instance void Base::Initialize()   // ★ 가상 호출!
IL_000c: ret

// Derived::.ctor — _name 대입은 Base() 이후
IL_0000: ldarg.0
IL_0001: call instance void Base::.ctor()            // ★ 여기서 Initialize()가 호출됨
IL_0006: ldarg.0
IL_0007: ldarg.1
IL_0008: stfld string Derived::_name                 // _name 대입은 이제서야!
IL_000d: ret
callvirt — 가상 메서드 호출 명령어 런타임에 객체의 실제 타입을 확인하여 vtable(가상 메서드 테이블)에서 올바른 메서드를 찾아 호출한다. call과 달리 다형성을 지원한다.
예시: callvirt instance void Base::Initialize() Base 타입으로 선언되어 있지만, 런타임 타입이 Derived면 Derived.Initialize()가 호출됨

new Derived("전사")를 호출하면:

  1. Derived::.ctor → 즉시 Base::.ctor() 호출
  2. Base::.ctorcallvirt Initialize()Derived.Initialize()가 실행됨
  3. 이 시점에서 _name은 아직 nullstfld가 실행되기 전
  4. Base::.ctor 리턴 후 비로소 _name = name 실행

결론: 생성자에서는 절대로 virtual/abstract 메서드를 호출하지 말라. 파생 클래스의 필드가 아직 초기화되지 않은 상태에서 오버라이드된 메서드가 실행된다.

C#
// ✅ 올바른 패턴 — 생성자에서 직접 초기화
class Base
{
    public Base() { }
}

class Derived : Base
{
    private readonly string _name;

    public Derived(string name)
    {
        _name = name;
        Initialize(); // 모든 필드가 초기화된 후 호출
    }

    private void Initialize() // virtual이 아닌 private 메서드
    {
        System.Console.WriteLine($"Derived: {_name}");
    }
}

함정 2: Unity에서 MonoBehaviour에 생성자 사용

C#
// ❌ 잘못된 패턴
public class PlayerController : MonoBehaviour
{
    private int _hp;

    public PlayerController() // MonoBehaviour에 생성자 금지!
    {
        _hp = 100;
    }
}

MonoBehaviour는 Unity 엔진(C++ 네이티브 코드)이 내부적으로 객체를 생성하고 직렬화한다. new로 직접 생성하면 transform, gameObject 등이 null이고 Awake, Start도 호출되지 않는다.

C#
// ✅ 올바른 패턴 — Awake/Start를 생성자 대신 사용
public class PlayerController : MonoBehaviour
{
    private int _hp;
    private Rigidbody _rb;

    private void Awake()
    {
        _hp = 100; // 자기 자신 초기화
    }

    private void Start()
    {
        _rb = GetComponent<Rigidbody>(); // 다른 컴포넌트 참조
    }
}

Unity에서 생성자가 빛나는 곳은 MonoBehaviour를 상속하지 않는 순수 C# 클래스다.

C#
// 데이터 클래스 — 생성자로 유효성 보장
public class DamageInfo
{
    public int Amount { get; }
    public string Source { get; }

    public DamageInfo(int amount, string source)
    {
        if (amount < 0) throw new System.ArgumentOutOfRangeException(nameof(amount));
        Amount = amount;
        Source = source ?? throw new System.ArgumentNullException(nameof(source));
    }
}

C# 버전별 변화

버전 변화
C# 1.0 인스턴스 생성자, this()/base() 체이닝 도입
C# 7.0 식 본문 생성자 (=>) 지원
C# 10 struct에 매개변수 없는 생성자 허용

C# 10 이전에는 구조체에 매개변수 없는 생성자를 선언할 수 없었다.

C#
// C# 10 이전: 컴파일 에러
struct Damage
{
    public int Amount;
    // public Damage() { Amount = 10; } // ❌ 불가능
}

// C# 10 이후: 허용
struct Damage
{
    public int Amount;
    public Damage() { Amount = 10; } // ✅ 가능
}

단, default(Damage)new Damage[5]는 생성자를 호출하지 않고 모든 필드를 0으로 초기화한다. new Damage()만 생성자를 호출한다는 점에 주의하라.

정리

  • new는 메모리 할당 + 제로 초기화 + 생성자 호출 + 참조 반환의 4단계
  • 필드 초기화 순서: 필드 이니셜라이저 → base() → 생성자 본문
  • this() 체이닝으로 마스터 생성자에 초기화를 집중하면 IL 코드 중복을 방지한다
  • base()는 파생 클래스 생성자에서 항상 먼저 실행된다
  • 생성자에서 virtual 메서드를 호출하면 파생 클래스 필드가 초기화되기 전에 오버라이드가 실행된다
  • MonoBehaviour는 Awake/Start를, 순수 C# 클래스는 생성자를 사용한다

정적 생성자

문제 제기

게임에서 설정값을 한 번만 읽어서 전역으로 쓰고 싶을 때가 있다. 최대 플레이어 수, 서버 틱 레이트 같은 값이다. "언제 초기화하지?", "여러 스레드에서 동시에 접근하면?", "초기화에 실패하면?" — 정적 생성자는 이 세 가지 질문에 대한 CLR 수준의 답이다.

개념 정의

정적 생성자는 건물의 준공 검사와 같다. 건물(타입)이 처음 사용되기 전에 딱 한 번만 실행되고, 검사관(CLR)이 알아서 시점을 결정한다. 개발자가 직접 호출할 수 없다.

인스턴스 생성자(.ctor) vs 정적 생성자(.cctor)
C#
class GameConfig
{
    public static readonly int MaxPlayers;
    public static readonly int TickRate;

    static GameConfig()
    {
        MaxPlayers = 4;
        TickRate = 60;
    }
}
IL
// GameConfig — beforefieldinit 플래그가 없다
.class private auto ansi GameConfig
    extends [System.Runtime]System.Object
{
    .field public static initonly int32 MaxPlayers
    .field public static initonly int32 TickRate

    // 정적 생성자 — .cctor
    .method private hidebysig specialname rtspecialname static 
        void .cctor () cil managed 
    {
        IL_0000: ldc.i4.4
        IL_0001: stsfld int32 GameConfig::MaxPlayers   // 정적 필드에 저장
        IL_0006: ldc.i4.s 60
        IL_0008: stsfld int32 GameConfig::TickRate
        IL_000d: ret
    }
}

stsfld는 정적 필드에 값을 저장하는 IL 명령어다. 인스턴스 필드의 stfld와 달리 ldarg.0(this)이 필요 없다 — 정적 생성자는 인스턴스와 무관하기 때문이다.

static readonly — 런타임 상수 static readonly 필드는 정적 생성자 또는 필드 이니셜라이저에서만 값을 설정할 수 있다. const와 달리 런타임에 값이 결정되므로 객체 참조나 계산 결과를 담을 수 있다.
예시: public static readonly int MaxPlayers = 4; 컴파일 타임이 아닌 런타임에 한 번 설정되고 이후 변경 불가

정적 생성자에 접근 한정자를 붙일 수 없는 이유는 간단하다. 호출 주체가 CLR뿐이므로 "누가 호출할 수 있는가"를 제어할 의미가 없다.

내부 동작

beforefieldinit 플래그

정적 생성자를 명시적으로 작성하는지 여부에 따라 CLR의 초기화 타이밍이 달라진다.

C#
// 명시적 정적 생성자가 없음 — 필드 이니셜라이저만 사용
class FastInit
{
    public static readonly int Value = 42;
}
IL
// FastInit — beforefieldinit 플래그가 있다
.class private auto ansi beforefieldinit FastInit
    extends [System.Runtime]System.Object
{
    .field public static initonly int32 Value

    .method private hidebysig specialname rtspecialname static 
        void .cctor () cil managed 
    {
        IL_0000: ldc.i4.s 42
        IL_0002: stsfld int32 FastInit::Value
        IL_0007: ret
    }
}
  명시적 정적 생성자 (GameConfig) 필드 이니셜라이저만 (FastInit)
beforefieldinit 없음 있음
초기화 시점 멤버에 처음 접근하는 정확한 시점 접근 이전 아무 시점
JIT 최적화 제한적 자유로움 (더 빠름)

beforefieldinit가 있으면 JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환하는 컴파일러)가 초기화 시점을 자유롭게 선택할 수 있어 루프 최적화 등이 가능하다. 초기화 시점이 정확히 제어되어야 하는 경우가 아니라면 명시적 정적 생성자를 피하는 것이 성능에 유리하다.

스레드 안전성

CLR은 정적 생성자 실행 시 내부적으로 락을 건다. 여러 스레드가 동시에 해당 타입에 접근해도 정적 생성자는 한 스레드에서 한 번만 실행된다. 나머지 스레드는 완료를 기다린다.

하지만 이것이 함정이 되기도 한다 — 정적 생성자 안에서 다른 스레드를 기다리면 데드락이 발생한다.

실전 적용

싱글톤 패턴에서 정적 생성자의 스레드 안전성을 활용할 수 있다.

C#
// 정적 생성자를 활용한 싱글톤
class AudioManager
{
    private static readonly AudioManager _instance;

    static AudioManager()
    {
        _instance = new AudioManager();
    }

    private AudioManager() { } // private 생성자

    public static AudioManager Instance => _instance;
}

CLR이 스레드 안전성을 보장하므로 별도의 lock이나 Lazy<T> 없이도 안전하다. 다만 beforefieldinit가 제거되므로 성능에 민감한 핫패스(매 프레임 호출되는 경로)에서는 Lazy 패턴이 더 나을 수 있다.

함정과 주의사항

함정 1: 정적 생성자 예외 = 타입 사망

C#
// ❌ 정적 생성자에서 예외가 발생하면 타입이 영구 사용 불가
class BrokenConfig
{
    public static readonly string Data;

    static BrokenConfig()
    {
        // 파일이 없으면 예외 발생
        Data = System.IO.File.ReadAllText("config.json");
    }
}

정적 생성자에서 예외가 발생하면 TypeInitializationException이 던져지고, 이후 해당 타입에 접근할 때마다 같은 예외가 반복 발생한다. 앱을 재시작할 때까지 복구할 수 없다.

C#
// ✅ try-catch로 안전하게 처리
class SafeConfig
{
    public static readonly string Data;

    static SafeConfig()
    {
        try
        {
            Data = System.IO.File.ReadAllText("config.json");
        }
        catch
        {
            Data = "{}"; // 기본값으로 폴백
        }
    }
}

함정 2: Unity 도메인 리로드와 정적 생성자

Unity 에디터에서 Play Mode 진입/해제 시 도메인 리로드(Domain Reload)가 발생한다. 이때 정적 필드가 초기화된다.

하지만 도메인 리로드를 비활성화(Project Settings → Editor → Enter Play Mode Settings)하면 정적 필드가 이전 Play Mode의 값을 유지한다. 정적 생성자는 도메인 리로드가 일어나야만 다시 실행되기 때문이다.

C#
// ❌ 도메인 리로드 비활성화 시 이전 세션의 구독자가 남아있다
public class EventBus
{
    private static readonly List<System.Action> _handlers = new();

    public static void Subscribe(System.Action handler) => _handlers.Add(handler);
}
C#
// ✅ RuntimeInitializeOnLoadMethod로 명시적 초기화
public class EventBus
{
    private static List<System.Action> _handlers = new();

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void Init()
    {
        _handlers = new List<System.Action>(); // 매 Play Mode 진입마다 초기화
    }

    public static void Subscribe(System.Action handler) => _handlers.Add(handler);
}

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]은 도메인 리로드 여부와 관계없이 Play Mode 진입 시 항상 호출된다.

C# 버전별 변화

정적 생성자는 C# 1.0부터 존재하며 문법적 변화가 없다. beforefieldinit 동작은 CLR 명세에 의해 정의되어 있으며 C# 버전과 무관하다.

정리

  • 정적 생성자(.cctor)는 타입당 딱 한 번, CLR이 자동으로 호출한다
  • 접근 한정자와 매개변수를 가질 수 없다
  • 명시적 정적 생성자를 작성하면 beforefieldinit가 제거되어 JIT 최적화가 제한된다
  • CLR이 스레드 안전성을 보장하지만, 내부에서 다른 스레드를 기다리면 데드락이 발생한다
  • 예외 발생 시 타입이 영구 사용 불가 — 반드시 try-catch로 보호하라
  • Unity에서 도메인 리로드 비활성화 시 [RuntimeInitializeOnLoadMethod]로 명시적 초기화가 필요하다

객체 초기화자

문제 제기

아이템 정보를 만들 때 프로퍼티가 10개인 클래스를 생각해 보자. 10개 매개변수를 받는 생성자를 만들면 호출 시 인자 순서를 외워야 한다. 그렇다고 프로퍼티마다 setter를 열어두면 필수 값을 빠뜨릴 수 있다. 객체 초기화자는 이 딜레마를 해결하는 문법이다.

개념 정의

객체 초기화자는 이사 후 가구 배치와 같다. 집(객체)은 이미 완성되었고(생성자 실행 완료), 그 후에 가구(프로퍼티)를 하나씩 들여놓는 것이다. 중요한 건 집이 먼저 완성된 후 가구가 들어간다는 순서다.

객체 초기화자의 실제 실행 순서
C#
class Item
{
    public string Name { get; set; } = "";
    public int Price { get; set; }
    public int StackCount { get; set; } = 1;
}

class Program
{
    static void Main()
    {
        var item = new Item
        {
            Name = "체력 포션",
            Price = 500,
            StackCount = 99
        };
    }
}
IL
// Main — 객체 초기화자의 실제 IL
.method private hidebysig static void Main () cil managed
{
    IL_0000: newobj instance void Item::.ctor()                    // 1. 생성자 먼저
    IL_0005: dup                                                   // 스택에 참조 복제
    IL_0006: ldstr "체력 포션"
    IL_000b: callvirt instance void Item::set_Name(string)         // 2. Name setter
    IL_0010: dup
    IL_0011: ldc.i4 500
    IL_0016: callvirt instance void Item::set_Price(int32)         // 3. Price setter
    IL_001b: dup
    IL_001c: ldc.i4.s 99
    IL_001e: callvirt instance void Item::set_StackCount(int32)    // 4. StackCount setter
    IL_0023: pop
    IL_0024: ret
}

객체 초기화자는 문법적 설탕(syntactic sugar)이다. 컴파일러가 newobj로 생성자를 먼저 호출한 뒤, 각 프로퍼티의 setter를 callvirt로 순서대로 호출하는 코드로 변환한다.

dup은 스택 위의 참조를 복제하는 명령어다. 로컬 변수에 저장하지 않고 dup으로 복제하여 각 setter에 전달하는 것은 Release 빌드의 최적화다.

실행 순서 주의: 생성자에서 Name = "", StackCount = 1이 먼저 설정된 후, 초기화자에서 "체력 포션", 99로 덮어쓴다. 즉 Name은 두 번, StackCount도 두 번 대입된다.

내부 동작

객체 초기화자는 C# 명세상 원자적(atomic)으로 동작하도록 설계되어 있다. 초기화 도중 예외가 발생하면 변수에 참조가 할당되지 않는다.

개념적으로 컴파일러는 다음과 같이 변환한다:

C#
// 개발자가 쓰는 코드
var item = new Item { Name = "포션", Price = 500 };

// 컴파일러가 변환하는 코드 (개념적)
var _temp = new Item();      // 임시 변수에 생성
_temp.Name = "포션";
_temp.Price = 500;
var item = _temp;            // 모든 설정 완료 후 최종 변수에 할당

초기화 도중 Price setter에서 예외가 나면 item에는 할당되지 않으므로, 부분적으로 초기화된 객체가 노출되지 않는다. 단 Release 빌드에서 dup을 쓰는 최적화와 이 원자성 보장은 양립한다.

실전 적용

required — 필수 멤버 지정 (C# 11) 프로퍼티에 required를 붙이면 객체 초기화자에서 해당 멤버를 반드시 설정해야 컴파일된다. 생성자 없이도 필수 값 입력을 강제할 수 있다.
예시: public required string Name { get; init; } new Item { }처럼 Name을 생략하면 컴파일 에러
init — 초기화 전용 setter (C# 9) set 대신 init을 쓰면 객체 생성 시에만 값을 설정할 수 있고, 이후에는 변경할 수 없다. 불변 객체를 만들 때 유용하다.
예시: public string Name { get; init; } new Item { Name = "포션" } 이후 item.Name = "다른" 시도 시 컴파일 에러
C#
// 생성자 없이 필수값을 강제하는 패턴
class SkillData
{
    public required string Name { get; init; }
    public required int Damage { get; init; }
    public float Cooldown { get; init; } = 1.0f; // 선택 — 기본값 있음
}

// Name과 Damage를 반드시 설정해야 컴파일된다
var skill = new SkillData
{
    Name = "파이어볼",
    Damage = 50,
    Cooldown = 3.0f
};

// var bad = new SkillData { Name = "파이어볼" }; // 컴파일 에러! Damage 누락

required + init 조합은 생성자 오버로딩 없이도 필수 값 강제 + 불변성을 동시에 달성한다.

생성자에서 required 멤버를 모두 설정하는 경우 [SetsRequiredMembers] 어트리뷰트를 붙여야 한다.

C#
class SkillData
{
    public required string Name { get; init; }
    public required int Damage { get; init; }

    [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
    public SkillData(string name, int damage)
    {
        Name = name;
        Damage = damage;
    }
}

함정과 주의사항

함정: 객체 초기화자는 생성자를 대체하지 않는다

C#
// ❌ 잘못된 이해 — 객체 초기화자가 유효성 검사를 대신한다고 생각
class Item
{
    public string Name { get; set; } = "";
    public int Price { get; set; }
}

var item = new Item { Name = "", Price = -100 }; // 컴파일 OK — 유효하지 않은 값!

객체 초기화자는 setter를 호출할 뿐이므로 유효성 검사를 강제할 수 없다. 값의 유효성이 중요하면 생성자에서 검사하거나 setter에 검증 로직을 넣어야 한다.

C#
// ✅ 올바른 패턴 — 생성자에서 유효성 검사
class Item
{
    public string Name { get; }
    public int Price { get; }

    public Item(string name, int price)
    {
        if (string.IsNullOrEmpty(name))
            throw new System.ArgumentException("이름은 비어있을 수 없습니다", nameof(name));
        if (price < 0)
            throw new System.ArgumentOutOfRangeException(nameof(price));
        Name = name;
        Price = price;
    }
}

C# 버전별 변화

버전 변화
C# 3.0 객체 초기화자, 컬렉션 초기화자 도입
C# 9.0 init 전용 setter 도입
C# 11.0 required 멤버 도입
C#
// C# 3.0 이전: 생성자 호출 후 프로퍼티를 한 줄씩 설정
var item = new Item();
item.Name = "포션";
item.Price = 500;

// C# 3.0 이후: 객체 초기화자로 한 번에
var item = new Item { Name = "포션", Price = 500 };
C#
// C# 9.0: init으로 불변성 확보
class Item
{
    public string Name { get; init; } = "";
    public int Price { get; init; }
}

var item = new Item { Name = "포션", Price = 500 };
// item.Name = "다른 것"; // 컴파일 에러! init은 생성 시에만 설정 가능

정리

  • 객체 초기화자는 문법적 설탕 — IL에서는 newobj + callvirt set_* 순차 호출
  • 생성자가 항상 먼저 실행된 후 프로퍼티가 설정된다
  • required + init 조합으로 생성자 없이도 필수값 강제 + 불변성을 달성할 수 있다
  • 유효성 검사가 필요하면 객체 초기화자가 아닌 생성자나 setter에서 처리하라

기본 생성자 (C# 12)

문제 제기

서비스 클래스를 만들 때 DI(Dependency Injection, 외부에서 의존 객체를 주입하는 패턴) 컨테이너에서 주입받을 인터페이스가 3~4개면, 생성자에서 매개변수를 받아 _field에 대입하는 보일러플레이트가 반복된다. 매개변수 이름과 필드 이름을 따로 관리해야 하니 실수도 생긴다.

C#
// 매번 이런 코드를 반복해야 했다
class GameService
{
    private readonly ILogger _logger;
    private readonly IDatabase _database;

    public GameService(ILogger logger, IDatabase database)
    {
        _logger = logger;
        _database = database;
    }

    public void Start()
    {
        _logger.Log("시작");
        _database.Connect();
    }
}

C# 12의 기본 생성자는 이 보일러플레이트를 없앤다.

개념 정의

기본 생성자(Primary Constructor)는 명함에 적힌 역할과 같다. 클래스 이름 옆에 매개변수를 바로 적어서 "이 클래스는 이것들이 필요합니다"를 선언한다. 별도의 필드 선언과 대입 코드가 필요 없다.

class vs record — 기본 생성자 매개변수의 차이
C#
// C# 12 기본 생성자 — 보일러플레이트 제거
class Greeter(string name)
{
    public string Greet() => $"안녕하세요, {name}님!";
}
IL
.class private auto ansi beforefieldinit Greeter
    extends [System.Runtime]System.Object
{
    // 컴파일러가 생성한 숨겨진 필드
    .field private string '<name>P'

    .method public hidebysig specialname rtspecialname 
        instance void .ctor (string name) cil managed 
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld string Greeter::'<name>P'    // 매개변수 → 숨겨진 필드에 저장
        IL_0007: ldarg.0
        IL_0008: call instance void [System.Runtime]System.Object::.ctor()
        IL_000d: ret
    }

    .method public hidebysig instance string Greet () cil managed 
    {
        IL_0000: ldstr "안녕하세요, "
        IL_0005: ldarg.0
        IL_0006: ldfld string Greeter::'<name>P'    // 숨겨진 필드에서 읽기
        IL_000b: ldstr "님!"
        IL_0010: call string System.String::Concat(string, string, string)
        IL_0015: ret
    }
}

컴파일러가 <name>P라는 숨겨진 private 필드를 자동 생성한다. 생성자에서 stfld로 매개변수를 이 필드에 저장하고, Greet() 메서드에서 ldfld로 읽는다. 개발자 눈에는 매개변수처럼 보이지만, 실제로는 필드다.

내부 동작

record와의 결정적 차이

C#
record PlayerData(string Name, int Level);
IL
.class private auto ansi beforefieldinit PlayerData
    extends [System.Runtime]System.Object
    implements class System.IEquatable`1<class PlayerData>
{
    .field private initonly string '<Name>k__BackingField'     // initonly = 불변
    .field private initonly int32 '<Level>k__BackingField'

    // + get/init 프로퍼티
    // + ToString, Equals, GetHashCode, <Clone>$, Deconstruct 자동 생성
}
구분 class Greeter(string name) record PlayerData(string Name)
필드 이름 <name>P <Name>k__BackingField
필드 속성 private (변경 가능) private initonly (불변)
프로퍼티 없음 get + init 자동 생성
Equals 없음 값 기반 동등성 자동 구현
ToString Object 기본값 모든 프로퍼티 포함 자동 생성

핵심 차이: class의 기본 생성자 매개변수는 프로퍼티가 되지 않고, readonly도 아니다. 클래스 내부의 다른 메서드에서 값을 변경할 수 있다.

실전 적용

Before: 전통적 DI 패턴

C#
class GameService
{
    private readonly ILogger _logger;
    private readonly IDatabase _database;

    public GameService(ILogger logger, IDatabase database)
    {
        _logger = logger;
        _database = database;
    }

    public void Start()
    {
        _logger.Log("시작");
        _database.Connect();
    }
}

After: C# 12 기본 생성자

C#
class GameService(ILogger logger, IDatabase database)
{
    public void Start()
    {
        logger.Log("시작");
        database.Connect();
    }
}

필드 선언, 생성자 본문, 대입 코드가 모두 사라졌다. DI 컨테이너에서 서비스를 주입받는 패턴에 가장 적합하다.

함정과 주의사항

함정: 매개변수가 변경 가능하다

C#
// ❌ 기본 생성자 매개변수를 변경하면 혼란스럽다
class Counter(int initial)
{
    public int Current => initial;

    public void Increment()
    {
        initial++; // 컴파일 OK — 하지만 의도치 않은 상태 변경!
    }
}

record와 달리 class의 기본 생성자 매개변수는 readonly가 아니다. 의도치 않은 변경을 방지하려면 명시적 필드에 복사하라.

C#
// ✅ 명시적 readonly 필드에 복사
class Counter(int initial)
{
    private readonly int _initial = initial;
    private int _count = initial;

    public int Initial => _initial;
    public int Current => _count;

    public void Increment() => _count++;
}

주의: 기본 생성자와 다른 생성자의 공존

기본 생성자가 있는 클래스에서 추가 생성자를 선언하려면 반드시 : this()로 기본 생성자를 호출해야 한다.

C#
class Player(string name, int hp)
{
    // 추가 생성자는 반드시 this()로 기본 생성자를 호출해야 한다
    public Player(string name) : this(name, 100) { }
}

C# 버전별 변화

버전 변화
C# 9.0 record에서 기본 생성자 도입 — 매개변수가 자동으로 public 프로퍼티
C# 10.0 record struct에서 기본 생성자 지원
C# 12.0 classstruct에서도 기본 생성자 사용 가능 — 단, 프로퍼티 자동 생성 없음
C#
// C# 9.0 — record만 가능
record PlayerData(string Name, int Level);

// C# 12.0 — class/struct도 가능
class GameService(ILogger logger, IDatabase db)
{
    public void Run() => logger.Log("실행");
}

정리

  • 기본 생성자 매개변수는 컴파일러가 숨겨진 private 필드(<name>P)로 캡처한다
  • record와 달리 프로퍼티가 자동 생성되지 않고, readonly도 아니다
  • DI 패턴에서 보일러플레이트를 크게 줄여준다
  • 매개변수가 변경 가능하므로, 불변이 필요하면 명시적 readonly 필드에 복사하라
  • 추가 생성자는 반드시 : this()로 기본 생성자를 호출해야 한다