책/Effective C++

[Effective C++] Cp 7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

Sonji 2026. 5. 26. 11:39

1. 이 항목이 왜 중요한가

C++에서는 기본 클래스(base class) 포인터로 파생 클래스(derived class) 객체를 다루는 일이 아주 흔하다. 다형성(polymorphism)을 쓰는 코드라면 거의 항상 이런 형태가 나온다.

Base* p = new Derived();
// ...
delete p;

겉보기에는 꽤 자연스럽지만, 여기에는 함정이 하나 숨어 있다.

만약 Base의 소멸자가 가상 함수(virtual function)가 아니라면, delete p를 했을 때 무슨 일이 벌어질까?

결론부터 말하면 정의되지 않은 동작(undefined behavior)이 수행된다. 보통은 다음과 같은 일이 일어난다.

  • 객체의 기본 클래스 부분(Base)은 소멸된다.
  • 하지만 파생 클래스 부분(Derived)은 소멸되지 않는다.
  • 결과적으로 객체가 "반쯤만 파괴된" 상태가 된다.

파생 클래스 쪽에서 잡고 있던 메모리, 파일 핸들, 소켓 같은 자원이 있었다면 그대로 새어 나간다.

이런 문제를 방지하려면, 기본 클래스 포인터로 파생 객체를 delete 할 때, 그 기본 클래스의 소멸자는 반드시 virtual이어야 한다.

2. 문제 상황을 코드로 보자

2-1. 시간 기록 클래스 예시

시간을 재는 클래스 계층을 만든다고 해보자. 추상적인 "시간 기록기"가 있고, 그 아래에 구현 방식이 다른 여러 종류가 있다.

class TimeKeeper
{
public:
    TimeKeeper();
    ~TimeKeeper();   // 주의: 가상 소멸자가 아니다
};

class AtomicClock : public TimeKeeper { /* ... */ };  // 원자 시계
class WaterClock  : public TimeKeeper { /* ... */ };  // 물 시계
class WristWatch  : public TimeKeeper { /* ... */ };  // 손목 시계

사용하는 쪽에서는 구체적으로 어떤 시계인지 신경 쓰고 싶지 않다. 그냥 "시간 기록기 하나 주세요"라고 말하고 싶다. 그래서 보통 팩토리 함수(factory function)를 쓴다.

// 호출하는 쪽은 구체 타입을 모른 채, 기본 클래스 포인터만 받는다
TimeKeeper* getTimeKeeper();

이 함수는 내부에서 AtomicClock이든 WaterClock이든 적당한 파생 객체를 new로 만들어서, TimeKeeper*로 돌려준다.

2-2. delete 하는 순간 문제가 터진다

팩토리 함수가 힙(heap)에 객체를 만들어 줬으니, 다 쓰고 나면 delete 해줘야 한다.

TimeKeeper* ptk = getTimeKeeper();  // 실제로는 파생 객체를 가리킨다

// ... ptk 사용 ...

delete ptk;   // 여기서 문제 발생

ptk의 정적 타입은 TimeKeeper*지만, 실제 가리키는 대상은 AtomicClock 같은 파생 객체다.

그런데 TimeKeeper의 소멸자가 virtual이 아니므로, delete는 "정적 타입인 TimeKeeper의 소멸자만" 부르려고 한다. 그 결과 파생 클래스 부분은 정리되지 않고 남는다.

말 그대로 객체가 반쪽만 파괴되는 셈이다. 자원 누수(leak)와 자료구조 오염으로 이어지기 딱 좋은 상황이다.

3. 해결 방법: 기본 클래스 소멸자를 가상으로

3-1. virtual 소멸자 하나면 끝

해결책은 의외로 단순하다. 기본 클래스의 소멸자에 virtual만 붙이면 된다.

class TimeKeeper
{
public:
    TimeKeeper();
    virtual ~TimeKeeper();   // 가상 소멸자
};

TimeKeeper* ptk = getTimeKeeper();

// ...

delete ptk;   // 이제 올바르게 동작한다

이렇게 해두면 delete ptk가 실제 객체의 타입(예: AtomicClock)에 맞는 소멸자를 먼저 부르고, 이어서 기본 클래스 소멸자까지 차례로 호출하기 때문에 객체 전체가 제대로 파괴된다.

3-2. 어떤 클래스에 가상 소멸자가 필요한가

가상 함수를 하나라도 가진 클래스라면, 소멸자도 거의 항상 가상이어야 한다.

가상 함수가 있다는 건 "이 클래스는 파생되어 다형적으로 쓰일 의도"라는 뜻이다. 그렇다면 기본 클래스 포인터로 객체를 delete 할 가능성이 있다는 의미이고, 따라서 소멸자도 가상이어야 앞뒤가 맞는다.

4. 그렇다고 모든 클래스에 virtual을 붙이면 안 된다

여기서 흔히 하는 오해가 있다. "그럼 안전하게 모든 소멸자를 가상으로 만들면 되지 않나?"

가상 함수는 공짜가 아니다.

4-1. vptr이라는 비용

클래스에 가상 함수가 하나라도 생기면, 객체마다 보통 가상 함수 테이블 포인터(vptr, virtual table pointer) 가 따라붙는다. 그만큼 객체 크기가 커진다.

예를 들어 좌표를 표현하는 단순한 클래스를 생각해 보자.

class Point
{
private:
    int x, y;
};

int가 32비트라면 이 객체는 64비트면 충분하다. 다른 언어나 C 구조체와 메모리 배치가 그대로 호환되기도 한다.

그런데 여기에 가상 함수(가상 소멸자 포함)를 하나 넣는 순간, vptr이 추가되면서 객체가 커진다. 32비트 환경이면 96비트, 64비트 포인터 환경이면 128비트로 늘어날 수 있다.

4-2. 호환성 문제

vptr이 끼면 객체의 메모리 배치가 "순수한 데이터 묶음"에서 벗어난다. 그러면 C로 짠 코드나 다른 언어와 같은 메모리 표현을 주고받기 어려워진다. 즉, 외부와 비트 단위로 호환되어야 하는 타입에 함부로 가상 함수를 넣으면 곤란하다.

그래서 정리하면 이렇다.

  • 다형적으로 쓸 기본 클래스다 → 소멸자를 가상으로.
  • 기본 클래스로 쓸 의도가 없거나, 다형적으로 다룰 일이 없다 → 굳이 가상 소멸자를 붙이지 않는다.

5. 표준 컨테이너를 상속하지 말자

이 항목에서 자연스럽게 따라오는 주의사항이 하나 있다.

std::string, std::vector, std::list, std::set 같은 표준 라이브러리 타입들은 소멸자가 가상이 아니다. 이들은 애초에 기본 클래스로 쓰라고 설계된 타입이 아니기 때문이다.

그래서 아래 같은 코드는 위험하다.

class SpecialString : public std::string  // 위험한 발상
{
    // ...
};
std::string* ps = new SpecialString(/* ... */);
delete ps;   // string의 소멸자가 비가상이므로, SpecialString 부분이 누락된다

std::string처럼 비가상 소멸자를 가진 클래스를 상속해서, 기본 클래스 포인터로 delete 하면 항목 7에서 본 그 문제가 똑같이 재현된다. 그러니 비가상 소멸자를 가진 클래스, 그리고 애초에 기본 클래스로 설계되지 않은 클래스는 상속 대상으로 삼지 않는 편이 안전하다.

6. 순수 가상 소멸자라는 기법

가끔 추상 클래스(abstract class)로 만들고 싶은데, 마땅히 순수 가상 함수로 둘 멤버가 없는 경우가 있다. 추상 클래스는 순수 가상 함수(pure virtual function)를 하나라도 가져야 만들어지는데, 딱히 그럴 함수가 없을 때다.

이럴 때 쓸 수 있는 약간의 트릭이 순수 가상 소멸자(pure virtual destructor) 다.

class AWOV   // Abstract Without Virtuals: 가상 함수 없는 추상 클래스
{
public:
    virtual ~AWOV() = 0;   // 순수 가상 소멸자
};

이렇게 하면 AWOV는 추상 클래스가 되어 직접 인스턴스를 만들 수 없게 된다. 동시에 소멸자가 가상이니 다형적 삭제도 안전해진다.

6-1. 단, 정의는 반드시 제공해야 한다

여기서 함정이 하나 있다. 순수 가상 소멸자는 선언만으로는 안 되고, 정의(구현)까지 반드시 제공해야 한다.

AWOV::~AWOV() {}   // 비어 있어도 좋으니, 정의는 꼭 있어야 한다

이유는 소멸 과정의 동작 방식 때문이다. 파생 클래스의 소멸자가 끝나면, 그 다음으로 기본 클래스의 소멸자가 자동으로 호출된다. 즉 AWOV의 소멸자는 실제로 불리게 되어 있고, 정의가 없으면 링크 단계에서 에러가 난다.

앞 cp.6에서 봤던 "선언만 하고 정의는 안 한다"와는 정반대 상황이라, 헷갈리기 쉬운 부분이다. 그쪽은 호출될 일이 없게 막는 게 목적이었고, 이쪽은 반드시 호출되므로 정의가 필요하다.

7. "기본 클래스"라고 다 가상 소멸자가 필요한 건 아니다

마지막으로 짚어둘 점. 이 항목의 규칙은 어디까지나 다형성을 가진 기본 클래스에만 적용된다.

세상의 모든 기본 클래스가 다형적으로 쓰이는 건 아니다. 어떤 기본 클래스는 다형적 삭제를 의도하지 않는다. 예를 들어 항목 6에서 본 Uncopyable 같은 클래스는, 복사 금지라는 기능을 물려주려고 만든 도구일 뿐이지 "기본 클래스 포인터로 다뤄지는 객체"를 만들려는 게 아니다. 표준 라이브러리의 input_iterator_tag 같은 타입들도 비슷하다.

이런 클래스들은 다형적으로 delete 될 일이 없으므로, 가상 소멸자가 필요 없다.

그러니 기계적으로 "기본 클래스니까 무조건 가상 소멸자"가 아니라, "이 객체를 기본 클래스 포인터로 받아서 delete 할 일이 있는가?" 를 기준으로 판단하는 게 맞다.

8. 항목 7 핵심 정리

반드시 기억할 것

  • 다형성을 가진 기본 클래스, 즉 기본 클래스 포인터/참조로 파생 객체를 다루는 클래스에는 반드시 가상 소멸자를 선언해야 한다.
  • 어떤 클래스가 가상 함수를 하나라도 가지고 있다면, 그 클래스의 소멸자도 가상이어야 한다.
  • 기본 클래스로 쓸 의도가 없거나 다형적으로 다룰 일이 없는 클래스에는 가상 소멸자를 붙이지 않는다. (vptr로 인한 크기 증가와 호환성 손실 때문)
  • std::string이나 표준 컨테이너처럼 비가상 소멸자를 가진 타입은 상속 대상으로 삼지 않는다.
  • 순수 가상 소멸자로 추상 클래스를 만들 수 있지만, 이때는 그 소멸자의 정의를 반드시 제공해야 한다.