포스트

CS — vtable deepdive

답변 흐름 — 오늘 모의면접에서 막힌 질문들을 정리한 심화 파일

CS — vtable deepdive

오늘 모의면접에서 vtable 꼬리질문에 제대로 답하지 못한 게 몇 개 있었다. 막혔던 질문들을 하나씩 다시 파서, 다음에 같은 질문이 오면 30초 안에 답할 수 있게 정리해 둔다. 기본 개념은 vtable 기본 글에 있고, 여기는 그 다음 단계다.


목차

  1. vtable 단점
  2. vtable은 객체마다? 클래스마다?
  3. 순수 가상 함수와 _purecall
  4. 자식 override 함수가 vtable 배열 안에서 어떻게 동작하나
  5. override가 없는 경우 vtable 슬롯은?
  6. virtual 선언 시 vtable과 함께 생성되는 RTTI
  7. 언리얼에서 SetPurecallHandler

1. vtable 단점

Q: “vtable을 사용하면 어떤 단점이 있나요?”

단점 4가지

단점설명크기
메모리 오버헤드객체마다 vptr 8바이트 추가낮음
간접 호출 비용vptr → vtable → 함수포인터, 역참조 2번중간
인라이닝 불가컴파일 타임에 어떤 함수 호출될지 모름 → 인라인 최적화 차단높음
캐시 미스vtable이 .rodata에 있어 캐시 워밍이 안 된 경우 캐시 미스중간~높음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 인라이닝 불가 예시
class Shape {
public:
    virtual void Draw() { }  // 컴파일러가 인라인 불가
};

// CRTP로 인라이닝 가능하게 변환
template<typename Derived>
class ShapeBase {
public:
    void Draw() {
        static_cast<Derived*>(this)->DrawImpl();  // 컴파일 타임 결정 → 인라인 가능
    }
};

추가 단점: 생성자/소멸자 내부 가상 함수 호출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
    Base() { Foo(); }       // 위험! 이 시점의 vptr은 Base::vtable
    virtual void Foo() { std::cout << "Base::Foo\n"; }
};

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

Derived d;  // "Base::Foo" 출력 — Derived::Foo가 아님!
// 이유: Base 생성자 실행 시점엔 vptr = &Base::vtable
//       Derived 생성자 실행 후에야 vptr = &Derived::vtable

30초 답변:
vtable의 단점은 세 가지입니다. 첫째, 간접 호출 비용 — vptr에서 vtable, vtable에서 함수 포인터까지 역참조가 2번 발생합니다. 둘째, 컴파일 타임에 호출 함수를 특정할 수 없어 인라인 최적화가 불가능합니다. 셋째, vtable 포인터 역참조 시 캐시 미스가 발생할 수 있습니다. 성능이 중요한 Hot Path라면 final 키워드로 devirtualize하거나 CRTP를 사용합니다.


2. vtable은 객체마다? 클래스마다?

Q: “vtable은 객체마다 생성되나요, 클래스마다 생성되나요?”

핵심 구분

1
2
vtable  → 클래스마다 1개  (컴파일 타임 생성, .rodata 읽기 전용 영역)
vptr    → 객체마다  1개  (런타임, 생성자 호출 시 초기화)
1
2
3
4
5
6
7
8
9
10
class Animal {
public:
    virtual void Speak() { }
};

Animal a1, a2, a3;

// vtable: Animal::vtable 딱 1개 — 세 객체가 공유
// vptr:   a1.vptr, a2.vptr, a3.vptr 각각 존재
//         (모두 같은 Animal::vtable을 가리킴)
1
2
3
4
5
6
7
8
메모리 구조:

a1: [ vptr → Animal::vtable ] [ 멤버 변수 ]
a2: [ vptr → Animal::vtable ] [ 멤버 변수 ]   ← 같은 vtable 가리킴
a3: [ vptr → Animal::vtable ] [ 멤버 변수 ]

Animal::vtable (읽기 전용, .rodata):
  [ &Animal::Speak, &Animal::~Animal ]

왜 클래스당 1개인가?

vtable의 내용(함수 포인터 배열)은 모든 객체에서 동일하기 때문에 공유해도 안전합니다. 객체별로 달라지는 것은 vptr이 가리키는 대상(어떤 클래스의 vtable인지)이지, vtable 자체의 내용이 아닙니다.

30초 답변:
vtable은 클래스마다 1개 생성됩니다. 컴파일 타임에 만들어져 읽기 전용 메모리(.rodata)에 저장됩니다. 반면 vptr은 객체마다 1개씩 존재하며, 생성자 호출 시 런타임에 해당 클래스의 vtable 주소로 초기화됩니다. 같은 클래스의 모든 객체는 동일한 vtable을 공유합니다.


3. 순수 가상 함수와 _purecall

Q: “순수 가상 함수는 vtable에서 어떻게 처리되나요? 호출하면 어떻게 됩니까?”

순수 가상 함수 선언

1
2
3
4
5
class IShape {
public:
    virtual void Draw() = 0;    // 순수 가상 함수
    virtual ~IShape() = default;
};
  • = 0 선언 → 이 클래스는 추상 클래스(instantiation 불가)
  • vtable 슬롯에는 __cxa_pure_virtual (GCC/Clang) 또는 _purecall (MSVC) 함수 포인터 등록

_purecall 발생 시나리오

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
    virtual void Foo() = 0;
    Base() {
        Foo();  // UB! 생성자 안에서 순수 가상 함수 호출
                // vptr이 아직 Base::vtable → _purecall 호출 → 프로그램 종료
    }
};

class Derived : public Base {
public:
    void Foo() override { }
};

Derived d;  // Base() 생성자에서 _purecall 호출됨

_purecall 동작:

  1. 순수 가상 함수 호출 감지
  2. MSVC CRT의 _purecall() 함수 실행
  3. 기본 동작: abort() → 프로그램 즉시 종료

_set_purecall_handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdlib.h>

// 커스텀 핸들러 — 디버깅/로깅 목적
void MyPureCallHandler() {
    // 스택 트레이스 출력, 크래시 리포트 전송 등
    std::cerr << "[FATAL] 순수 가상 함수 호출 감지!\n";
    // 반환하지 말 것 — 반환하면 UB
    abort();
}

int main() {
    _set_purecall_handler(MyPureCallHandler);
    // 이후 _purecall 발생 시 MyPureCallHandler 호출
}
  • _set_purecall_handler로 커스텀 핸들러 교체 가능
  • 핸들러는 반드시 종료해야 함 (반환 시 Undefined Behavior)
  • 디버그 빌드에서 콜스택 덤프, 크래시 리포터 연동에 활용

4. 자식 override 함수가 vtable 배열 안에서 어떻게 동작하나

Q: “자식 클래스에서 override한 함수가 vtable 배열 안에서 어떻게 처리됩니까?”

슬롯 교체 메커니즘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Animal {
public:
    virtual void Speak() { }   // 슬롯 [0]
    virtual void Move()  { }   // 슬롯 [1]
    virtual ~Animal()    { }   // 슬롯 [2]
};

class Dog : public Animal {
public:
    void Speak() override { }  // 슬롯 [0] 교체
    // Move()는 override 안 함
};

// Animal vtable: [ &Animal::Speak, &Animal::Move, &Animal::~Animal ]
// Dog    vtable: [ &Dog::Speak,    &Animal::Move, &Dog::~Dog       ]
//                  ^^^^^^^^^^^^— 슬롯 [0]만 교체됨

런타임 호출 흐름

1
2
3
4
5
6
7
8
9
10
11
12
Animal* a = new Dog();
a->Speak();

1. a가 가리키는 객체의 vptr 읽기     → Dog::vtable 주소
2. Dog::vtable[0] 읽기              → &Dog::Speak
3. Dog::Speak() 호출                ← 올바른 자식 함수!

a->Move();

1. a의 vptr 읽기                    → Dog::vtable 주소
2. Dog::vtable[1] 읽기              → &Animal::Move  (복사된 포인터)
3. Animal::Move() 호출

핵심: 슬롯 인덱스는 상속 계층 전반에서 동일하게 유지됩니다. 컴파일러는 virtual void Speak()가 항상 슬롯 [0]임을 알고 있으므로, 포인터 타입이 무엇이든 같은 오프셋으로 접근합니다.


5. override가 없는 경우 vtable 슬롯은?

Q: “자식 클래스에서 override하지 않은 가상 함수는 어떻게 됩니까?”

부모 함수 포인터 그대로 복사

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
public:
    virtual void Speak() { std::cout << "...\n"; }
    virtual void Move()  { std::cout << "이동\n"; }
};

class Dog : public Animal {
public:
    void Speak() override { std::cout << "멍멍\n"; }
    // Move()는 override 안 함
};

Animal* a = new Dog();
a->Move();   // Animal::Move() 호출
             // Dog vtable의 [1] = &Animal::Move (복사된 포인터이므로)
1
2
3
4
5
6
7
Dog vtable 생성 과정:
1. Animal vtable을 그대로 복사:
   [ &Animal::Speak, &Animal::Move, &Animal::~Animal ]

2. override된 슬롯만 교체:
   [ &Dog::Speak,    &Animal::Move, &Dog::~Dog       ]
                      ^^^^^^^^^^^^— 교체 없음, 부모 것 그대로

결과: Dog 객체에서 Move()를 호출해도 Animal::Move()가 실행됩니다. 슬롯에 부모 함수 포인터가 복사되어 있기 때문입니다.

30초 답변:
자식 클래스가 가상 함수를 override하지 않으면, 자식의 vtable에는 부모의 함수 포인터가 그대로 복사됩니다. 따라서 자식 객체를 부모 포인터로 가리키고 해당 함수를 호출해도 부모의 구현이 실행됩니다.


6. virtual 선언 시 vtable과 함께 생성되는 RTTI

Q: “virtual 키워드를 쓰면 vtable 생성과 함께 RTTI도 생성되나요?”

vtable의 실제 구조 (숨겨진 헤더 포함)

1
2
3
4
5
6
7
8
9
10
vtable 메모리 레이아웃 (GCC/Clang ABI 기준):

[ type_info*   ]  ← RTTI 정보 포인터 (vtable[-1])
[ offset_to_top]  ← 다중 상속용 오프셋 (vtable[-2])
[ Func1*       ]  ← 가상 함수 포인터 [0]
[ Func2*       ]  ← 가상 함수 포인터 [1]
[ ...          ]

vptr는 Func1* 위치를 가리킴
RTTI는 vptr[-1]로 접근
1
2
3
4
5
6
7
8
9
10
11
12
class Animal {
public:
    virtual void Speak() { }  // virtual 하나만 있어도 RTTI 자동 생성
};

Animal* a = new Dog();

// typeid 사용 → 내부적으로 vptr[-1]의 type_info 접근
std::cout << typeid(*a).name();  // "Dog" 출력

// dynamic_cast도 RTTI 사용
Dog* d = dynamic_cast<Dog*>(a);  // vtable의 type_info로 타입 확인

RTTI 생성 조건

조건RTTI 생성 여부
virtual 함수 없음생성 안 됨
virtual 함수 1개 이상자동 생성
-fno-rtti 컴파일 플래그명시적으로 비활성화
1
2
3
4
5
6
7
8
9
10
class Plain {          // virtual 없음 → RTTI 없음
    int x;
};

class Base {           // virtual 있음 → RTTI 자동 생성
    virtual void Foo() { }
};

// typeid(Plain{}) — 정적 RTTI (컴파일 타임, vtable 불필요)
// typeid(*base_ptr) — 동적 RTTI (런타임, vtable 필요)

30초 답변:
virtual 키워드를 사용하면 컴파일러는 vtable을 생성하면서 type_info 포인터도 함께 vtable 헤더에 심습니다. typeid(*ptr)dynamic_cast를 호출하면 vptr을 통해 이 type_info에 접근하여 런타임 타입을 확인합니다. 즉 RTTI는 vtable이 있어야만 동작하는 동적 타입 정보 시스템입니다.


7. 언리얼에서 SetPurecallHandler

Q: “언리얼 엔진에서 SetPurecallHandler는 무엇인가요?”

언리얼은 엔진 초기화 시 _set_purecall_handler를 이용해 커스텀 핸들러를 등록합니다. 순수 가상 함수 호출(개발 중 실수)을 엔진 크래시 리포터와 연동하기 위해서입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 언리얼 엔진 내부 (Windows 플랫폼)
// UnrealEngine/Engine/Source/Runtime/Core/Private/Windows/WindowsPlatformCrashContext.cpp

static void PureCallHandler() {
    // 1. 콜스택 캡처
    // 2. 크래시 리포터로 전송
    // 3. 에디터면 메시지 박스 표시
    UE_LOG(LogCore, Fatal, TEXT("Pure virtual function called"));
    // → FPlatformMisc::RaiseException 호출 → 크래시 다이얼로그
}

// 엔진 시작 시:
_set_purecall_handler(PureCallHandler);

실제로 마주치는 상황

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 언리얼에서 자주 발생하는 케이스:
// 생성자에서 순수 가상 함수 호출 (vtable이 완성되기 전)

UCLASS()
class AMyActor : public AActor {
    GENERATED_BODY()
public:
    AMyActor() {
        InitData();  // 만약 InitData가 순수 가상이라면 — purecall!
    }
    virtual void InitData() = 0;  // 순수 가상
};

// 에러 메시지:
// "Pure virtual function called" + 크래시 리포터 팝업

실무 팁: 언리얼에서 이 오류를 보면 생성자 내 가상 함수 호출 여부 먼저 확인.
해결책: 생성자 대신 BeginPlay()PostInitializeComponents()에서 초기화.


핵심 요약 (30초 답변 카드)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vtable 단점:
  간접 호출(역참조 2번) + 인라이닝 불가 + 캐시 미스
  → Hot Path: final(devirtualize) 또는 CRTP

vtable vs vptr:
  vtable = 클래스당 1개 (컴파일 타임, .rodata)
  vptr   = 객체당 1개  (런타임, 생성자에서 초기화)

순수 가상 + purecall:
  = 0 선언 → vtable 슬롯에 _purecall 등록
  호출 시 프로그램 종료 (_set_purecall_handler로 커스텀 가능)

override 없는 슬롯:
  부모 함수 포인터 그대로 복사 → 부모 구현 실행

vtable + RTTI:
  virtual 있으면 type_info* 가 vtable 헤더에 자동 생성
  typeid / dynamic_cast = vtable[-1] 접근

참고

핵심 요약 — vtable은 클래스당 1개(.rodata), vptr은 객체당 1개(생성자에서 초기화)라는 구분이 모든 꼬리질문의 출발점이었다. override 안 한 슬롯엔 부모 함수 포인터가 그대로 복사되고, 순수 가상 함수 슬롯엔 _purecall이 들어가며, 생성자 안에서는 vptr이 아직 부모 vtable을 가리켜서 자식 override가 호출되지 않는다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.