C++ VTable | '가상 함수 테이블' 가이드 | 핵심 개념과 실전 활용
이 글의 핵심
가상 함수를 사용하면 런타임에 올바른 함수를 찾아야 합니다. VTable은 이를 위한 함수 포인터 배열이고, vptr은 객체가 자신의 vtable을 가리키는 포인터입니다.
들어가며
C++에서 가상 함수(virtual function)를 사용하면 다형성(polymorphism)이 가능해집니다. 하지만 이게 어떻게 동작할까요?
이 글에서는:
- VTable이 무엇인지 - 가상 함수 테이블의 구조
- VTable이 어떻게 작동하는지 - vptr과 함수 호출 과정
- 실무에서 주의할 점 - 성능, 메모리, 흔한 실수들
💡
undefined reference to vtable에러가 나서 급하게 해결하고 싶다면 vtable 에러 해결을 먼저 보세요.
1. VTable이란?
기본 개념
VTable(Virtual Table, 가상 함수 테이블)은 클래스의 가상 함수 주소를 담고 있는 함수 포인터 배열입니다.
class Base {
public:
virtual void func() { std::cout << "Base::func\n"; }
};
// 컴파일러가 자동으로 생성하는 VTable (개념적)
// Base_VTable = {
// [0] = &Base::func
// }
왜 필요한가?
- 일반 함수는 컴파일 타임에 호출할 함수가 정해집니다
- 가상 함수는 런타임에 실제 객체 타입을 보고 호출할 함수를 결정해야 합니다
- VTable이 있어야 런타임에 올바른 함수를 찾을 수 있습니다
메모리 구조
class Base {
public:
virtual void func() {}
int data = 10;
};
// 메모리 레이아웃:
// ┌──────────┐
// │ vptr │ ←─ 첫 8바이트 (64비트 시스템)
// ├──────────┤
// │ data (10)│ ←─ 4바이트
// ├──────────┤
// │ padding │ ←─ 4바이트 (정렬)
// └──────────┘
// 총 크기: 16바이트
int main() {
Base b;
std::cout << "크기: " << sizeof(b) << std::endl; // 16
}
vptr(Virtual Pointer): 각 객체가 가지고 있는 포인터로, 자신의 클래스 vtable을 가리킵니다.
2. VTable 작동 원리
단계별 설명
class Animal {
public:
virtual void speak() {
std::cout << "Animal\n";
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "Woof!\n";
}
};
1단계: VTable 생성 (컴파일 타임)
Animal_VTable:
[0] = &Animal::speak
Dog_VTable:
[0] = &Dog::speak // override했으므로 다른 주소
2단계: 객체 생성 (런타임)
Animal* a = new Dog();
// Dog 객체의 vptr이 Dog_VTable을 가리킴
3단계: 함수 호출 (런타임)
a->speak();
// 실제 실행 과정:
// 1. a->vptr 읽기 (Dog_VTable 주소)
// 2. Dog_VTable[0] 읽기 (&Dog::speak)
// 3. Dog::speak() 호출
// 출력: "Woof!"
이 과정이 동적 디스패치(dynamic dispatch)입니다.
3. 실전 예시
예시 1: 다형성 활용
class Shape {
public:
virtual void draw() = 0; // 순수 가상 함수
virtual double area() = 0;
virtual ~Shape() = default; // 가상 소멸자 필수!
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
void draw() override {
std::cout << "○ Circle\n";
}
double area() override {
return 3.14 * radius * radius;
}
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
void draw() override {
std::cout << "▭ Rectangle\n";
}
double area() override {
return width * height;
}
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5));
shapes.push_back(std::make_unique<Rectangle>(4, 6));
// ✅ 다형성: 같은 인터페이스로 다른 동작
for (auto& shape : shapes) {
shape->draw();
std::cout << "넓이: " << shape->area() << "\n\n";
}
}
// 출력:
// ○ Circle
// 넓이: 78.5
//
// ▭ Rectangle
// 넓이: 24
예시 2: 성능 비교
// 비가상 함수
class NonVirtual {
public:
void func() { /* ... */ }
};
// 가상 함수
class Virtual {
public:
virtual void func() { /* ... */ }
};
// 호출 비용 비교
NonVirtual nv;
nv.func(); // 직접 호출 (빠름)
Virtual v;
v.func(); // vptr → vtable → 함수 (간접 호출, 조금 느림)
성능 차이:
- 비가상 함수: 컴파일러가 인라인 최적화 가능
- 가상 함수: 간접 호출 + 캐시 미스 가능성
- 실무: 대부분 경우 무시할 정도지만, 타이트한 루프에서는 고려 필요
4. 자주 발생하는 문제
문제 1: 가상 소멸자 누락 ⚠️
class Base {
public:
~Base() { std::cout << "~Base\n"; } // ❌ 비가상
};
class Derived : public Base {
int* data;
public:
Derived() { data = new int[100]; }
~Derived() {
delete[] data;
std::cout << "~Derived\n";
}
};
Base* b = new Derived();
delete b;
// ❌ Derived 소멸자가 호출되지 않음!
// 출력: "~Base"만 나옴 → 메모리 누수!
해결책:
class Base {
public:
virtual ~Base() = default; // ✅ 가상 소멸자
};
// 이제 올바르게 동작:
// 출력: "~Derived" → "~Base"
규칙: 기저 클래스 포인터로 delete할 가능성이 있으면 무조건 가상 소멸자!
문제 2: 생성자에서 가상 함수 호출
class Base {
public:
Base() {
init(); // ❌ Base::init이 호출됨 (다형성 안 됨!)
}
virtual void init() {
std::cout << "Base init\n";
}
};
class Derived : public Base {
public:
void init() override {
std::cout << "Derived init\n";
}
};
Derived d;
// 출력: "Base init" ← Derived::init을 기대했는데!
왜 이런가?
Derived생성자가 실행되기 전에Base생성자가 먼저 실행됩니다- 이 시점에는 아직
Derived객체가 완전히 생성되지 않았습니다 - 따라서
Base::init()이 호출됩니다
해결책: 생성자에서 가상 함수를 호출하지 말고, 별도 초기화 함수를 만드세요.
Derived d;
d.init(); // ✅ 완전히 생성된 후 호출
문제 3: 메모리 오버헤드
// 가상 함수 없는 클래스
class NoVirtual {
int x, y, z;
};
std::cout << sizeof(NoVirtual) << "\n"; // 12 바이트
// 가상 함수 있는 클래스
class WithVirtual {
int x, y, z;
virtual void func() {}
};
std::cout << sizeof(WithVirtual) << "\n"; // 24 바이트
// vptr (8) + x,y,z (12) + padding (4)
영향:
- 객체가 많으면 메모리 사용량 증가
- 캐시 효율성 저하 가능
- 대부분 경우 무시 가능, 수백만 개 객체 생성 시에만 고려
5. VTable 최적화
1. final 키워드
class Base {
virtual void func() {}
};
// ✅ 더 이상 override되지 않음을 명시
class Derived final : public Base {
void func() override final {}
};
// 컴파일러가 최적화 가능:
Derived d;
d.func(); // final이므로 직접 호출 가능
2. 비가상 인터페이스 패턴 (NVI)
class Base {
public:
void doSomething() { // ✅ public, 비가상
// 전처리
doSomethingImpl();
// 후처리
}
private:
virtual void doSomethingImpl() {} // 실제 구현은 가상
};
장점:
- 공개 인터페이스는 안정적 (비가상)
- 구현만 파생 클래스에서 변경 가능
- 전후처리 로직을 기저 클래스에서 통제
3. CRTP (Curiously Recurring Template Pattern)
// 가상 함수 없이 다형성
template<typename Derived>
class Base {
public:
void doSomething() {
static_cast<Derived*>(this)->doSomethingImpl();
}
};
class Concrete : public Base<Concrete> {
public:
void doSomethingImpl() { /* ... */ }
};
// ✅ vtable 없음, 컴파일 타임에 모두 해결
6. 정리
VTable 핵심 정리
| 항목 | 설명 |
|---|---|
| VTable | 가상 함수 주소를 담은 배열 |
| vptr | 객체 안에 있는 포인터, vtable을 가리킴 |
| 생성 시점 | 컴파일 타임에 클래스마다 하나씩 생성 |
| 메모리 비용 | vptr 크기 (보통 8바이트) |
| 성능 비용 | 간접 호출 1회 + 캐시 미스 가능성 |
실무 체크리스트
- 기저 클래스에 가상 소멸자 선언했는가?
- 생성자/소멸자에서 가상 함수를 호출하지 않는가?
- 성능이 중요한 핫 루프에서 가상 함수 남용하지 않는가?
- 더 이상 override하지 않는 함수에
final사용했는가? - 다형성이 정말 필요한지 (템플릿으로 대체 가능한지) 검토했는가?
언제 가상 함수를 사용하는가?
✅ 사용해야 할 때:
- 런타임에 타입이 결정되는 다형성 필요
- 플러그인 시스템, 팩토리 패턴
- 인터페이스 기반 설계
❌ 피해야 할 때:
- 컴파일 타임에 타입이 확정됨 → 템플릿 고려
- 극도의 성능이 중요한 타이트한 루프
- 메모리가 매우 제한적인 임베디드 환경
같이 보면 좋은 글
- C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
- C++ 가상 함수 | “Virtual Functions” 가이드
- C++ vtable 에러 | ‘undefined reference to vtable’ 링커 에러 해결
- C++ CRTP 완벽 가이드 | 정적 다형성과 컴파일 타임 최적화
이 글에서 다루는 키워드
C++, vtable, virtual-function, polymorphism, 가상함수, vptr, 동적디스패치, 다형성 등으로 검색하시면 이 글이 도움이 됩니다.