반응형

[PART7.클래스와 객체 입문(7/21)] 생성자와 this — 객체가 태어나는 순간 무슨 일이 벌어지는가

기본 생성자 / 매개변수 있는 생성자 / 생성자 오버로딩 / this() 체이닝 — IL로 본 객체 초기화의 실제 순서


1. 문제 제기 — 생성자를 모르면 어디서 사고가 나는가

다음은 Unity 신입이 흔히 작성하는 코드입니다.

C#
public class Enemy
{
    public int Hp;
    public int Damage;
    public string Name;

    public Enemy()
    {
        Hp = 100;
        Damage = 10;
        Name = "Slime";
    }

    public Enemy(string name)
    {
        Hp = 100;       // 또 적었습니다
        Damage = 10;    // 또 적었습니다
        Name = name;
    }

    public Enemy(string name, int hp, int damage)
    {
        Hp = hp;
        Damage = damage;
        Name = name;
    }
}

작동은 합니다. 그런데 며칠 뒤 디자이너가 "기본 데미지를 12로 올려달라"라고 합니다. 어디를 고쳐야 합니까. 두 군데입니다. 한 군데를 빠뜨리면 new Enemy()로 만든 적과 new Enemy("이름")로 만든 적이 다른 데미지를 갖게 됩니다. 이 버그는 컴파일러가 잡아주지 않습니다.

또 다른 시나리오를 봅시다. Unity 프로젝트에서 다음과 같이 작성하면 무슨 일이 일어날까요.

C#
public class Enemy : MonoBehaviour
{
    private int _hp;

    public Enemy(int hp)   // 매개변수 있는 생성자
    {
        _hp = hp;
    }
}

빌드는 됩니다. 그런데 씬에서 AddComponent<Enemy>()를 호출하면 콘솔에 빨간 에러가 찍힙니다. 왜냐하면 Unity 엔진이 컴포넌트를 만들 때 호출하는 것은 매개변수 없는 생성자(new Enemy())이기 때문입니다. 매개변수 있는 생성자를 정의하는 순간, C# 컴파일러는 자동으로 생성해 주던 기본 생성자를 더 이상 만들어주지 않습니다.

이 글에서 풀어야 할 질문은 세 가지입니다.

  1. 생성자가 호출될 때 필드 초기화·베이스 생성자 호출·본문 실행은 어떤 순서로 일어나는가
  2. 생성자 여러 개의 코드 중복을 this() 체이닝으로 어떻게 제거하는가, 그리고 IL 레벨에서 그게 왜 더 효율적인가
  3. Unity의 MonoBehaviour에서 생성자가 왜 위험한가, 그리고 무엇으로 대체해야 하는가

답을 찾으면 객체가 "태어나는 순간"의 동작을 IL 수준에서 그려낼 수 있게 됩니다.


2. 개념 정의 — 생성자란 객체의 "초기 상태 약속"이다

2-1. 비유: 게임 캐릭터 생성 화면

캐릭터 생성 화면을 떠올려봅시다. "기본 캐릭터로 시작하기" 버튼은 직업·HP·공격력을 미리 정해진 값으로 채워줍니다. "커스텀 시작"은 사용자가 입력한 직업과 능력치로 만들어줍니다. 어느 쪽이든 게임이 시작되기 전 캐릭터는 "유효한 상태"여야 합니다 — HP가 비어있거나 직업이 정해지지 않은 상태로 게임에 들어가면 곤란합니다.

생성자는 정확히 이 역할을 하는 메서드입니다. 객체가 만들어진 순간 "필드가 모두 의미 있는 값으로 채워졌다"라고 약속하는 코드 블록입니다.

2-2. 구조 — 생성자 호출 시 일어나는 일

new Enemy(

순서가 핵심입니다. 본문(this.Hp = hp;)은 가장 마지막에 실행됩니다. 본문 앞에 이미 필드 이니셜라이저와 베이스 생성자가 끝나 있습니다.

2-3. 기본 생성자 — 컴파일러가 자동으로 만들어주는 것

생성자 (Constructor) — 클래스 또는 구조체의 인스턴스를 생성할 때 호출되는 특수한 메서드. C#에서는 클래스 이름과 동일한 이름을 가지며 반환 타입이 없다. IL 레벨에서는 .ctor라는 특수한 이름으로 컴파일된다.

클래스에 생성자를 하나도 작성하지 않으면 컴파일러는 매개변수가 없는 public 생성자를 자동으로 합성합니다.

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

    // 생성자를 안 적으면 컴파일러가 아래와 동등한 생성자를 만들어줍니다.
    // public Player() { }
}

var p = new Player();   // OK
// p.Hp == 0, p.Name == null  (모든 필드는 0/null로 클리어된 상태)
IL
// 컴파일러가 자동 생성한 .ctor — 사실상 base 호출만 합니다
.method public hidebysig specialname rtspecialname 
    instance void .ctor () cil managed 
{
    IL_0000: ldarg.0                                                   // this 로드
    IL_0001: call instance void [System.Runtime]System.Object::.ctor() // base 생성자 호출
    IL_0006: ret
} // end of method Player::.ctor

자동 생성된 기본 생성자는 IL 레벨에서 Object::.ctor만 호출하고 끝납니다. 필드 클리어(0/null)는 CLR이 메모리 할당 단계에서 이미 수행하기 때문에 IL에 추가 코드가 없습니다.

중요한 규칙: 매개변수 있는 생성자를 단 하나라도 정의하는 순간, 컴파일러는 더 이상 기본 생성자를 자동으로 만들어주지 않습니다.

C#
public class Enemy
{
    public Enemy(int hp) { /* ... */ }
}

var e = new Enemy();   // CS7036: 'hp' 매개변수에 해당하는 인수가 없습니다

2-4. 매개변수 있는 생성자 — 외부에서 초기값 받기

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

    public Player(string name, int hp)
    {
        // this 키워드: "매개변수 name이 아니라 필드 Name에 대입한다"
        this.Name = name;
        this.Hp = hp;
    }
}

var p = new Player("전사", 200);
IL
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name, int32 hp) cil managed 
{
    IL_0000: ldarg.0
    IL_0001: call instance void [System.Runtime]System.Object::.ctor()  // 먼저 base
    IL_0006: ldarg.0                                                    // this
    IL_0007: ldarg.1                                                    // name
    IL_0008: stfld string Player::Name                                  // this.Name = name
    IL_000d: ldarg.0
    IL_000e: ldarg.2                                                    // hp
    IL_000f: stfld int32 Player::Hp                                     // this.Hp = hp
    IL_0014: ret
}

IL을 읽으면 본문이 한 줄씩 그대로 매핑됩니다. 흥미로운 건 ldarg.0this를 가리킨다는 점입니다. 인스턴스 메서드는 IL 레벨에서 첫 번째 인자가 항상 this이므로, 매개변수 nameldarg.1, hpldarg.2로 한 칸씩 밀려 있습니다.

2-5. this 키워드 — 세 가지 얼굴

this 키워드 — 현재 인스턴스를 가리키는 참조. 인스턴스 메서드와 생성자 안에서만 사용 가능하다. 정적 메서드에서는 사용할 수 없다.

C#에서 this는 세 가지 역할을 합니다.

  1. 현재 인스턴스 참조this.Name은 "이 객체의 Name 필드"를 의미.
  2. 매개변수 그림자(parameter shadowing) 해소 — 매개변수 이름과 필드 이름이 같을 때 둘을 구분.
  3. 확장 메서드 표시 — 정적 메서드의 첫 매개변수 앞에 this를 붙이면 확장 메서드가 됨.
C#
// (1) 현재 인스턴스 참조 — 다른 메서드에 자기 자신을 넘김
public void RegisterTo(EnemyManager mgr) => mgr.Add(this);

// (2) 매개변수 그림자 해소 — name 필드와 name 매개변수 충돌
public Player(string name) { this.name = name; }

// (3) 확장 메서드 — Vector3에 마치 인스턴스 메서드처럼 메서드 추가
public static class VectorExtensions
{
    public static Vector3 FlattenY(this Vector3 v) => new Vector3(v.x, 0, v.z);
}
// 사용: someVector.FlattenY()  ← 마치 Vector3 멤버처럼 호출 가능

이 글에서는 첫 번째와 두 번째 의미가 중심입니다. 세 번째(확장 메서드)는 별도 주제이므로 여기서는 짧게만 언급합니다.


3. 내부 동작 — 필드 이니셜라이저 · base · 본문의 정확한 실행 순서

3-1. 필드 이니셜라이저는 본문보다 먼저 실행된다

필드 선언과 동시에 값을 대입하는 문법을 필드 이니셜라이저(field initializer)라고 합니다.

C#
public class WithInit
{
    public int Hp = 100;          // 필드 이니셜라이저
    public string Name = "기본이름"; // 필드 이니셜라이저

    public WithInit(string name)
    {
        this.Name = name;          // 본문에서 다시 덮어씀
    }
}

var w = new WithInit("전사");
// w.Hp == 100, w.Name == "전사"

본문에서 Name을 다시 덮어썼으니 최종 값은 "전사"가 됩니다. 그런데 IL을 보면 흥미로운 사실이 드러납니다.

IL
.method public hidebysig specialname rtspecialname 
    instance void .ctor (string name) cil managed 
{
    // ── 필드 이니셜라이저: base 호출 "이전"에 박힘 ──
    IL_0000: ldarg.0
    IL_0001: ldc.i4.s 100
    IL_0003: stfld int32 WithInit::Hp                 // this.Hp = 100
    IL_0008: ldarg.0
    IL_0009: ldstr "기본이름"
    IL_000e: stfld string WithInit::Name              // this.Name = "기본이름"

    // ── 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 WithInit::Name              // this.Name = name (덮어쓰기)
    IL_0020: ret
}

핵심 발견: 컴파일러는 모든 필드 이니셜라이저 코드를 생성자의 맨 앞에 박아넣습니다. base..ctor() 호출조차 그 뒤에 옵니다. 그래서 Name이 두 번 대입되는 IL 명령이 나타납니다 — 한 번은 "기본이름", 다음은 "전사".

이건 사소한 비효율 같지만, 필드 이니셜라이저가 비싸면(예: new List<int>(1024)) 그만큼 낭비가 발생합니다. 본문에서 어차피 덮어쓸 거라면 필드 이니셜라이저는 빼는 편이 유리합니다.

3-2. 상속이 있을 때의 호출 순서

new Derived() 호출 시 실행 순서
C#
public class Base
{
    public string Tag = "base-init";  // ②에서 실행
    public Base() { Console.WriteLine($"Base 본문 / Tag={Tag}"); }
}

public class Derived : Base
{
    public string Name = "derived-init";  // ①에서 실행
    public Derived() { Console.WriteLine($"Derived 본문 / Name={Name}"); }
}

new Derived();
// 출력:
// Base 본문 / Tag=base-init
// Derived 본문 / Name=derived-init

자식 필드 이니셜라이저는 부모 생성자가 호출되기 전에 실행됩니다. 그래서 부모 생성자에서 자식 필드를 읽어도 자식 필드 이니셜라이저가 적용된 값이 보입니다(다만 자식 본문은 아직 안 돌았습니다 — 6장의 함정 참고).

3-3. base() 호출 — 부모 생성자에 인수 전달

C#
public class Unit
{
    public int Hp;
    public Unit(int hp) { this.Hp = hp; }
}

public class Hero : Unit
{
    public string ClassName;

    // base(hp) — 부모 생성자에 hp를 넘긴다
    public Hero(string className, int hp) : base(hp)
    {
        this.ClassName = className;
    }
}

base(...)를 명시하지 않으면 컴파일러는 부모의 매개변수 없는 생성자를 자동으로 호출합니다. 부모에 매개변수 없는 생성자가 없으면 컴파일 에러가 납니다 — 자식이 명시적으로 base(...)로 인수를 넘겨줘야 합니다.


4. 실전 적용 — this() 체이닝으로 중복 제거하기

4-1. Before — 같은 코드 세 번 적기

Unity에서 아이템 데이터를 표현하는 흔한 패턴입니다.

C#
public class Item
{
    public string Name;
    public int Price;
    public int Stock;

    public Item()
    {
        this.Name = "Unknown";
        this.Price = 0;
        this.Stock = 1;
    }

    public Item(string name)
    {
        this.Name = name;
        this.Price = 0;
        this.Stock = 1;
    }

    public Item(string name, int price, int stock)
    {
        this.Name = name;
        this.Price = price;
        this.Stock = stock;
    }
}

Price = 0; Stock = 1;이 두 번 반복됩니다. 디자이너가 "기본 재고를 5로 바꿔달라"라고 하면 두 군데를 동시에 고쳐야 합니다.

IL을 보면 정확히 똑같이 쓸데없는 중복이 박혀 있습니다.

IL
// Item() 기본 생성자
.method ... .ctor () ...
{
    IL_0000: ldarg.0
    IL_0001: call instance void [System.Runtime]System.Object::.ctor()
    IL_0006: ldarg.0
    IL_0007: ldstr "Unknown"
    IL_000c: stfld string Item::Name
    IL_0011: ldarg.0
    IL_0012: ldc.i4.0
    IL_0013: stfld int32 Item::Price       // ← 중복
    IL_0018: ldarg.0
    IL_0019: ldc.i4.1
    IL_001a: stfld int32 Item::Stock       // ← 중복
    IL_001f: ret
}

// Item(string name) 생성자도 마찬가지로 Price=0; Stock=1; 코드가 그대로 박혀있습니다
.method ... .ctor (string name) ... 
{
    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 Item::Name
    IL_000d: ldarg.0
    IL_000e: ldc.i4.0
    IL_000f: stfld int32 Item::Price       // ← 또 중복
    IL_0014: ldarg.0
    IL_0015: ldc.i4.1
    IL_0016: stfld int32 Item::Stock       // ← 또 중복
    IL_001b: ret
}

.ctor가 28바이트, 32바이트씩 동일한 stfld를 다시 박아넣고 있습니다. 코드뿐 아니라 IL 본문도 중복입니다.

4-2. After — this() 체이닝으로 마스터 생성자에 위임

this() — 생성자 체이닝(constructor chaining) 같은 클래스의 다른 생성자를 호출한다. 콜론(:) 뒤에 this(...) 형태로 적으며, 매개변수가 가장 많은 "마스터 생성자"에 초기화 로직을 모은 뒤 다른 생성자들이 거기에 위임할 때 쓴다.
예시: public Item() : this("Unknown", 0, 1) { } 매개변수 없는 생성자가 호출되면, 본문 실행 전 마스터 생성자가 먼저 호출된다.
C#
public class Item
{
    public string Name;
    public int Price;
    public int Stock;

    // 매개변수 없는 호출 → 마스터 생성자에 위임
    public Item() : this("Unknown", 0, 1) { }

    // 이름만 받는 호출 → 마스터 생성자에 위임
    public Item(string name) : this(name, 0, 1) { }

    // 마스터 생성자 — 실제 초기화 로직은 여기에만 있음
    public Item(string name, int price, int stock)
    {
        this.Name = name;
        this.Price = price;
        this.Stock = stock;
    }
}

이제 기본값을 바꾸려면 한 군데(this("Unknown", 0, 1))만 고치면 됩니다.

IL
// Item() 기본 생성자 — 14바이트로 줄었습니다
.method ... .ctor () ...
{
    IL_0000: ldarg.0
    IL_0001: ldstr "Unknown"
    IL_0006: ldc.i4.0
    IL_0007: ldc.i4.1
    IL_0008: call instance void Item::.ctor(string, int32, int32)  // ← 마스터 생성자 호출
    IL_000d: ret
}

// Item(string name) 생성자 — 10바이트로 줄었습니다
.method ... .ctor (string name) ... 
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: ldc.i4.0
    IL_0003: ldc.i4.1
    IL_0004: call instance void Item::.ctor(string, int32, int32)  // ← 마스터 생성자 호출
    IL_0009: ret
}

// 마스터 생성자 — 진짜 stfld가 있는 곳
.method ... .ctor (string name, int32 price, int32 stock) ...
{
    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 Item::Name
    // ... Price, Stock 대입
    IL_001b: ret
}

핵심 IL 차이:

  1. this()로 위임받은 생성자에는 Object::.ctor() 호출이 없습니다. base 생성자 호출 책임은 마스터 생성자가 가져갑니다.
  2. 본문 stfld 코드도 마스터에만 한 번 박힙니다. 코드 크기가 28바이트 → 14바이트로 절반 가까이 줄었습니다.
  3. 위임 호출은 call(정적 디스패치)이지 callvirt(가상 디스패치)가 아닙니다. 생성자는 가상이 될 수 없으므로 항상 정적 호출입니다.

4-3. Unity 실전 — 데이터 클래스에 this() 체이닝 적용

Unity에서 자주 쓰는 [Serializable] 데이터 클래스도 같은 패턴이 유용합니다.

C#
using UnityEngine;

[System.Serializable]
public class WeaponData
{
    public string Id;
    public int Damage;
    public float CritRate;
    public float CritMultiplier;

    // 마스터 생성자
    public WeaponData(string id, int damage, float critRate, float critMultiplier)
    {
        this.Id = id;
        this.Damage = damage;
        this.CritRate = critRate;
        this.CritMultiplier = critMultiplier;
    }

    // 일반 무기 — 치명타 5%, 배수 1.5배
    public WeaponData(string id, int damage)
        : this(id, damage, 0.05f, 1.5f) { }

    // 치명타 무기 — 마스터 생성자에 다른 기본값 전달
    public static WeaponData CreateCriticalType(string id, int damage)
        => new WeaponData(id, damage, 0.25f, 2.0f);
}

// 호출 측
var sword = new WeaponData("sword_01", 50);                    // 일반
var dagger = WeaponData.CreateCriticalType("dagger_01", 30);  // 치명타 특화

판단 기준:

상황 해결 패턴
같은 타입의 매개변수 조합으로 다양한 생성 방법 this() 체이닝
의미가 완전히 다른 생성 (예: 치명타 무기 vs 일반 무기) 정적 팩토리 메서드
매개변수 일부만 생략하고 싶음 선택적 매개변수(default value) 또는 객체 이니셜라이저

5. 함정과 주의사항 — 신입이 가장 많이 다치는 곳

5-1. Unity MonoBehaviour에 매개변수 있는 생성자 금지

이 함정은 Unity 신입이 거의 한 번씩은 빠집니다.

C#
// ❌ 잘못된 예 — 빌드는 되지만 런타임 에러
public class Enemy : MonoBehaviour
{
    private int _hp;

    public Enemy(int hp)
    {
        _hp = hp;
    }
}
C#
// 사용하려고 하면:
var go = new GameObject();
var enemy = go.AddComponent<Enemy>();
// 콘솔: "The script needs to derive from MonoBehaviour or be a ScriptableObject"
// 또는 Enemy 객체가 만들어지지만 _hp가 0인 채로 남음

왜 이렇게 동작하는가: Unity 엔진은 C++로 작성되어 있고, MonoBehaviour 컴포넌트의 수명을 엔진이 직접 관리합니다. 엔진은 컴포넌트를 생성할 때 매개변수 없는 생성자만 호출합니다(직렬화 복원 시에도 마찬가지). 매개변수 있는 생성자를 정의하는 순간 컴파일러는 자동 기본 생성자를 만들어주지 않으므로, 엔진이 컴포넌트를 생성할 방법이 사라집니다.

심지어 매개변수 없는 생성자에 코드를 넣어도 위험합니다. 생성자 안에서 transform, gameObject, GetComponent 같은 Unity API를 부르면 엔진이 아직 네이티브 객체를 결합해주지 않은 상태이므로 NullReferenceException이 납니다.

C#
// ✅ 올바른 예 — Awake/Start 사용
public class Enemy : MonoBehaviour
{
    [SerializeField] private int hp = 100;     // 인스펙터에서 설정

    private void Awake()
    {
        // Unity가 컴포넌트를 완전히 결합한 뒤에 호출하는 라이프사이클 메서드
    }

    // 외부에서 데이터를 주입할 때는 별도 Initialize 메서드
    public void Initialize(int customHp)
    {
        this.hp = customHp;
    }
}

// 호출 측
var go = new GameObject();
var enemy = go.AddComponent<Enemy>();
enemy.Initialize(200);

기억할 규칙: MonoBehaviourScriptableObject생성자를 사용하지 않는다. 초기화는 Awake/OnEnable/Start 또는 별도 Initialize 메서드로 한다.

5-2. 생성자에서 가상 메서드 호출 금지

다음은 어떤 언어에서든 어렵게 발견되는 함정입니다.

C#
// ❌ 잘못된 예 — 자식 본문이 실행되기도 전에 자식의 오버라이드가 호출됨
public class Base
{
    public Base()
    {
        // 가상 메서드 호출! 자식이 오버라이드했다면 자식 버전이 호출됨
        Init();
    }

    public virtual void Init() { }
}

public class Derived : Base
{
    private string _data = "준비완료";  // 필드 이니셜라이저

    public override void Init()
    {
        // _data를 사용하는데...
        Console.WriteLine($"길이={_data.Length}");
    }
}

new Derived();
// 무엇이 출력될까요?

3장 3-2에서 본 호출 순서를 떠올려봅시다. Derived의 필드 이니셜라이저는 ①에서 실행되므로 _data는 "준비완료"입니다. 운이 좋습니다. 그런데 이런 경우는 어떨까요.

C#
public class Derived : Base
{
    private string _data;

    public Derived(string s)   // 본문에서 _data 초기화 예정
    {
        _data = s;
    }

    public override void Init()
    {
        Console.WriteLine($"길이={_data.Length}");  // 💥 NullReferenceException
    }
}

new Derived("hello");

호출 순서를 추적하면:

  1. Derived 필드 이니셜라이저 — _data는 null
  2. Base 생성자 본문 → Init() 호출 → Derived.Init() 실행 → _data.Length 호출 → 💥
  3. Derived 생성자 본문 — 도달하지 못함

자식의 생성자 본문이 돌기 전에 자식의 오버라이드가 호출됩니다. _data는 아직 null입니다.

C#
// ✅ 올바른 예 — 생성자에서 가상 호출 안 함
public class Base
{
    protected Base()
    {
        // 생성자에서는 자기 자신의 비가상 로직만
    }

    public virtual void Init() { }
}

public class Derived : Base
{
    private string _data;

    public Derived(string s)
    {
        _data = s;
    }

    public override void Init()
    {
        Console.WriteLine($"길이={_data.Length}");  // 안전
    }
}

var d = new Derived("hello");
d.Init();   // 생성이 끝난 뒤 명시적으로 호출

Roslyn 분석기는 이 패턴을 CA2214로 경고합니다. 무시하지 말고 잡아냅시다.

5-3. ambiguous match — 오버로딩 + 선택적 매개변수의 함정

C#
// ❌ 잘못된 예 — 어느 쪽으로 갈지 모호
public class Spawner
{
    public Spawner(string id, int count = 1) { }      // (1)
    public Spawner(string id, float radius = 0.5f) { } // (2)
}

new Spawner("orc");
// CS0121: 'Spawner.Spawner(string, int)'와 'Spawner.Spawner(string, float)'
//         사이에서 호출이 모호합니다.

선택적 매개변수가 있어 두 시그니처 모두 Spawner("orc")에 매칭 가능합니다. 컴파일러는 "더 구체적인" 매칭을 결정할 수 없어 에러를 냅니다.

C#
// ✅ 올바른 예 — 시그니처를 분명히 다르게
public class Spawner
{
    public Spawner(string id) { }                     // (0) 명시적
    public Spawner(string id, int count) { }          // (1)
    public Spawner(string id, float radius) { }       // (2)
}

new Spawner("orc");        // (0) 호출
new Spawner("orc", 3);     // (1) 호출
new Spawner("orc", 0.5f);  // (2) 호출

규칙: 선택적 매개변수와 오버로딩을 섞을 때는 매번 컴파일러 입장에서 어떤 호출이 어디로 연결될지 판별이 가능한지 살핀다.


6. C# 버전별 변화 — 생성자 문법의 발전사

6-1. C# 1.0 (2002) — 표준 생성자

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

    public Player(string name, int hp)
    {
        this.Name = name;
        this.Hp = hp;
    }
}

이 글에서 다룬 내용은 모두 C# 1.0부터 가능했습니다.

6-2. C# 4.0 (2010) — 선택적 매개변수와 명명된 인수

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

    // 매개변수 기본값
    public Player(string name = "이름없음", int hp = 100)
    {
        this.Name = name;
        this.Hp = hp;
    }
}

new Player();                       // 생성자 오버로딩 없이 가능
new Player("전사");                  // hp는 기본값 100
new Player(hp: 200);                // 명명된 인수
new Player(name: "마법사", hp: 80);  // 둘 다 명명

선택적 매개변수가 들어오면서 단순한 오버로딩 일부는 불필요해졌습니다. 다만 5-3에서 본 ambiguous match 위험이 같이 따라옵니다.

6-3. C# 9.0 (2020) — record와 init 접근자

C#
// 위치 매개변수 record — 컴파일러가 생성자·프로퍼티·Equals·GetHashCode를 한 번에 생성
public record Player(string Name, int Hp);

var p = new Player("전사", 200);
// p.Name, p.Hp 자동 노출 (init-only 프로퍼티)

데이터 중심 클래스를 만들 때 record를 쓰면 생성자를 직접 안 써도 됩니다. 단, 이 글의 주제는 일반 class 생성자이므로 record는 별도 주제입니다.

6-4. C# 12 (2023) — 일반 클래스의 Primary Constructor

C#
// Before — C# 11 이하
public class Service
{
    private readonly ILogger _logger;
    private readonly Database _db;

    public Service(ILogger logger, Database db)
    {
        _logger = logger;
        _db = db;
    }

    public void Save(Item it) => _db.Insert(it);
}

// After — C# 12
public class Service(ILogger logger, Database db)
{
    public void Save(Item it) => db.Insert(it);  // logger·db를 메서드에서 직접 사용
}

primary constructor 매개변수는 클래스 본문 어디에서나 참조할 수 있습니다. 단, record와 달리 일반 class의 primary constructor 매개변수는 자동 프로퍼티가 만들어지지 않습니다. 매개변수가 메서드에서 사용되면 컴파일러가 내부에 숨은 필드를 만들어 캡처합니다.

primary constructor 자체는 별도 주제(7/11)에서 자세히 다루고, 이 글에서는 "C# 12부터 더 짧게 쓸 수도 있다"는 것만 기억하면 됩니다.


7. 정리 — 이것만 기억하라

생성자 호출 시 실행 순서 (한 장 요약)
항목 핵심
기본 생성자 클래스에 생성자가 하나도 없으면 컴파일러가 매개변수 없는 public 생성자를 자동 생성. 매개변수 있는 생성자를 하나라도 정의하면 자동 생성은 사라진다.
매개변수 있는 생성자 외부에서 초기값을 받아 필드를 의미 있는 상태로 채운다. IL에서는 base → stfld 순서로 박힌다.
필드 이니셜라이저 생성자 본문보다 먼저 실행되며, IL 레벨에서는 base 호출보다도 앞에 박힌다. 본문에서 어차피 덮어쓸 값이라면 이니셜라이저는 빼는 편이 낭비가 적다.
생성자 오버로딩 매개변수 시그니처가 다르면 여러 개 정의 가능. 선택적 매개변수와 같이 쓸 때 ambiguous match에 주의.
this() 체이닝 다른 생성자에 위임. IL에서 base 호출과 stfld 본문 코드 중복이 사라져 코드 크기와 유지보수성이 모두 개선된다.
this 키워드 (1) 현재 인스턴스 참조 (2) 매개변수 그림자 해소 (3) 확장 메서드 표시.
Unity 함정 MonoBehaviour/ScriptableObject는 생성자를 쓰지 말 것. 매개변수 있는 생성자를 정의하면 엔진이 컴포넌트를 만들 수 없게 된다. 초기화는 Awake/Initialize로.
가상 메서드 호출 금지 생성자에서 가상 메서드를 부르면 자식 본문이 실행되기 전에 자식 오버라이드가 호출되어 버그가 난다.
C# 12 primary constructor로 더 짧게 쓸 수 있다. 단, 일반 class에서는 자동 프로퍼티가 만들어지지 않는다는 점에 유의.

한 줄 요약: 생성자는 "객체가 의미 있는 상태로 태어나는 약속"이고, this() 체이닝은 그 약속을 한곳에 모아 IL 중복까지 줄여주는 도구다. Unity의 MonoBehaviour만은 예외 — 생성자가 아니라 Awake로 초기화하라.

반응형

+ Recent posts