C++ 소멸자 완전 정리 | 가상 소멸자·RAII·규칙 0·예외까지 실무 패턴
이 글의 핵심
다형 삭제와 가상 소멸자 트레이드오프, 객체 파괴 순서와 멤버·기저 순서, Rule of Five와 = default, 소멸자 스택 풍선화까지 현장 디버깅에 바로 연결되는 내용입니다.
들어가며
C++에서 객체가 소멸될 때 어떤 일이 벌어질까요?
이 글에서는 세 가지 핵심 주제를 다룹니다:
- 소멸자 호출 순서 - 파생 클래스와 기저 클래스, 멤버 변수들이 어떤 순서로 정리되는지
- 가상 소멸자 - 언제 필요하고, 왜 빼먹으면 메모리 누수가 나는지
- RAII 패턴 - 자원을 자동으로 안전하게 정리하는 방법
💡 지금 당장
virtual destructor에러를 해결하고 싶다면 가상 소멸자 에러 해결을 먼저 보세요.
1. 소멸자가 호출되는 순서
기본 규칙
객체가 소멸될 때(스택 변수가 범위를 벗어나거나 delete 호출 시) 다음 순서로 진행됩니다:
- 파생 클래스 소멸자 본문 실행
- 파생 클래스 멤버 변수들 선언 역순으로 소멸
- 기저 클래스 소멸자 본문 실행
- 기저 클래스 멤버 변수들 선언 역순으로 소멸
왜 중요한가?
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 ptr는 Base 소멸자만 호출합니다Derived소멸자는 호출되지 않음 →data가 정리되지 않아 메모리 누수- C++ 표준에서는 이를 미정의 동작(undefined behavior)이라고 합니다
- 실행할 때마다 결과가 달라질 수 있음
- 때로는 잘 동작하는 것처럼 보이다가 갑자기 크래시
해결 방법
class Base {
public:
virtual ~Base() = default; // ✅ 가상 소멸자
};
단 한 단어 virtual만 추가하면 됩니다. 이제 delete ptr는 올바른 소멸자(Derived → Base 순)를 호출합니다.
관련 에러 해결법: 가상 소멸자 에러 해결
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)
정리
소멸자는 클래스 설계에서 마지막에 보이지만 가장 중요한 규약입니다.
핵심 원칙:
- 기저 클래스로 delete한다면 virtual 소멸자 필수
- Rule of 0을 목표로 스마트 포인터 활용
- 소멸자에서 예외 금지, noexcept 선언
- 멤버 소멸 순서 이해하고 의존성 관리
- 문서화: 특수한 패턴(protected 소멸자 등) 사용 시 명확히 기록
호출 순서 위반과 다형 삭제 실수 한 번이 몇 시간의 디버깅으로 이어질 수 있습니다. 팀에서는 패턴 카탈로그와 체크리스트를 만들어 PR 리뷰에서 자동으로 검토하는 것을 권장합니다.