C++ undefined reference to vtable: 첫 비인라인 가상 함수 정의 누락과 해결

이 글의 핵심

가상 함수를 선언만 하고 정의하지 않으면 컴파일은 통과하지만, 링커가 vtable을 찾지 못해 원인과 동떨어져 보이는 메시지를 냅니다. 소멸자 정의 누락, 순수 가상 소멸자를 = 0으로만 두고 본문을 안 만든 경우, 빌드에서 .cpp가 빠진 경우를 구분하고, 인터페이스 클래스와 추상 베이스 클래스를 안전하게 작성하는 예시로 정리했습니다.

들어가며: “undefined reference to vtable for MyClass”

C++에서 가상 함수를 선언만 하고 정의하지 않으면 vtable 링커 에러가 발생합니다.

// ❌ vtable 에러
class Base {
public:
    virtual void foo();  // 선언만
    virtual ~Base();     // 선언만
};

// 링크 에러:
// undefined reference to `vtable for Base'

이 에러가 까다로운 이유는 메시지가 가리키는 대상이 실제 원인과 다르기 때문입니다. 우리가 빠뜨린 것은 Base::foo()나 Base::~Base()의 본문인데, 링커는 “vtable이 없다”고만 말합니다. 게다가 에러 위치로 찍히는 곳은 보통 Base의 생성자(또는 Derived의 생성자)입니다. 생성자가 객체에 vptr(vtable을 가리키는 숨은 포인터)을 써 넣으려면 vtable for Base라는 심볼의 주소가 필요한데, 그 심볼이 어떤 오브젝트 파일에도 만들어지지 않았기 때문입니다. 그래서 코드에서 foo()를 한 번도 호출하지 않았더라도 객체를 하나라도 생성하면 에러가 납니다.

컴파일러와 링커에 따라 메시지 모양도 다릅니다. GCC + GNU ld는 위와 같이 undefined reference to 'vtable for Base'를 내고, LLVM의 lld는 여기에 “the vtable symbol may be undefined because the class is missing its key function”이라는 힌트를 덧붙여 줍니다. MSVC는 vtable 방식이 달라서 vtable 대신 빠진 함수 자체를 LNK2001: unresolved external symbol "public: virtual __cdecl Base::~Base(void)"처럼 직접 보고하므로 원인을 찾기가 더 쉽습니다.

이 글에서 다루는 것:

  • vtable이란?
  • vtable 에러 원인
  • 해결 방법
  • 순수 가상 함수

vtable이란?

vtable (Virtual Table)

vtable은 가상 함수 테이블로, 런타임에 어떤 함수를 호출할지 결정하는 테이블입니다.

class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo\n";
    }
    
    virtual void bar() {
        std::cout << "Base::bar\n";
    }
};

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

// vtable 구조 (개념적)
// Base vtable:
//   [0] -> Base::foo
//   [1] -> Base::bar
//
// Derived vtable:
//   [0] -> Derived::foo
//   [1] -> Base::bar

vtable 생성 시점

컴파일러는 첫 번째 비인라인 가상 함수가 정의된 번역 단위에 vtable을 생성합니다.

이 규칙은 C++ 표준이 아니라 GCC·Clang이 따르는 Itanium C++ ABI의 약속이고, 이때 기준이 되는 함수를 key function이라고 부릅니다. 헤더에 선언된 클래스는 여러 .cpp 파일에서 include되므로, 모든 번역 단위가 vtable을 만들면 중복이 생깁니다. 그래서 컴파일러는 “클래스 안에서 선언 순서상 처음 나오는, 인라인이 아니고 순수 가상도 아닌 가상 함수”를 하나 골라, 그 함수의 본문이 정의된 .cpp에만 vtable을 한 벌 만들어 둡니다. 나머지 번역 단위는 vtable을 외부 심볼로 참조만 합니다.

이 구조에서 에러가 나는 흐름은 단순합니다. key function의 정의가 어디에도 없으면 vtable을 만들 번역 단위가 하나도 없고, 참조만 남아 링크가 실패합니다. 반대로 클래스에 key function이 아예 없으면(모든 가상 함수가 클래스 본문 안에 인라인으로 정의되었거나 = default인 경우) 컴파일러는 vtable을 사용하는 모든 번역 단위에 약한 심볼(weak/COMDAT)로 vtable을 만들고 링커가 하나로 합칩니다. 그래서 해결책 2·3처럼 헤더에 인라인으로 정의하면 에러가 사라지는 것입니다.


vtable 에러 원인

원인 1: 가상 함수 정의 누락

// ❌ 정의 없음
// base.h
class Base {
public:
    virtual void foo();  // 선언만
    virtual ~Base();     // 선언만
};

// 링크 에러: undefined reference to `vtable for Base'

원인 2: 소멸자 정의 누락

// ❌ 소멸자 정의 없음
class Base {
public:
    virtual ~Base();  // 선언만
    
    virtual void foo() {
        std::cout << "foo\n";
    }
};

// 링크 에러: undefined reference to `vtable for Base'

원인 2가 특히 헷갈립니다. foo()는 본문까지 있는데 왜 에러가 날까요? foo()는 클래스 안에서 정의되어 인라인이므로 key function이 될 수 없고, 선언 순서상 남는 비인라인 가상 함수는 ~Base()뿐입니다. 즉 이 클래스의 key function은 소멸자이고, 소멸자 본문이 없으니 vtable도 생성되지 않습니다. 실무에서 가장 자주 보는 형태가 바로 이것으로, 헤더에 virtual ~Base();만 적어 두고 .cpp에 Base::~Base() {}를 쓰는 것을 잊은 경우입니다.

원인 3: 순수 가상 함수 구현

// ❌ 순수 가상 함수를 일반 함수처럼 선언
class Base {
public:
    virtual void foo();  // = 0 없음
};

// Base 또는 Base를 상속한 클래스의 객체를 만들면 링크 에러

설계상 파생 클래스가 반드시 구현해야 하는 함수였다면 = 0을 빠뜨린 실수입니다. = 0을 붙이면 순수 가상 함수는 key function 후보에서 제외되고, Base는 추상 클래스가 되어 직접 인스턴스화하려 할 때 링크 단계가 아니라 컴파일 단계에서 cannot declare variable 'b' to be of abstract type 'Base'처럼 명확한 에러가 납니다. 같은 실수라도 링커 에러보다 컴파일 에러가 훨씬 찾기 쉽다는 점에서, 의도를 코드로 표현하는 것이 중요합니다.

원인 4: 정의는 있는데 빌드에 포함되지 않음

코드상으로는 base.cpp에 모든 정의가 있는데도 같은 에러가 나는 경우가 있습니다. 이때는 소스가 아니라 빌드 설정을 봐야 합니다. CMake의 add_library/add_executable 목록에 base.cpp가 빠졌거나, 정적 라이브러리를 링크 명령에서 사용하는 쪽보다 앞에 두어 GNU ld가 필요한 오브젝트를 뽑아 오지 못한 경우가 대표적입니다. 이럴 때는 nm -C base.o | grep vtable로 해당 오브젝트에 vtable for Base가 정의 심볼(D, R, V 등)로 있는지, U(미정의 참조)로만 있는지를 확인하면 원인이 소스인지 빌드인지 바로 갈립니다.

Qt를 쓴다면 또 하나의 흔한 원인이 있습니다. 클래스에 Q_OBJECT 매크로를 추가했는데 moc가 그 헤더를 처리하지 않으면, 매크로가 선언한 metaObject() 같은 가상 함수의 정의(moc가 생성하는 파일에 있음)가 없어 undefined reference to vtable for MyWidget이 납니다. 제가 이 에러를 만나는 경우의 상당수가 여기에 해당하는데, 매크로를 나중에 추가한 뒤 qmake/CMake를 다시 실행하지 않아 moc 대상에 헤더가 등록되지 않은 것이 원인이었습니다. CMake라면 CMAKE_AUTOMOC ON과 헤더를 소스 목록에 포함하는지, qmake라면 qmake를 다시 돌렸는지 확인하십시오.


해결 방법

해결책 1: .cpp에 정의 추가

// ✅ base.h
class Base {
public:
    virtual void foo();
    virtual ~Base();
};

// ✅ base.cpp
void Base::foo() {
    std::cout << "Base::foo\n";
}

Base::~Base() {
    std::cout << "~Base\n";
}

해결책 2: 헤더에 인라인 정의

// ✅ base.h
class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo\n";
    }
    
    virtual ~Base() {
        std::cout << "~Base\n";
    }
};

해결책 3: = default 사용

// ✅ base.h
class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo\n";
    }
    
    virtual ~Base() = default;  // 기본 구현
};

세 가지 해결책은 결과는 같지만 트레이드오프가 다릅니다. .cpp에 정의하면 vtable과 RTTI 정보(typeinfo)가 한 오브젝트 파일에만 생기고, 헤더를 고치지 않고 구현을 바꿀 수 있어 헤더를 include하는 파일들의 재컴파일이 줄어듭니다. 공유 라이브러리(.so/.dll)로 배포하는 클래스라면 이 방식이 사실상 필수입니다. vtable이 라이브러리 한 곳에만 존재해야 dynamic_cast나 예외 타입 비교가 라이브러리 경계를 넘어 안정적으로 동작하기 때문입니다.

헤더 인라인 정의나 = default는 간단하지만, key function이 없어지므로 해당 클래스를 쓰는 모든 번역 단위가 vtable 사본을 만들고 링커가 중복을 버립니다. 작은 프로젝트에서는 문제가 되지 않지만, 클래스가 많고 헤더가 널리 include되는 코드베이스에서는 빌드 시간과 오브젝트 파일 크기가 늘어납니다. Clang의 -Wweak-vtables 경고가 바로 이 상황을 알려 주는 옵션입니다. 그래서 LLVM 같은 대형 프로젝트는 “헤더에 가상 함수를 모두 인라인으로 두는 클래스에도 .cpp에 빈 가상 함수 하나(보통 소멸자나 anchor())를 정의한다”는 규칙을 두고 있습니다.


순수 가상 함수

순수 가상 함수는 = 0

// ✅ 순수 가상 함수
class Base {
public:
    virtual void foo() = 0;  // 순수 가상
    virtual ~Base() = default;
};

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

// Base b;  // 컴파일 에러: 추상 클래스
Derived d;  // OK

순수 가상 소멸자는 정의 필요

// ✅ 순수 가상 소멸자
class Base {
public:
    virtual ~Base() = 0;  // 순수 가상
};

// 정의 필수!
Base::~Base() {
    std::cout << "~Base\n";
}

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

순수 가상 소멸자에 본문이 필요한 이유는 소멸 순서에 있습니다. Derived 객체가 파괴될 때 ~Derived()가 끝나면 컴파일러는 기반 클래스의 소멸자 Base::~Base()를 정적으로(가상 디스패치 없이) 호출합니다. 다른 순수 가상 함수는 파생 클래스가 오버라이드하면 기반 버전이 호출될 일이 없지만, 소멸자는 파생 클래스가 무엇을 하든 반드시 기반 버전까지 호출되므로 본문이 없으면 undefined reference to 'Base::~Base()'가 납니다. 이 경우에는 vtable이 아니라 소멸자 심볼 자체가 없다고 나오는 경우가 많습니다.

순수 가상 소멸자는 “다른 순수 가상 함수는 없지만 이 클래스를 추상으로 만들고 싶을 때” 쓰는 관용구입니다. 주의할 점은 정의를 헤더에 둘 때 inline을 붙여야 한다는 것입니다. 클래스 밖에 Base::~Base() {}를 헤더에 그대로 쓰면 그 헤더를 include한 모든 .cpp에 정의가 생겨 이번에는 multiple definition of 'Base::~Base()' 링크 에러가 납니다. 표준 C++에서는 순수 가상 선언에 = 0과 본문을 한 줄에 함께 쓸 수 없으므로(virtual ~Base() = 0 {}는 MSVC만 확장으로 허용), 헤더에 둔다면 클래스 밖에 inline Base::~Base() {} 형태로 씁니다.


실전 예시

예시 1: 인터페이스 클래스

// ✅ 인터페이스
class ILogger {
public:
    virtual void log(const std::string& msg) = 0;
    virtual ~ILogger() = default;
};

class ConsoleLogger : public ILogger {
public:
    void log(const std::string& msg) override {
        std::cout << msg << '\n';
    }
};

예시 2: 추상 베이스 클래스

// ✅ 추상 클래스
class Shape {
public:
    virtual double area() const = 0;
    virtual double perimeter() const = 0;
    virtual ~Shape() = default;
};

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

두 예시 모두 “모든 멤버 함수가 순수 가상이고 소멸자는 = default”인 형태라 key function이 없고, 따라서 vtable 링크 에러가 날 여지가 원천적으로 없습니다. 인터페이스 클래스를 이 형태로 통일해 두면 .cpp 파일을 따로 만들 필요가 없다는 장점이 있습니다. 반면 Shape처럼 공통 구현(예: 이름을 반환하는 name())을 기반 클래스에 두기 시작하면 그 함수가 key function이 되므로, 본문을 .cpp에 둘지 헤더에 인라인으로 둘지 의식적으로 정해야 합니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 모든 가상 함수를 정의했는데도 undefined reference to vtable 에러가 나면 무엇을 확인하나요?

A. GCC와 Clang은 클래스에서 inline이 아닌 첫 번째 가상 함수(key function)가 정의된 번역 단위에 vtable을 생성합니다. 따라서 그 함수를 정의한 .cpp 파일이 CMake의 add_executable/add_library 소스 목록에서 빠져 있거나, 링크 명령에 해당 오브젝트 파일이 포함되지 않으면 정의가 있어도 같은 에러가 납니다. 빌드 대상 소스 목록을 먼저 확인하고, 오래된 오브젝트 파일이 남아 있을 수 있으니 클린 빌드도 함께 해 보는 것이 좋습니다.