반응형

[PART8.상속과 인터페이스 사용법(2/11)] virtual · override · base — 부모를 갈아끼우는 세 개의 약속

부모가 virtual로 허락해야 자식이 override할 수 있다 / base.Method()로 부모 구현을 호출한다 / override 없이 같은 이름이면 "숨김(method hiding)"이며 new 키워드로 명시한다 / 그 차이는 IL의 callvirtcall에서 갈린다


왜 이걸 알아야 하는가

Unity에서 적 캐릭터를 만든다고 가정해 봅시다. BaseEnemy라는 부모 클래스가 있고, TakeDamage(int amount)라는 메서드를 자식 클래스 BossEnemy가 다르게 동작하게 만들고 싶습니다. 보스는 일반 적과 달리 데미지를 절반만 받아야 합니다.

C#
public class BaseEnemy {
    public void TakeDamage(int amount) {
        hp -= amount;
    }
}

public class BossEnemy : BaseEnemy {
    public void TakeDamage(int amount) {
        hp -= amount / 2;  // 보스는 절반만
    }
}

이 코드에는 두 가지 문제가 숨어 있습니다.

첫째, 컴파일러는 경고를 띄웁니다. 자식 클래스의 TakeDamage가 부모를 가리는 것이 의도인지 실수인지 알 수 없기 때문입니다. 둘째, 더 심각한 문제로 — BaseEnemy enemy = new BossEnemy();처럼 부모 타입 변수에 자식 객체를 담아 enemy.TakeDamage(100)을 호출하면 부모의 메서드가 호출됩니다. 다형성이 작동하지 않습니다.

해결책은 부모가 virtual로 "이 메서드는 자식이 바꿔도 좋다"고 허락하고, 자식이 override로 "정식으로 재정의한다"고 선언하는 것입니다. 이 약속이 어디서 시작되고, 런타임이 어떻게 자식의 메서드를 찾아내며, base.TakeDamage()로 부모 로직을 어떻게 다시 부르는지 — 이 글에서 코드와 IL을 따라가며 정리합니다.


개념 정의 — 세 키워드의 역할

virtual — 가상 메서드 (Virtual method) 부모 클래스가 "이 메서드는 자식이 재정의해도 된다"고 허락하는 키워드. virtual이 없으면 자식은 같은 이름의 메서드를 만들 수 없으며, 만들면 "숨김(hiding)" 처리된다.
예시: public virtual void Speak() { ... } 자식이 override로 갈아끼울 수 있는 확장 지점을 만든다.
override — 재정의 (Override) 자식 클래스가 부모의 virtual 메서드를 자기 구현으로 갈아끼울 때 붙이는 키워드. 부모의 vtable 슬롯에 자식 메서드 주소가 덮어써지므로, 부모 타입 변수로 호출해도 자식 구현이 실행된다(다형성).
예시: public override void Speak() { ... } 부모 타입 변수로 호출해도 자식 구현이 실행된다.
base — 부모 멤버 접근 키워드 (Base) 자식 클래스 안에서 부모 클래스의 멤버에 접근할 때 사용한다. base.Method()는 부모 구현을 직접 호출하며, 다형성을 우회해 vtable 조회 없이 비가상 호출(call)로 컴파일된다.
예시: public override void Speak() { base.Speak(); ... } 부모의 기본 동작을 먼저 실행한 뒤 자식의 추가 동작을 이어 붙인다.

세 키워드의 관계를 한눈에 보면 이렇습니다.

class Animal (부모)

기본 동작을 코드로 확인합니다.

C#
public class Animal {
    public virtual void Speak() {
        System.Console.WriteLine("Animal");
    }
}

public class Dog : Animal {
    public override void Speak() {
        System.Console.WriteLine("Dog");
    }
}

public class Program {
    public static void Main() {
        Animal a = new Dog();   // 변수 타입은 Animal, 실제 객체는 Dog
        a.Speak();              // 출력: "Dog"
    }
}

변수의 정적 타입은 Animal이지만 실제 객체는 Dog입니다. virtual/override 한 쌍 덕분에 런타임이 실제 객체 타입을 보고 자식 메서드를 찾아냅니다. 이것이 다형성(polymorphism)이며, 객체 지향에서 "같은 호출 코드, 다른 동작"을 가능하게 하는 메커니즘입니다.

이 호출이 IL에서는 어떻게 표현될까요?

IL
// Program.Main
IL_0000: newobj   instance void Dog::.ctor()
IL_0005: stloc.0                                  // 지역변수에 Dog 인스턴스 저장
IL_0006: ldloc.0                                  // 스택에 다시 로드
IL_0007: callvirt instance void Animal::Speak()   // ★ 가상 호출
IL_000c: ret

핵심은 callvirt 명령어입니다. 컴파일러는 Speak()가 가상 메서드이므로 vtable을 거쳐 실제 타입의 메서드를 찾으라는 의미로 callvirt를 방출합니다. 호출 시점의 변수 타입(Animal)이 아니라 실제 객체 타입(Dog)이 호출 대상을 결정합니다.

요약: virtual은 부모의 허락, override는 자식의 재정의 선언, 그리고 IL의 callvirt가 런타임 디스패치를 구현한다.

내부 동작 — vtable과 callvirt의 만남

C#에서 virtual 메서드 호출이 동작하는 메커니즘은 vtable(가상 메서드 테이블, Virtual Method Table)이라는 자료구조를 거칩니다. 모든 참조 타입 객체는 힙(heap, 동적으로 메모리를 할당받는 영역)에 할당될 때 데이터 외에 타입 객체 포인터(Method Table Pointer) 라는 숨겨진 필드를 가지며, 이 포인터가 해당 타입의 vtable을 가리킵니다.

변수 a

런타임이 a.Speak()를 처리하는 순서는 다음과 같습니다.

  1. 변수 a가 가리키는 힙 객체를 따라간다.
  2. 객체의 메서드 테이블 포인터(MT 포인터)를 읽어 Dog의 vtable로 점프한다.
  3. vtable에서 Speak에 해당하는 슬롯(예: 0번 인덱스)을 찾는다.
  4. 슬롯에 저장된 주소(Dog가 override했으므로 Dog::Speak)로 점프한다.

이 모든 단계는 컴파일러가 방출한 한 줄짜리 IL 명령어 callvirt에 의해 트리거됩니다. C# 컴파일러는 흥미롭게도 비가상 메서드를 호출할 때도 callvirt를 사용합니다. 이유는 callvirt가 객체가 null인지 자동으로 확인해 NullReferenceException을 던지는 기능을 포함하기 때문입니다.

비가상 메서드 호출도 IL에서 같은 명령어가 나가는지 직접 확인합니다.

C#
public class Animal {
    public virtual void Speak() { System.Console.WriteLine("Animal"); }
    public void Walk() { System.Console.WriteLine("Walk"); }   // virtual 아님
}

public class Program {
    public static void Main() {
        Animal a = new Animal();
        a.Speak();   // 가상 메서드
        a.Walk();    // 비가상 메서드
    }
}

위 코드를 컴파일한 IL은 다음과 같습니다.

IL
// Program.Main
IL_0000: newobj   instance void Animal::.ctor()
IL_0005: dup
IL_0006: callvirt instance void Animal::Speak()   // ★ 가상 호출
IL_000b: callvirt instance void Animal::Walk()    // ★ 비가상이지만 callvirt
IL_0010: ret

Walkvirtual이 아니지만 컴파일러는 그래도 callvirt를 방출합니다. 이는 호출 직전에 객체 null 체크를 강제하기 위해서입니다. 그러나 JIT 컴파일러는 Walk가 비가상임을 알고 있으므로 vtable 조회는 건너뛰고 정적 디스패치(직접 호출)로 최적화합니다. 즉 IL의 callvirt가 항상 vtable을 거치는 것은 아니며, 실제 디스패치 방식은 메서드의 virtual 여부에 따라 갈립니다.

요약: 모든 객체의 첫 필드는 vtable을 가리키는 포인터. callvirt는 그 포인터를 따라가 실제 타입의 슬롯으로 점프한다. null 체크는 덤이다.

base 호출 — 부모를 다시 부르는 방법

override는 부모의 메서드를 완전히 갈아끼우지만, 많은 경우 부모의 로직을 그대로 두고 자식의 동작을 덧붙이고 싶습니다. 이때 base.Method() 구문을 사용합니다.

Player.TakeDamage(int amount) — override

코드로 보면 다음과 같습니다.

C#
public class Animal {
    public virtual void Speak() {
        System.Console.WriteLine("Animal");
    }
}

public sealed class Dog : Animal {
    public override void Speak() {
        base.Speak();                          // 부모 Animal::Speak 호출
        System.Console.WriteLine("Dog");       // 자식 추가 동작
    }
}

// Animal a = new Dog(); a.Speak();
// 출력:
//   Animal
//   Dog

이 코드의 IL을 보면 base.Speak()가 일반 가상 호출과 다르게 컴파일됩니다.

IL
// Dog::Speak
IL_0000: ldarg.0                              // this 로드
IL_0001: call instance void Animal::Speak()   // ★ callvirt 아닌 call (정적 호출)
IL_0006: ldstr     "Dog"
IL_000b: call void [System.Console]System.Console::WriteLine(string)
IL_0010: ret

핵심은 base.Speak()callvirt가 아닌 call 명령어로 컴파일된다는 점입니다. 이유는 단순합니다. 만약 base.Speak()callvirt로 호출하면 vtable이 Dog::Speak를 가리키므로 — 결국 자기 자신을 다시 호출하는 무한 재귀가 됩니다. 컴파일러는 이를 막기 위해 base 호출을 명시적으로 비가상 호출(call)로 방출하며, 그 결과 정확히 부모의 구현이 실행됩니다.

base 호출이 call인 이유 한 줄 요약: callvirt는 vtable을 보고 실제 타입의 메서드로 가지만, base는 "부모 구현을 그대로 부르고 싶다"는 의도이므로 vtable을 우회한다.

base를 사용하는 대표 시나리오는 다음과 같습니다.

  • 기능 확장(Augment) — 부모의 핵심 로직은 유지하면서 전·후로 자식 로직 추가
  • 필수 초기화/정리 — 부모가 반드시 실행해야 하는 자원 등록·해제 로직 보존
  • 데코레이터 스타일 — 부모의 동작을 감싸 부가 처리 적용

자식이 base.Method()를 호출할지 말지는 부모 메서드의 의무를 누구도 대신할 수 없을 때 결정합니다. UI 창을 활성화하는 부모 로직, 이벤트 핸들러를 등록하는 부모 로직 — 이런 것들이 누락되면 자식만의 추가 로직은 작동해도 시스템 전체가 깨집니다.

요약: base.Method()는 vtable을 우회한 부모 직접 호출. IL에서는 callvirt가 아닌 call로 컴파일된다.

new 키워드와 메서드 숨김 — override의 가짜 친구

override 없이 부모와 같은 이름의 메서드를 자식이 정의하면 메서드 숨김(method hiding) 이 됩니다. 컴파일러는 이를 실수로 의심해 경고를 띄우며, 의도적이라면 new 키워드로 명시해야 합니다.

new (메서드 한정자) — 메서드 숨김 (Method hiding) 자식 클래스가 부모와 같은 이름의 멤버를 새로 정의하되, override처럼 vtable을 덮어쓰지 않고 부모 멤버를 정적으로 가린다는 뜻. 다형성이 작동하지 않으며, 어느 메서드가 호출될지는 변수의 정적 타입이 결정한다.
예시: public new void Eat() { ... } 자식 타입 변수로 호출하면 자식 메서드, 부모 타입 변수로 호출하면 부모 메서드가 실행된다.

overridenew의 결정적 차이를 코드로 확인합니다.

C#
public class Animal {
    public virtual void Speak() { System.Console.WriteLine("Animal-virtual"); }
    public void Eat() { System.Console.WriteLine("Animal-eat"); }
}

public class Dog : Animal {
    public override void Speak() { System.Console.WriteLine("Dog-override"); }
    public new void Eat() { System.Console.WriteLine("Dog-new-eat"); }
}

public class Program {
    public static void Main() {
        Animal a = new Dog();
        a.Speak();   // 출력: "Dog-override"  ← 다형성 작동
        a.Eat();     // 출력: "Animal-eat"    ← 다형성 작동 안 함
    }
}

같은 객체(Dog)인데 Speak는 자식의 구현이, Eat는 부모의 구현이 호출됩니다. IL을 보면 차이가 더 명확합니다.

IL
// Program.Main
IL_0000: newobj   instance void Dog::.ctor()
IL_0005: dup
IL_0006: callvirt instance void Animal::Speak()   // 가상 → vtable → Dog::Speak
IL_000b: callvirt instance void Animal::Eat()     // 비가상 → 정적 결정 → Animal::Eat
IL_0010: ret

두 호출 모두 IL에서는 callvirt로 보이지만, EatAnimal에서 virtual이 아니므로 컴파일러와 JIT는 호출 대상을 변수의 정적 타입(Animal)으로 정적 결정합니다. 그래서 a.Eat()Dog::Eat이 아니라 Animal::Eat을 부르게 됩니다.

override (다형성 ○)

new는 다형성을 깨뜨리므로, 공개 API에서 별다른 이유 없이 사용하는 것은 거의 항상 실수입니다. 사용 정당성이 있는 경우는 다음과 같이 매우 제한적입니다.

  • 외부 라이브러리의 부모 클래스에 새 메서드가 추가되어 자식과 이름이 충돌할 때 — 자식 메서드 시그니처를 유지하기 위해
  • override할 수 없는 비가상 메서드를 자식에서 다른 의미로 사용해야 할 때
요약: override는 vtable을 덮어쓰고 다형성을 따른다. new는 부모를 가리기만 하고 호출 대상은 변수의 정적 타입이 결정한다.

실전 적용 — Unity에서 자주 만나는 패턴

Before — base 호출을 빼먹은 코드

Unity에서 적 캐릭터 계층을 만든다고 가정합니다. BaseEnemyOnDamaged에서 데미지 처리, 사운드 재생, 이벤트 호출까지 모두 처리합니다.

C#
using UnityEngine;

public class BaseEnemy : MonoBehaviour {
    protected int hp = 100;

    public virtual void OnDamaged(int amount) {
        hp -= amount;
        AudioManager.Play("hit");                  // 사운드
        EventBus.Raise(new DamagedEvent(this));    // 이벤트
        if (hp <= 0) Die();
    }

    protected virtual void Die() { /* ... */ }
}

public class BossEnemy : BaseEnemy {
    public override void OnDamaged(int amount) {
        // 보스는 절반만 받자
        int reduced = amount / 2;
        hp -= reduced;
        // ❌ base.OnDamaged(reduced) 누락
        // → 사운드도 안 나오고 이벤트도 안 발생한다
    }
}

테스트해 보면 보스의 체력은 줄어드는데 피격 사운드가 안 나오고, UI의 데미지 텍스트도 뜨지 않습니다. 자식이 부모를 완전히 대체해 버렸기 때문입니다.

After — base 호출로 부모 로직 보존

C#
public class BossEnemy : BaseEnemy {
    public override void OnDamaged(int amount) {
        int reduced = amount / 2;        // 자식 전처리: 데미지 절반
        base.OnDamaged(reduced);         // ✅ 부모 로직 실행 (사운드·이벤트·HP·사망 체크)
        ScreenShake.Trigger(0.2f);       // 자식 후처리: 보스 전용 화면 흔들기
    }
}

이제 사운드와 이벤트가 정상 발생하고, 보스만의 화면 흔들림 효과까지 추가됩니다. 자식의 IL은 다음과 같습니다.

IL
// BossEnemy::OnDamaged
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldc.i4.2
IL_0003: div                                      // amount / 2
IL_0004: stloc.0                                  // reduced 저장
IL_0005: ldarg.0                                  // this
IL_0006: ldloc.0                                  // reduced
IL_0007: call instance void BaseEnemy::OnDamaged(int32)   // ★ call (vtable 우회)
IL_000c: ldc.r4 ...
IL_0011: call void ScreenShake::Trigger(float32)
IL_0016: ret

base.OnDamaged(reduced)call로 컴파일되었으므로 무한 재귀 없이 정확히 BaseEnemy::OnDamaged가 실행됩니다.

Unity의 매직 메서드 주의Awake, Start, Update, OnEnable, OnDisable 같은 Unity 라이프사이클 메서드는 MonoBehaviourvirtual로 선언되어 있지 않습니다. Unity 엔진이 리플렉션 비슷한 메커니즘으로 스크립트 내 메서드 이름을 찾아 호출합니다. 따라서 일반적인 컴포넌트에서는 override 키워드를 붙이지 않습니다. 단, 본인이 직접 만든 베이스 클래스(BaseEnemy 등)에서 라이프사이클을 가상화하려면 명시적으로 protected virtual void Awake() 등으로 선언해야 합니다.

성능 — JIT의 Devirtualization과 sealed

가상 호출은 vtable 조회 단계가 추가되므로 비가상 호출보다 약간 느리고, JIT가 인라이닝(inlining, 메서드 본문을 호출 지점에 펼쳐 넣어 호출 비용을 없애는 최적화)을 적용하기 어렵습니다. JIT(Just-In-Time, 런타임에 IL을 기계어로 변환하는 컴파일러)는 이를 극복하기 위해 devirtualization(탈가상화) 최적화를 시도합니다.

C#
public class Animal {
    public virtual void Speak() { /* ... */ }
}

// 더 이상 자식 클래스가 나올 수 없음을 컴파일 시점에 보장
public sealed class Dog : Animal {
    public override void Speak() { System.Console.WriteLine("Dog"); }
}

// Dog d = new Dog();
// d.Speak();  ← JIT가 "이건 무조건 Dog::Speak"라고 확신 가능 → call로 변환 → 인라이닝 가능

sealed는 "이 클래스는 더 이상 상속되지 않는다"는 약속이므로, JIT는 Dog d로 호출하는 가상 메서드를 안전하게 비가상 호출(call)로 바꾸고 더 나아가 본문을 인라이닝할 수 있습니다. Unity 핫패스(Update·LateUpdate처럼 매 프레임 호출되는 코드 경로)에서 호출되는 메서드를 가진 리프(leaf) 클래스에 sealed를 붙이는 것이 작은 성능 개선 팁입니다.

다만 일반적인 게임 로직에서 virtual 호출 비용은 무시할 수 있는 수준입니다. 설계의 명확성을 우선하고, 프로파일러로 측정된 핫패스에서만 sealed/devirtualization을 고려하는 것이 올바른 순서입니다.

요약: base 호출 누락은 부모의 부수효과(사운드·이벤트·정리 로직)를 통째로 날린다. 핫패스에서는 sealed로 JIT의 devirtualization을 도와주면 인라이닝까지 적용된다.

함정과 주의사항

함정 1 — virtual 없는 메서드를 override로 표시

C#
public class Animal {
    public void Speak() { /* ... */ }   // virtual 없음
}

public class Dog : Animal {
    public override void Speak() { /* ... */ }   // ❌ 컴파일 에러
    // CS0506: 'Dog.Speak()': inherited member 'Animal.Speak()' is not marked as virtual, abstract, or override
}

부모가 허락하지 않은 메서드는 자식이 override할 수 없습니다. 부모를 수정할 수 있다면 virtual을 추가하고, 수정할 수 없으면 new로 숨기거나 다른 이름으로 메서드를 만드는 것이 옳습니다.

C#
// ✅ 부모를 수정할 수 있을 때
public class Animal {
    public virtual void Speak() { /* ... */ }
}
public class Dog : Animal {
    public override void Speak() { /* ... */ }
}

함정 2 — override를 까먹고 new인 것처럼 만들기

C#
public class Animal {
    public virtual void Speak() { System.Console.WriteLine("Animal"); }
}

public class Dog : Animal {
    public void Speak() { System.Console.WriteLine("Dog"); }   // ❌ 의도는 override였는데 new가 됨
    // 컴파일러 경고 CS0108: 'Dog.Speak()' hides inherited member ... use the new keyword if hiding was intended
}

// Animal a = new Dog();
// a.Speak();   // 출력: "Animal"  ← 다형성 깨짐

자식 타입 변수로 직접 호출하면 의도대로 동작하지만, 부모 타입 변수로 받는 컬렉션·이벤트·콜백에서 다형성이 깨집니다. 컴파일러 경고 CS0108은 절대 무시하면 안 됩니다.

C#
// ✅ override를 명시하거나
public class Dog : Animal {
    public override void Speak() { System.Console.WriteLine("Dog"); }
}

// ✅ 정말 숨김이 의도라면 new를 명시한다
public class Dog : Animal {
    public new void Speak() { System.Console.WriteLine("Dog"); }
}

함정 3 — 생성자에서 virtual 호출

C#
public class BaseEnemy {
    public BaseEnemy() {
        Initialize();   // ❌ 생성자에서 가상 메서드 호출
    }

    public virtual void Initialize() { /* ... */ }
}

public class BossEnemy : BaseEnemy {
    private int phase;

    public override void Initialize() {
        // 자식 필드 phase는 아직 0 (자식 생성자 실행 전)
        SetupBossPhase(phase);
    }
}

// new BossEnemy();
// → BaseEnemy 생성자 → Initialize() (가상) → BossEnemy::Initialize 실행
// → 그러나 BossEnemy 생성자는 아직 시작도 안 함 → phase 필드 초기화 전

C#에서 부모 생성자는 자식 생성자보다 먼저 실행되지만, 그 시점에 객체는 이미 자식 타입(BossEnemy)으로 존재합니다. 그래서 Initialize() 호출은 vtable을 통해 BossEnemy::Initialize로 디스패치되며 — 자식의 필드는 아직 초기화되기 전이라 미묘한 버그가 생깁니다.

C#
// ✅ 초기화는 별도 메서드로 분리해 외부에서 호출
public class BaseEnemy {
    public BaseEnemy() { /* 가상 메서드 호출 안 함 */ }
    public virtual void Initialize() { /* ... */ }
}

// var e = new BossEnemy();
// e.Initialize();   ← 객체가 완전히 생성된 후 호출

함정 4 — Unity에서 부모만 갖고 있는 [SerializeField]는 자식이 override해도 그대로 직렬화

C#
public class BaseEnemy : MonoBehaviour {
    [SerializeField] protected int hp;
    public virtual void OnDamaged(int amount) { hp -= amount; }
}

public class BossEnemy : BaseEnemy {
    public override void OnDamaged(int amount) {
        // ✅ 부모의 [SerializeField] hp 필드가 그대로 인스펙터에 노출됨
        base.OnDamaged(amount / 2);
    }
}

이건 함정이라기보다 의도된 동작이지만, 신입이 자주 헷갈립니다. 부모의 protected 필드와 가상 메서드는 자식 컴포넌트에서도 그대로 직렬화·인스펙터 노출됩니다. 자식에서 동일한 이름의 필드를 다시 선언하지 않는 한 인스펙터 값은 부모 필드 하나로 관리됩니다.

요약: virtual 없으면 override 못 한다. 경고 CS0108은 다형성이 깨졌다는 신호다. 생성자에서 가상 메서드는 부르지 말 것.

C# 버전별 변화

virtual/override/base 자체는 C# 1.0부터 존재한 핵심 키워드이며 의미는 변하지 않았습니다. 다만 관련 기능이 시간을 두고 추가되었습니다.

버전 추가 사항 효과
C# 2.0 sealed override 조합 자식이 override하면서도 더 이상 재정의 불가하게 봉인 — JIT의 devirtualization 도움
C# 8.0 인터페이스의 기본 구현(default interface methods) + virtual 인터페이스도 가상 메서드를 가질 수 있게 됨 — 별도 주제로 분리 (이 글의 범위 밖)
C# 9.0 공변 반환 형식(covariant return types) override할 때 반환 타입을 더 구체적인 파생 타입으로 좁힐 수 있음 — 다음 글 주제

sealed override의 효과만 코드로 확인합니다.

C#
public class Animal {
    public virtual void Speak() { System.Console.WriteLine("Animal"); }
}

public class Dog : Animal {
    public sealed override void Speak() { System.Console.WriteLine("Dog"); }
    // ↑ Dog의 자식이 더 Speak를 override하지 못하도록 봉인
}

public class Puppy : Dog {
    // public override void Speak() { ... }   // ❌ 컴파일 에러: sealed로 봉인됨
}

IL에서 sealed는 메서드 시그니처에 final 플래그로 표현됩니다.

IL
.method public hidebysig virtual final
    instance void Speak () cil managed
{
    // final 플래그 → JIT가 더 이상 자식 override 없음을 확신
    // → callvirt를 call로 변환 + 인라이닝 가능
    ...
}

JIT는 final 플래그를 보고 이 메서드는 더 이상 재정의되지 않을 것이라 확신하고, callvirtcall 변환 + 인라이닝까지 시도할 수 있습니다.

요약: 키워드 의미는 1.0부터 동일. sealed override는 JIT 최적화 도구. C# 9의 공변 반환 형식은 다음 글에서.

정리 — 이것만 기억하세요

  • [ ] 약속의 방향: 부모가 virtual로 허락 → 자식이 override로 재정의. 부모가 허락하지 않은 메서드는 자식이 override할 수 없다.
  • [ ] base.Method(): vtable을 우회한 부모 직접 호출. IL에서는 callvirt가 아닌 call로 컴파일되며, 무한 재귀를 막는다.
  • [ ] override vs new: override는 vtable 슬롯을 덮어쓰고 실제 객체 타입이 호출 대상을 결정한다(다형성 ○). new는 정적 타입이 호출 대상을 결정한다(다형성 ✕). 컴파일러 경고 CS0108은 무시 금지.
  • [ ] vtable과 callvirt: 모든 객체 첫 필드는 메서드 테이블 포인터. callvirt는 그 포인터 → vtable → 슬롯 주소 순으로 실제 메서드를 찾는다. 비가상 메서드도 IL에서 callvirt인 이유는 자동 null 체크.
  • [ ] base 호출 누락 함정: 자식 override에서 base 호출을 빼먹으면 부모의 사운드·이벤트·정리 로직이 통째로 사라진다. 부모의 의무가 무엇인지 항상 확인할 것.
  • [ ] 생성자에서 가상 호출 금지: 부모 생성자에서 virtual 호출 시 자식 필드가 초기화되기 전에 자식 메서드가 실행될 수 있다.
  • [ ] Unity 매직 메서드는 virtual 아님: Awake, Start, Update 등은 엔진이 리플렉션으로 찾는다. 일반 컴포넌트에서는 override 붙이지 말 것.
  • [ ] 성능 팁: 핫패스에서 호출되는 리프 클래스에 sealed를 붙이면 JIT가 devirtualization + 인라이닝을 적용해 가상 호출 비용을 제거할 수 있다.
반응형

+ Recent posts