| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 게임개발
- C#
- 2D Camera
- Unity Editor
- TextMeshPro
- base64
- Tween
- RSA
- Dots
- 샘플
- 최적화
- 직장인공부
- unity
- Job 시스템
- job
- 패스트캠퍼스
- sha
- 환급챌린지
- Framework
- 프레임워크
- 오공완
- 가이드
- ui
- 암호화
- 직장인자기계발
- 패스트캠퍼스후기
- Custom Package
- AES
- DotsTween
- adfit
- Today
- Total
EveryDay.DevUp
[PART3.상속과 다형성(2/4)] virtual / override / new — 다형성의 실제 동작 본문
virtual / override / new — 다형성의 실제 동작
Weapon weapon = new Sword();로 무기를 들었다. weapon.Attack()을 호출하면 검의 공격이 나갈까, 기본 공격이 나갈까? override를 썼느냐 new를 썼느냐에 따라 결과가 완전히 달라진다. 변수 타입이 아니라 객체의 실제 타입이 메서드를 결정하는 구조 — 다형성의 핵심을 vtable과 IL 수준에서 파헤친다.
목차
C#의 다형성(Polymorphism)은 세 키워드로 작동한다. virtual은 "이 메서드는 자식이 바꿀 수 있다"고 선언하고, override는 "부모의 그 메서드를 내가 대체하겠다"고 응답하며, new는 "부모와 상관없이 같은 이름의 새 메서드를 만들겠다"고 선언한다. 이 세 키워드가 런타임에서 어떻게 다르게 동작하는지, 왜 new가 위험한지를 IL 코드로 증명한다.
virtual — 재정의를 허용하는 메서드
문제 제기
Unity에서 적(Enemy) 시스템을 설계한다. 모든 적에게 공통으로 TakeDamage() 메서드가 필요하지만, 골렘은 피해를 반으로 줄이고, 슬라임은 죽을 때 분열해야 한다. 부모 클래스에 메서드를 하나 만들면, 자식마다 다른 동작을 넣을 방법이 없다.
"자식 클래스가 부모의 메서드를 자기만의 버전으로 교체할 수 있게 하려면 어떻게 해야 하지?"
개념 정의
virtual— 가상 메서드 선언 키워드 부모 클래스에서 메서드를 선언할 때virtual을 붙이면, 자식 클래스가override로 해당 메서드를 재정의할 수 있도록 허용한다.virtual이 없으면 자식은 재정의할 수 없다.
예시:public virtual void Attack() { ... }자식 클래스에서public override void Attack()으로 동작을 교체할 수 있다.
virtual은 리모컨의 프로그래밍 가능 버튼과 같다. 공장 출하 시 기본 동작이 있지만, 사용자가 원하면 다른 기능으로 바꿀 수 있도록 열어둔 것이다. virtual이 없는 메서드는 버튼이 고정된 것 — 아무리 원해도 동작을 바꿀 수 없다.

핵심은 vtable(가상 메서드 테이블)이다. CLR(Common Language Runtime, .NET 코드를 실행하는 런타임 엔진)은 모든 타입에 vtable을 만든다. virtual 메서드를 선언하면 vtable에 새 슬롯이 생기고, 그 슬롯에 메서드의 주소가 저장된다.
class Animal
{
public virtual void Speak() => System.Console.WriteLine("Animal");
}
class Dog : Animal
{
public override void Speak() => System.Console.WriteLine("Dog");
}
class Cat : Animal
{
public new void Speak() => System.Console.WriteLine("Cat");
}
class Program
{
static void Main()
{
Animal animal = new Animal();
Animal dog = new Dog();
Animal cat = new Cat();
animal.Speak(); // Animal
dog.Speak(); // Dog (override — 실제 타입의 메서드 실행)
cat.Speak(); // Animal (new — 선언 타입의 메서드 실행!)
Cat realCat = new Cat();
realCat.Speak(); // Cat (선언 타입이 Cat이므로 Cat.Speak)
}
}
// Animal.Speak — virtual + newslot: vtable에 새 슬롯 생성
.method public hidebysig newslot virtual
instance void Speak () cil managed
{
IL_0000: ldstr "Animal"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret
}
// Dog.Speak — virtual (newslot 없음): 부모의 슬롯을 자신의 구현으로 교체
.method public hidebysig virtual
instance void Speak () cil managed
{
IL_0000: ldstr "Dog"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret
}
// Cat.Speak — virtual도 newslot도 없음: vtable과 무관한 별개의 메서드
.method public hidebysig
instance void Speak () cil managed
{
IL_0000: ldstr "Cat"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret
}
IL 수준에서 세 메서드의 선언부를 비교하면 차이가 명확하다.
| 키워드 | IL 플래그 | vtable 동작 |
|---|---|---|
virtual |
newslot virtual |
새 슬롯 생성 |
override |
virtual (newslot 없음) |
부모 슬롯의 주소를 교체 |
new |
(플래그 없음) | vtable과 무관 — 별개의 메서드 |
newslot— IL 메서드 플래그 vtable에 새로운 슬롯을 만들라는 지시.virtual과 함께 쓰이면 "이 메서드가 가상 메서드 체인의 시작점"임을 의미한다.override메서드에는newslot이 붙지 않아 부모의 기존 슬롯을 재사용한다.
내부 동작
callvirt vs call — 메서드 호출의 두 가지 방식
런타임이 메서드를 호출하는 IL 명령어는 두 가지다.
// Main 메서드의 호출부
IL_0012: callvirt instance void Animal::Speak() // animal.Speak()
IL_0018: callvirt instance void Animal::Speak() // dog.Speak()
IL_001d: callvirt instance void Animal::Speak() // cat.Speak() — Animal 타입으로 호출
IL_0027: callvirt instance void Cat::Speak() // realCat.Speak() — Cat 타입으로 호출
callvirt— 가상 메서드 호출 명령어 런타임에 객체의 실제 타입을 확인하고, 그 타입의 vtable에서 메서드 주소를 찾아 호출한다. null 검사도 함께 수행한다.call— 정적 메서드 호출 명령어 컴파일 시점에 확정된 주소로 직접 호출한다. vtable 조회가 없어 빠르지만 다형성을 지원하지 않는다.
dog.Speak() 호출을 추적해보자.
- IL에서
callvirt instance void Animal::Speak()가 실행된다 - CLR이
dog객체 내부의 타입 객체 포인터를 따라간다 - Dog 타입의 vtable을 찾는다
- vtable[0]에 저장된 주소를 확인한다 —
Dog.Speak의 주소가 들어있다 Dog.Speak()가 실행된다 — "Dog" 출력
같은 callvirt instance void Animal::Speak()이지만 cat.Speak()은 결과가 다르다. Cat의 vtable[0]에는 여전히 Animal.Speak의 주소가 들어있기 때문이다 — new는 vtable을 건드리지 않는다.

정리하면: callvirt는 항상 객체의 실제 타입에 있는 vtable을 조회한다. override는 그 vtable을 수정하고, new는 vtable을 수정하지 않는다. 같은 callvirt 명령어를 실행해도 결과가 달라지는 이유가 바로 이것이다.
실전 적용
Unity 적 시스템 — virtual/override로 다형적 데미지 처리
Unity에서 다양한 적을 List<Enemy>로 관리할 때, virtual/override가 없으면 적마다 if (enemy is Golem) 같은 타입 검사를 해야 한다.
class Unit
{
public virtual void TakeDamage(float amount)
{
System.Console.WriteLine($"Unit: -{amount} HP");
}
}
class ArmoredUnit : Unit
{
public override void TakeDamage(float amount)
{
float reduced = amount * 0.5f;
base.TakeDamage(reduced); // 부모의 로직을 재사용
}
}
class Boss : ArmoredUnit
{
public override void TakeDamage(float amount)
{
if (amount < 10f) return; // 최소 데미지 무시
base.TakeDamage(amount);
}
}
class Program
{
static void Main()
{
Unit boss = new Boss();
boss.TakeDamage(100f); // Boss → ArmoredUnit → Unit 체인
boss.TakeDamage(5f); // Boss에서 무시됨
}
}
// ArmoredUnit.TakeDamage — base 호출은 call (정적 디스패치)
.method public hidebysig virtual
instance void TakeDamage (float32 amount) cil managed
{
IL_0000: ldarg.1
IL_0001: ldc.r4 0.5
IL_0006: mul // amount * 0.5f
IL_0007: stloc.0
IL_0008: ldarg.0
IL_0009: ldloc.0
IL_000a: call instance void Unit::TakeDamage(float32) // base 호출 → call (callvirt 아님!)
IL_000f: ret
}
// Boss.TakeDamage — 조건 분기 + base 호출
.method public hidebysig virtual
instance void TakeDamage (float32 amount) cil managed
{
IL_0000: ldarg.1
IL_0001: ldc.r4 10
IL_0006: bge.un.s IL_0009 // amount >= 10이면 계속 진행
IL_0008: ret // 10 미만이면 즉시 반환
IL_0009: ldarg.0
IL_000a: ldarg.1
IL_000b: call instance void ArmoredUnit::TakeDamage(float32) // base 호출 → call
IL_0010: ret
}
// Main — callvirt로 다형적 호출
IL_000b: callvirt instance void Unit::TakeDamage(float32) // vtable 조회 → Boss.TakeDamage 실행
base— 부모 클래스 참조 키워드 자식 클래스에서base.메서드()를 호출하면 부모 클래스의 구현을 실행한다. IL에서는callvirt가 아닌call로 번역되어 vtable을 거치지 않고 직접 부모 메서드를 호출한다.
base 호출이 call인 이유가 중요하다. 만약 callvirt로 호출하면 vtable을 다시 조회해서 자기 자신(Boss.TakeDamage)이 호출되고, 무한 재귀에 빠진다. 컴파일러가 base 호출을 call(정적 디스패치)로 강제하는 것은 이런 위험을 원천 차단하기 위함이다.
함정과 주의사항
생성자에서 virtual 메서드 호출 — 초기화 순서 함정
class Base
{
public Base()
{
Initialize(); // 생성자에서 virtual 호출 — 위험!
}
public virtual void Initialize()
{
System.Console.WriteLine("Base 초기화");
}
}
class Derived : Base
{
private string name;
public Derived(string name)
{
this.name = name; // 이 줄은 Base() 생성자 이후에 실행된다
}
public override void Initialize()
{
// name이 아직 null이다! Derived 생성자가 아직 실행되지 않았기 때문.
System.Console.WriteLine($"Derived 초기화: {name?.Length}");
}
}
class Program
{
static void Main()
{
var d = new Derived("Hello");
// 출력: "Derived 초기화: " (name이 null이므로 name?.Length도 null)
// "Base 초기화"는 출력되지 않는다 — override 때문
}
}
C# 객체 생성 순서는 부모 생성자 → 자식 생성자다. Base() 생성자가 실행될 때 Initialize()를 호출하면, vtable에 의해 Derived.Initialize()가 실행된다. 하지만 이 시점에 Derived의 생성자는 아직 실행되지 않았으므로, name 필드는 null이다.
규칙: 생성자에서 virtual 메서드를 호출하지 않는다. Unity의 Awake()나 Start()에서 초기화하는 습관을 들이면 이 함정을 자연스럽게 피할 수 있다.
정리
virtual은 vtable에 새 슬롯을 만든다 (IL:newslot virtual)- 자식이
override하면 vtable 슬롯의 주소가 교체된다 (IL:virtual, newslot 없음) callvirt는 런타임에 객체의 실제 타입의 vtable을 조회하여 호출할 메서드를 결정한다base호출은call(정적 디스패치)로 번역되어 무한 재귀를 방지한다- 생성자에서 virtual 메서드를 호출하면 초기화되지 않은 필드에 접근할 수 있다
override — 부모의 동작을 교체하는 메서드
문제 제기
부모 클래스에 virtual로 열어둔 메서드가 있다. 자식에서 같은 이름으로 메서드를 만들었는데, override를 빼먹으면 어떻게 될까? 컴파일러는 경고만 내고 new로 처리한다 — 그리고 다형성이 조용히 깨진다. 이 차이를 IL에서 확인한다.
개념 정의
override의 핵심 역할은 vtable 슬롯의 주소를 교체하는 것이다. 앞 섹션에서 본 것처럼, override 메서드는 IL에서 virtual 플래그만 붙고 newslot은 붙지 않는다. 이는 "새 슬롯을 만들지 말고, 부모의 기존 슬롯을 재사용하되 주소만 내 것으로 바꿔라"는 뜻이다.
override를 사용하려면 세 가지 조건을 충족해야 한다.
- 부모 메서드에
virtual,abstract, 또는override가 있어야 한다 - 메서드 이름, 매개변수 타입과 수, 반환 타입이 정확히 일치해야 한다
- 접근 한정자(access modifier)가 부모와 동일해야 한다
내부 동작
다중 상속 체인에서의 vtable
A → B → C 3단계 상속에서 각각 override하면, vtable 슬롯 하나를 계속 교체하는 것이다.
class A
{
public virtual void Do() => System.Console.WriteLine("A");
}
class B : A
{
public override void Do() => System.Console.WriteLine("B");
}
class C : B
{
public override void Do() => System.Console.WriteLine("C");
}
class Program
{
static void Main()
{
A obj = new C();
obj.Do(); // "C" — vtable[0]이 C.Do로 교체되어 있다
}
}
vtable 변화를 추적하면:
- A의 vtable[0] = A.Do
- B의 vtable[0] = B.Do (A.Do 주소를 B.Do로 교체)
- C의 vtable[0] = C.Do (B.Do 주소를 C.Do로 교체)
A obj = new C()에서 callvirt는 C의 vtable[0]을 조회하므로 C.Do가 실행된다. 중간의 B.Do는 건너뛴다 — vtable에는 마지막으로 교체된 주소만 남기 때문이다.
실전 적용
Before — override 빼먹기 (다형성 파괴)
class Weapon
{
public virtual float GetDamage() => 10f;
}
class Bow : Weapon
{
public new float GetDamage() => 30f; // 실수! override 대신 new
}
class Program
{
static void Main()
{
Weapon bow = new Bow();
System.Console.WriteLine(bow.GetDamage()); // 10 — 부모의 메서드가 호출된다!
}
}
// Bow.GetDamage — virtual 플래그 없음 (vtable과 무관)
.method public hidebysig
instance float32 GetDamage () cil managed
{
IL_0000: ldc.r4 30
IL_0005: ret
}
// Main — callvirt로 Weapon::GetDamage를 호출
IL_0005: callvirt instance float32 Weapon::GetDamage() // vtable[0] → Weapon.GetDamage (10f)
Bow.GetDamage에 virtual 플래그가 없다. vtable을 수정하지 않았으므로, Weapon 타입 변수로 호출하면 Weapon.GetDamage(10f)가 실행된다.
After — override 사용 (다형성 정상 동작)
class Weapon
{
public virtual float GetDamage() => 10f;
}
class Bow : Weapon
{
public override float GetDamage() => 30f; // override로 vtable 교체
}
class Program
{
static void Main()
{
Weapon bow = new Bow();
System.Console.WriteLine(bow.GetDamage()); // 30 — 정상!
}
}
// Bow.GetDamage — virtual 플래그 있음 (vtable 슬롯 교체)
.method public hidebysig virtual
instance float32 GetDamage () cil managed
{
IL_0000: ldc.r4 30
IL_0005: ret
}
// Main — 같은 callvirt지만 결과가 다르다
IL_0005: callvirt instance float32 Weapon::GetDamage() // vtable[0] → Bow.GetDamage (30f)
IL 명령어는 동일한 callvirt instance float32 Weapon::GetDamage()다. 하지만 Bow.GetDamage에 virtual 플래그가 있어 vtable[0]의 주소가 Bow.GetDamage로 교체되었고, 런타임에 이 주소가 호출된다.
함정과 주의사항
override를 빼먹어도 컴파일은 된다
C# 컴파일러는 부모에 virtual 메서드가 있는데 자식에서 같은 시그니처로 override 없이 메서드를 정의하면 경고(CS0114)를 내보낸다. 하지만 에러가 아니다 — 컴파일은 성공한다. 컴파일러가 암묵적으로 new를 적용하여 다형성이 조용히 깨진다.
경고를 무시하는 습관이 있다면, 팀 전체에서 다형성 버그가 발생할 수 있다. Unity 프로젝트의 .csproj에 <WarningsAsErrors>CS0114</WarningsAsErrors>를 추가하면 이 경고를 에러로 승격시킬 수 있다.
정리
override메서드는 IL에서virtual플래그만 붙고newslot은 없다 — 기존 vtable 슬롯을 재사용- 다중 상속 체인에서 vtable 슬롯에는 마지막으로 override한 메서드의 주소만 남는다
override를 빼먹으면 컴파일러가new를 암묵적으로 적용하여 다형성이 깨진다 (경고만 발생, 에러 아님)
new — 부모 메서드를 숨기는 메서드
문제 제기
수정할 수 없는 외부 에셋의 클래스를 상속받았다. 부모에 Move()라는 메서드가 있는데 virtual이 아니다. 자식에서 Move()를 새로 정의하고 싶다면? 유일한 방법은 new다. 하지만 이 선택은 대가가 크다.
개념 정의
new— 멤버 숨기기 한정자 (Member Hiding Modifier) 부모 클래스에 같은 이름의 멤버가 있을 때, 그것을 재정의하는 것이 아니라 완전히 별개의 새 멤버를 만든다. 부모의 vtable을 수정하지 않으므로 다형성이 작동하지 않는다.
예시:public new void Move() { ... }부모의 Move()와 이름만 같을 뿐, vtable에서는 완전히 독립된 메서드다.
new는 건물의 같은 층에 같은 번호의 방을 만드는 것과 같다. 1층(부모)에 101호가 있고, 2층(자식)에도 101호를 만들었다. 엘리베이터(vtable)는 1층 101호만 안내하고, 2층 101호는 직접 2층에 가야만 찾을 수 있다.
내부 동작
new로 선언한 메서드는 IL에서 virtual 플래그도 newslot 플래그도 없는 일반 인스턴스 메서드로 취급된다. vtable에 슬롯을 차지하지 않으므로, 부모 타입 변수로 호출하면 부모의 vtable 슬롯이 그대로 사용된다.
앞 섹션의 IL을 다시 보면:
// Cat.Speak — new로 선언: virtual 플래그 없음
.method public hidebysig
instance void Speak () cil managed
hidebysig(hide-by-signature)만 있다. 이 플래그는 "같은 이름+시그니처의 부모 멤버를 숨긴다"는 의미인데, vtable을 수정하는 것이 아니라 컴파일러의 이름 해결(name resolution)에만 영향을 준다.
실전 적용
new를 써야 하는 유일한 정당한 사례
수정할 수 없는 외부 라이브러리 클래스를 상속받았는데, 부모에 비가상(non-virtual) 메서드가 있고 자식에서 같은 이름이 필요할 때.
// 외부 라이브러리 — 수정 불가
class ThirdPartyController
{
public void Reset() => System.Console.WriteLine("ThirdParty Reset");
}
// 우리 코드
class MyController : ThirdPartyController
{
public new void Reset()
{
base.Reset(); // 부모의 Reset도 호출
System.Console.WriteLine("My additional reset logic");
}
}
class Program
{
static void Main()
{
MyController ctrl = new MyController();
ctrl.Reset(); // "ThirdParty Reset" + "My additional reset logic"
ThirdPartyController baseCtrl = ctrl;
baseCtrl.Reset(); // "ThirdParty Reset" — 다형성 없음!
}
}
이 경우에도 baseCtrl.Reset()은 부모의 메서드만 실행된다. new를 쓸 때는 반드시 "이 객체를 부모 타입 변수에 담아서 호출하는 곳이 있는가?"를 확인해야 한다.
함정과 주의사항
Unity에서 new가 가장 위험한 순간
// ❌ 잘못된 패턴 — new로 인해 다형성이 깨진 무기 시스템
class Weapon
{
public virtual float GetDamage() => 10f;
}
class Sword : Weapon
{
public override float GetDamage() => 50f;
}
class Bow : Weapon
{
public new float GetDamage() => 30f; // 실수!
}
class Program
{
static void Main()
{
Weapon sword = new Sword();
Weapon bow = new Bow();
System.Console.WriteLine(sword.GetDamage()); // 50 — override 정상
System.Console.WriteLine(bow.GetDamage()); // 10 — new로 인해 Weapon.GetDamage 호출!
Bow realBow = new Bow();
System.Console.WriteLine(realBow.GetDamage()); // 30 — Bow 타입으로 직접 호출하면 정상
}
}
// Bow.GetDamage — virtual 플래그 없음 (new)
.method public hidebysig
instance float32 GetDamage () cil managed
{
IL_0000: ldc.r4 30
IL_0005: ret
}
// Main에서 bow.GetDamage() 호출
IL_0016: callvirt instance float32 Weapon::GetDamage() // Weapon의 vtable → 10f
// Main에서 realBow.GetDamage() 호출
IL_0025: callvirt instance float32 Bow::GetDamage() // Bow 타입 직접 → 30f
같은 객체(Bow)인데, 담고 있는 변수의 타입에 따라 다른 메서드가 호출된다. Weapon bow로 선언하면 10f, Bow realBow로 선언하면 30f — 이것이 new가 만드는 예측 불가능한 동작이다.
Unity에서 List<Weapon>으로 무기를 관리하면 모든 무기가 Weapon 타입 변수에 담기므로, new로 선언된 Bow의 GetDamage()는 절대 호출되지 않는다.
// ✅ 올바른 패턴 — override 사용
class Bow : Weapon
{
public override float GetDamage() => 30f; // override → vtable 교체
}
규칙: 다형성이 필요한 메서드에는 절대 new를 쓰지 않는다. new는 부모가 virtual을 제공하지 않고 수정도 불가능할 때의 최후 수단이다.
정리
new메서드는 IL에서virtual플래그가 없는 일반 인스턴스 메서드다- vtable을 수정하지 않으므로 부모 타입 변수로 호출하면 부모의 메서드가 실행된다
- 같은 객체가 변수 타입에 따라 다르게 동작하는 예측 불가능한 코드를 만든다
new는 수정 불가능한 외부 라이브러리의 비가상 메서드와 이름이 충돌할 때만 사용한다
sealed override — 재정의 체인을 끊는 최적화
문제 제기
virtual/override는 유연하지만 대가가 있다. callvirt는 매번 vtable을 조회해야 하고, JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환하는 컴파일러) 컴파일러는 가상 메서드를 인라이닝(inlining, 메서드 호출을 본문으로 대체하는 최적화)할 수 없다. 만약 "이 메서드는 더 이상 재정의되지 않는다"고 확신할 수 있다면, 성능을 되찾을 방법이 있을까?
개념 정의
sealed override— 재정의 봉인override한 메서드에sealed를 추가하면, 이 클래스의 자식은 해당 메서드를 더 이상override할 수 없다. 재정의 체인이 여기서 끊긴다.
예시:public sealed override void Speak() { ... }이 클래스를 상속받는 자식에서override void Speak()을 시도하면 컴파일 에러가 발생한다.
class Animal
{
public virtual void Speak() => System.Console.WriteLine("Animal");
}
class Dog : Animal
{
public sealed override void Speak() => System.Console.WriteLine("Dog");
}
// class Puppy : Dog
// {
// public override void Speak() { } // 컴파일 에러! sealed 메서드는 override 불가
// }
class Program
{
static void Main()
{
Dog dog = new Dog();
dog.Speak();
Animal poly = new Dog();
poly.Speak();
}
}
// Dog.Speak — final 플래그가 추가됨
.method public final hidebysig virtual
instance void Speak () cil managed
{
IL_0000: ldstr "Dog"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret
}
IL에서 sealed override는 final 플래그로 표현된다. final은 "이 메서드는 이 지점에서 vtable 체인이 끝난다"는 의미다.
내부 동작
devirtualization — JIT의 성능 최적화
JIT 컴파일러는 final 플래그를 보면 devirtualization(역가상화)을 수행할 수 있다. Dog 타입 변수로 Speak()를 호출할 때, "이 메서드는 더 이상 override될 수 없으니 vtable 조회 없이 직접 호출해도 안전하다"고 판단한다.
이론적인 최적화 과정:
// 최적화 전 (callvirt — vtable 조회 필요)
callvirt instance void Animal::Speak()
// JIT가 devirtualization 적용 후 (call — 직접 호출)
call instance void Dog::Speak()
vtable 조회가 사라지면 두 가지 이점이 있다.
- 호출 오버헤드 감소 — vtable 인덱싱 + 간접 점프가 사라진다
- 인라이닝 가능 —
call로 변환되면 JIT가 메서드 본문을 호출부에 직접 삽입할 수 있다. Unity의 핫패스(Update, FixedUpdate 등 매 프레임 실행되는 경로)에서 반복 호출되는 가상 메서드를sealed override로 봉인하면 체감 가능한 성능 향상을 얻을 수 있다.
실전 적용
언제 sealed override를 사용하는가
- 상속 체인의 최종 구현이 확실한 메서드
- 핫패스에서 매 프레임 호출되는 가상 메서드
- 더 이상의 확장이 의미 없는 구체적인 클래스
// Unity 예시: 최종 구현이 확정된 이동 로직
class Unit
{
public virtual void Move(float delta) { /* 기본 이동 */ }
}
class FlyingUnit : Unit
{
public sealed override void Move(float delta)
{
// 비행 유닛의 이동은 이것이 최종 구현
// 자식이 이동 로직을 바꾸는 것을 막는다
}
}
정리
sealed override는 IL에서final플래그로 표현된다- JIT 컴파일러가 devirtualization으로
callvirt→call변환을 수행할 수 있다 - 인라이닝이 가능해져 핫패스 성능이 개선된다
- 상속 체인의 최종 구현이 확정된 메서드에 사용한다
C# 버전별 변화
C# 1.0 — virtual / override / new 도입
virtual, override, new 세 키워드는 C# 1.0부터 존재하며, 핵심 동작은 현재까지 변하지 않았다.
C# 9.0 — 공변 반환 타입 (Covariant Return Types)
C# 9.0 이전에는 override 메서드의 반환 타입이 부모와 정확히 동일해야 했다. C# 9.0부터는 반환 타입을 더 구체적인 파생 타입으로 변경할 수 있다.
// Before — C# 8.0 이하: 반환 타입이 정확히 같아야 한다
class Factory
{
public virtual Animal Create() => new Animal();
}
class DogFactory : Factory
{
public override Animal Create() => new Dog(); // 반환 타입이 Animal이어야 함
}
class Program
{
static void Main()
{
var factory = new DogFactory();
Animal a = factory.Create(); // Dog 객체지만 Animal 타입으로 받아야 한다
}
}
// After — C# 9.0 이상: 공변 반환 타입 사용
class Factory
{
public virtual Animal Create() => new Animal();
}
class DogFactory : Factory
{
public override Dog Create() => new Dog(); // 반환 타입을 Dog로 좁힐 수 있다!
}
class Program
{
static void Main()
{
var factory = new DogFactory();
Dog d = factory.Create(); // Dog 타입으로 직접 받을 수 있다 — 캐스팅 불필요
}
}
공변 반환 타입을 사용하면 팩토리 패턴에서 불필요한 다운캐스팅을 제거할 수 있다.
C# 8.0 — 인터페이스 기본 구현 (Default Interface Methods)
C# 8.0부터 인터페이스에도 메서드 본문을 넣을 수 있게 되었다. 이는 클래스의 virtual/override와 유사한 개념을 인터페이스로 확장한 것이지만, Unity에서 사용하는 Mono/IL2CPP 런타임과의 호환성 문제가 있을 수 있으므로 주의가 필요하다.
정리
| 키워드 | IL 플래그 | vtable 동작 | 호출 방식 | 다형성 |
|---|---|---|---|---|
virtual |
newslot virtual |
새 슬롯 생성 | callvirt |
시작점 |
override |
virtual |
슬롯 주소 교체 | callvirt |
유지 |
new |
(없음) | 변경 없음 | 변수 타입에 따라 다름 | 깨짐 |
sealed override |
final virtual |
슬롯 주소 교체 + 봉인 | devirtualization 가능 | 유지 |
체크리스트:
- [ ] 자식이 동작을 바꿀 수 있어야 하는 메서드에
virtual을 선언했는가? - [ ] 부모의
virtual메서드를 자식에서 재정의할 때override를 사용했는가? - [ ]
new키워드를 사용한 곳이 있다면, 부모 타입 변수로 호출될 가능성을 확인했는가? - [ ] 핫패스에서 매 프레임 호출되는 가상 메서드에
sealed override를 고려했는가? - [ ] 생성자에서
virtual메서드를 호출하지 않았는가? - [ ] 컴파일러 경고 CS0114를 무시하고 있지 않은가?
'C# 심화' 카테고리의 다른 글
| [PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법 (0) | 2026.04.05 |
|---|---|
| [PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가 (0) | 2026.04.05 |
| [PART3.상속과 다형성(1/4)] 상속 vs 컴포지션 — 언제 상속을 쓰고 언제 쓰지 않는가 (0) | 2026.04.05 |
| [PART2.클래스와 객체(7/7)] 연산자 오버로딩 — 사용자 정의 타입에 연산자를 부여하는 방법 (1) | 2026.04.05 |
| [PART2.클래스와 객체(6/7)] 접근 한정자 완전 정리 — public·internal·protected·private protected (0) | 2026.04.05 |
