C++ vtable과 vptr: 가상 함수 호출이 동작하는 방식과 메모리·호출 비용
이 글의 핵심
가상 함수를 사용하면 런타임에 올바른 함수를 찾아야 합니다. VTable은 이를 위한 함수 포인터 배열이고, vptr은 객체가 자신의 vtable을 가리키는 포인터입니다.
들어가며
C++에서 가상 함수(virtual function)를 사용하면 다형성(polymorphism)이 가능해집니다. 하지만 이게 어떻게 동작할까요?
이 글에서는:
- VTable이 무엇인지 - 가상 함수 테이블의 구조
- VTable이 어떻게 작동하는지 - vptr과 함수 호출 과정
- 실무에서 주의할 점 - 성능, 메모리, 흔한 실수들
💡
undefined reference to vtable에러가 나서 급하게 해결하고 싶다면 vtable 에러 해결을 먼저 보세요.
VTable이란?
기본 개념
VTable(Virtual Table, 가상 함수 테이블)은 클래스의 가상 함수 주소를 담고 있는 함수 포인터 배열입니다.
class Base {
public:
virtual void func() { std::cout << "Base::func\n"; }
};
// 컴파일러가 자동으로 생성하는 VTable (개념적)
// Base_VTable = {
// [0] = &Base::func
// }
왜 필요한가?
- 일반 함수는 컴파일 타임에 호출할 함수가 정해집니다
- 가상 함수는 런타임에 실제 객체 타입을 보고 호출할 함수를 결정해야 합니다
- VTable이 있어야 런타임에 올바른 함수를 찾을 수 있습니다
VTable을 클래스마다 하나씩만 만드는 이유는 효율성 때문입니다 — 만약 함수 포인터를 객체마다 저장한다면 같은 타입의 인스턴스가 100만 개 있을 때 함수 포인터도 100만 벌 중복 저장되겠지만, 실제로는 어차피 같은 클래스의 모든 인스턴스가 (오버라이드하지 않는 한) 같은 함수를 호출하므로 VTable 자체는 클래스당 하나만 정적으로 존재하고, 각 객체는 그 VTable을 “가리키는” 포인터(vptr) 하나만 갖습니다. 이 설계 덕분에 다형성의 메모리 비용이 객체당 포인터 하나(보통 8바이트)로 고정되고, 클래스에 가상 함수가 몇 개 있든 객체 크기에는 영향을 주지 않습니다 — 함수가 10개든 100개든 vptr은 여전히 하나뿐입니다.
메모리 구조
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을 가리킵니다.
참고로 C++ 표준에는 vtable이나 vptr이라는 단어가 한 번도 나오지 않습니다. 표준은 “가상 함수 호출은 동적 타입의 최종 오버라이더를 부른다”는 동작만 정의하고, 구현 방법은 컴파일러에 맡깁니다. 다만 GCC·Clang이 따르는 Itanium C++ ABI와 MSVC 모두 “객체 앞부분에 vptr, 클래스별 테이블 하나”라는 같은 구조를 쓰기 때문에 사실상 표준처럼 통용됩니다. Itanium ABI의 vtable에는 함수 포인터 말고도 typeid·dynamic_cast가 쓰는 RTTI 정보 포인터와, 다중 상속에서 this를 보정하는 오프셋(offset-to-top)이 함께 들어 있습니다. -fno-rtti로 빌드하면 dynamic_cast를 쓸 수 없는 것도 이 때문입니다.
vtable이 어느 오브젝트 파일에 생성되는지도 알아 둘 가치가 있습니다. Itanium ABI에서는 클래스의 “키 함수(key function)”, 즉 인라인이 아니면서 순수 가상도 아닌 첫 번째 가상 함수가 정의된 번역 단위에만 vtable을 내보냅니다. 헤더에 virtual void func();만 선언하고 .cpp에 정의를 빠뜨리면 링커가 undefined reference to 'vtable for Base'를 내는데, 에러 메시지가 누락된 함수 이름이 아니라 vtable을 가리켜서 원인을 찾기 어렵습니다. 처음 이 에러를 만나면 대부분 소멸자나 가상 함수 하나의 정의를 빠뜨렸거나, 해당 .cpp가 빌드 대상에서 빠진 경우입니다.
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)입니다. 핵심은 a의 정적 타입(컴파일 타임에 선언된 타입, 여기서는 Animal*)과 동적 타입(실제로 가리키는 객체의 진짜 타입, 여기서는 Dog)이 다를 수 있고, 가상 함수 호출은 항상 동적 타입을 기준으로 동작한다는 점입니다. 컴파일러는 a의 선언만 보고는 Dog::speak를 호출해야 할지 알 수 없으므로, 컴파일 타임에는 “vptr을 따라가서 그 안의 함수 포인터를 호출하라”는 간접 호출 코드만 생성해 두고, 실제로 어떤 함수가 호출될지는 런타임에 vptr이 가리키는 값에 의해 결정됩니다. 일반 함수 호출(비가상)이 컴파일 타임에 호출 대상 주소를 코드에 직접 박아 넣는 것과 근본적으로 다른 지점이 여기입니다.
실전 예시
예시 1: 다형성 활용
Shape가 draw()와 area()를 순수 가상 함수(= 0)로 선언한 것은 “이 클래스는 직접 인스턴스화될 수 없고, 파생 클래스가 반드시 이 함수들을 구현해야 한다”는 계약을 컴파일러가 강제하도록 만드는 것입니다. std::vector<std::unique_ptr<Shape>>에 서로 다른 구체 타입(Circle, Rectangle)을 담고도 shape->draw()라는 동일한 코드로 각각 다른 동작을 이끌어내는 것이 다형성의 실질적 가치입니다 — 새로운 도형(Triangle 등)을 추가해도 이 반복문 코드는 한 줄도 바뀌지 않습니다. 이것이 VTable이 실제로 해결하는 문제입니다: 컴파일 시점에는 벡터 안에 어떤 구체 타입들이 들어올지 알 수 없는데도, 런타임에 각 객체의 vptr을 따라가는 것만으로 올바른 draw/area 구현이 호출됩니다.
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: 성능 비교
nv.func()가 빠른 이유는 컴파일러가 정적 타입 NonVirtual만으로 호출할 함수 주소를 완전히 결정할 수 있어, 함수 호출 자체를 인라인으로 펼쳐버리거나 최소한 직접 call 명령으로 컴파일할 수 있기 때문입니다. 반면 참조나 포인터를 통한 r.func()는 컴파일러가 (일반적인 경우) vptr을 메모리에서 읽고 그 안의 함수 포인터를 다시 읽어 호출하는 두 단계의 메모리 접근을 거칩니다. 주의할 점은 Virtual v; v.func();처럼 객체를 값으로 직접 호출하면 동적 타입이 곧 Virtual로 확정되므로, 가상 함수라도 컴파일러가 직접 호출로 컴파일한다는 것입니다. 가상 디스패치가 실제로 일어나는 것은 포인터·참조를 거칠 때뿐입니다. 이 간접 호출 자체의 비용은 최신 CPU에서 보통 무시할 수준이지만, 진짜 비용은 인라인 최적화가 원천적으로 막힌다는 데 있습니다 — 컴파일러는 런타임에 어떤 파생 클래스의 함수가 호출될지 알 수 없으므로, 그 함수 안의 코드를 호출 지점에 펼쳐 넣는 최적화(인라이닝과 그로부터 파생되는 상수 전파, 루프 최적화 등)를 적용할 수 없습니다. 타이트한 루프 안에서 매 반복마다 가상 호출이 일어난다면, 이 “최적화 기회 상실”이 간접 호출 자체보다 훨씬 큰 성능 차이를 만드는 경우가 많습니다.
// 비가상 함수
class NonVirtual {
public:
void func() { /* ... */ }
};
// 가상 함수
class Virtual {
public:
virtual void func() { /* ... */ }
};
// 호출 비용 비교
NonVirtual nv;
nv.func(); // 직접 호출 (빠름)
Virtual v;
v.func(); // 값으로 호출: 동적 타입이 확정되므로 직접 호출
Virtual& r = v;
r.func(); // 참조로 호출: vptr → vtable → 함수 (간접 호출)
성능 차이:
- 비가상 함수: 컴파일러가 인라인 최적화 가능
- 가상 함수: 간접 호출 + 캐시 미스 가능성
- 실무: 대부분 경우 무시할 정도지만, 타이트한 루프에서는 고려 필요
가상 호출의 비용을 측정해 보면 한 가지 더 눈에 띕니다. 같은 타입의 객체만 연속으로 처리할 때는 CPU의 분기 예측기가 간접 분기 대상을 거의 맞히므로 비용이 작지만, Circle·Rectangle·Triangle이 무작위로 섞인 벡터를 돌면 예측이 자주 빗나가 파이프라인이 비워집니다. 그래서 성능이 중요한 코드에서는 가상 함수를 없애기 전에 먼저 객체를 타입별로 정렬하거나 타입별 컨테이너로 나누는 방법을 시도해 볼 만합니다. 코드 구조는 그대로 두고 예측 적중률만 높이는 셈입니다.
가상 소멸자·생성자 호출·메모리 오버헤드 함정
문제 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할 가능성이 있으면 무조건 가상 소멸자!
이 버그가 위험한 이유는 컴파일 경고 없이 조용히 발생한다는 점입니다. Base의 소멸자가 비가상이면, delete b는 정적 타입인 Base의 소멸자만 직접 호출하도록 컴파일되고 — 다른 가상 함수와 달리 vptr을 거치지 않습니다. 즉 delete를 통한 소멸은 기본적으로 정적 바인딩이며, virtual을 붙여야 비로소 동적 타입(Derived)의 소멸자부터 시작해 기저 클래스 소멸자까지 순서대로 호출되는 사슬이 만들어집니다. 이 사슬이 끊기면 Derived가 소멸자에서 해제하려던 리소스(data 배열 등)는 그대로 누수되며, 이런 종류의 누수는 단발성 테스트에서는 잘 드러나지 않고 장시간 실행되는 서버 프로세스에서 서서히 메모리를 갉아먹는 형태로 나타나는 경우가 많습니다.
문제 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()이 호출됩니다
vtable 관점에서 보면 이유가 더 분명합니다. 컴파일러는 각 생성자의 앞부분에서 vptr을 그 생성자가 속한 클래스의 vtable로 설정합니다. Base 생성자 본문이 실행되는 동안 객체의 vptr은 Base의 vtable을 가리키고, Derived 생성자로 넘어가면서 비로소 Derived의 vtable로 바뀝니다. 소멸자는 이 과정을 거꾸로 밟으므로 소멸자 안의 가상 호출도 같은 문제를 겪습니다. 이 동작은 버그가 아니라 의도된 안전장치입니다. 아직 초기화되지 않은 Derived의 멤버를 Derived::init이 건드리는 일을 막아 줍니다.
더 나쁜 경우는 생성자에서 호출한 함수가 순수 가상 함수일 때입니다. 직접 호출하면 컴파일러가 경고나 링크 에러를 내지만, 생성자가 부른 비가상 헬퍼 함수 안에서 순수 가상 함수를 호출하면 컴파일은 통과하고 실행 중에 pure virtual method called 메시지와 함께 terminate로 프로세스가 죽습니다. 이런 크래시를 처음 보면 원인을 짐작하기 어려운데, 대부분 생성자나 소멸자에서 간접적으로 가상 함수를 부른 경우입니다.
해결책: 생성자에서 가상 함수를 호출하지 말고, 별도 초기화 함수를 만드세요.
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)
영향:
- 객체가 많으면 메모리 사용량 증가
- 캐시 효율성 저하 가능
- 대부분 경우 무시 가능, 수백만 개 객체 생성 시에만 고려
가상 호출 비용 줄이기: final, NVI, CRTP
final 키워드
final이 최적화를 가능하게 하는 이유는 컴파일러에게 “이 클래스 계층은 여기서 끝난다”는 확정적인 정보를 주기 때문입니다. Derived가 final로 선언되면, 컴파일러는 Derived 타입의 객체에 대해서는 더 이상 파생될 클래스가 없다는 것을 알 수 있고, 정적 타입이 Derived로 명확히 알려진 호출 지점에서는 vptr을 거치지 않고 Derived::func를 직접 호출(devirtualization)하도록 최적화할 수 있습니다. 다만 이 최적화는 컴파일러의 재량이며 Base* 포인터를 통한 호출처럼 정적 타입이 Derived로 확정되지 않는 경우에는 여전히 간접 호출이 발생할 수 있다는 점은 유의해야 합니다.
class Base {
public:
virtual ~Base() = default;
virtual void func() {}
};
// ✅ 더 이상 override되지 않음을 명시
class Derived final : public Base {
public:
void func() override {} // 클래스가 final이면 함수에 final을 따로 붙일 필요 없음
};
// 컴파일러가 최적화 가능:
void call(Derived& d) {
d.func(); // Derived는 final이므로 동적 타입이 Derived로 확정 → 직접 호출
}
final이 없다면 Derived&로 받은 객체의 실제 타입이 Derived를 상속한 또 다른 클래스일 수 있으므로 컴파일러는 간접 호출을 유지해야 합니다. 반대로 LTO(링크 타임 최적화)에 -fwhole-program-vtables(Clang) 같은 옵션을 켜면, 프로그램 전체에서 오버라이드가 하나뿐인 가상 함수를 컴파일러가 스스로 찾아 직접 호출로 바꾸기도 합니다. final은 그런 전역 분석 없이도 같은 정보를 번역 단위 안에서 알려 주는 수단입니다.
비가상 인터페이스 패턴 (NVI)
이 패턴이 노리는 것은 성능이 아니라 설계 안정성입니다. doSomething()을 public이면서 비가상으로 고정해 두면, 파생 클래스가 “언제, 어떤 순서로” 호출되는지에 대한 계약(전처리 → 구현 → 후처리) 자체를 오버라이드로 깨뜨릴 수 없게 됩니다 — 오직 doSomethingImpl()이라는 좁은 확장 지점만 열려 있으므로, 기저 클래스 작성자가 “이 함수는 항상 이런 순서로 실행된다”는 불변식을 확실히 통제할 수 있습니다. 이는 상속을 “구현을 재사용하되 계약은 기저 클래스가 정의한다”는 방향으로 유도하는 설계 원칙이며, 순수하게 모든 것을 가상으로 열어두는 설계보다 유지보수 중 실수(전처리를 빼먹고 오버라이드하는 등)를 줄여줍니다.
class Base {
public:
void doSomething() { // ✅ public, 비가상
// 전처리
doSomethingImpl();
// 후처리
}
private:
virtual void doSomethingImpl() {} // 실제 구현은 가상
};
장점:
- 공개 인터페이스는 안정적 (비가상)
- 구현만 파생 클래스에서 변경 가능
- 전후처리 로직을 기저 클래스에서 통제
CRTP (Curiously Recurring Template Pattern)
CRTP가 vtable을 완전히 없앨 수 있는 이유는 Base<Derived>가 이미 템플릿 인자로 정확한 파생 타입을 알고 있기 때문입니다 — static_cast<Derived*>(this)->doSomethingImpl()은 컴파일 타임에 Derived가 무엇인지 확정되어 있으므로, 런타임에 vptr을 따라갈 필요 없이 곧바로 Concrete::doSomethingImpl을 호출하는 코드로 컴파일됩니다. 이것이 “정적 다형성”이라 불리는 이유이며, 가상 함수의 런타임 비용(간접 호출, 인라이닝 차단)을 완전히 제거하는 대신 두 가지를 대가로 치릅니다: 각 Base<T>가 서로 다른 템플릿 인스턴스화이므로 std::vector<Base<T>*>처럼 서로 다른 파생 타입을 하나의 컨테이너에 균일하게 담을 수 없고(런타임 다형성이 필요하면 CRTP는 애초에 대안이 아닙니다), 템플릿 인스턴스화가 늘어나는 만큼 바이너리 크기와 컴파일 시간이 증가할 수 있습니다. 요컨대 CRTP는 “호출 대상이 컴파일 타임에 이미 알려져 있다”는 전제가 성립할 때만 가상 함수를 대체할 수 있는 선택지입니다.
// 가상 함수 없이 다형성
template<typename Derived>
class Base {
public:
void doSomething() {
static_cast<Derived*>(this)->doSomethingImpl();
}
};
class Concrete : public Base<Concrete> {
public:
void doSomethingImpl() { /* ... */ }
};
// ✅ vtable 없음, 컴파일 타임에 모두 해결
CRTP 말고도 “타입 집합이 닫혀 있다”면 std::variant와 std::visit이 좋은 대안입니다. std::variant<Circle, Rectangle>의 벡터는 객체를 힙에 따로 할당하지 않고 연속된 메모리에 담으므로 캐시 효율이 좋고, 새 연산을 추가할 때 클래스를 고칠 필요가 없습니다. 대신 새 타입을 추가하려면 variant를 쓰는 모든 곳을 다시 컴파일해야 하므로, 플러그인처럼 타입이 바깥에서 계속 추가되는 구조에는 여전히 가상 함수가 맞습니다. 즉 “타입이 자주 늘어나면 가상 함수, 연산이 자주 늘어나면 variant”가 대략적인 기준입니다.
가상 함수를 쓸지 템플릿으로 갈지 정하는 기준
기준은 “구체 타입이 언제 정해지는가”입니다. 설정 파일이나 플러그인 로딩처럼 실행 중에야 타입이 결정되고, 서로 다른 타입의 객체를 한 컨테이너에 담아야 한다면 가상 함수가 가장 단순한 답입니다. 반대로 호출 지점에서 타입이 이미 확정되어 있다면 템플릿(또는 CRTP)으로 정적 디스패치를 쓰는 편이 인라이닝 여지를 남깁니다. 가상 호출 자체는 간접 호출 한 번이지만 실제로 비용이 커지는 것은 인라이닝이 막혀 루프 최적화가 사라질 때이므로, 핫 루프가 아닌 곳에서 성능을 이유로 가상 함수를 피할 필요는 거의 없습니다.
어느 쪽을 택하든 조용히 틀리는 지점은 같습니다. 다형적으로 삭제되는 기저 클래스에 가상 소멸자가 없으면 delete base_ptr가 파생 클래스 소멸자를 건너뛰어 정의되지 않은 동작이 되고, 생성자·소멸자 안에서 부른 가상 함수는 파생 클래스의 오버라이드가 아니라 지금 생성·소멸 중인 클래스의 버전으로 호출됩니다.
같이 보면 좋은 글
- C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
- C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
- C++ undefined reference to vtable: 첫 비인라인 가상 함수 정의 누락과 해결
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this