C++ 가상 소멸자가 없을 때 생기는 누수: virtual·protected 소멸자 선택 기준
이 글의 핵심
다형성으로 쓰는 클래스에 가상 소멸자를 빠뜨리면 컴파일러는 경고 없이 기반 클래스 소멸자만 호출하고, 표준상으로도 정의되지 않은 동작이 됩니다. 반대로 다형적으로 삭제하지 않을 클래스라면 protected 비가상 소멸자로 잘못된 delete 자체를 막는 선택지도 있습니다. 두 선택의 기준과 vtable 비용, shared_ptr로 관리할 때의 예외적인 동작까지 정리했습니다.
들어가며: 파생 객체를 삭제했는데 자원이 새는 경우
기반 클래스 포인터로 파생 객체를 delete할 때 소멸자가 가상이 아니면, 파생 클래스의 소멸자가 호출되지 않아 파생 클래스가 확보한 메모리와 핸들이 해제되지 않습니다.
그림: 실제 실행 결과 — 베이스 포인터로 delete하면 Base 크기만큼만 해제하려 해서, ASan이 할당 크기와 해제 크기가 다르다고 알려 준다. (환경: Ubuntu 24.04 · g++ 13.3)
#include <iostream>
// ❌ 가상 소멸자 없음
class Base {
public:
~Base() { // 비가상 소멸자
std::cout << "~Base\n";
}
};
class Derived : public Base {
int* data_;
public:
Derived() : data_(new int[1000]) {}
~Derived() {
delete[] data_; // 호출되지 않음
std::cout << "~Derived\n";
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // ❌ 미정의 동작. 흔한 결과: ~Base만 출력되고 data_ 누수
}
가상 소멸자가 필요한 이유
왜 ~Base만 불리는지는 delete ptr이 하는 일을 보면 알 수 있습니다. delete는 먼저 소멸자를 호출하고 그다음 메모리를 해제합니다. 소멸자가 가상이 아니면 컴파일러는 포인터의 정적 타입인 Base를 보고 Base::~Base()를 직접 호출하는 코드를 만듭니다. 실제로 가리키는 객체가 Derived라는 정보는 런타임에만 있는데, 비가상 호출은 그 정보를 보지 않습니다. 일반 멤버 함수에서 virtual을 빼면 파생 클래스의 오버라이드가 호출되지 않는 것과 정확히 같은 원리입니다.
위 예제의 주석에 미정의 동작이라고 적은 것은 과장이 아닙니다. 표준은 “삭제하는 포인터의 정적 타입이 실제 객체 타입과 다르면 정적 타입에 가상 소멸자가 있어야 하며, 그렇지 않으면 동작이 정의되지 않는다”고 명시합니다. 그래서 “파생 소멸자만 빠지고 나머지는 멀쩡하다”는 보장조차 없습니다. 특히 다중 상속에서 Base가 첫 번째 기반 클래스가 아니면 ptr이 객체의 시작 주소가 아닌 중간을 가리키므로, delete가 할당된 적 없는 주소를 해제하려다 “free(): invalid pointer” 같은 에러로 크래시할 수 있습니다.
컴파일러가 이 실수를 잡아 주는지는 상황에 따라 다릅니다. GCC와 Clang은 Base에 다른 가상 함수가 있는데 소멸자만 비가상이면 “deleting object of polymorphic class type ‘Base’ which has non-virtual destructor might cause undefined behavior”(-Wdelete-non-virtual-dtor, -Wall에 포함) 경고를 냅니다. 하지만 이 예제처럼 가상 함수가 하나도 없는 클래스는 다형적으로 쓰일 의도가 없다고 보고 경고하지 않습니다. 가장 조용히 지나가는 경우가 바로 이 글의 예제들입니다.
해결: 가상 소멸자
#include <iostream>
// ✅ 가상 소멸자
class Base {
public:
virtual ~Base() {
std::cout << "~Base\n";
}
};
class Derived : public Base {
public:
~Derived() override {
std::cout << "~Derived\n";
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // ✅ 실제 타입의 소멸자 호출
// 출력:
// ~Derived
// ~Base
}
virtual을 붙이면 delete ptr은 vtable을 통해 실제 객체 타입의 소멸자를 찾아 호출합니다. ~Derived가 먼저 실행되고, 끝나면 컴파일러가 자동으로 ~Base를 이어서 호출합니다. 소멸은 생성의 역순이라, 파생 클래스가 자기 자원을 정리한 뒤 기반 클래스가 정리합니다. 기반 클래스의 소멸자가 한 번 virtual이 되면 모든 파생 클래스의 소멸자도 자동으로 가상이 되므로, 파생 쪽에 virtual을 다시 쓸 필요는 없습니다. override를 붙여 두면 누군가 기반 클래스에서 virtual을 지웠을 때 “marked ‘override’, but does not override” 에러로 알려 주는 안전장치가 됩니다.
소멸 과정에서 알아 둘 규칙이 하나 더 있습니다. ~Base() 안에서 가상 함수를 호출하면 Derived의 오버라이드가 아니라 Base 버전이 호출됩니다. 그 시점에 Derived 부분은 이미 파괴되었기 때문입니다. 순수 가상 함수를 소멸자에서 간접 호출하면 미정의 동작이 되고, 흔히 “pure virtual method called” 메시지와 함께 프로그램이 종료됩니다. 정리 로직을 기반 클래스 소멸자의 가상 함수 호출로 구현하려다 자주 만나는 에러입니다.
누수 예시
동적 할당 메모리
// ❌ 메모리 누수
class Base {
public:
~Base() {}
};
class Derived : public Base {
int* data_;
public:
Derived() : data_(new int[1000000]) {} // int 100만 개 = 약 4MB
~Derived() { delete[] data_; } // 호출되지 않음
};
int main() {
for (int i = 0; i < 100; ++i) {
Base* ptr = new Derived();
delete ptr; // ❌ 반복할 때마다 약 4MB씩 누수
}
}
이 누수는 크래시가 없어서 오래 숨어 있습니다. 서버 프로세스라면 메모리 사용량이 요청 수에 비례해 조금씩 늘다가 어느 순간 OOM으로 종료되고, 그때는 원인이 된 코드가 어디인지 알기 어렵습니다. 이런 누수를 찾는 데는 AddressSanitizer(-fsanitize=address, LeakSanitizer 포함)나 Valgrind가 유용합니다. 누수된 블록이 Derived::Derived()에서 할당되었다고 스택과 함께 보여 주므로, “생성자에서 할당했는데 소멸자에서 해제되지 않았다”는 단서로 가상 소멸자 누락을 의심할 수 있습니다. ASan은 여기에 더해, 할당한 객체 크기(sizeof(Derived))와 delete가 넘긴 크기(sizeof(Base))가 다르면 new-delete-type-mismatch 에러로 이 버그를 직접 가리키기도 합니다.
이 경우 new int[...]로 받은 배열은 해제되지 않지만, delete ptr이 해제하는 Derived 객체 자체의 메모리는 대부분의 구현에서 해제됩니다. 새는 것은 파생 클래스가 따로 확보한 자원입니다.
파일 핸들
#include <fstream>
#include <iostream>
#include <string>
// ❌ 파일 핸들 누수
class Base {
public:
~Base() {}
};
class FileLogger : public Base {
std::ofstream file_;
public:
explicit FileLogger(const std::string& path) : file_(path) {}
~FileLogger() {
file_.close(); // 호출되지 않음
}
};
int main() {
Base* ptr = new FileLogger("log.txt");
delete ptr; // ❌ ~FileLogger도, 멤버 file_의 소멸자도 호출되지 않음
}
이 예제는 메모리보다 더 눈에 띄는 증상을 만듭니다. ~FileLogger가 호출되지 않으면 file_.close()만 빠지는 것이 아니라 멤버 std::ofstream의 소멸자도 호출되지 않습니다. 멤버의 소멸자는 소유한 클래스의 소멸자 끝에서 자동으로 호출되는데, 그 소멸자 자체가 불리지 않았기 때문입니다. 결과적으로 버퍼에 남은 로그가 디스크에 기록되지 않아 로그 마지막 부분이 사라지고, 파일 핸들도 프로세스가 끝날 때까지 열려 있습니다. RAII 멤버(std::string, std::vector, std::unique_ptr)로 자원 관리를 잘 해 두었더라도, 가상 소멸자가 없으면 그 RAII가 전부 무력화된다는 점이 이 버그의 무서운 부분입니다.
순수 가상 소멸자
순수 가상 소멸자는 클래스를 추상 클래스로 만들지만, 반드시 정의를 제공해야 합니다.
#include <iostream>
class Base {
public:
virtual ~Base() = 0; // 순수 가상
};
Base::~Base() { // 정의 필수
std::cout << "~Base\n";
}
class Derived : public Base {
public:
~Derived() override {
std::cout << "~Derived\n";
}
};
int main() {
// Base b; // 컴파일 에러: 추상 클래스
Base* ptr = new Derived();
delete ptr; // OK: ~Derived, ~Base 순서로 호출
}
순수 가상 소멸자는 다른 순수 가상 함수가 없는 클래스를 추상 클래스로 만들고 싶을 때 씁니다. 정의가 필요한 이유는, 파생 클래스의 소멸자가 끝나면 항상 기반 클래스 소멸자를 호출하기 때문입니다. 일반 순수 가상 함수는 파생 클래스가 구현을 대신 제공하지만, 소멸자는 파생 클래스가 대신할 수 없습니다. 정의를 빠뜨리면 컴파일은 되고 링크 단계에서 “undefined reference to Base::~Base()” 에러가 납니다. 헤더에 정의를 두고 싶다면 클래스 밖에 inline Base::~Base() {}로 적어야 하며(= 0과 함수 본문을 클래스 안에서 함께 쓸 수는 없습니다), inline을 빼고 헤더에 두면 여러 번역 단위에 포함될 때 “multiple definition” 링크 에러가 납니다.
실무에서 순수 가상 소멸자가 필요한 경우는 드뭅니다. 대부분의 인터페이스 클래스에는 이미 다른 순수 가상 함수가 있어 추상 클래스가 되므로, 소멸자는 virtual ~Base() = default;로 충분합니다.
protected 소멸자
protected 비가상 소멸자는 기반 클래스 포인터로 삭제하는 코드 자체를 컴파일 에러로 막습니다.
#include <iostream>
class Base {
protected:
~Base() { // protected, 비가상
std::cout << "~Base\n";
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "~Derived\n";
}
};
int main() {
Derived* d = new Derived();
Base* asBase = d;
// delete asBase; // 컴파일 에러: 'Base::~Base()' is protected within this context
delete d; // OK: ~Derived, ~Base
}
이 방식은 vtable을 만들지 않고 잘못된 삭제를 컴파일 시점에 막지만, 그 대가로 기반 클래스 포인터를 통한 다형적 삭제는 아예 불가능해집니다.
C++ Core Guidelines의 C.35 규칙은 “기반 클래스의 소멸자는 public이면서 virtual이거나, protected이면서 비가상이어야 한다”입니다. 두 선택지는 의도가 다릅니다. 기반 클래스 포인터로 객체를 소유하고 삭제할 거라면(플러그인, 도형 목록 같은 이종 컬렉션) public virtual을 씁니다. 기반 클래스가 공통 구현을 물려주는 믹스인 역할일 뿐 포인터로 소유할 일이 없다면(CRTP 기반 클래스, 정책 클래스) protected 비가상을 씁니다.
세 번째 선택지는 상속 자체를 막는 것입니다. 상속을 의도하지 않은 클래스라면 class Widget final { ... };으로 선언하세요. final 클래스는 파생될 수 없으므로 가상 소멸자 문제가 원천적으로 생기지 않습니다. 표준 라이브러리 컨테이너를 상속해 쓰는 흔한 실수(class MyVector : public std::vector<int>)도 같은 이유로 피해야 합니다. std::vector의 소멸자는 가상이 아니라서, std::vector<int>*로 MyVector를 삭제하면 이 글의 문제가 그대로 생깁니다.
shared_ptr로 관리할 때의 예외
std::make_shared<Derived>()나 std::shared_ptr<Base>(new Derived)로 만든 경우에는 제어 블록이 생성 시점의 Derived 타입 삭제자를 기억하므로, 가상 소멸자가 없어도 Derived 소멸자가 호출됩니다. 하지만 std::unique_ptr<Base>나 delete basePtr은 이런 정보를 갖지 않아 여전히 미정의 동작이며, shared_ptr<Base>(basePtr)처럼 이미 Base*로 바뀐 값을 넘겨도 보호받지 못합니다. 이 동작은 생성 방식에 따라 달라지는 우연에 가까우므로, 다형적으로 삭제될 기반 클래스에는 가상 소멸자를 두는 것이 원칙입니다.
성능 오버헤드
#include <iostream>
class NonVirtual {
int x;
};
class Virtual {
int x;
public:
virtual ~Virtual() {}
};
int main() {
std::cout << sizeof(NonVirtual) << '\n'; // 4
std::cout << sizeof(Virtual) << '\n'; // 16 (vtable 포인터 8 + int 4 + 패딩 4)
}
sizeof 값은 일반적인 64비트 플랫폼 기준입니다(32비트라면 vtable 포인터가 4바이트라 8). 크기 증가는 클래스에 가상 함수가 처음 생길 때 한 번만 일어나므로, 이미 다른 가상 함수가 있는 다형 클래스라면 소멸자를 가상으로 바꿔도 크기는 그대로입니다. 비용이 의미 있는 경우는 Point처럼 작고 수백만 개씩 배열로 쓰는 값 타입인데, 그런 타입은 애초에 다형적으로 삭제할 일이 없으니 가상 소멸자를 붙일 이유도 없습니다. 또 가상 함수가 생기면 그 클래스는 더 이상 트리비얼하게 복사 가능한 타입이 아니어서 memcpy로 복사하거나 C 구조체와 메모리 배치를 맞추는 용도로 쓸 수 없게 됩니다.
호출 비용은 delete ptr이 소멸자를 직접 호출하는 대신 vtable을 거쳐 간접 호출한다는 차이뿐입니다. 객체 하나를 삭제할 때 드는 메모리 해제 비용에 비하면 대개 무시할 수준이고, 컴파일러가 실제 타입을 알 수 있는 곳에서는 간접 호출을 직접 호출로 바꾸기도 합니다(devirtualization).
같은 맥락에서 알아 둘 함정이 배열의 다형적 삭제입니다. Base* arr = new Derived[10]; delete[] arr;은 가상 소멸자가 있어도 미정의 동작입니다. delete[]는 원소 크기를 sizeof(Base) 기준으로 계산해 포인터를 이동하는데, Derived가 더 크면 두 번째 원소부터 엉뚱한 주소에서 소멸자를 호출합니다. 다형 객체의 모음은 std::vector<std::unique_ptr<Base>>로 관리하는 것이 정답입니다.
실제로 확인한 세 가지: 경고, unique_ptr, 이동 생성
환경은 Ubuntu 24.04, g++ 13.3, -std=c++20입니다.
컴파일러가 잡는 것과 놓치는 것
소멸자가 가상이 아닌 Base와 int[100]을 소유하는 Derived로, 원시 포인터 삭제와 unique_ptr<Base> 소멸을 함께 실행했습니다.
#include <iostream>
#include <memory>
struct Base { virtual void run() {} ~Base() { std::cout << "~Base\n"; } };
struct Derived : Base {
int* data = new int[100];
~Derived() { delete[] data; std::cout << "~Derived\n"; }
};
int main() {
Base* p = new Derived;
delete p; // (1)
std::unique_ptr<Base> q = std::make_unique<Derived>(); // (2)
}
-Wall로 컴파일하면 (1)에만 경고가 나옵니다.
v1.cpp:7:5: warning: deleting object of polymorphic class type 'Base' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
(2)의 unique_ptr<Base>는 같은 문제인데도 경고가 없었습니다. 실행 결과는 다음과 같습니다.
~Base
~Base
~Derived가 한 번도 출력되지 않았고, data로 할당한 배열도 해제되지 않았습니다. 같은 코드를 -fsanitize=address로 빌드하면 new-delete-type-mismatch 오류로 잡힙니다. 정리하면 경고는 delete 표현식만 잡고 스마트 포인터 안의 삭제는 놓치므로, 다형적으로 쓰이는 베이스에는 경고 유무와 상관없이 가상 소멸자를 직접 붙여야 합니다.
가상 소멸자를 붙이면 이동 생성이 복사로 바뀐다
virtual ~T() = default;처럼 소멸자를 직접 선언하면 컴파일러가 이동 생성자를 자동으로 만들지 않습니다. 멤버가 복사와 이동을 구분해서 출력하도록 만들어 확인했습니다.
struct Tracer { Tracer() = default;
Tracer(const Tracer&) { std::cout << "복사\n"; }
Tracer(Tracer&&) noexcept { std::cout << "이동\n"; } };
struct Plain { Tracer t; };
struct WithVD { Tracer t; virtual ~WithVD() = default; };
struct Fixed { Tracer t; Fixed() = default; Fixed(Fixed&&) = default;
Fixed(const Fixed&) = default; virtual ~Fixed() = default; };
Plain: 이동
WithVD: 복사
Fixed: 이동
가상 소멸자만 추가한 WithVD는 이동해도 멤버가 복사됩니다. 컴파일 오류도 경고도 없이 성능만 나빠지므로 눈에 띄지 않습니다. 가상 소멸자를 붙일 때 이동 생성자와 복사 생성자를 = default로 함께 선언하면 Fixed처럼 이동이 유지됩니다. 기본 생성자도 사라지므로 Fixed() = default;를 같이 적어야 합니다.