본문으로 건너뛰기
Previous
Next
C++ 소멸자 완전 정리 | 가상 소멸자·RAII·규칙 0·예외까지 실무 패턴

C++ 소멸자 완전 정리 | 가상 소멸자·RAII·규칙 0·예외까지 실무 패턴

C++ 소멸자 완전 정리 | 가상 소멸자·RAII·규칙 0·예외까지 실무 패턴

이 글의 핵심

다형 삭제와 가상 소멸자 트레이드오프, 객체 파괴 순서와 멤버·기저 순서, Rule of Five와 = default, 소멸자 스택 풍선화까지 현장 디버깅에 바로 연결되는 내용입니다.

들어가며

C++에서 객체가 소멸될 때 어떤 일이 벌어질까요?

이 글에서는 세 가지 핵심 주제를 다룹니다:

  1. 소멸자 호출 순서 - 파생 클래스와 기저 클래스, 멤버 변수들이 어떤 순서로 정리되는지
  2. 가상 소멸자 - 언제 필요하고, 왜 빼먹으면 메모리 누수가 나는지
  3. RAII 패턴 - 자원을 자동으로 안전하게 정리하는 방법

💡 지금 당장 virtual destructor 에러를 해결하고 싶다면 가상 소멸자 에러 해결을 먼저 보세요.


1. 소멸자가 호출되는 순서

기본 규칙

객체가 소멸될 때(스택 변수가 범위를 벗어나거나 delete 호출 시) 다음 순서로 진행됩니다:

  1. 파생 클래스 소멸자 본문 실행
  2. 파생 클래스 멤버 변수들 선언 역순으로 소멸
  3. 기저 클래스 소멸자 본문 실행
  4. 기저 클래스 멤버 변수들 선언 역순으로 소멸

왜 중요한가?

class Base {
    std::shared_ptr<Logger> logger;
public:
    ~Base() { 
        logger->log("Base 소멸"); // ✅ logger가 아직 살아있음
    }
};

class Derived : public Base {
    std::shared_ptr<Data> data;
public:
    ~Derived() {
        // ✅ 이 시점에는 data도, Base의 logger도 살아있음
        logger->log(data->name); 
    }
};

실무 함정: 멤버 변수 A가 멤버 변수 B를 참조할 때, 선언 순서를 바꾸면 소멸 순서도 바뀌어 “이미 죽은 객체”를 참조하는 버그가 생깁니다. 이런 버그는 재현이 어려워 디버깅이 힘듭니다.


2. 비가상 소멸자의 위험

문제 상황

class Base {
public:
    ~Base() { std::cout << "Base 소멸\n"; } // 비가상
};

class Derived : public Base {
    std::vector<int> data; // 동적 메모리 할당
public:
    ~Derived() { std::cout << "Derived 소멸\n"; }
};

// 문제 발생 지점
Base* ptr = new Derived();
delete ptr; // ❌ 위험!
// 출력: "Base 소멸"만 나옴 - Derived 소멸자 호출 안 됨!

무엇이 잘못되었나?

  • Base 소멸자가 비가상이면, delete ptrBase 소멸자만 호출합니다
  • Derived 소멸자는 호출되지 않음data가 정리되지 않아 메모리 누수
  • C++ 표준에서는 이를 미정의 동작(undefined behavior)이라고 합니다
    • 실행할 때마다 결과가 달라질 수 있음
    • 때로는 잘 동작하는 것처럼 보이다가 갑자기 크래시

해결 방법

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

한 단어 virtual만 추가하면 됩니다. 이제 delete ptr는 올바른 소멸자(DerivedBase 순)를 호출합니다.

관련 에러 해결법: 가상 소멸자 에러 해결


3. 가상 소멸자가 필요한 경우

필수로 필요한 상황

상황 1: 기저 클래스 포인터로 delete

Base* ptr = new Derived();
delete ptr; // 가상 소멸자 필요!

상황 2: 플러그인/라이브러리 인터페이스

// 라이브러리가 제공
class IPlugin {
public:
    virtual ~IPlugin() = default; // 필수!
    virtual void execute() = 0;
};

// 사용자 코드
class MyPlugin : public IPlugin { 
    /* ... */ 
};

// 라이브러리가 사용자 플러그인을 정리
void cleanup(IPlugin* plugin) {
    delete plugin; // MyPlugin 소멸자가 호출되어야 함
}
struct Base {
    virtual ~Base() = default;
};
struct Derived : Base {
    ~Derived() override = default;
};

비용: 가상 함수 테이블(vtable)이 생성됩니다. 대부분의 경우 무시할 정도로 작지만, 초경량 라이브러리에서는 고려할 수 있습니다.

대안: protected ~Base() (비가상)

언제 쓰는가?

  • 기저 클래스 포인터로 delete하는 것을 아예 막고 싶을 때
  • 파생 클래스만 객체를 정리할 수 있게 하려는 경우
class Base {
protected:
    ~Base() = default; // 비가상 + protected
public:
    // 다른 public 메서드들
};

class Derived : public Base {
public:
    ~Derived() = default; // public 소멸자
};

// Base* ptr = new Derived();
// delete ptr; // ❌ 컴파일 에러! protected 소멸자라 접근 불가

Derived d; // ✅ 스택에서는 OK

주의: 이 패턴을 선택했다면 문서화가 필수입니다. 팀원이 무심코 unique_ptr<Base> 같은 코드를 작성하면 컴파일 에러가 날 수 있습니다.


4. 순수 가상 함수와 순수 가상 소멸자

추상 기저 클래스에서 소멸자를 순수 가상으로 만들 수 있습니다:

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

// ❗ 중요: 순수 가상이어도 정의는 필요!
AbstractBase::~AbstractBase() = default;

왜 정의가 필요한가?

  • 파생 클래스 소멸자가 실행된 후 기저 소멸자도 호출되기 때문
  • 정의를 빼먹으면 링크 에러 발생

실무 팁: 클래스 템플릿 스니펫에 이 패턴을 저장해 두면 실수를 방지할 수 있습니다.


5. 소멸자에서 피해야 할 것들

5.1 예외를 바깥으로 던지지 않기

class BadExample {
    File* file;
public:
    ~BadExample() {
        if (!file->close()) {
            throw std::runtime_error("Close failed"); // ❌ 위험!
        }
    }
};

왜 위험한가?

  • 다른 예외 처리 중에 소멸자가 호출되면, 예외가 두 개 동시에 존재
  • C++에서는 이 경우 std::terminate()를 호출해 프로그램이 강제 종료

올바른 방법:

class GoodExample {
    File* file;
public:
    ~GoodExample() noexcept { // noexcept 명시
        try {
            if (file) file->close();
        } catch (...) {
            // 로깅만 하고 예외는 흡수
            std::cerr << "File close failed in destructor\n";
        }
    }
};

5.2 이미 파괴된 멤버에 접근하지 않기

class Order {
    Customer* customer; // 먼저 선언
    Product* product;   // 나중에 선언
public:
    ~Order() {
        // 소멸 순서: product 먼저 → customer 나중
        product->log(customer->name); // ⚠️ product는 이미 소멸됨!
    }
};

해결: 멤버 변수 선언 순서를 신중하게 결정하거나, 소멸자에서 복잡한 로직을 피하세요.

5.3 무거운 I/O나 동기 작업 피하기

class Logger {
public:
    ~Logger() {
        // ❌ 피해야 할 패턴
        saveToDatabase(); // 네트워크 I/O, 시간 오래 걸림
        syncWithCloud();  // 블로킹 작업
    }
};

문제:

  • 프로그램 종료 시 다른 싱글톤들도 같이 파괴되는 중일 수 있음
  • 데드락이나 예상치 못한 크래시 발생 가능

대안: 명시적인 close() 메서드를 제공하고, 소멸자는 최소한의 정리만 수행


6. Rule of 0 / 3 / 5와 = default

Rule of 0 (가장 이상적)

class GoodClass {
    std::unique_ptr<Resource> resource; // 스마트 포인터 사용
    std::vector<int> data;
    // 소멸자, 복사, 이동 모두 컴파일러 기본값 사용
};

원칙: 자원 관리를 스마트 포인터에 맡기면, 소멸자를 직접 작성할 필요가 없습니다.

Rule of 5 (사용자 정의 필요 시)

class ResourceOwner {
    Resource* resource;
public:
    ~ResourceOwner() { delete resource; }                    // 1. 소멸자
    ResourceOwner(const ResourceOwner&) { /* ... */ }       // 2. 복사 생성자
    ResourceOwner& operator=(const ResourceOwner&) { /* ... */ } // 3. 복사 대입
    ResourceOwner(ResourceOwner&&) noexcept { /* ... */ }   // 4. 이동 생성자
    ResourceOwner& operator=(ResourceOwner&&) noexcept { /* ... */ } // 5. 이동 대입
};

원칙: 5개 중 하나라도 정의하면, 나머지 4개도 명시적으로 처리해야 합니다 (= default 또는 = delete).

실무 함정: 소멸자만 정의하고 복사·이동을 방치하면 얕은 복사 문제 발생


7. 스마트 포인터와 소멸자

unique_ptr와 가상 소멸자

std::unique_ptr<Base> ptr = std::make_unique<Derived>();
// ptr이 스코프를 벗어날 때 자동으로 delete
// ✅ Base에 가상 소멸자가 있으면 Derived 소멸자도 호출됨

주의: Base에 가상 소멸자가 없으면 여전히 문제 발생!

순환 참조 주의

class Node {
    std::shared_ptr<Node> next; // 다음 노드
    std::shared_ptr<Node> prev; // ❌ 순환 참조!
    // prev는 weak_ptr로 만들어야 함
};

해결: 한쪽은 weak_ptr로 변경하여 순환 참조 방지


8. 실무 체크리스트

  • 기저 클래스로 delete할 수 있다면 소멸자를 virtual로 선언했는가?
  • 사용자 정의 소멸자를 만들었다면 복사·이동도 검토했는가? (Rule of 5)
  • 소멸자에서 예외를 던지지 않는가? (noexcept 확인)
  • 소멸자에서 무거운 I/O 작업을 피했는가?
  • 멤버 변수 선언 순서가 소멸 순서에 영향을 주는가?
  • 스마트 포인터로 자원 관리를 단순화할 수 있는가? (Rule of 0)

정리

소멸자는 클래스 설계에서 마지막에 보이지만 가장 중요한 규약입니다.

핵심 원칙:

  1. 기저 클래스로 delete한다면 virtual 소멸자 필수
  2. Rule of 0을 목표로 스마트 포인터 활용
  3. 소멸자에서 예외 금지, noexcept 선언
  4. 멤버 소멸 순서 이해하고 의존성 관리
  5. 문서화: 특수한 패턴(protected 소멸자 등) 사용 시 명확히 기록

호출 순서 위반과 다형 삭제 실수 한 번이 몇 시간의 디버깅으로 이어질 수 있습니다. 팀에서는 패턴 카탈로그체크리스트를 만들어 PR 리뷰에서 자동으로 검토하는 것을 권장합니다.