C++ 소멸자에 virtual 키워드를 붙이는 이유
C++에서 virtual 키워드는 다형성(Polymorphism)을 구현하는 핵심적인 메커니즘입니다. 멤버 함수에 virtual을 붙이면, 해당 함수는 컴파일 시점이 아닌 런타임에 호출될 함수가 결정됩니다. 소멸자 역시 함수이므로 virtual을 붙일 수 있으며, 특히 상속 관계에서 이는 단순한 선택이 아닌 필수적인 규칙이 될 때가 많습니다.
1. C++의 다형성 (Polymorphism)
문제를 이해하기 위해서는 C++의 다형성에 대한 이해가 필요합니다.
- 다형성: "여러 개의 형태를 갖는다"는 의미로, 부모 클래스의 포인터나 참조를 통해 자식 클래스의 객체를 다룰 수 있는 객체 지향 프로그래밍의 특징입니다.
- 업캐스팅 (Up-casting): 자식 클래스의 포인터(또는 참조)를 부모 클래스의 포인터(또는 참조)로 변환하는 것. 이는 항상 안전하며 암시적으로 허용됩니다.
class GameEntity { ... };
class Player : public GameEntity { ... };
class Monster : public GameEntity { ... };
// 업캐스팅: 자식(Player, Monster) 객체를 부모(GameEntity) 포인터로 다룸
GameEntity* entity1 = new Player();
GameEntity* entity2 = new Monster();
- 동적 바인딩 (Dynamic Binding):
virtual로 선언된 함수를 포인터나 참조를 통해 호출하면, 포인터의 타입이 아닌 포인터가 실제로 가리키는 객체의 타입에 따라 호출될 함수가 런타임에 결정됩니다.
2. 문제 상황: virtual이 아닌 소멸자
부모 클래스의 포인터로 자식 객체를 다루는 다형적인 상황에서, 만약 부모 클래스의 소멸자가 virtual이 아니라면 어떤 일이 벌어질까요?
#include <iostream>
class Base {
public:
Base() { std::cout << "Base 생성자" << std::endl; }
// virtual이 아닌 소멸자
~Base() { std::cout << "Base 소멸자" << std::endl; }
};
class Derived : public Base {
public:
Derived() {
std::cout << "Derived 생성자" << std::endl;
data = new int[100]; // 자식 클래스에서 리소스 할당
}
~Derived() {
std::cout << "Derived 소멸자" << std::endl;
delete[] data; // 리소스 해제
}
private:
int* data;
};
int main() {
Base* p = new Derived(); // 업캐스팅
delete p; // p를 통해 소멸자 호출
return 0;
}
실행 결과:
Base 생성자
Derived 생성자
Base 소멸자
문제점 분석:
delete p;가 실행될 때, 컴파일러는p의 타입인Base*를 보고Base클래스의 소멸자를 호출하도록 코드를 생성합니다(정적 바인딩).Base의 소멸자만 호출되고,Derived의 소멸자는 호출되지 않습니다.- 결과적으로,
Derived생성자에서 할당했던data배열이 해제되지 않아 메모리 누수(Memory Leak)가 발생합니다.
이것은 C++에서 매우 치명적인 버그의 원인이 됩니다. 메모리뿐만 아니라 파일 핸들, 네트워크 소켓, 뮤텍스 락 등 모든 종류의 리소스가 누수될 수 있습니다.
3. 해결책: virtual 소멸자
이 문제를 해결하는 방법은 부모 클래스의 소멸자를 virtual로 선언하는 것입니다.
class Base {
public:
Base() { std::cout << "Base 생성자" << std::endl; }
// virtual 소멸자
virtual ~Base() { std::cout << "Base 소멸자" << std::endl; }
};
// Derived 클래스는 동일
수정 후 실행 결과:
Base 생성자
Derived 생성자
Derived 소멸자
Base 소멸자
해결 과정 분석:
delete p;가 실행될 때, 컴파일러는Base의 소멸자가virtual임을 확인합니다.- 이제 소멸자 호출은 정적 바인딩이 아닌 동적 바인딩으로 처리됩니다.
- 런타임에
p가 실제로 가리키는 객체는Derived타입이므로,Derived의 소멸자가 호출됩니다. - 자식 클래스의 소멸자 실행이 끝나면, 자동으로 부모 클래스의 소멸자가 연쇄적으로 호출됩니다.
Derived의 소멸자에서delete[] data;가 정상적으로 실행되어 리소스 누수 문제가 해결됩니다.
4. 내부 구조: 가상 테이블 (Virtual Table)
virtual 키워드가 어떻게 동적 바인딩을 가능하게 하는지 이해하려면 가상 테이블(vtable)과 가상 포인터(vptr)의 개념을 알아야 합니다.
- 가상 테이블 (vtable): 클래스에
virtual함수가 하나라도 있으면, 컴파일러는 해당 클래스에 대한 가상 테이블이라는 정적 배열을 생성합니다. 이 테이블에는 해당 클래스의virtual함수들의 실제 주소가 저장됩니다. - 가상 포인터 (vptr):
virtual함수를 가진 클래스의 객체가 생성될 때, 객체의 메모리 레이아웃 맨 앞부분에 가상 포인터(vptr)라는 숨겨진 포인터가 추가됩니다. 이 포인터는 해당 객체의 클래스에 맞는 vtable을 가리킵니다. - 동작 방식:
Base* p = new Derived();가 실행되면,Derived객체가 생성되고 이 객체의 vptr은Derived클래스의 vtable을 가리킵니다.delete p;가 실행될 때,Base의 소멸자가virtual이므로, 프로그램은p가 가리키는 객체의 vptr을 따라가서 vtable을 찾습니다.- vtable에서 소멸자에 해당하는 항목을 찾아 그 주소에 있는 함수를 호출합니다.
p의 vptr은Derived의 vtable을 가리키고 있었으므로, 결국Derived의 소멸자가 호출되는 것입니다.
계층 구조에서의 동작 요약
- 사용자 코드:
Base* p = new Derived(); delete p; - 컴파일러:
Base의 소멸자가virtual인 것을 확인.delete p를 특정 소멸자(~Base()) 호출이 아닌,p의 vptr을 통해 vtable에서 소멸자 주소를 찾아 호출하는 간접 호출 코드로 번역. - 런타임:
new Derived():Derived객체 메모리 할당 ->vptr이Derived의 vtable을 가리키도록 설정 ->Base생성자 호출 ->Derived생성자 호출.delete p:p의 vptr을 따라Derived의 vtable에 접근 -> vtable에서 소멸자 주소(~Derived()) 획득 ->~Derived()호출 ->~Derived()실행 종료 후 자동으로~Base()호출 -> 메모리 해제.
5. 결론 및 핵심 규칙
- 언제 virtual 소멸자를 사용해야 하는가?
클래스를 다형적 부모 클래스로 사용하려면, 소멸자는 반드시 public이면서 virtual이어야 한다. 그렇지 않다면, protected이면서 non-virtual이어야 한다.
-
public virtual ~Base(): 이 클래스를 부모 클래스로 상속받아 자식 객체를 부모 포인터로 안전하게delete할 수 있도록 허용하겠다는 의미입니다. 가장 일반적인 경우입니다.protected non-virtual ~Base(): 이 클래스를 상속할 수는 있지만, 부모 포인터로delete하는 행위는 금지하겠다는 의미입니다.delete를 시도하면 컴파일 에러가 발생하여 실수를 방지합니다. 자식 클래스 내부에서만delete this와 같은 형태로 소멸을 제어할 때 사용됩니다.
virtual함수의 오버헤드: vptr로 인한 객체 크기 증가(포인터 크기만큼)와 vtable을 통한 간접 호출로 인한 약간의 성능 저하가 있지만, 대부분의 애플리케이션에서 이는 무시할 수 있는 수준입니다. 다형성으로 얻는 유연성과 안정성이 훨씬 더 중요합니다.- 상속을 의도하지 않은 클래스:
final키워드(C++11)를 사용하여 클래스가 상속되지 않도록 명시할 수 있습니다. 이런 클래스는virtual소멸자가 필요 없습니다.
6. 구술형 요약
부모 클래스의 소멸자에 virtual을 붙이는 이유는 다형적인 상황에서 자식 클래스의 소멸자가 정상적으로 호출되도록 보장하기 위함입니다.
만약 부모 클래스 포인터로 자식 클래스 객체를 가리키고 있다가 delete를 할 때, 부모의 소멸자가 virtual이 아니라면 포인터 타입에 따라 부모의 소멸자만 호출됩니다. 이 경우 자식 클래스에서 할당한 리소스가 해제되지 않아 메모리 누수와 같은 심각한 문제가 발생합니다.
하지만 소멸자를 virtual로 선언하면, delete 시점에 포인터가 실제로 가리키는 객체 타입(자식 클래스)의 소멸자가 동적 바인딩을 통해 먼저 호출됩니다. 그리고 자식 소멸자 실행이 끝나면 부모 소멸자가 자동으로 호출되어, 전체 객체가 안전하게 소멸되는 것이 보장됩니다.
'C++' 카테고리의 다른 글
| std::find() 와 std::binary_search() 차이 (0) | 2025.12.10 |
|---|---|
| 생성자와 소멸자 (0) | 2025.12.08 |
| 객체 복사를 막는 이유와 객체 복사를 막는 방법 (0) | 2025.12.05 |
| std::list가 sort를 멤버 함수로 제공하는 이유 (0) | 2025.12.03 |
| C++ lvalue, rvalue (0) | 2025.12.01 |
