[PART2.변수와 기본 데이터 타입(1/13)] 변수 선언과 초기화 — int age = 10; 한 줄에 담긴 메모리와 안정성
스택에 자리 잡는 4바이트 / 기본값 0과 null의 경계 / Definite Assignment 가 지키는 것
목차
1. 이 개념이 왜 문제가 되는가
처음 C# 을 배울 때 int age = 10; 은 너무 당연해서 "한 줄을 읽는 눈" 이 사라집니다. 그런데 Unity 에서 다음 코드를 인스펙터로 연 순간부터 질문이 시작됩니다.
public class PlayerController : MonoBehaviour
{
[SerializeField] private float speed = 5.0f; // (1) 코드에 5를 썼다
private int damage; // (2) 아무것도 안 썼다
private Rigidbody rb; // (3) 참조인데 = null 도 안 썼다
void Start()
{
rb.AddForce(Vector3.up); // NullReferenceException!
}
}
- (1) 인스펙터에서
speed를 10으로 바꿨는데, 게임 시작 시점에 정확히 어떤 값이 들어가나요? - (2)
damage는 정말로 0인가요, 아니면 "쓰레기 값" 인가요? - (3)
rb는 왜NullReferenceException을 던지나요? "초기화 안 하면 0이라며?"
이 세 질문은 같은 주제 — "선언·초기화가 메모리에서 어떻게 일어나는가" — 를 서로 다른 각도에서 묻는 것입니다. 하나라도 흐릿하면 Unity 핫패스(Update, FixedUpdate 등 매 프레임 도는 코드 경로)에서 원인 모를 버그와 할당을 만납니다. 이 글은 "int age = 10; 한 줄" 을 스택·IL·생성자 실행 순서까지 끌고 내려가 위의 세 질문에 모두 답합니다.
2. 개념 정의 — 선언·초기화·사용의 세 박자
2.1 비유: 서랍에 이름표 붙이고, 값을 집어넣고, 꺼내 쓴다
변수 한 개를 다루는 일은 책상 서랍 한 칸을 사용하는 것과 같습니다.
- 선언 (Declaration) — 서랍에
age라는 이름표를 붙인다. 크기(4바이트)도 미리 정해둔다. - 초기화 (Initialization) — 서랍에
10이라는 종이를 집어넣는다. 처음으로 값을 넣는 순간을 특별히 이렇게 부른다. - 사용 (Usage) — 서랍을 열어 종이를 꺼내 읽는다.
int age = 10; 한 줄은 선언과 초기화를 동시에 해주는 축약형입니다.

2.2 기본 코드와 IL — 스택 한 칸의 일대기
public class Program
{
public static void Main()
{
int age = 10;
System.Console.WriteLine(age);
}
}
.method public hidebysig static void Main () cil managed
{
.maxstack 1
.entrypoint
.locals init (
[0] int32 // age 를 위한 스택 슬롯 0번, int32(4바이트) 확보
)
IL_0000: nop
IL_0001: ldc.i4.s 10 // 상수 10 을 평가 스택에 올린다 (load constant int32)
IL_0003: stloc.0 // 평가 스택의 10 을 지역 슬롯 0번(age)에 저장 (store local)
IL_0004: ldloc.0 // 슬롯 0번(age)의 값을 다시 평가 스택에 올린다 (load local)
IL_0005: call void [System.Console]System.Console::WriteLine(int32)
IL_000a: nop
IL_000b: ret
}
IL 해설
ldloc/stloc/ldc— 변수 한 칸의 기본 동작 3명령
ldc.*: load constant — 상수(리터럴)를 평가 스택에 올린다. 숫자 크기에 따라ldc.i4.s,ldc.r4,ldstr등으로 갈라진다.stloc.N: store local — 평가 스택 최상단 값을 N번 지역 슬롯에 저장한다.ldloc.N: load local — N번 지역 슬롯의 값을 평가 스택에 복사해 올린다.
1. .locals init ([0] int32) — 메서드가 호출되면 JIT 은 스택 프레임을 만들면서 이 선언대로 4바이트를 밀어올려 놓고 0으로 밀어둡니다(init 플래그). 선언 자체가 슬롯 확보이고, 그 자리가 바로 age 입니다.
2. ldc.i4.s 10 → stloc.0 — 초기화 = 10 은 실제로 이 두 명령입니다. ldc 로 10을 평가 스택에 올리고, stloc.0 으로 슬롯 0에 박습니다. 이 두 명령은 값 타입이므로 힙을 쓰지 않습니다 — GC와 무관합니다.
3. ldloc.0 → call WriteLine — 사용(WriteLine(age))은 슬롯에서 값을 복사해 올려 메서드에 넘기는 것입니다. 값 타입 지역 변수 사용은 복사가 기본입니다.
Unity 관점 — 왜int지역 변수에는 GC 걱정이 없는가.locals init으로 확보된 값 타입 슬롯은 메서드 호출 시 스택 프레임에 얹히고, 메서드 종료 시 프레임이 통째로 팝되면서 사라집니다. GC의 Mark & Sweep 대상이 아닙니다. 그래서Update()루프 안에서int speed = 10;같은 지역 변수는 아무리 호출돼도 GC 스파이크를 만들지 않습니다.
3. 내부 동작 — 기본값은 어디서 누가 채우는가
3.1 값 타입 vs 참조 타입의 기본값
필드와 배열은 명시적으로 초기화하지 않아도 CLR(Common Language Runtime, .NET의 실행 엔진) 이 모든 비트를 0으로 밀어주는 규칙을 가집니다. 그 결과가 타입에 따라 다르게 "해석" 될 뿐입니다.
CLR (Common Language Runtime) .NET 프로그램이 실제로 실행되는 런타임. 메모리 할당, GC, 타입 안전성, 예외 처리를 담당한다. C# 컴파일러는 IL 만 만들고, 이 IL 을 기계어로 바꾸고 메모리를 관리하는 것은 CLR 이다.

public class Enemy
{
public int hp; // 필드 - 자동 0으로 초기화
public string name; // 필드 - 자동 null로 초기화
}
public class Program
{
public static void Main()
{
Enemy e = new Enemy();
int defaultInt = default; // default 키워드
string defaultStr = default;
System.Console.WriteLine(e.hp); // 0
System.Console.WriteLine(e.name == null);// True
System.Console.WriteLine(defaultInt); // 0
System.Console.WriteLine(defaultStr == null); // True
}
}
.class public auto ansi beforefieldinit Enemy
extends [System.Runtime]System.Object
{
.field public int32 hp
.field public string name
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// 주목: stfld 로 hp=0 이나 name=null 을 "명시적으로 쓰는" 코드가 없다
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor() // base(): object 생성자만 호출
IL_0006: nop
IL_0007: ret
}
}
.method public hidebysig static void Main () cil managed
{
.locals init (
[0] class Enemy,
[1] int32, // defaultInt
[2] string // defaultStr
)
IL_0001: newobj instance void Enemy::.ctor() // 힙 할당 + 메모리 0으로 밀고 + ctor 호출
IL_0006: stloc.0
IL_0007: ldc.i4.0 // default(int) == 0
IL_0008: stloc.1
IL_0009: ldnull // default(string) == null
IL_000a: stloc.2
// ... 출력 생략
}
IL 해설
1. Enemy::.ctor() 안에 stfld 가 없다 — 개발자가 hp = 0, name = null 을 직접 쓸 필요가 없는 이유는, CLR이 newobj 단계에서 객체 메모리 전체를 0으로 밀어주기 때문입니다. 그래서 생성자 IL 은 object::.ctor() 호출 한 번만으로 끝납니다. 이것이 C/C++ 의 "쓰레기 값" 문제가 C# 에 없는 결정적 이유입니다.
2. default 는 타입에 따라 다른 IL 로 내려간다 — default(int) 는 ldc.i4.0 (정수 0 로드), default(string) 은 ldnull (null 참조 로드) 로 컴파일 타임에 각각 다르게 변환됩니다. default 는 런타임에 타입을 조회하는 키워드가 아니라, 컴파일러가 타입 맥락을 보고 0 비트를 알맞게 표현해주는 문법 설탕입니다.
3. e.name == null 이 예외 없이 True 를 반환한다 — name 필드의 비트가 0이고 참조 타입의 0은 null 로 해석되므로, 비교만 하는 한 예외는 없습니다. 예외는 그 null 참조를 역참조(e.name.Length) 할 때 발생합니다.
3.2 지역 변수는 예외 — Definite Assignment
필드·배열·값 타입 기본 생성자는 "자동 0 초기화" 혜택을 받지만, 지역 변수만큼은 자동으로 0이 되지 않습니다. 컴파일러가 CS0165: Use of unassigned local variable 오류로 사용 자체를 막기 때문입니다.

이 규칙은 안전 장치입니다. CLR 이 필드/배열에서는 0 을 보장하지만, 지역 변수 슬롯은 .locals init 이 붙지 않으면 정말로 이전 스택 프레임의 잔여 비트가 남을 수 있습니다. 설령 .locals init 이 붙어 0 이 보장되더라도, 컴파일러는 "의도적으로" 초기화했는지를 구분해 프로그래머의 실수를 잡아줍니다.
public static int Calc(bool flag)
{
int damage;
if (flag)
damage = 10;
else
damage = 0; // 모든 경로에서 초기화 → 컴파일 통과
return damage;
}
.method public hidebysig static int32 Calc (bool flag) cil managed
{
.locals init (
[0] int32, // damage
[1] bool,
[2] int32
)
IL_0001: ldarg.0
IL_0002: stloc.1
IL_0003: ldloc.1
IL_0004: brfalse.s IL_000b // flag == false 면 IL_000b 로 점프
IL_0006: ldc.i4.s 10 // true 경로: damage = 10
IL_0008: stloc.0
IL_0009: br.s IL_000d // 분기 종료 — 병합점으로
IL_000b: ldc.i4.0 // false 경로: damage = 0
IL_000c: stloc.0
IL_000d: ldloc.0 // 어느 경로로 왔든 damage 는 할당됨
IL_000e: stloc.2
IL_0011: ldloc.2
IL_0012: ret
}
IL 해설 — 컴파일러는 brfalse.s 로 갈라진 두 경로를 모두 추적하며 병합점(IL_000d) 까지 모든 경로에서 stloc.0 이 실행되는지 정적으로 검사합니다. 한쪽 경로에서라도 stloc.0 이 누락되면 그 경로에서 ldloc.0 을 쓸 때 CS0165 로 거부됩니다. 런타임 비용이 아니라 컴파일 타임 검증입니다.
3.3 struct 의 0 초기화 — initobj
값 타입을 default 나 매개변수 없는 new() 로 만들면, 힙에 가지 않고 스택에 잡은 영역의 모든 바이트를 0으로 밀어버리는 initobj 명령이 실행됩니다.
public struct Vec3
{
public float x;
public float y;
public float z;
}
public class Program
{
public static void Main()
{
Vec3 a = default; // initobj 로 모든 바이트 0
Vec3 b = new Vec3(); // 동일: initobj
}
}
.locals init (
[0] valuetype Vec3, // a (12바이트 스택 영역)
[1] valuetype Vec3 // b
)
IL_0001: ldloca.s 0 // a 의 주소를 평가 스택에 올린다 (load local address)
IL_0003: initobj Vec3 // 그 주소의 sizeof(Vec3) 만큼을 0으로 민다 — 힙 할당 없음
IL_0009: ldloca.s 1 // b 의 주소
IL_000b: initobj Vec3 // 동일 — new Vec3() 도 initobj 로 컴파일
IL 해설
ldlocavsldloc— 값 복사와 주소 접근ldloc.N은 N번 슬롯의 값을 복사해 올리고,ldloca.N은 N번 슬롯의 주소를 올린다.initobj처럼 "그 자리에 쓰기" 를 해야 하는 명령은 주소를 받아야 하므로 반드시ldloca가 쓰인다.
1. new Vec3() == default(Vec3) — IL 레벨에서 완벽히 동일 — C# 구문은 달라 보이지만 둘 다 ldloca + initobj 로 컴파일됩니다. 힙 할당(newobj) 이 아닙니다. Unity 에서 new Vector3() 가 GC 를 만들지 않는 이유가 여기 있습니다.
2. initobj 는 memset(ptr, 0, size) 의 IL 버전 — struct 가 커도(예: 64바이트) 한 번의 명령으로 밀립니다. 다만 struct 가 크면 복사 비용도 같이 커진다는 건 별개의 이야기입니다.
Unity 함정 —default(Quaternion)는 회전이 깨진다Quaternion도 struct 이므로default는(x=0, y=0, z=0, w=0)이 됩니다. 그런데 단위 회전은(0, 0, 0, 1)— 즉 w=1 이 필요합니다.default로 만든 Quaternion 을 트랜스폼에 대입하면 객체가 0 크기로 일그러집니다. 항상Quaternion.identity를 써야 합니다. 이 함정은 "기본값 = 모든 비트 0" 이라는 CLR 의 보편 규칙이 의미론적 단위 원소(identity) 와 일치하지 않을 때 일어납니다.
4. 실전 적용 — 필드·인스펙터·상수를 구분해 쓴다
4.1 필드 이니셜라이저는 "생성자보다 먼저" 실행된다
C# 신입이 가장 자주 혼동하는 지점입니다. 아래 코드의 필드 speed = 5.0f 는 언제 어디서 실행될까요?
public class PlayerController
{
public float speed = 5.0f;
public string stateName = "Idle";
}
IL 은 이렇게 생성됩니다.
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
IL_0000: ldarg.0
IL_0001: ldc.r4 5 // 5.0f 를 평가 스택에
IL_0006: stfld float32 PlayerController::speed // this.speed = 5.0f
IL_000b: ldarg.0
IL_000c: ldstr "Idle"
IL_0011: stfld string PlayerController::stateName // this.stateName = "Idle"
IL_0016: ldarg.0
IL_0017: call instance void [System.Runtime]System.Object::.ctor() // base()
IL_001c: nop
IL_001d: ret
}
IL 해설 — 필드 이니셜라이저의 위치가 핵심 — stfld 두 개가 object::.ctor() 호출보다 먼저 실행됩니다. 즉 필드 이니셜라이저는 생성자 본문이 아니라 "생성자의 가장 앞" 에서 실행됩니다. 이것이 바로 [SerializeField] private float speed = 5f; 필드 값과 인스펙터 값이 충돌하는 지점의 근원입니다.

4.2 Before/After — 무엇을 필드 이니셜라이저로 둘 것인가
❌ Before — 참조 타입을 필드 이니셜라이저로 만들기
public class InventoryBad : MonoBehaviour
{
// 필드 이니셜라이저는 생성자보다 먼저 — Unity 가 인스턴스를 만드는
// 시점(=씬 로드, Instantiate)마다 매번 new 가 호출된다.
// 심지어 역직렬화가 이 List 를 곧바로 덮어쓰므로 방금 만든 객체가 버려진다.
[SerializeField] private List<string> items = new List<string>();
}
✅ After — Awake 에서 "필요한 순간에만" 초기화
public class InventoryGood : MonoBehaviour
{
[SerializeField] private List<string> items; // null 허용 — 역직렬화가 담당
void Awake()
{
// 인스펙터에서 비워둔 경우에만 1회 new
if (items == null)
items = new List<string>();
}
}
IL 관점 요지 — Before 의 items = new List<string>() 는 .ctor IL 에 newobj List<string>::.ctor() 를 찍어넣습니다. Unity 가 MonoBehaviour 를 만들 때마다 이 newobj 가 실행되고, 이어서 역직렬화가 다시 새 List 를 박아 넣습니다. 방금 할당한 List 는 바로 쓰레기가 되어 GC 대상이 됩니다. After 는 그 newobj 를 Awake 안 조건부 경로로 옮겨 인스펙터 사용 시 할당 0회를 만듭니다.
4.3 const vs static readonly — 컴파일 타임 상수 vs 런타임 상수
public class GameConfig
{
public const float MaxSpeed = 10f; // 컴파일 타임 리터럴
public static readonly float Gravity = -9.81f; // 런타임에 한 번 평가
}
public class Program
{
public static void Main()
{
float s = GameConfig.MaxSpeed;
float g = GameConfig.Gravity;
}
}
.class public auto ansi beforefieldinit GameConfig
extends [System.Runtime]System.Object
{
.field public static literal float32 MaxSpeed = float32(10) // literal — 값이 메타데이터에 박힘
.field public static initonly float32 Gravity // initonly — 정적 생성자에서만 쓰기 가능
.method private hidebysig specialname rtspecialname static
void .cctor () cil managed // 정적 생성자
{
IL_0000: ldc.r4 -9.81
IL_0005: stsfld float32 GameConfig::Gravity // 처음 클래스 사용 시 1회 실행
IL_000a: ret
}
}
.method public hidebysig static void Main () cil managed
{
IL_0001: ldc.r4 10 // const → 리터럴 그대로 박힘!
IL_0006: stloc.0
IL_0007: ldsfld float32 GameConfig::Gravity // static readonly → 필드 로드
IL_000c: stloc.1
// ...
}
IL 해설
readonly/const— 불변성의 두 가지 표현
const: 컴파일 타임 상수. 선언과 동시에 초기화해야 하며, 원시 타입·문자열만 가능.readonly: 런타임 상수. 생성자(인스턴스면.ctor, 정적이면.cctor) 에서만 할당 가능.static readonly: 타입 단위 런타임 상수. 런타임 값(예:DateTime.Now) 도 담을 수 있다.
1. const 는 호출 측에 값이 박힌다 — ldc.r4 10 — Program::Main 안에서 GameConfig::MaxSpeed 를 참조하는 IL 자체가 사라졌습니다. 컴파일러가 10 이라는 리터럴을 그 자리에 박아버렸기 때문입니다. 이것은 성능상 이점이지만, 다른 어셈블리에서 참조할 때는 지뢰입니다. DLL-A 에 const MaxSpeed = 10 을 정의하고 DLL-B 에서 쓰면, DLL-A 를 15로 바꿔 배포해도 DLL-B 를 재컴파일하지 않으면 DLL-B 에는 여전히 10 이 박혀 있습니다.
2. static readonly 는 정적 생성자에서 한 번 실행 — .cctor 에 ldc.r4 -9.81 → stsfld 가 들어갑니다. 사용처에서는 ldsfld 로 매번 필드를 읽어옵니다. 따라서 값을 바꿔 재배포하면 즉시 반영됩니다.
3. Unity 실전 선택 기준 — 게임 내 튜닝 수치(중력, 최대 속도) 는 대부분 static readonly 또는 ScriptableObject 가 맞습니다. const 는 "수식에 등장하는 수학 상수" 처럼 값이 진짜로 변하지 않는 것에만 씁니다. Unity Asset Bundles 나 AssetBundle-분리 빌드를 쓸 때 const 를 재정의하고 어셈블리를 분리 배포하면 값 불일치로 디버깅이 어려워집니다.
5. 함정과 주의사항
5.1 함정 1 — var 로 null 을 선언할 수 없다
var 는 선언과 동시에 타입을 추론할 근거 가 필요합니다. null 은 타입이 없으므로 거부됩니다.
❌ Before
// 컴파일 오류 CS0815: Cannot assign <null> to an implicitly-typed variable
// var target = null;
✅ After — 명시적 타입 선언
public class Program
{
public static void Main()
{
GameObject? target = null; // 명시적 선언
var backup = (GameObject?)null; // 또는 캐스트로 타입 힌트
}
}
5.2 함정 2 — var 는 동적 타입이 아니다 (IL로 확인)
var 는 컴파일 타임 타입 추론 일 뿐이며, 런타임에는 명시적 타입과 완전히 동일합니다.
public static void Main()
{
var count = 5;
var name = "Hero";
System.Console.WriteLine(count);
System.Console.WriteLine(name);
}
.locals init (
[0] int32, // var count → int32 로 고정됨
[1] string // var name → string 으로 고정됨
)
IL_0001: ldc.i4.5
IL_0002: stloc.0
IL_0003: ldstr "Hero"
IL_0008: stloc.1
IL 해설 — .locals init 에 int32, string 타입이 그대로 박혀 있습니다. var 는 IL 수준에서 흔적이 없습니다. dynamic 과 혼동하지 마세요 — dynamic 은 System.Object + CallSite 인프라를 끌어와 런타임 바인딩을 만들지만, var 는 단순한 속기법입니다.
5.3 함정 3 — Unity [SerializeField] 와 필드 이니셜라이저의 충돌
❌ Before
public class Enemy : MonoBehaviour
{
// "기본 체력을 100으로 설정했다고 생각" — 하지만...
[SerializeField] private int hp = 100;
void Start()
{
// 인스펙터에서 hp 를 50으로 바꾼 오브젝트를 씬에 배치했다면,
// 이 시점의 hp 는 100 이 아니라 50 이다.
Debug.Log(hp);
}
}
.ctorIL 은hp = 100을 실행하지만, 그 직후 역직렬화가 인스펙터 값으로 덮어씁니다.- 신규 오브젝트(
new Enemy()비권장 — Unity 는AddComponent<Enemy>()) 에서도 역직렬화가 개입하면hp = 100은 무효화됩니다.
✅ After — 인스펙터가 주 소스임을 전제로 한다
public class Enemy : MonoBehaviour
{
[SerializeField] private int hp; // 이니셜라이저 제거 — 인스펙터에서만 세팅
[SerializeField] private int maxHp;
void Awake()
{
// 인스펙터에서 빠뜨렸을 경우에만 기본값 보정
if (maxHp <= 0) maxHp = 100;
if (hp <= 0) hp = maxHp;
}
}
핵심 — [SerializeField] 가 붙은 필드의 "기본값" 은 코드가 아니라 인스펙터 에 쓰는 것이 원칙입니다. 코드 이니셜라이저는 Unity 의 직렬화 파이프라인과 이중으로 관리되어 혼란을 키웁니다.
5.4 함정 4 — readonly 는 "얕은 불변성" 만 보장한다
readonly 는 필드에 새 참조를 대입하는 것만 막습니다. 참조가 가리키는 객체의 내부는 얼마든지 바꿀 수 있습니다.
❌ Before — readonly List 가 "불변 리스트" 라고 착각
public class Inventory
{
public readonly List<string> items = new List<string>();
public void ResetItems()
{
// items = new List<string>(); // 컴파일 오류 — 여긴 막힌다
items.Clear(); // 하지만 내부는 이렇게 비울 수 있다!
items.Add("Broken");
}
}
✅ After — 진짜 불변이 필요하면 ImmutableArray 또는 IReadOnlyList
using System.Collections.Generic;
using System.Collections.Immutable;
public class InventoryImmutable
{
public readonly ImmutableArray<string> items;
public InventoryImmutable(IEnumerable<string> initial)
{
items = initial.ToImmutableArray(); // 이후 items.Add 는 새 ImmutableArray 반환
}
}
6. C# 버전별 변화
6.1 C# 3 — var 도입
// 이전: Dictionary<string, List<Enemy>> enemies = new Dictionary<string, List<Enemy>>();
var enemies = new Dictionary<string, List<Enemy>>(); // 타입 반복 제거
IL 관점에서 차이는 없습니다 (앞의 5.2 참고). 목적은 오직 가독성.
6.2 C# 7.1 — default 리터럴
// 이전: int x = default(int);
int x = default; // 타입 맥락에서 추론 가능하면 생략
default 와 default(T) 는 IL 수준에서 완전히 동일한 ldc.i4.0 / ldnull / initobj 로 컴파일됩니다.
6.3 C# 9 — target-typed new
// 이전: List<int> scores = new List<int>();
List<int> scores = new();
scores.Add(100);
IL_0001: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldc.i4.s 100
IL_000a: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
IL 해설 — new() 는 new List<int>() 와 완전히 동일한 newobj IL 을 생성합니다. var 와 달리 좌변 타입이 명시적으로 필요하지만, 제네릭 타입명 반복을 줄여줍니다.
6.4 C# 9 — init 세터와 불변 객체
public class Hero
{
public string Name { get; init; } // 객체 초기화자에서만 할당 가능
public int Hp { get; init; }
}
// 사용
var h = new Hero { Name = "Archer", Hp = 100 };
// h.Name = "Mage"; // 컴파일 오류 — init 이후에는 불변
IL 에서 init 세터는 set_Name 메서드에 modreq(IsExternalInit) 가 붙은 형태로 생성됩니다. C# 컴파일러는 객체 초기화 블록에서만 이 세터를 호출하도록 허용하고, 이후에는 거부합니다.
6.5 C# 11 — required 수식어
public class Enemy
{
public required string Name { get; init; } // 객체 초기화자에서 반드시 할당
public int Hp { get; init; }
}
// 사용
var e = new Enemy { Name = "Slime", Hp = 50 }; // OK
// var e2 = new Enemy(); // 컴파일 오류 CS9035: Required member 'Enemy.Name' must be set
.class public auto ansi beforefieldinit Enemy
... RequiredMemberAttribute ...
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// 주의: .ctor 에 CompilerFeatureRequiredAttribute("RequiredMembers") 가 붙는다
// 컴파일러는 객체 초기화자에 Name 할당이 없으면 이 ctor 호출을 거부한다
}
// 사용 측 IL
IL_0001: newobj instance void Enemy::.ctor()
IL_0006: dup // 스택에 참조 복제 — 반복 setter 호출용
IL_0007: ldstr "Slime"
IL_000c: callvirt instance void modreq(IsExternalInit) Enemy::set_Name(string)
IL_0012: dup
IL_0013: ldc.i4.s 50
IL_0015: callvirt instance void modreq(IsExternalInit) Enemy::set_Hp(int32)
IL 해설 — required 는 런타임 검사가 아닙니다. 컴파일러가 CompilerFeatureRequiredAttribute 를 .ctor 에 붙이고, 호출 측에서 초기화자로 해당 필드를 쓰지 않으면 컴파일을 거부하는 컴파일 타임 강제 입니다. 런타임 성능 비용은 0. Unity SerializeField 대체품이 아님에 주의 — Unity 직렬화는 매개변수 없는 생성자를 요구하므로 required 와 충돌할 수 있습니다.
7. 정리 — 이것만 기억한다
| 체크 | 핵심 내용 |
|---|---|
| ☑ | int age = 10; 은 스택 슬롯 할당(.locals init) + ldc + stloc 세 동작의 축약. 값 타입 지역 변수는 GC 와 무관하다. |
| ☑ | 필드/배열은 자동 0 초기화, 지역 변수는 Definite Assignment 로 사용 전 할당 강제. 값 타입은 0, 참조 타입은 null 로 해석된 0비트다. |
| ☑ | default(T) 는 런타임 조회가 아니라 컴파일 타임에 ldc.i4.0 / ldnull / initobj 로 타입별로 갈라진다. |
| ☑ | new Vec3() == default(Vec3) — IL 동일(initobj). struct 기본값은 "모든 비트 0" 이므로 Quaternion.identity 같은 단위 원소와 다를 수 있다. |
| ☑ | 필드 이니셜라이저는 .ctor 가장 앞에서 실행 — base() 호출보다 먼저. Unity 에서는 그 뒤에 역직렬화가 덮어쓰므로 [SerializeField] 에는 이니셜라이저 대신 인스펙터 기본값을 쓴다. |
| ☑ | const 는 사용처에 리터럴이 박히고, static readonly 는 .cctor 에서 한 번 평가 후 ldsfld 로 매번 읽는다. 어셈블리 분리 배포 시 const 는 지뢰. |
| ☑ | var 는 IL 에 흔적이 없는 컴파일 타임 타입 추론 — dynamic 과 다르다. null, 메서드 인자, 필드에는 쓸 수 없다. |
| ☑ | init / required (C# 9/11) 는 객체 초기화자 시점의 불변성·필수성 을 컴파일 타임에 강제한다 — 런타임 비용 0. |
참고: 이 글에서 사용한 IL 명령 요약
| 명령 | 역할 | 이 글의 등장 이유 |
|---|---|---|
.locals init |
지역 변수 슬롯을 확보하고 0으로 밀기 | 스택 메모리 할당의 실체 |
ldc.i4.* / ldc.r4 / ldstr / ldnull |
상수 리터럴을 평가 스택에 로드 | 초기화의 핵심 |
stloc.N / ldloc.N |
지역 변수 슬롯 쓰기·읽기 | 선언/사용 대응 |
ldloca.N |
지역 변수의 주소 로드 | initobj 가 필요로 함 |
stfld |
인스턴스 필드에 쓰기 | 필드 이니셜라이저와 .ctor 관계 |
stsfld / ldsfld |
정적 필드에 쓰기/읽기 | static readonly 메커니즘 |
newobj |
힙에 인스턴스 생성 + ctor 호출 | 참조 타입 생성 = 할당 |
initobj |
값 타입 영역을 0으로 밀기 | default / new Struct() 의 실체 |
.cctor |
정적 생성자 — 타입 최초 사용 시 1회 | static readonly 초기화 위치 |