C++ 가상 소멸자가 없을 때 생기는 누수: virtual·protected 소멸자 선택 기준
이 글의 핵심
다형성으로 쓰는 클래스에 가상 소멸자를 빠뜨리면 컴파일러는 경고 없이 기반 클래스 소멸자만 호출하고, 표준상으로도 정의되지 않은 동작이 됩니다. 반대로 다형적으로 삭제하지 않을 클래스라면 protected 비가상 소멸자로 잘못된 delete 자체를 막는 선택지도 있습니다. 규칙과 체크리스트, shared_ptr로 관리할 때의 예외적인 동작까지 정리했습니다.
들어가며: “파생 클래스를 삭제했는데 메모리 누수가 생겼어요”
C++에서 베이스 클래스 포인터로 파생 클래스를 삭제할 때, 가상 소멸자가 없으면 파생 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생합니다.
// ❌ 가상 소멸자 없음
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; // ❌ ~Derived 호출 안 됨 → 메모리 누수
// 출력: ~Base
}
이 글에서 다루는 것:
- 가상 소멸자가 필요한 이유
- 메모리 누수와 미정의 동작
- 순수 가상 소멸자
- protected 소멸자
가상 소멸자가 필요한 이유
문제: 비가상 소멸자
// ❌ 비가상 소멸자
class Base {
public:
~Base() {
std::cout << "~Base\n";
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "~Derived\n";
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // ❌ 미정의 동작
// 출력: ~Base (파생 클래스 소멸자 호출 안 됨)
}
왜 ~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에 포함) 경고를 냅니다. 하지만 이 예제처럼 가상 함수가 하나도 없는 클래스는 다형적으로 쓰일 의도가 없다고 보고 경고하지 않습니다. 가장 조용히 지나가는 경우가 바로 이 글의 예제들입니다.
해결: 가상 소멸자
// ✅ 가상 소멸자
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” 메시지와 함께 프로그램이 종료되는데, 정리 로직을 기반 클래스 소멸자의 가상 함수 호출로 구현하려다 흔히 만나는 에러입니다.
메모리 누수 예시
예시 1: 동적 할당 메모리
// ❌ 메모리 누수
class Base {
public:
~Base() {}
};
class Derived : public Base {
int* data_;
public:
Derived() : data_(new int[1000000]) {
std::cout << "Allocated 4MB\n";
}
~Derived() {
delete[] data_; // 호출 안 됨!
std::cout << "Freed 4MB\n";
}
};
int main() {
for (int i = 0; i < 100; ++i) {
Base* ptr = new Derived();
delete ptr; // ❌ 4MB 누수 × 100 = 400MB 누수
}
}
이 누수는 크래시가 없어서 오래 숨어 있습니다. 서버 프로세스라면 메모리 사용량이 요청 수에 비례해 조금씩 늘다가 어느 순간 OOM으로 종료되고, 그때는 원인이 된 코드가 어디인지 알기 어렵습니다. 제가 이런 누수를 찾을 때 가장 먼저 쓰는 도구는 AddressSanitizer의 LeakSanitizer(-fsanitize=address)나 Valgrind입니다. 누수된 블록이 Derived::Derived()에서 할당되었다고 스택과 함께 보여 주므로, “생성자에서 할당했는데 소멸자에서 해제되지 않았다”는 단서로 가상 소멸자 누락을 금방 의심할 수 있습니다. 참고로 이 경우 new int[...]로 받은 메모리는 해제되지 않지만, delete ptr이 해제하는 Derived 객체 자체의 메모리는 대부분의 구현에서 해제됩니다. 새는 것은 파생 클래스가 따로 확보한 자원입니다.
예시 2: 파일 핸들
// ❌ 파일 핸들 누수
class Base {
public:
~Base() {}
};
class FileLogger : public Base {
std::ofstream file_;
public:
FileLogger(const std::string& path) : file_(path) {}
~FileLogger() {
file_.close(); // 호출 안 됨!
std::cout << "File closed\n";
}
};
int main() {
Base* ptr = new FileLogger("log.txt");
delete ptr; // ❌ 파일 핸들 누수
}
이 예제는 메모리보다 더 눈에 띄는 증상을 만듭니다. ~FileLogger가 호출되지 않으면 file_.close()만 빠지는 것이 아니라 멤버 std::ofstream의 소멸자도 호출되지 않습니다. 멤버의 소멸자는 소유한 클래스의 소멸자 끝에서 자동으로 호출되는데, 그 소멸자 자체가 불리지 않았으니까요. 결과적으로 버퍼에 남은 로그가 디스크에 기록되지 않아 로그 마지막 부분이 사라지고, 파일 핸들도 프로세스가 끝날 때까지 열려 있습니다. RAII 멤버(std::string, std::vector, std::unique_ptr)를 써서 자원 관리를 잘 해 두었더라도, 가상 소멸자가 없으면 그 RAII가 전부 무력화된다는 점이 이 버그의 무서운 부분입니다.
순수 가상 소멸자
순수 가상 소멸자
순수 가상 소멸자는 클래스를 추상 클래스로 만들지만, 반드시 정의를 제공해야 합니다.
// 순수 가상 소멸자
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
}
사용 시기: 다른 순수 가상 함수 없이 추상 클래스를 만들고 싶을 때.
순수 가상 소멸자에 정의가 필요한 이유는, 파생 클래스의 소멸자가 끝나면 항상 기반 클래스 소멸자를 호출하기 때문입니다. 일반 순수 가상 함수는 파생 클래스가 구현을 대신 제공하지만, 소멸자는 파생 클래스가 대신할 수 없습니다. 정의를 빠뜨리면 컴파일은 되고 링크 단계에서 “undefined reference to Base::~Base()” 에러가 납니다. 헤더에 정의를 두고 싶다면 클래스 밖에 inline Base::~Base() {}로 적어야 하며, inline을 빼고 헤더에 두면 여러 번역 단위에 포함될 때 “multiple definition” 링크 에러가 납니다.
실무에서 순수 가상 소멸자가 필요한 경우는 드뭅니다. 대부분의 인터페이스 클래스에는 이미 다른 순수 가상 함수가 있어 추상 클래스가 되므로, 소멸자는 virtual ~Base() = default;로 충분합니다.
protected 소멸자
protected 소멸자
protected 소멸자는 베이스 클래스 포인터로 삭제를 방지합니다.
// protected 소멸자
class Base {
protected:
~Base() { // protected (비가상)
std::cout << "~Base\n";
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "~Derived\n";
}
};
int main() {
Base* ptr = new Derived();
// delete ptr; // 컴파일 에러: ~Base is protected
Derived* ptr2 = new Derived();
delete ptr2; // OK
}
장점:
- vtable 오버헤드 없음
- 잘못된 삭제 방지
단점:
- 다형성 삭제 불가
C++ Core Guidelines의 C.35 규칙은 “기반 클래스의 소멸자는 public이면서 virtual이거나, protected이면서 비가상이어야 한다”입니다. 두 선택지는 의도가 다릅니다. 기반 클래스 포인터로 객체를 소유하고 삭제할 거라면(플러그인, 도형 목록 같은 이종 컬렉션) public virtual을 씁니다. 기반 클래스가 공통 구현을 물려주는 믹스인 역할일 뿐 포인터로 소유할 일이 없다면(CRTP 기반 클래스, 정책 클래스) protected 비가상을 씁니다. 후자는 잘못된 delete를 “‘Base::~Base()’ is protected within this context” 컴파일 에러로 막아 주고, vtable도 만들지 않습니다.
세 번째 선택지는 상속 자체를 막는 것입니다. 상속을 의도하지 않은 클래스라면 class Widget final { ... };으로 선언하세요. final 클래스는 파생될 수 없으므로 가상 소멸자 문제가 원천적으로 생기지 않고, 표준 라이브러리 컨테이너를 상속해 쓰는 흔한 실수(class MyVector : public std::vector<int>)도 같은 이유로 피해야 합니다. std::vector의 소멸자는 가상이 아니라서, std::vector<int>*로 MyVector를 삭제하면 이 글의 문제가 그대로 생깁니다.
성능 오버헤드
메모리 오버헤드
class NonVirtual {
int x;
};
class Virtual {
int x;
virtual ~Virtual() {}
};
std::cout << sizeof(NonVirtual) << '\n'; // 4
std::cout << sizeof(Virtual) << '\n'; // 16 (vtable 포인터 8 + int 4 + 패딩 4)
호출 오버헤드
// 비가상: 직접 호출
delete ptr; // ~Derived() 직접 호출
// 가상: 간접 호출
delete ptr; // vtable을 통한 간접 호출 (약간 느림)
결론: 오버헤드는 미미하며, 안전성이 훨씬 중요합니다.
sizeof 값은 64비트 플랫폼 기준입니다(32비트라면 vtable 포인터가 4바이트라 8). 크기 증가는 클래스에 가상 함수가 처음 생길 때 한 번만 일어나므로, 이미 다른 가상 함수가 있는 다형 클래스라면 소멸자를 가상으로 바꿔도 크기는 그대로입니다. 비용이 의미 있는 경우는 Point처럼 작고 수백만 개씩 배열로 쓰는 값 타입인데, 그런 타입은 애초에 다형적으로 삭제할 일이 없으니 가상 소멸자를 붙일 이유도 없습니다. 또 vtable 포인터가 생기면 그 클래스는 더 이상 트리비얼 타입이 아니어서 memcpy로 복사하거나 C 구조체와 메모리 배치를 맞추는 용도로 쓸 수 없게 됩니다.
같은 맥락에서 알아 둘 함정이 배열의 다형적 삭제입니다. Base* arr = new Derived[10]; delete[] arr;은 가상 소멸자가 있어도 미정의 동작입니다. delete[]는 원소 크기를 sizeof(Base) 기준으로 계산해 포인터를 이동하는데, Derived가 더 크면 두 번째 원소부터 엉뚱한 주소에서 소멸자를 호출합니다. 다형 객체의 모음은 std::vector<std::unique_ptr<Base>>로 관리하는 것이 정답입니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. shared_ptr 로 파생 객체를 관리하면 가상 소멸자가 없어도 안전한가요?
A. std::make_shared<Derived>()나 std::shared_ptr<Base>(new Derived)로 만든 경우에는 제어 블록이 생성 시점의 Derived 타입 삭제자를 기억하므로, 가상 소멸자가 없어도 Derived 소멸자가 호출됩니다. 하지만 std::unique_ptr<Base>나 delete basePtr은 이런 정보를 갖지 않아 여전히 미정의 동작이며, shared_ptr<Base>(basePtr)처럼 이미 Base 포인터로 바뀐 값을 넘겨도 보호받지 못합니다. 이 동작은 생성 방식에 따라 달라지는 우연에 가까우므로, 다형적으로 삭제될 기반 클래스에는 가상 소멸자를 두는 것이 원칙입니다.