| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 직장인공부
- 샘플
- Dots
- Framework
- 게임개발
- 2D Camera
- AES
- C#
- 오공완
- 가이드
- 프레임워크
- RSA
- Unity Editor
- 패스트캠퍼스
- job
- sha
- DotsTween
- 암호화
- ui
- Tween
- TextMeshPro
- 직장인자기계발
- 최적화
- base64
- Job 시스템
- 환급챌린지
- 패스트캠퍼스후기
- unity
- Custom Package
- adfit
- Today
- Total
EveryDay.DevUp
생성자 — 객체를 올바르게 초기화하는 방법 본문
생성자 — 객체를 올바르게 초기화하는 방법
new Player("전사", 100)를 호출하는 순간, 내부에서는 무슨 일이 벌어질까? 생성자의 호출 순서 하나만 잘못 이해해도 null 참조 버그가 생긴다. 생성자 체이닝, 정적 생성자, 객체 초기화자, 그리고 C#12 기본 생성자까지 — 객체 초기화의 모든 것을 IL 수준에서 파헤친다.
인스턴스 생성자와 생성자 체이닝
문제 제기
Unity에서 캐릭터를 만들 때를 생각해 보자. 전사는 이름·체력·방어력이 필요하고, 마법사는 이름·체력·마나가 필요하다. 클래스마다 생성자를 여러 개 만들다 보면 같은 초기화 코드가 여기저기 복사된다. 하나를 수정하면 나머지를 빠뜨리고, 결국 "이 필드는 왜 초기화가 안 됐지?"라는 버그를 만난다.
생성자가 하는 일과 호출 순서를 정확히 알면 이 문제를 구조적으로 방지할 수 있다.
개념 정의
생성자는 건물의 착공식과 같다. 부지(메모리)는 이미 확보되어 있고, 착공식(생성자)에서 전기·수도·가스(필드)를 연결하는 것이다. 착공식을 건너뛰면 빈 껍데기만 남는다.
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)에 해당하는 명령어
생성자를 하나도 작성하지 않으면 컴파일러가 매개변수 없는 기본 생성자를 자동 생성한다. 하지만 매개변수가 있는 생성자를 하나라도 직접 정의하면 기본 생성자는 사라진다.
class Enemy
{
public int Hp;
public Enemy(int hp)
{
Hp = hp;
}
}
// var e = new Enemy(); // 컴파일 에러 — 기본 생성자가 없다
.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로 직접 확인해 보자.
class Character
{
private string _name = "기본";
private int _hp = 100;
public Character(string name, int hp)
{
_name = name;
_hp = hp;
}
}
.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개짜리 마스터 생성자에 위임
여러 생성자에서 같은 초기화를 반복하는 대신, 하나의 마스터 생성자에 초기화를 몰아넣고 나머지는 위임한다.
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) { }
}
// 마스터 생성자 — 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에서 같은 클래스의 다른 .ctor를 call하는 코드로 변환된다.
생성자 체이닝: base()
: base()— 부모 클래스의 생성자 호출 파생 클래스 생성자에서: base(인자)를 붙이면, 부모 클래스의 해당 생성자를 먼저 호출한다. 명시하지 않으면 컴파일러가base()(매개변수 없는 버전)를 자동 삽입한다.
예시:public Warrior(string name, int hp, int armor) : base(name, hp) { }자식 생성자가 부모Character(string, int)생성자를 먼저 호출
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;
}
}
// 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: 중복 초기화
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;
}
}
// 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() 체이닝
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) { }
}
// 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: 생성자에서 가상 메서드 호출
// ❌ 잘못된 패턴 — 생성자에서 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}");
}
}
// 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("전사")를 호출하면:
Derived::.ctor→ 즉시Base::.ctor()호출Base::.ctor→callvirt Initialize()→Derived.Initialize()가 실행됨- 이 시점에서
_name은 아직null—stfld가 실행되기 전 Base::.ctor리턴 후 비로소_name = name실행
결론: 생성자에서는 절대로 virtual/abstract 메서드를 호출하지 말라. 파생 클래스의 필드가 아직 초기화되지 않은 상태에서 오버라이드된 메서드가 실행된다.
// ✅ 올바른 패턴 — 생성자에서 직접 초기화
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에 생성자 사용
// ❌ 잘못된 패턴
public class PlayerController : MonoBehaviour
{
private int _hp;
public PlayerController() // MonoBehaviour에 생성자 금지!
{
_hp = 100;
}
}
MonoBehaviour는 Unity 엔진(C++ 네이티브 코드)이 내부적으로 객체를 생성하고 직렬화한다. new로 직접 생성하면 transform, gameObject 등이 null이고 Awake, Start도 호출되지 않는다.
// ✅ 올바른 패턴 — 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# 클래스다.
// 데이터 클래스 — 생성자로 유효성 보장
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# 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)이 알아서 시점을 결정한다. 개발자가 직접 호출할 수 없다.
class GameConfig
{
public static readonly int MaxPlayers;
public static readonly int TickRate;
static GameConfig()
{
MaxPlayers = 4;
TickRate = 60;
}
}
// 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의 초기화 타이밍이 달라진다.
// 명시적 정적 생성자가 없음 — 필드 이니셜라이저만 사용
class FastInit
{
public static readonly int Value = 42;
}
// 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은 정적 생성자 실행 시 내부적으로 락을 건다. 여러 스레드가 동시에 해당 타입에 접근해도 정적 생성자는 한 스레드에서 한 번만 실행된다. 나머지 스레드는 완료를 기다린다.
하지만 이것이 함정이 되기도 한다 — 정적 생성자 안에서 다른 스레드를 기다리면 데드락이 발생한다.
실전 적용
싱글톤 패턴에서 정적 생성자의 스레드 안전성을 활용할 수 있다.
// 정적 생성자를 활용한 싱글톤
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: 정적 생성자 예외 = 타입 사망
// ❌ 정적 생성자에서 예외가 발생하면 타입이 영구 사용 불가
class BrokenConfig
{
public static readonly string Data;
static BrokenConfig()
{
// 파일이 없으면 예외 발생
Data = System.IO.File.ReadAllText("config.json");
}
}
정적 생성자에서 예외가 발생하면 TypeInitializationException이 던져지고, 이후 해당 타입에 접근할 때마다 같은 예외가 반복 발생한다. 앱을 재시작할 때까지 복구할 수 없다.
// ✅ 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의 값을 유지한다. 정적 생성자는 도메인 리로드가 일어나야만 다시 실행되기 때문이다.
// ❌ 도메인 리로드 비활성화 시 이전 세션의 구독자가 남아있다
public class EventBus
{
private static readonly List<System.Action> _handlers = new();
public static void Subscribe(System.Action handler) => _handlers.Add(handler);
}
// ✅ 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를 열어두면 필수 값을 빠뜨릴 수 있다. 객체 초기화자는 이 딜레마를 해결하는 문법이다.
개념 정의
객체 초기화자는 이사 후 가구 배치와 같다. 집(객체)은 이미 완성되었고(생성자 실행 완료), 그 후에 가구(프로퍼티)를 하나씩 들여놓는 것이다. 중요한 건 집이 먼저 완성된 후 가구가 들어간다는 순서다.
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
};
}
}
// 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)으로 동작하도록 설계되어 있다. 초기화 도중 예외가 발생하면 변수에 참조가 할당되지 않는다.
개념적으로 컴파일러는 다음과 같이 변환한다:
// 개발자가 쓰는 코드
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 = "다른"시도 시 컴파일 에러
// 생성자 없이 필수값을 강제하는 패턴
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] 어트리뷰트를 붙여야 한다.
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;
}
}
함정과 주의사항
함정: 객체 초기화자는 생성자를 대체하지 않는다
// ❌ 잘못된 이해 — 객체 초기화자가 유효성 검사를 대신한다고 생각
class Item
{
public string Name { get; set; } = "";
public int Price { get; set; }
}
var item = new Item { Name = "", Price = -100 }; // 컴파일 OK — 유효하지 않은 값!
객체 초기화자는 setter를 호출할 뿐이므로 유효성 검사를 강제할 수 없다. 값의 유효성이 중요하면 생성자에서 검사하거나 setter에 검증 로직을 넣어야 한다.
// ✅ 올바른 패턴 — 생성자에서 유효성 검사
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# 3.0 이전: 생성자 호출 후 프로퍼티를 한 줄씩 설정
var item = new Item();
item.Name = "포션";
item.Price = 500;
// C# 3.0 이후: 객체 초기화자로 한 번에
var item = new Item { Name = "포션", Price = 500 };
// 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에 대입하는 보일러플레이트가 반복된다. 매개변수 이름과 필드 이름을 따로 관리해야 하니 실수도 생긴다.
// 매번 이런 코드를 반복해야 했다
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)는 명함에 적힌 역할과 같다. 클래스 이름 옆에 매개변수를 바로 적어서 "이 클래스는 이것들이 필요합니다"를 선언한다. 별도의 필드 선언과 대입 코드가 필요 없다.
// C# 12 기본 생성자 — 보일러플레이트 제거
class Greeter(string name)
{
public string Greet() => $"안녕하세요, {name}님!";
}
.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와의 결정적 차이
record PlayerData(string Name, int Level);
.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 패턴
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 기본 생성자
class GameService(ILogger logger, IDatabase database)
{
public void Start()
{
logger.Log("시작");
database.Connect();
}
}
필드 선언, 생성자 본문, 대입 코드가 모두 사라졌다. DI 컨테이너에서 서비스를 주입받는 패턴에 가장 적합하다.
함정과 주의사항
함정: 매개변수가 변경 가능하다
// ❌ 기본 생성자 매개변수를 변경하면 혼란스럽다
class Counter(int initial)
{
public int Current => initial;
public void Increment()
{
initial++; // 컴파일 OK — 하지만 의도치 않은 상태 변경!
}
}
record와 달리 class의 기본 생성자 매개변수는 readonly가 아니다. 의도치 않은 변경을 방지하려면 명시적 필드에 복사하라.
// ✅ 명시적 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()로 기본 생성자를 호출해야 한다.
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 | class와 struct에서도 기본 생성자 사용 가능 — 단, 프로퍼티 자동 생성 없음 |
// 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()로 기본 생성자를 호출해야 한다
'C# 심화' 카테고리의 다른 글
| 메서드 오버로딩 — 컴파일러는 어떤 메서드를 고르는가 (0) | 2026.04.02 |
|---|---|
| 프로퍼티 — 필드와 무엇이 다른가 (0) | 2026.04.02 |
| struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.01 |
| string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.01 |
| string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 (0) | 2026.03.31 |
