C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]

C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]

이 글의 핵심

C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]에 대한 실전 가이드입니다.

들어가며: “가상 함수가 뭐예요?” 면접에서 어떻게 답할까

면접관이 진짜 묻는 것

“가상 함수는 오버라이딩에 쓰인다”까지만 말하면 면접관은 그 다음을 묻습니다. “그럼 실행 시점에 어떻게 올바른 함수가 호출되죠?”, “메모리에는 어떻게 저장되나요?”, “vtable이 뭔가요?”
이 글은 가상 함수 테이블(vtable)객체 메모리 레이아웃까지 들어가서, “포인터/참조로 베이스 타입을 통해 호출할 때, 실제 객체 타입에 맞는 함수가 어떻게 선택되는지”를 설명합니다. 면접에서 “vtable을 알고 있다”까지 보여주면 이해도가 높게 평가됩니다.

실제 겪는 문제 시나리오

시나리오 1: 게임 엔진 적 시스템
게임 엔진에서 Enemy 베이스 클래스와 Zombie, Dragon 같은 파생 클래스가 있습니다. std::vector<Enemy*>에 다양한 적을 넣고, 매 프레임 for (auto* e : enemies) e->attack();처럼 호출합니다. 이때 **포인터 타입은 Enemy*인데, 실제 객체는 ZombieDragon**입니다. attack()을 가상 함수로 두지 않으면 항상 Enemy::attack()만 호출되고, 각 적의 고유 동작이 실행되지 않습니다. 가상 함수와 vtable이 없었다면, 이런 다형적 호출을 구현하려면 타입별 switch문이나 함수 포인터를 직접 관리하는 복잡한 코드가 필요했을 것입니다.

시나리오 2: GUI 위젯 계층
Button, TextBox, Slider 등 다양한 위젯이 Widget 베이스 클래스를 상속합니다. std::vector<Widget*>에 위젯들을 담고 for (auto* w : widgets) w->draw();로 화면에 그립니다. 각 위젯의 draw()가 가상이 아니면, 항상 Widget::draw()만 호출되어 모든 위젯이 동일한 모양으로 그려지는 버그가 발생합니다.

시나리오 3: 플러그인/핸들러 시스템
네트워크 서버에서 HttpHandler, WebSocketHandler, GrpcHandler 등 다양한 프로토콜 핸들러가 RequestHandler 베이스 클래스를 상속합니다. 들어오는 요청을 handler->handle(request)로 처리할 때, 가상 함수가 없으면 타입별 분기 코드가 늘어나고 새 프로토콜 추가 시 기존 코드를 수정해야 합니다.

비유로 이해하기

전화번호부 비유: vtable은 “이 클래스의 가상 함수들이 어디에 있는지” 주소를 모아 둔 전화번호부입니다. 1번 함수는 이 주소, 2번 함수는 저 주소로 적혀 있어서, 실행 시점에 “실제 객체 타입”에 맞는 함수를 찾을 수 있게 해 줍니다. 객체마다 “내 전화번호부는 이것”을 가리키는 vptr이 있고, 호출할 때마다 그 전화번호부에서 해당 번호를 찾아 연결합니다.

이 글에서 다루는 것:

  • 가상 함수동적 바인딩: 컴파일 시점이 아니라 실행 시점에 함수가 결정되는 이유
  • vtable(가상 함수 테이블): 클래스마다 하나, 함수 포인터 배열
  • 객체와 vtable의 연결: 객체 선두에 있는 vptr이 가리키는 것
  • 메모리 관점에서의 동작 (다이어그램과 코드로 정리)
  • 일반적인 실수성능 고려사항
  • 프로덕션 패턴 (팩토리, NVI, 플러그인)

목차

  1. 가상 함수와 동적 바인딩
  2. vtable이란 무엇인가
  3. 객체 메모리와 vptr
  4. 호출이 결정되는 과정
  5. 일반적인 실수와 주의점
  6. 가상 함수 모범 사례
  7. 성능 고려사항
  8. 다중 상속과 vtable
  9. 순수 가상 함수와 추상 클래스
  10. 실전 예시: 게임 엔진 적 시스템
  11. 실무 활용 팁
  12. 프로덕션 패턴
  13. 면접에서 이렇게 답하기

1. 가상 함수와 동적 바인딩

정적 바인딩 vs 동적 바인딩

  • 정적 바인딩: 컴파일 시점에 “이 호출은 이 함수다”가 고정됨. 일반 멤버 함수, 오버로딩이 여기 해당.
  • 동적 바인딩: 실행 시점에 “실제 객체가 누구인지”에 따라 호출할 함수가 결정됨. 가상 함수(virtual로 선언된, 파생 클래스에서 재정의할 수 있는 멤버 함수)가 이렇게 동작합니다.

Base에는 virtual void virt()가 있고, Derived에서 override로 재정의했습니다. Base* p = new Derived()로 베이스 포인터가 파생 객체를 가리킬 때, p->normal()포인터 타입(Base) 기준으로 Base::normal이 호출되고, p->virt()실제 객체 타입(Derived) 기준으로 Derived::virt가 호출됩니다. 이 “실제 객체에 맞는 함수를 고르는” 정보가 컴파일러가 만든 vtable(가상 함수 테이블)과 객체 안의 vptr(vtable을 가리키는 포인터)로 구현되며, 면접에서 “동적 바인딩이 vtable을 통해 일어난다”까지 말할 수 있으면 좋습니다.

// 컴파일: g++ -std=c++17 -o vtable_demo vtable_demo.cpp && ./vtable_demo
#include <iostream>

class Base {
public:
    void normal() { std::cout << "Base::normal\n"; }
    virtual void virt() { std::cout << "Base::virt\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void normal() { std::cout << "Derived::normal\n"; }
    void virt() override { std::cout << "Derived::virt\n"; }
};

int main() {
    Base* p = new Derived();
    p->normal();  // Base::normal  — 포인터 타입(Base) 기준, 정적
    p->virt();    // Derived::virt — 실제 객체(Derived) 기준, 동적
    delete p;
    return 0;
}

실행 결과:

Base::normal
Derived::virt

p타입Base*이지만, 가리키는 객체Derived입니다. normal()은 컴파일러가 Base::normal로 고정하고, virt()는 “실제 객체가 가리키는 가상 함수 정보”를 따라가서 Derived::virt가 호출됩니다. 이 “실제 객체 기준으로 고르는” 정보가 vtablevptr로 구현됩니다.

정적 vs 동적 바인딩 시각화

flowchart TB
    subgraph static["정적 바인딩 (normal())"]
        S1["p-normal()"] --> S2["컴파일 시점: p 타입 = Base*"]
        S2 --> S3["Base normal 호출 고정"]
    end
    subgraph dynamic["동적 바인딩 (virt())"]
        D1["p-virt()"] --> D2["실행 시점: p가 가리키는 객체 확인"]
        D2 --> D3["객체의 vptr → vtable 조회"]
        D3 --> D4["Derived virt 호출"]
    end

참조를 통한 호출도 동일

포인터뿐 아니라 참조로 베이스 타입을 통해 호출할 때도 동적 바인딩이 적용됩니다.

void callByRef(Base& b) {
    b.virt();  // 실제 객체 타입에 따라 호출됨
}

int main() {
    Derived d;
    callByRef(d);  // Derived::virt 호출
    return 0;
}

완전한 다형성 예제: 3단계 상속과 override

다형성이 여러 단계 상속에서 어떻게 동작하는지 확인하는 예제입니다. Base → Derived → MostDerived 구조에서 MostDerived 객체를 Base*로 가리킬 때, virt()는 항상 가장 파생된 클래스의 구현이 호출됩니다.

#include <iostream>

class Base {
public:
    virtual void virt() { std::cout << "Base::virt\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void virt() override { std::cout << "Derived::virt\n"; }
};

class MostDerived : public Derived {
public:
    void virt() override { std::cout << "MostDerived::virt\n"; }
};

int main() {
    Base* p1 = new Base();
    Base* p2 = new Derived();
    Base* p3 = new MostDerived();

    p1->virt();  // Base::virt
    p2->virt();  // Derived::virt
    p3->virt();  // MostDerived::virt (가장 파생된 타입의 구현 호출)

    delete p1;
    delete p2;
    delete p3;
    return 0;
}

실행 결과:

Base::virt
Derived::virt
MostDerived::virt

가상 함수 핵심 키워드: virtual, override, final

키워드용도
virtual파생에서 오버라이드 가능하게 함
override베이스 가상 함수 재정의 (시그니처 불일치 시 컴파일 에러)
final상속/오버라이드 불가, devirtualization 여지
#include <iostream>

class Base {
public:
    virtual void process() { std::cout << "Base::process\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void process() override { std::cout << "Derived::process\n"; }
};

class Leaf final : public Derived {  // final: 더 이상 상속 불가
public:
    void process() override final { std::cout << "Leaf::process\n"; }
    // void process() override { }  // 에러: final로 재정의 불가
};

// class Bad : public Leaf { };  // 에러: Leaf는 final

int main() {
    Base* p = new Leaf();
    p->process();  // Leaf::process
    delete p;
    return 0;
}

실행 결과:

Leaf::process

2. vtable이란 무엇인가

가상 함수 테이블 (vtable)

vtable(virtual table, 가상 함수 테이블)은 “이 클래스의 가상 함수들이 어디에 있는지” 주소를 모아 둔 테이블입니다. 비유하면 전화번호부처럼 “1번 함수는 이 주소, 2번 함수는 저 주소”로 적혀 있어서, 실행 시점에 “실제 객체 타입”에 맞는 함수를 찾을 수 있게 해 줍니다.

  • 클래스 타입마다 (가상 함수가 하나라도 있으면) 테이블 하나가 생성됩니다. 이 테이블은 “해당 클래스에서 호출 가능한 가상 함수들의 주소”를 순서대로 담은 함수 포인터 배열이라고 보면 됩니다.
  • Base 클래스용 vtable: Base::virt 주소, Base::~Base 주소 등.
  • Derived 클래스용 vtable: Derived::virt 주소 (Base 쪽 가상 함수를 오버라이드한 경우 그 슬롯이 파생 구현으로 채워짐), Derived::~Derived 주소 등.

(실제 구현은 컴파일러마다 다르지만, 개념적으로는 “클래스당 하나의 테이블, 슬롯당 가상 함수 하나”로 이해하면 충분합니다.) vtable은 컴파일 시점에 한 번만 만들어지고, 그 클래스 타입의 모든 객체가 같은 vtable을 가리킵니다. 즉, Derived 객체가 100개 있든 1개 있든 Derived용 vtable은 하나뿐이고, 각 객체의 vptr만 그 테이블을 가리킵니다.

vtable 구조 다이어그램

flowchart LR
    subgraph base_vtable["Base vtable"]
        B0[""(0"] Base::virt 주소"]
        B1[""(1"] Base::~Base 주소"]
    end
    subgraph derived_vtable["Derived vtable"]
        D0[""(0"] Derived::virt 주소"]
        D1[""(1"] Derived::~Derived 주소"]
    end
    subgraph base_obj["Base 객체"]
        Bvptr["vptr"] --> base_vtable
    end
    subgraph derived_obj["Derived 객체"]
        Dvptr["vptr"] --> derived_vtable
    end

왜 테이블인가?

포인터/참조로 “베이스 타입”만 알 때, 컴파일러는 어떤 파생 타입이 올지 모릅니다. 그래서 “이 번호(인덱스)의 가상 함수를 호출해라”만 코드에 넣어 두고, 실행 시점에 “현재 객체가 가리키는 vtable”에서 그 인덱스의 함수 주소를 꺼내 호출합니다. 그 “현재 객체가 가리키는 vtable”을 가리키는 포인터가 vptr입니다.

vtable 내용 확인 (GCC)

GCC에서는 -fdump-class-hierarchy 옵션으로 vtable 레이아웃을 덤프할 수 있습니다.

g++ -std=c++17 -fdump-class-hierarchy -c vtable_demo.cpp -o vtable_demo.o
cat vtable_demo.cpp.002t.class

출력 예시 (GCC, 실제 출력은 컴파일러/버전에 따라 다름):

Vtable for Base
  Base::_ZTV4Base: 3u entries
  ...

Vtable for Derived
  Derived::_ZTV7Derived: 3u entries
  ...

출력에서 vtable for Base, vtable for Derived와 각 슬롯에 들어가는 함수를 확인할 수 있습니다.

vtable 레이아웃 상세 (개념적 구조)

베이스와 파생 클래스는 동일한 슬롯 순서를 유지합니다. 파생이 오버라이드하면 해당 슬롯에 파생 구현 주소가 들어가고, 오버라이드하지 않으면 베이스 주소가 유지됩니다. p->speak() 호출 시 “vtable의 [0]번 슬롯”을 조회합니다.

flowchart TB
    subgraph animal_vtable["Animal vtable"]
        A0[""(0"] speak"]
        A1[""(1"] move"]
        A2[""(2"] ~Animal"]
    end
    subgraph dog_vtable["Dog vtable"]
        D0[""(0"] Dog::speak"]
        D1[""(1"] Dog::move"]
        D2[""(2"] Dog::~Dog"]
    end
    subgraph dog_obj["Dog 객체"]
        vptr["vptr"] --> dog_vtable
    end

3. 객체 메모리와 vptr

vptr (가상 테이블 포인터)

  • 가상 함수가 하나라도 있는 클래스의 객체에는, 보통 객체 메모리 선두(또는 구현에 따라 특정 위치)에 숨겨진 멤버가 하나 있습니다. 이를 vptr(virtual table pointer, 가상 테이블 포인터—객체가 “자기 클래스의 vtable”을 가리키는 포인터)이라고 부릅니다.
  • vptr은 해당 객체의 실제 타입에 해당하는 vtable을 가리킵니다.
    • Base 객체 → Base의 vtable을 가리키는 vptr
    • Derived 객체 → Derived의 vtable을 가리키는 vptr

그래서 “포인터로 가상 함수를 호출한다”는 것은, 내부적으로:

  1. 포인터가 가리키는 객체로 가서
  2. 그 객체의 vptr을 읽고
  3. vptr이 가리키는 vtable에서 “몇 번째 가상 함수인지”에 해당하는 함수 포인터를 꺼내
  4. 함수를 호출

하는 과정으로 이해할 수 있습니다. 이렇게 하면 실제 객체가 Derived면 Derived의 vtable을 보게 되므로, Derived::virt가 호출됩니다.

메모리 레이아웃 (개념)

(실제 오프셋·크기는 컴파일러/ABI에 따라 다릅니다. 개념만 정리합니다.)

flowchart TB
    subgraph base_mem["Base 객체 메모리"]
        B1[""(0"] vptr → Base vtable"]
        B2[""(8"] Base 멤버 변수들..."]
    end
    subgraph derived_mem["Derived 객체 메모리"]
        D1[""(0"] vptr → Derived vtable"]
        D2[""(8"] Base 서브오브젝트 멤버들"]
        D3[""(N"] Derived만의 멤버들..."]
    end
  • Base 객체 하나:

    • [vptr] → Base vtable
    • [Base의 멤버들…]
  • Derived 객체 하나:

    • [vptr] → Derived vtable (Base 서브오브젝트 부분을 덮어썼거나, Derived 전용 vptr이 있음)
    • [Base 서브오브젝트 멤버들]
    • [Derived만의 멤버들…]

즉, 객체와 vtable은 vptr 한 줄로 연결되어 있고, “이 객체가 실제로 어떤 클래스인가”에 따라 vptr이 가리키는 테이블이 달라집니다. 그래서 동적 바인딩이 가능해집니다.

vptr 크기 확인

#include <iostream>

class Empty { };
class WithVirtual {
    virtual void f() { }
};

int main() {
    std::cout << "sizeof(Empty): " << sizeof(Empty) << "\n";           // 보통 1 (최소 크기)
    std::cout << "sizeof(WithVirtual): " << sizeof(WithVirtual) << "\n"; // vptr 크기 (64비트: 8)
    return 0;
}

실행 결과:

sizeof(Empty): 1
sizeof(WithVirtual): 8

64비트 시스템에서 sizeof(WithVirtual)는 8바이트(vptr 하나)입니다. 가상 함수가 하나만 있어도 vptr이 추가되므로, 객체 크기가 포인터 크기만큼 늘어납니다.


4. 호출이 결정되는 과정

한 줄 요약

p->virt(); 일 때:

  1. p가 가리키는 주소로 가서 vptr을 읽는다.
  2. vptr이 가리키는 vtable에서 virt에 해당하는 슬롯의 함수 주소를 꺼낸다.
  3. 주소로 점프해 함수를 실행한다.

실제 객체가 Derived면 vptr은 Derived의 vtable을 가리키고, 그 테이블의 virt 슬롯에는 Derived::virt 주소가 들어 있으므로, 최종적으로 Derived::virt가 호출됩니다. 이게 “실행 시점에 결정된다”는 의미입니다.

호출 시퀀스 다이어그램

sequenceDiagram
    participant Code as 호출 코드
    participant Obj as Derived 객체
    participant Vptr as vptr
    participant Vtable as Derived vtable
    participant Func as Derived::virt

    Code->>Obj: p->virt()
    Obj->>Vptr: vptr 읽기
    Vptr->>Vtable: vtable 접근
    Vtable->>Vtable: [0] 슬롯 = virt 주소
    Vtable->>Func: 함수 주소로 점프
    Func->>Code: Derived::virt 실행

소멸자를 virtual로 두는 이유

베이스 클래스 포인터로 파생 객체를 delete할 때, 소멸자가 가상이 아니면 컴파일러는 Base::~Base()만 부릅니다. 그러면 파생 쪽 멤버는 정리되지 않고, 그냥 베이스 부분만 해제되면 미정의 동작이 됩니다.
소멸자를 가상으로 두면, delete p 시에도 vtable을 통해 실제 객체 타입의 소멸자가 호출되고, 그 다음 베이스 소멸자가 호출되는 순서가 보장됩니다. 그래서 다형적으로 쓰이는 베이스 클래스는 소멸자를 virtual로 두는 것이 필수에 가깝습니다.

가상 소멸자 없을 때의 문제 (실제 코드)

#include <iostream>

class BaseBad {
public:
    ~BaseBad() { std::cout << "BaseBad::~BaseBad\n"; }
};

class DerivedBad : public BaseBad {
    int* data;
public:
    DerivedBad() : data(new int[100]) { }
    ~DerivedBad() {
        delete[] data;
        std::cout << "DerivedBad::~DerivedBad\n";
    }
};

int main() {
    BaseBad* p = new DerivedBad();
    delete p;  // BaseBad::~BaseBad만 호출됨! DerivedBad 소멸자 미호출 → 메모리 누수
    return 0;
}

실행 결과:

BaseBad::~BaseBad

DerivedBad::~DerivedBad는 호출되지 않아 data가 해제되지 않고 메모리 누수가 발생합니다.

// 올바른 예: 가상 소멸자
class BaseGood {
public:
    virtual ~BaseGood() { std::cout << "BaseGood::~BaseGood\n"; }
};

class DerivedGood : public BaseGood {
    int* data;
public:
    DerivedGood() : data(new int[100]) { }
    ~DerivedGood() override {
        delete[] data;
        std::cout << "DerivedGood::~DerivedGood\n";
    }
};

int main() {
    BaseGood* p = new DerivedGood();
    delete p;  // DerivedGood::~DerivedGood → BaseGood::~BaseGood 순서로 호출
    return 0;
}

실행 결과:

DerivedGood::~DerivedGood
BaseGood::~BaseGood

5. 일반적인 실수와 주의점

실수 1: 소멸자를 virtual로 두지 않음

원인: 다형적으로 사용하는 베이스 클래스(포인터/참조로 파생 객체를 다룸)인데 소멸자가 가상이 아님.

해결법: 베이스 클래스에 virtual ~Base() = default; 또는 virtual ~Base() { } 추가.

실수 2: 가상 함수에서 기본 인자 사용

class Base {
public:
    virtual void print(int x = 10) { std::cout << "Base: " << x << "\n"; }
};

class Derived : public Base {
public:
    void print(int x = 20) override { std::cout << "Derived: " << x << "\n"; }
};

int main() {
    Base* p = new Derived();
    p->print();  // Derived::print 호출되지만, x는 10! (Base의 기본 인자 사용)
    delete p;
    return 0;
}

실행 결과:

Derived: 10

함수는 Derived 것이 호출되지만, 기본 인자는 컴파일 시점에 결정되므로 Base::print의 기본값 10이 사용됩니다.

해결법: 가상 함수에는 기본 인자를 두지 않거나, 파생 클래스에서도 동일한 기본 인자를 명시적으로 반복합니다. 더 나은 방법은 기본 인자를 피하고 호출하는 쪽에서 명시적으로 넘기는 것입니다.

실수 3: 생성자/소멸자에서 가상 함수 호출

class Base {
public:
    Base() {
        virt();  // 위험: 이 시점에는 아직 Derived가 완성되지 않음
    }
    virtual void virt() { std::cout << "Base::virt\n"; }
};

class Derived : public Base {
public:
    void virt() override { std::cout << "Derived::virt\n"; }
};

int main() {
    Derived d;  // Base 생성자에서 virt() 호출 → Base::virt 호출 (Derived::virt 아님)
    return 0;
}

실행 결과:

Base::virt

원인: 생성자 실행 중에는 파생 클래스 서브오브젝트가 아직 초기화되지 않았기 때문에, vptr이 베이스 vtable을 가리키거나 아직 설정 중입니다. 따라서 Base::virt가 호출됩니다. 소멸자에서도 마찬가지로, 파생 부분이 먼저 소멸된 후에는 베이스 vtable을 보게 됩니다.

해결법: 생성자/소멸자에서는 가상 함수를 호출하지 않습니다. 필요한 동작은 템플릿 메서드 패턴이나 초기화 함수를 별도로 두어 해결합니다.

실수 4: override 키워드 누락

class Derived : public Base {
public:
    void virt(int x);  // 오타: Base::virt()와 시그니처가 다름 → 새 가상 함수로 추가됨!
};

해결법: C++11 override 키워드를 사용하면, 베이스에 해당 함수가 없을 때 컴파일 에러가 나서 실수를 방지할 수 있습니다.

void virt(int x) override;  // 에러: Base에 virt(int) 없음
void virt() override;      // OK

실수 5: 객체 슬라이싱 (Object Slicing)

값으로 복사할 때 파생 클래스 부분이 잘려 나가는 문제입니다. 다형성이 필요한 경우 반드시 포인터나 참조를 사용해야 합니다.

#include <iostream>

class Base {
public:
    virtual void print() const { std::cout << "Base\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void print() const override { std::cout << "Derived\n"; }
};

void passByValue(Base b) {
    b.print();  // 항상 Base::print — Derived 정보가 잘림!
}

void passByRef(const Base& b) {
    b.print();  // 실제 타입에 따라 동적 바인딩
}

int main() {
    Derived d;
    passByValue(d);   // Base 출력 (슬라이싱)
    passByRef(d);     // Derived 출력 (정상)
    return 0;
}

해결법: 다형적 객체를 함수에 전달할 때 Base&, const Base&, Base*를 사용합니다. Base 값으로 복사하지 않습니다.

실수 6: 가상 함수를 private으로 선언한 베이스의 오버라이드

파생 클래스에서 public으로 오버라이드해도, 호출 경로에 따라 접근이 막힐 수 있습니다. 베이스의 가상 함수는 public 또는 protected로 두는 것이 일반적입니다.

class Base {
private:
    virtual void foo() { }  // private 가상 함수
public:
    void callFoo() { foo(); }
};

class Derived : public Base {
public:
    void foo() override { }  // 오버라이드는 되지만, Base::foo는 private
};

int main() {
    Derived d;
    d.callFoo();  // OK: Base::callFoo()를 통해 호출
    // d.foo();   // 에러: Base에서 private으로 상속됨
    return 0;
}

해결법: 다형적으로 외부에서 호출할 가상 함수는 public으로 두거나, NVI(Non-Virtual Interface) 패턴으로 public 비가상 래퍼를 제공합니다.

실수 7: 소멸자에서 예외 발생

가상 소멸자에서 예외가 발생하면, delete 중 다른 소멸자도 호출될 수 있어 스택 언와인딩 중 예외가 발생하면 프로그램이 종료됩니다. 소멸자에서는 예외를 밖으로 전파하지 않는 것이 원칙입니다.

class Base {
public:
    virtual ~Base() {
        // throw std::runtime_error("error");  // 위험!
    }
};

해결법: 소멸자에서는 예외를 잡아서 로깅만 하거나, noexcept로 선언해 조기 실패를 유도합니다.

실수 8: 가상 함수 시그니처 불일치

파생에서 오버라이드할 때 반환 타입, const, 매개변수가 베이스와 정확히 일치해야 합니다. void draw() { }void draw() const를 오버라이드하지 않으면 새 가상 함수가 추가됩니다. override를 사용하면 시그니처 불일치 시 컴파일 에러로 발견됩니다.

실수 9: 다형적 베이스가 아닌데 virtual 사용

Base*로 파생을 가리키지 않는 클래스에 virtual을 붙이면 vptr만 추가되어 객체 크기가 늘어납니다. 수백만 개 생성 시 메모리 사용량이 증가합니다. 포인터/참조로 다형적으로 다룰 때만 virtual을 사용합니다.

실수 10: 복사/이동 시 vptr 처리

파생의 복사 대입 연산자에서 베이스 부분을 먼저 복사해야 합니다. Base::operator=(rhs)를 호출하지 않으면 vptr이 잘못된 vtable을 가리킬 수 있습니다.


가상 함수 모범 사례 (Best Practices)

1. 다형적 베이스는 반드시 가상 소멸자

Base* 또는 std::unique_ptr<Base>로 파생 객체를 다룰 때, 베이스 소멸자는 항상 virtual로 선언합니다. 위반 시 메모리 누수와 미정의 동작 위험이 있습니다.

2. override 키워드 일관 사용

파생에서 가상 함수 재정의 시 **항상 override**를 붙입니다. 시그니처 오타나 불일치를 컴파일 시점에 잡을 수 있습니다.

3. 가상 함수는 기본 인자 사용 금지

기본 인자는 정적 바인딩으로 결정되므로, 가상 함수 호출 시 의도와 다르게 동작할 수 있습니다. 기본 인자 대신 오버로드나 명시적 인자 전달을 사용합니다.

4. 생성자/소멸자에서 가상 함수 호출 금지

생성자·소멸자 실행 중에는 vptr이 아직 완전히 설정되지 않았거나, 이미 베이스 vtable을 가리키고 있습니다. 가상 함수를 호출해도 베이스 구현만 호출됩니다. 초기화/정리 로직은 별도 init() 또는 NVI 패턴으로 분리합니다.

5. 다형적 객체는 포인터/참조로 전달

값으로 전달하면 객체 슬라이싱이 발생합니다. Base&, const Base&, Base*, std::unique_ptr<Base> 등을 사용합니다.

6. final로 상속 계층 제한

더 이상 확장하지 않을 클래스는 final로 선언해 실수로 상속되는 것을 막고, devirtualization 최적화 여지를 열어 둡니다.


6. 성능 고려사항

가상 함수 호출 오버헤드

가상 함수 호출은 일반 함수 호출보다 다음 비용이 추가됩니다.

  1. vptr 읽기: 객체에서 vptr 한 번 로드
  2. vtable 인덱싱: vtable에서 해당 슬롯의 함수 주소 로드
  3. 간접 호출: 로드한 주소로 점프 (CPU가 직접 호출 주소를 알 수 없어 인라인·분기 예측이 어려움)
호출 유형상대 비용인라인 가능
일반 멤버 함수1x가능
가상 함수1.5~2x거의 불가

언제 가상 함수를 피할까?

  • 핫 루프 내부: 수백만 번 호출되는 경로에서는 가상 함수 대신 if (type == A) a->f(); else b->f(); 같은 타입 분기나, CRTP 같은 컴파일 타임 다형성을 고려할 수 있습니다.
  • 크리티컬한 성능 구간: 프로파일링으로 병목이 확인된 경우에만 최적화합니다.

성능 최적화 팁

기법설명적용 시점
Devirtualization컴파일러가 “이 포인터는 항상 Derived”라고 추론하면 vtable 조회 생략final 클래스, LTO(Link Time Optimization)
타입 분기파생 타입이 2~3개로 고정이면 if (typeid(*p) == typeid(Derived)) 또는 enum 분기핫 루프, 프로파일링으로 확인 후
CRTP컴파일 타임 다형성, 인라인 가능정말 성능이 중요한 구간
캐시 친화적 배치같은 타입 객체를 연속으로 배치하면 vtable 조회 시 캐시 히트 개선std::vector<unique_ptr<Base>> 대신 타입별 벡터 분리

Devirtualization(가상 비동기화) 완전 예제

Devirtualization은 컴파일러가 “이 포인터는 항상 Derived 타입이다”를 추론할 수 있을 때, vtable 조회를 생략하고 직접 호출로 바꾸는 최적화입니다.

class Base {
public:
    virtual void work() { }
    virtual ~Base() = default;
};

class Derived final : public Base {  // final → devirtualization 가능
public:
    void work() override { }
};

int main() {
    Derived d;
    d.work();       // 타입 확정 → 직접 호출로 최적화 가능
    Base* p = &d;
    p->work();      // LTO 시 p가 Derived만 가리킴을 추론하면 devirtualization 가능
    return 0;
}

적용 조건: final 클래스, LTO, 지역 변수로 타입이 명확할 때. g++ -O3 -S로 어셈블리에서 확인 가능합니다.

벤치마크 예시 (개념)

// 가상 호출 vs 직접 호출 — 수백만 번 반복 시 차이 발생
// 일반적으로 가상 호출 1회당 수 ns 추가 (CPU에 따라 다름)
// 인라인된 직접 호출은 0에 가깝게 최적화됨

실무 팁: 대부분의 코드에서는 가상 함수 오버헤드가 무시할 수준입니다. 가독성과 유지보수성을 우선하고, 실제 성능 문제가 측정된 경우에만 최적화하는 것이 좋습니다. “가상 함수가 느리다”는 선입견보다, 프로파일러로 측정한 뒤 결정하는 것이 중요합니다.


7. 다중 상속과 vtable

다중 상속 시 vptr 개수

클래스가 여러 베이스를 상속하면, 각 베이스마다 가상 함수가 있을 수 있어 vptr이 여러 개일 수 있습니다.

class A { virtual void fa(); };
class B { virtual void fb(); };
class C : public A, public B { };

// C 객체: A 서브오브젝트용 vptr, B 서브오브젝트용 vptr 각각 가질 수 있음

객체 메모리에는 [A vptr][A 멤버][B vptr][B 멤버][C 멤버]처럼 배치되고, C*A*B*로 변환할 때 포인터 값이 바뀔 수 있습니다(오프셋 조정). 이는 단일 상속에서는 발생하지 않는 복잡한 부분입니다.

다중 상속 시 주의점

다중 상속은 설계를 복잡하게 만들 수 있습니다. 다이아몬드 상속(D가 B, C를 상속하고 B, C가 각각 A를 상속하는 경우)에서는 virtual 상속으로 중복 베이스 서브오브젝트를 제거할 수 있지만, vtable 구조가 더 복잡해집니다. 면접에서는 “다중 상속 시 vptr이 여러 개일 수 있다” 정도만 언급해도 충분합니다.


순수 가상 함수와 추상 클래스

순수 가상 함수 (= 0)

구현을 제공하지 않고, 파생 클래스에서 반드시 오버라이드하도록 강제하는 함수입니다. 선언 끝에 = 0을 붙입니다.

class Shape {
public:
    virtual double area() const = 0;  // 순수 가상 함수
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double r;
public:
    Circle(double radius) : r(radius) { }
    double area() const override { return 3.14159 * r * r; }
};

class Rectangle : public Shape {
    double w, h;
public:
    Rectangle(double width, double height) : w(width), h(height) { }
    double area() const override { return w * h; }
};

int main() {
    // Shape s;  // 에러: 추상 클래스는 인스턴스화 불가
    Shape* p = new Circle(5.0);
    std::cout << p->area() << "\n";  // Circle::area 호출
    delete p;
    return 0;
}

추상 클래스

순수 가상 함수가 하나라도 있는 클래스추상 클래스이며, 인스턴스를 만들 수 없습니다. 파생 클래스가 모든 순수 가상 함수를 구현해야만 그 파생 클래스를 인스턴스화할 수 있습니다. vtable에는 순수 가상 함수 슬롯에 “호출 시 에러를 내는” 플레이스홀더 함수 주소가 들어가거나, 링커가 제공하는 __cxa_pure_virtual 같은 함수가 들어갈 수 있습니다.

인터페이스 패턴

C++에는 interface 키워드가 없지만, 순수 가상 함수만 가진 클래스로 인터페이스를 흉내 냅니다.

class ISerializable {
public:
    virtual void serialize(std::ostream& out) const = 0;
    virtual void deserialize(std::istream& in) = 0;
    virtual ~ISerializable() = default;
};

실전 예시: 게임 엔진 적 시스템

문제: 다양한 적 타입을 통일된 방식으로 다루기

게임에서 Zombie, Dragon, Archer 등 다양한 적이 있고, 매 프레임 update()attack()을 호출해야 합니다. 가상 함수를 사용하면 std::vector<Enemy*> 하나로 모든 적을 관리할 수 있습니다.

#include <iostream>
#include <vector>
#include <memory>

class Enemy {
public:
    virtual void update(float dt) {
        // 기본 구현: 아무것도 안 함
    }
    virtual void attack() = 0;  // 각 적마다 다르게 구현
    virtual ~Enemy() = default;
};

class Zombie : public Enemy {
public:
    void update(float dt) override {
        std::cout << "Zombie: 걸어가는 중...\n";
    }
    void attack() override {
        std::cout << "Zombie: 물어뜯기!\n";
    }
};

class Dragon : public Enemy {
public:
    void update(float dt) override {
        std::cout << "Dragon: 날개 펼침\n";
    }
    void attack() override {
        std::cout << "Dragon: 화염 브레스!\n";
    }
};

int main() {
    std::vector<std::unique_ptr<Enemy>> enemies;
    enemies.push_back(std::make_unique<Zombie>());
    enemies.push_back(std::make_unique<Dragon>());

    for (auto& e : enemies) {
        e->update(0.016f);
        e->attack();
    }
    return 0;
}

실행 결과:

Zombie: 걸어가는 중...
Zombie: 물어뜯기!
Dragon: 날개 펼침
Dragon: 화염 브레스!

포인터 타입은 Enemy*이지만, 각 객체의 vptr이 가리키는 vtable에 따라 Zombie::update, Dragon::update실제 타입에 맞는 함수가 호출됩니다. 가상 함수가 없다면 Enemy::update만 호출되거나, 타입별로 if (type == ZOMBIE) ... 같은 분기 코드가 필요합니다.


vtable 디버깅 팁

GDB에서 vtable 확인

# vtable이 있는 클래스의 객체에서 vptr 역참조
(gdb) p *obj
(gdb) p *(void**)obj
# vtable 주소 출력 후, 해당 주소의 함수 포인터들 확인

vtable 덤프로 상속 구조 파악

복잡한 상속 구조에서 “어떤 함수가 실제로 호출되는지” 헷갈릴 때, GCC의 -fdump-class-hierarchy 출력을 보면 vtable 슬롯과 오버라이드 관계를 한눈에 파악할 수 있습니다.


구현 체크리스트

다형적 베이스 클래스를 설계할 때 확인할 항목입니다.

  • 베이스 클래스 소멸자를 virtual로 선언했는가?
  • 파생 클래스에서 가상 함수 오버라이드 시 override 키워드를 사용했는가?
  • 가상 함수에 기본 인자를 두었다면, 파생에서도 동일하게 유지했는가? (또는 기본 인자 사용을 피했는가?)
  • 생성자/소멸자 내부에서 가상 함수를 호출하지 않았는가?
  • 다형적으로 사용하지 않는 클래스에 불필요하게 virtual을 붙이지 않았는가? (객체 크기 증가)

실무 활용 팁

다형적 베이스 vs 값 타입

  • 다형적으로 쓸 때: Base*, std::unique_ptr<Base> 등으로 포인터/스마트 포인터를 사용합니다. 객체의 실제 타입이 여러 가지이므로 vtable을 통한 동적 바인딩이 필요합니다.
  • 값으로 쓸 때: Derived d;처럼 스택에 두고, Derived만 쓴다면 가상 함수가 없어도 됩니다. 단, Base b = d;처럼 객체 슬라이싱이 발생하면 파생 부분이 잘려 나가므로, 다형성이 필요한 경우에는 포인터/참조를 사용해야 합니다.

스마트 포인터와 가상 소멸자

std::unique_ptr<Base>std::shared_ptr<Base>로 파생 객체를 관리할 때도, Base의 소멸자가 가상이어야 합니다. 스마트 포인터가 delete를 호출할 때 포인터 타입은 Base*이므로, 가상 소멸자가 없으면 파생 소멸자가 호출되지 않습니다.

플러그인/공유 라이브러리 경계

DLL이나 .so에서 내보낸 클래스를 가상 함수로 사용할 때, ABI 호환성이 중요합니다. vtable 레이아웃은 컴파일러·버전에 따라 다를 수 있어, 다른 컴파일러로 빌드한 라이브러리와 링크하면 vtable이 깨질 수 있습니다. C 인터페이스로 래핑하거나, 동일한 툴체인으로 빌드하는 것이 안전합니다.


프로덕션 패턴

패턴 1: 팩토리 + 다형성

객체 생성은 팩토리에서 담당하고, 사용하는 쪽은 베이스 포인터만 다룹니다. 타입별 생성 로직을 한 곳에 모아 유지보수성을 높입니다.

#include <memory>
#include <string>

class Document {
public:
    virtual void save() = 0;
    virtual ~Document() = default;
};

class PdfDocument : public Document {
public:
    void save() override { /* PDF 저장 */ }
};

class WordDocument : public Document {
public:
    void save() override { /* Word 저장 */ }
};

std::unique_ptr<Document> createDocument(const std::string& type) {
    if (type == "pdf") return std::make_unique<PdfDocument>();
    if (type == "word") return std::make_unique<WordDocument>();
    return nullptr;
}

// 사용: auto doc = createDocument("pdf"); doc->save();

패턴 2: NVI (Non-Virtual Interface)

public 인터페이스는 비가상 함수로 두고, 실제 동작은 protected 가상 함수에서 오버라이드합니다. 전후 처리(로깅, 락 등)를 한 곳에서 관리할 수 있습니다.

class Base {
public:
    void execute() {        // 비가상 — 항상 이 흐름
        preExecute();
        doExecute();        // 가상 — 파생에서 오버라이드
        postExecute();
    }
    virtual ~Base() = default;
protected:
    virtual void doExecute() = 0;
private:
    void preExecute() { /* 로깅, 락 등 */ }
    void postExecute() { /* 정리 */ }
};

class Derived : public Base {
protected:
    void doExecute() override { /* 실제 동작 */ }
};

패턴 3: 플러그인 아키텍처

공유 라이브러리(.so, .dll)에서 클래스를 로드하고, 베이스 인터페이스로 통일해 사용합니다. ABI 호환을 위해 동일 컴파일러·버전으로 빌드하거나, C API로 래핑하는 것이 안전합니다.

// 플러그인 인터페이스 (메인 프로그램과 플러그인이 공유)
class IPlugin {
public:
    virtual void init() = 0;
    virtual void run() = 0;
    virtual ~IPlugin() = default;
};

// extern "C" createPlugin() 로 팩토리 함수 export
// dlopen/dlsym 또는 LoadLibrary/GetProcAddress로 로드

패턴 4: 스마트 포인터 + 가상 소멸자

다형적 객체를 std::unique_ptr<Base> 또는 std::shared_ptr<Base>로 관리할 때, Base에 가상 소멸자가 반드시 있어야 합니다. 그래야 스마트 포인터가 소멸 시 올바른 파생 소멸자 체인이 호출됩니다.

std::vector<std::unique_ptr<Enemy>> enemies;
enemies.push_back(std::make_unique<Zombie>());
enemies.push_back(std::make_unique<Dragon>());
// 벡터 소멸 시 각 unique_ptr이 delete → 가상 소멸자로 올바른 정리

패턴 5: 전략 패턴 (Strategy)

알고리즘을 런타임에 교체할 때 가상 함수로 전략을 추상화합니다. SortStrategy 인터페이스와 std::unique_ptr<SortStrategy>DataProcessor가 전략을 전환합니다.

패턴 6: Observer/리스너 패턴

이벤트 발생 시 EventListener::onEvent() 가상 함수로 등록된 리스너들에게 알림을 보냅니다.

패턴 7: Pimpl + 가상 인터페이스

공개 헤더에는 IDataProcessor 인터페이스만 두고, 구현은 .cpp에 두어 ABI 안정성을 유지합니다.


13. 면접에서 이렇게 답하기

Q: 가상 함수가 뭔가요?

  • “오버라이딩된 함수를 포인터나 참조로 베이스 타입을 통해 호출할 때, 실제 객체 타입에 맞는 함수가 호출되게 해 주는 메커니즘입니다. 컴파일 시점이 아니라 실행 시점에 어떤 함수를 부를지 결정되므로 동적 바인딩이라고 부릅니다.”

Q: vtable이 뭔가요?

  • “가상 함수를 가진 클래스마다 하나씩 만들어지는 함수 포인터 배열입니다. 각 슬롯은 그 클래스에서 사용하는 가상 함수 하나의 주소를 담고 있고, 실제 호출할 함수는 이 테이블을 보고 실행 시점에 결정됩니다.”

Q: 객체와 vtable은 어떻게 연결되나요?

  • “가상 함수가 있는 클래스의 객체에는 vptr이라는 숨겨진 멤버(포인터)가 들어 있습니다. 이 포인터가 해당 객체의 실제 타입에 해당하는 vtable을 가리킵니다. 그래서 p->virt()를 호출하면, p가 가리키는 객체의 vptr → vtable → 해당 가상 함수 슬롯 → 그 주소로 점프, 순서로 실제 타입의 함수가 호출됩니다.”

이 정도로 “가상 함수 → vtable → vptr → 객체 메모리”를 한 줄기로 말할 수 있으면, C++ 다형성과 메모리 모델을 이해했다고 보는 면접관이 많습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ VTable | “가상 함수 테이블” 가이드
  • C++ 가상 함수 | “Virtual Functions” 가이드
  • C++ 상속과 다형성 | “virtual 함수” 완벽 가이드

이 글에서 다루는 키워드 (관련 검색어)

C++ 가상 함수, vtable, 다형성, 동적 바인딩, vptr 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • 가상 함수: 포인터/참조로 베이스 타입을 통해 호출할 때 실제 객체 타입의 오버라이드된 함수가 호출되게 하는 것. 동적 바인딩.
  • vtable: 클래스당 하나, 가상 함수들의 주소를 담은 테이블(함수 포인터 배열 개념).
  • vptr: 객체 안에 들어 있는 vtable을 가리키는 포인터. 객체와 vtable을 연결해, 실행 시점에 “어떤 함수를 부를지” 결정하게 함.
  • 소멸자: 다형적으로 쓰이는 베이스 클래스는 반드시 가상 소멸자로 두어, delete base_ptr 시 파생 쪽 정리가 되도록 해야 함.
  • 일반적인 실수: 가상 소멸자 누락, 가상 함수 기본 인자, 생성자/소멸자에서 가상 함수 호출, override 미사용.

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 게임 엔진의 적/플레이어 타입, 플러그인 시스템, GUI 위젯 계층, 네트워크 핸들러 등 다형성이 필요한 모든 곳에서 사용합니다. C++ 다형성 면접 1순위 주제이기도 합니다. 가상 함수가 오버라이딩에만 쓰이는 게 아니라, 메모리 상에서 객체와 vtable이 어떻게 연결되고 동적 바인딩이 일어나는지 이해하면 디버깅과 설계에 도움이 됩니다.

Q. vtable은 언제 생성되나요?

A. 컴파일 시점에 생성됩니다. 링크된 바이너리의 데이터 섹션에 들어가며, 프로그램 실행 시 메모리에 로드됩니다. 런타임에 동적으로 만들거나 수정되지 않습니다.

Q. 가상 함수 없이 다형성을 구현할 수 있나요?

A. 가능합니다. std::variant, std::visit, CRTP(Curiously Recurring Template Pattern), 함수 포인터/std::function 등을 사용할 수 있습니다. 각각 trade-off가 있으므로 상황에 맞게 선택합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreferenceVirtual function specifier와 Itanium C++ ABI 문서(vtable 레이아웃)를 참고하세요.

Q. final 키워드는 vtable과 어떤 관계인가요?

A. final은 클래스나 가상 함수에 붙여 “더 이상 상속/오버라이드하지 마라”고 지정합니다. 클래스에 final을 붙이면 파생 클래스가 없으므로, 컴파일러가 devirtualization 최적화를 적용할 수 있는 경우가 있습니다. 즉, “이 포인터는 항상 Derived 타입”이라고 추론 가능하면 vtable 조회 없이 직접 호출로 바꿀 수 있습니다. override와 함께 사용하면 실수를 줄이고, 최적화 여지도 열어 둘 수 있습니다.

class Base {
public:
    virtual void f();
};

class Derived final : public Base {  // final: 더 이상 상속 불가
public:
    void f() override final;  // final: 파생에서 오버라이드 불가
};

Q. 가상 함수와 템플릿을 같이 쓸 수 있나요?

A. 가능합니다. 가상 함수는 런타임 다형성, 템플릿은 컴파일 타임 다형성입니다. 베이스 클래스에 virtual void process(const T& data) = 0처럼 템플릿 가상 함수를 두는 것은 불가능합니다(vtable 슬롯 수가 무한해지므로). 대신 타입 소거(type erasure) 기법으로 std::any, std::function, 또는 커스텀 래퍼를 사용해 “템플릿 + 가상 함수” 조합을 구현할 수 있습니다.

한 줄 요약: 가상 함수·vtable로 다형성과 동적 바인딩이 동작하는 방식을 이해할 수 있습니다. 다음으로 복사·이동(#33-2)를 읽어보면 좋습니다.

다음 글: [C++ 면접 #33-2] 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics)

이전 글: [C++ 코테 압축 #32-3] 코테용 STL 컨테이너/알고리즘 시간복잡도 치트시트


관련 글

  • C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
  • C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]
  • C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]
  • C++ Data Race |
  • C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결