CS — vtable
vtable과 vptr, 동적 디스패치의 동작 원리를 면접 답변 흐름으로 정리
모의면접 답변 — “Vtable이란 무엇인가요?”
Vtable, 즉 가상 함수 테이블은 C++ 컴파일러가 런타임 다형성을 구현하기 위해 자동으로 생성하는 함수 포인터 배열입니다. 가상 함수(virtual)가 하나라도 있는 클래스는 컴파일 타임에 vtable이 생성되고, 각 객체는 vptr(가상 포인터)을 통해 자신의 클래스 vtable을 가리킵니다.
가상 함수를 호출할 때는 컴파일 타임에 어떤 함수를 호출할지 결정하는 것이 아니라, 런타임에 vptr → vtable → 함수 포인터 순으로 탐색해 실제 함수를 호출합니다. 이것이 동적 디스패치(Dynamic Dispatch)입니다.
결과적으로 vtable 덕분에 부모 타입 포인터로 자식 객체를 가리키더라도, 항상 실제 타입에 맞는 올바른 함수가 호출되어 OOP의 런타임 다형성이 실현됩니다.
객체의 vptr → 클래스 vtable → 함수 포인터 경로로 실제 함수를 찾는다. 자식 vtable은 부모 것을 복사한 뒤 재정의한 슬롯(Speak)만 교체하고, 재정의 안 한 슬롯(Move)은 부모 함수를 그대로 상속한다.
키워드
- vtable (Virtual Function Table) — 클래스별로 생성되는 가상 함수 포인터 배열
- vptr (Virtual Pointer) — 각 객체가 내부적으로 보유하는 vtable 포인터
- 동적 디스패치 (Dynamic Dispatch) — 런타임에 vtable을 통해 실제 함수를 찾아 호출하는 메커니즘
- 정적 바인딩 (Static Binding) — 컴파일 타임에 호출 함수가 결정되는 방식 (non-virtual)
- 동적 바인딩 (Dynamic Binding) — 런타임에 호출 함수가 결정되는 방식 (virtual)
- 순수 가상 함수 (Pure Virtual) —
= 0으로 선언, vtable 슬롯에__cxa_pure_virtual포인터 등록
Vtable이란?
Vtable은 C++ 컴파일러가 가상 함수를 지원하기 위해 클래스마다 자동으로 생성하는 함수 포인터 배열입니다. virtual 키워드 하나만 붙여도 컴파일러가 만들어 주기 때문에, 코드에는 보이지 않지만 항상 뒤에서 동작하고 있습니다.
vtable과 vptr의 관계
| 항목 | vtable | vptr |
|---|---|---|
| 존재 단위 | 클래스마다 1개 | 객체마다 1개 |
| 생성 시점 | 컴파일 타임 | 런타임 (생성자 호출 시) |
| 저장 위치 | 읽기 전용 메모리 (.rodata) | 객체 메모리의 맨 앞 |
| 내용 | 가상 함수 포인터 배열 | 해당 클래스의 vtable 주소 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
public:
virtual void Speak() { } // vtable 슬롯 0
virtual void Move() { } // vtable 슬롯 1
virtual ~Animal() { } // vtable 슬롯 2
};
class Dog : public Animal {
public:
void Speak() override { } // 슬롯 0 → Dog::Speak 로 교체
// Move()는 재정의 안 함 → 슬롯 1 은 Animal::Move 그대로
};
// Animal vtable: [ Animal::Speak, Animal::Move, Animal::~Animal ]
// Dog vtable: [ Dog::Speak, Animal::Move, Dog::~Dog ]
핵심은 위 주석 두 줄입니다. 자식 클래스의 vtable은 부모 vtable을 복사한 뒤 재정의(override)한 슬롯만 새 함수 포인터로 교체하고, 재정의하지 않은 슬롯은 부모의 함수 포인터를 그대로 물려받습니다.
vptr과 메모리 구조
vptr(Virtual Pointer)은 객체가 내부적으로 숨겨서 보유하는 포인터로, 해당 객체의 실제 클래스 vtable을 가리킵니다. 생성자가 호출될 때 vptr이 자동으로 초기화되며, 객체 메모리의 맨 앞에 위치합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Animal {
public:
virtual void Speak() { }
int age; // 멤버 변수
float weight;
};
// Animal 객체 메모리 구조 (64비트 기준):
// ┌──────────────────────────────────────────┐
// │ vptr (8 bytes) → Animal의 vtable 주소 │
// ├──────────────────────────────────────────┤
// │ int age (4 bytes) │
// ├──────────────────────────────────────────┤
// │ float weight (4 bytes) │
// └──────────────────────────────────────────┘
// sizeof(Animal) = 16 bytes
class Plain { // 가상 함수 없음
public:
int age;
float weight;
};
// sizeof(Plain) = 8 bytes → vptr 없음!
가상 함수가 있으면 sizeof(객체)가 포인터 크기만큼 커지는 이유가 바로 이 vptr입니다.
vptr 초기화 과정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
virtual void Foo() { }
};
class Derived : public Base {
public:
void Foo() override { }
};
// 생성 순서:
// 1. Base 생성자 실행 → vptr = &Base::vtable
// 2. Derived 생성자 실행 → vptr = &Derived::vtable
Derived d;
// d.vptr → Derived::vtable → [ Derived::Foo ]
Base* p = &d;
p->Foo(); // vptr 따라가면 Derived::vtable → Derived::Foo
복기:
- vptr은 객체 생성 시 생성자가 자동으로 초기화 (숨겨진 동작)
Base* p = &Derived객체— vptr은 여전히 Derived의 vtable 가리킴- 생성자 안에서 가상 함수 호출 주의 — 그 시점의 vptr은 아직 자기 클래스의 vtable이라 파생 클래스 함수가 호출되지 않음
동적 디스패치 (Dynamic Dispatch)
동적 디스패치는 런타임에 vtable을 참조해 실제 호출할 함수를 결정하는 메커니즘입니다.
정적 vs 동적 바인딩
| 정적 바인딩 | 동적 바인딩 | |
|---|---|---|
| 결정 시점 | 컴파일 타임 | 런타임 |
| 조건 | non-virtual, 값 타입 호출 | virtual, 포인터/참조 호출 |
| 성능 | 빠름 | vtable 간접 참조 비용 |
| 다형성 | 불가 | 가능 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Shape {
public:
virtual void Draw() { }
};
class Circle : public Shape {
public:
void Draw() override { }
};
Shape* s = new Circle();
// s->Draw() 내부 동작:
// 1. s가 가리키는 객체에서 vptr 읽기 (mov rax, [s])
// 2. vtable에서 Draw 슬롯 읽기 (mov rax, [rax + 0])
// 3. 함수 포인터로 간접 호출 (call rax)
// → Circle::Draw() 호출
Shape s2;
s2.Draw(); // 정적 바인딩 → 직접 호출, vtable 없음
주의할 점은 동적 디스패치가 일어나는 조건입니다. 포인터나 참조를 통한 가상 함수 호출에서만 발생하고, 값 타입으로 호출하면 슬라이싱(Object Slicing — 자식 객체를 부모 값 타입에 담으면 자식 부분이 잘려 나가는 현상)과 함께 정적 바인딩으로 처리됩니다.
상속 계층에서의 Vtable
자식 클래스는 부모 vtable을 복사한 뒤, 재정의(override)한 슬롯만 새 주소로 교체합니다. 이때 가상 함수의 슬롯 인덱스는 상속 계층 전반에서 동일하게 유지되기 때문에, 어떤 타입이든 같은 인덱스로 올바른 함수를 찾을 수 있습니다.
단일 상속
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A {
public:
virtual void Foo() { } // 슬롯 0
virtual void Bar() { } // 슬롯 1
};
class B : public A {
public:
void Foo() override { } // 슬롯 0 → B::Foo
};
// A vtable: [ A::Foo, A::Bar ]
// B vtable: [ B::Foo, A::Bar ]
class C : public B {
public:
void Bar() override { }
};
// C vtable: [ B::Foo, C::Bar ]
다중 상속
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base1 { public: virtual void F1() { } };
class Base2 { public: virtual void F2() { } };
class Multi : public Base1, public Base2 {
public:
void F1() override { }
void F2() override { }
};
// Multi 객체 메모리:
// ┌────────────────────────────────────────┐
// │ vptr1 → Multi의 Base1용 vtable │
// │ (Base1 멤버 변수들) │
// ├────────────────────────────────────────┤
// │ vptr2 → Multi의 Base2용 vtable │
// │ (Base2 멤버 변수들) │
// └────────────────────────────────────────┘
// vptr이 2개!
다중 상속에서는 각 부모마다 vtable이 별도로 존재하고 객체 안에 vptr도 여러 개 생기므로, 그만큼 메모리·성능 오버헤드가 붙습니다. 다이아몬드 상속에 가상 상속(virtual public)까지 더해지면 vtable 구조는 훨씬 복잡해집니다.
Vtable과 성능 비용
Vtable은 런타임 다형성을 가능하게 하지만, 간접 호출 비용과 인라이닝 불가라는 성능 트레이드오프가 있습니다.
비용 분석
| 비용 항목 | 설명 | 영향도 |
|---|---|---|
| 메모리 오버헤드 | 객체당 vptr 1개 (8 bytes) | 낮음 |
| 간접 호출 비용 | vptr → vtable → 함수 포인터 2회 역참조 | 중간 |
| 인라이닝 불가 | 컴파일 타임에 함수 특정 불가 | 높음 |
| 캐시 미스 | vtable 포인터 역참조 시 | 중간~높음 |
성능 개선 방법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. final 키워드로 devirtualization 유도
class Circle final : public Shape {
public:
void Draw() override { } // 더 이상 재정의 불가
};
Circle* c = new Circle();
c->Draw(); // devirtualized → vtable 참조 없이 직접 호출!
// 2. 값 타입으로 직접 호출
Circle c2;
c2.Draw(); // 정적 바인딩
// 3. CRTP — 컴파일 타임 다형성 (제로 오버헤드)
template<typename Derived>
class ShapeBase {
public:
void Draw() { static_cast<Derived*>(this)->DrawImpl(); }
};
class SquareCRTP : public ShapeBase<SquareCRTP> {
public:
void DrawImpl() { }
};
정리하면 final로 재정의 불가를 명시해 devirtualize(가상 호출을 직접 호출로 바꾸는 컴파일러 최적화)를 유도하거나, 성능이 중요한 Hot Path는 CRTP로 컴파일 타임 다형성을 쓰는 방법이 있습니다. 다만 대부분의 경우 vtable 비용은 측정 가능한 병목이 아니므로, 먼저 프로파일링을 해보고 판단해야 합니다.
언리얼 엔진과 Vtable
언리얼 엔진의 AActor, UObject 등 핵심 클래스는 가상 함수를 광범위하게 활용합니다.
주요 가상 함수
| 함수 | 선언 위치 | vtable 역할 |
|---|---|---|
| BeginPlay() | AActor | 각 Actor 타입의 초기화 동적 디스패치 |
| Tick(float DeltaTime) | AActor | 매 프레임 타입별 업데이트 동적 디스패치 |
| TakeDamage() | AActor | 데미지 처리 로직 다형성 |
| PostInitializeComponents() | AActor | 컴포넌트 초기화 후 커스텀 로직 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AActor : public UObject {
public:
virtual void BeginPlay();
virtual void Tick(float DeltaTime);
virtual float TakeDamage(...);
};
UCLASS()
class AMyCharacter : public ACharacter {
GENERATED_BODY()
public:
virtual void BeginPlay() override {
Super::BeginPlay(); // 부모 vtable 슬롯 직접 호출 (정적 바인딩)
}
virtual void Tick(float DeltaTime) override {
Super::Tick(DeltaTime);
}
};
// 엔진 내부:
// TArray<AActor*> Actors;
// for (AActor* Actor : Actors)
// Actor->Tick(DeltaTime); // vtable → 각 타입의 올바른 Tick 호출
BeginPlay()와 Tick()이 결국 가상 함수이고, 엔진이 TArray<AActor*> 하나로 온갖 타입의 액터를 돌리면서도 각 타입의 올바른 함수를 호출할 수 있는 이유가 vtable입니다. 반대로 Super::BeginPlay()는 부모 함수를 직접 지정해 부르는 정적 바인딩입니다. 성능이 민감한 코드라면 FORCEINLINE, 비가상 함수 분리, ECS 패턴 같은 대안을 고려합니다.
핵심 요약 — 가상 함수가 하나라도 있으면 클래스마다 vtable, 객체마다 vptr이 생기고, 가상 함수 호출은 vptr → vtable → 함수 포인터 순의 간접 호출로 처리된다. 그 대가로 간접 호출 비용과 인라이닝 불가가 생기며
final(devirtualize)이나 CRTP로 줄일 수 있다.