C++ 소멸자와 RAII: 가상 소멸자, 규칙 0, 소멸자에서의 예외
이 글의 핵심
다형 삭제와 가상 소멸자 트레이드오프, 객체 파괴 순서와 멤버·기저 순서, Rule of Five와 = default, 소멸자와 스택 되감기(stack unwinding) 중 예외까지 현장 디버깅에 바로 연결되는 내용입니다.
들어가며
C++에서 객체가 소멸될 때 어떤 일이 벌어질까요?
이 글에서는 세 가지 핵심 주제를 다룹니다:
- 소멸자 호출 순서 - 파생 클래스와 기저 클래스, 멤버 변수들이 어떤 순서로 정리되는지
- 가상 소멸자 - 언제 필요하며, 왜 빼먹으면 메모리 누수가 나는지
- RAII 패턴 - 자원을 자동으로 안전하게 정리하는 방법
지금 당장
virtual destructor에러를 해결하고 싶다면 가상 소멸자 에러 해결을 먼저 보세요.
소멸자가 호출되는 순서
기본 규칙
객체가 소멸될 때(스택 변수가 범위를 벗어나거나 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를 참조할 때, 선언 순서를 바꾸면 소멸 순서도 바뀌어 “이미 죽은 객체”를 참조하는 버그가 생깁니다. 이런 버그는 재현이 어려워 디버깅이 힘듭니다.
이 순서는 생성 순서의 정확한 역순입니다. 생성은 기저 → 멤버(선언 순) → 파생 생성자 본문 순서로 진행되므로, 소멸은 파생 소멸자 본문 → 멤버(선언 역순) → 기저 순서가 됩니다. “나중에 만들어진 것은 먼저 만들어진 것에 의존할 수 있다”는 가정을 지키기 위한 규칙입니다. 그래서 소멸자 본문 안에서는 자기 멤버와 기저 부분이 모두 살아 있다고 믿어도 됩니다. 문제가 생기는 곳은 본문이 아니라 멤버들의 소멸자끼리의 관계입니다. 예를 들어 스레드를 멤버로 가진 클래스에서 스레드가 쓰는 큐 멤버가 스레드보다 뒤에 선언되어 있으면, 큐가 먼저 파괴된 뒤 스레드 멤버의 소멸(또는 join)이 일어나는 동안 스레드가 이미 파괴된 큐에 접근합니다. 멤버 선언 순서를 “의존되는 것이 위, 의존하는 것이 아래”로 두는 습관이 이런 버그를 막습니다.
비가상 소멸자의 위험
문제 상황
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)이라고 합니다
- 실행할 때마다 결과가 달라질 수 있음
- 때로는 잘 동작하는 것처럼 보이다가 갑자기 크래시
“누수” 정도로 끝나지 않는 경우도 있습니다. 다중 상속에서 Base가 첫 번째 기저가 아니면 Base*의 주소가 실제 객체 시작 주소와 달라서, delete가 할당받지 않은 주소를 해제하려 하고 free(): invalid pointer 같은 에러로 즉시 죽습니다. GCC·Clang은 이런 코드에 -Wdelete-non-virtual-dtor 경고(-Wall에 포함)를 내므로, 경고를 에러로 다루는 빌드 설정이 있으면 대부분 컴파일 단계에서 잡힙니다.
해결 방법
class Base {
public:
virtual ~Base() = default; // ✅ 가상 소멸자
};
단 한 단어 virtual만 추가하면 됩니다. 이제 delete ptr는 올바른 소멸자(Derived → Base 순)를 호출합니다.
관련 에러 해결법: 가상 소멸자 에러 해결
가상 소멸자가 필요한 경우
필수로 필요한 상황
상황 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)이 하나 생기고, 객체마다 vtable을 가리키는 포인터(vptr, 64비트에서 보통 8바이트)가 추가됩니다. 이미 다른 가상 함수가 있는 클래스라면 vptr이 이미 있으므로 가상 소멸자의 추가 비용은 사실상 없습니다. 비용이 의미 있는 경우는 가상 함수가 전혀 없던 작은 값 타입(예: 수백만 개를 배열로 저장하는 Point)에 가상 소멸자만 붙이는 경우인데, 그런 타입은 애초에 다형적으로 삭제될 이유가 없으므로 가상 소멸자를 두지 않는 것이 맞습니다. std::vector나 std::string 같은 표준 컨테이너에 가상 소멸자가 없는 것도 같은 이유이며, 이들을 public 상속해 기저 포인터로 삭제하면 안 됩니다.
대안: 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> 같은 코드를 작성하면 컴파일 에러가 날 수 있습니다.
이 패턴은 C++ Core Guidelines C.35의 권고(“기저 클래스 소멸자는 public virtual이거나 protected non-virtual이어야 한다”)에서 나왔습니다. CRTP 기저나 믹스인(mixin)처럼 “기능을 물려주기 위한 기저”는 다형적으로 다룰 일이 없으므로, vptr 비용 없이 잘못된 삭제만 컴파일 단계에서 막을 수 있습니다. 컴파일 에러 메시지도 'Base::~Base()' is protected within this context처럼 명확해서, 잘못 쓴 쪽이 즉시 알 수 있습니다.
순수 가상 함수와 순수 가상 소멸자
추상 기저 클래스에서 소멸자를 순수 가상으로 만들 수 있습니다:
class AbstractBase {
public:
virtual ~AbstractBase() = 0; // 순수 가상 소멸자
};
// ❗ 중요: 순수 가상이어도 정의는 필요!
AbstractBase::~AbstractBase() = default;
왜 정의가 필요한가?
- 파생 클래스 소멸자가 실행된 후 기저 소멸자도 호출되기 때문
- 정의를 빼먹으면 링크 에러 발생
순수 가상 소멸자는 “이 클래스를 추상 클래스로 만들고 싶은데 순수 가상으로 만들 다른 함수가 마땅히 없을 때” 쓰는 기법입니다. 다른 순수 가상 함수가 이미 있다면 굳이 소멸자를 순수 가상으로 만들 필요 없이 virtual ~AbstractBase() = default;로 충분합니다. 정의를 빠뜨렸을 때의 링크 에러는 undefined reference to 'AbstractBase::~AbstractBase()' 형태로, 선언만 보고는 원인을 떠올리기 어려워 처음 겪으면 당황하기 쉽습니다.
소멸자에서 피해야 할 것들
예외를 바깥으로 던지지 않기
class BadExample {
File* file;
public:
~BadExample() {
if (!file->close()) {
throw std::runtime_error("Close failed"); // ❌ 위험!
}
}
};
왜 위험한가?
- C++11부터 소멸자는 암묵적으로
noexcept입니다. 그래서 위 코드는 다른 예외와 상관없이throw가 소멸자 밖으로 나가는 순간std::terminate()가 호출됩니다. GCC는throw will always call terminate()경고를 냅니다 noexcept(false)를 붙여 예외를 허용하더라도, 다른 예외 때문에 스택을 되감는 중에 소멸자가 또 예외를 던지면 예외가 두 개 동시에 존재하게 되어 역시std::terminate()로 프로그램이 강제 종료됩니다
프로덕션에서 이 문제는 “예외 로그 없이 프로세스가 terminate called after throwing an instance of 'std::runtime_error'만 남기고 죽는” 형태로 나타납니다. 스택 되감기 도중 여러 객체가 연쇄적으로 소멸하므로, 크래시 덤프의 콜스택에는 원래 예외를 던진 곳이 아니라 소멸자가 보이는 경우가 많습니다.
올바른 방법: 실패를 호출자에게 알려야 하는 정리 작업(파일 flush, 트랜잭션 커밋)은 명시적인 close()를 public으로 제공해 거기서 예외나 에러 코드를 돌려주고, 소멸자는 close()가 호출되지 않았을 때를 대비한 마지막 안전망으로만 둡니다. std::fstream도 이 방식을 따라, 소멸자는 조용히 닫고 에러를 알고 싶으면 close() 후 스트림 상태를 확인하게 되어 있습니다.
class GoodExample {
File* file;
public:
~GoodExample() noexcept { // noexcept 명시
try {
if (file) file->close();
} catch (...) {
// 로깅만 하고 예외는 흡수
std::cerr << "File close failed in destructor\n";
}
}
};
이미 파괴된 멤버에 접근하지 않기
// Connection은 소멸자에서 Logger를 사용한다고 가정 (둘 다 완전한 타입으로 정의되어 있음)
class Session {
Connection conn_; // 먼저 선언 → 나중에 소멸
Logger logger_; // 나중에 선언 → 먼저 소멸
public:
~Session() {
logger_.log("세션 종료"); // ✅ 본문에서는 모든 멤버가 살아 있음
}
// 본문이 끝난 뒤: logger_ 소멸 → conn_ 소멸
// conn_의 소멸자가 logger_를 참조하고 있었다면 ⚠️ 이미 파괴된 logger_ 사용
};
해결: 소멸자 본문에서는 모든 멤버가 살아 있으므로 안전합니다. 위험한 것은 한 멤버의 소멸자가 다른 멤버를 참조하는 경우입니다. 의존되는 멤버(logger_)를 먼저 선언해 나중에 소멸되게 하거나, 멤버끼리 원시 참조로 얽히지 않게 설계합니다. 이 버그는 AddressSanitizer로 빌드하면 heap-use-after-free 또는 stack-use-after-scope로 정확한 위치가 보고되므로, 종료 시점에만 가끔 죽는 프로그램은 ASan 빌드로 먼저 돌려 보는 것이 좋습니다.
무거운 I/O나 동기 작업 피하기
class Logger {
public:
~Logger() {
// ❌ 피해야 할 패턴
saveToDatabase(); // 네트워크 I/O, 시간 오래 걸림
syncWithCloud(); // 블로킹 작업
}
};
문제:
- 프로그램 종료 시 다른 싱글톤들도 같이 파괴되는 중일 수 있음
- 데드락이나 예상치 못한 크래시 발생 가능
대안: 명시적인 close() 메서드를 제공하며, 소멸자는 최소한의 정리만 수행
전역·정적 객체의 소멸 순서는 같은 번역 단위 안에서는 생성의 역순이지만, 서로 다른 .cpp 파일에 있는 전역 객체끼리는 정해져 있지 않습니다. 그래서 전역 Logger의 소멸자가 다른 파일의 전역 Database를 부르면 이미 파괴된 객체를 쓰게 될 수 있습니다(static destruction order fiasco). 프로그램 종료 직전에만 가끔 크래시가 나고 콜스택에 __cxa_finalize나 exit가 보인다면 이 문제를 의심해 볼 만합니다. 흔한 해결책은 main 끝에서 명시적으로 종료 순서를 제어하거나, 끝까지 살아 있어야 하는 객체는 일부러 해제하지 않는 것(static Logger& get() { static auto* p = new Logger; return *p; })입니다.
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).
실무 함정: 소멸자만 정의하고 복사·이동을 방치하면 얕은 복사 문제 발생
ResourceOwner에서 소멸자만 쓰고 나머지를 지우면, 컴파일러가 만든 기본 복사 생성자가 resource 포인터 값을 그대로 복사합니다. 복사본과 원본이 같은 Resource를 가리키다가 둘 다 소멸할 때 두 번 delete되어 double free or corruption으로 죽습니다. 소멸자를 사용자가 선언하면 이동 연산은 암묵적으로 생성되지 않는다는 점도 함께 기억해야 합니다. 이동을 기대하고 std::move로 넘겨도 복사가 선택되어 같은 이중 해제가 일어납니다. 대부분의 경우 정답은 Resource*를 std::unique_ptr<Resource>로 바꿔 Rule of 0으로 돌아가는 것입니다.
스마트 포인터와 소멸자
unique_ptr와 가상 소멸자
std::unique_ptr<Base> ptr = std::make_unique<Derived>();
// ptr이 스코프를 벗어날 때 자동으로 delete
// ✅ Base에 가상 소멸자가 있으면 Derived 소멸자도 호출됨
주의: Base에 가상 소멸자가 없으면 여전히 문제 발생! unique_ptr<Base>의 기본 삭제자는 delete static_cast<Base*>(p)를 호출할 뿐이기 때문입니다. 흥미롭게도 std::shared_ptr<Base> p = std::make_shared<Derived>();는 생성 시점에 Derived를 삭제하는 방법을 제어 블록에 기록하므로 가상 소멸자 없이도 Derived 소멸자가 호출됩니다. 하지만 shared_ptr<Base>(static_cast<Base*>(new Derived))처럼 한 번 Base*로 바꾼 뒤 넘기면 그 정보가 사라지므로, 이 동작에 기대는 대신 가상 소멸자를 두는 것이 안전합니다.
순환 참조 주의
class Node {
std::shared_ptr<Node> next; // 다음 노드
std::shared_ptr<Node> prev; // ❌ 순환 참조!
// prev는 weak_ptr로 만들어야 함
};
해결: 한쪽은 weak_ptr로 변경하여 순환 참조 방지
실무 체크리스트
- 기저 클래스로
delete할 수 있다면 소멸자를virtual로 선언했는가? - 사용자 정의 소멸자를 만들었다면 복사·이동도 검토했는가? (Rule of 5)
- 소멸자에서 예외를 던지지 않는가? (
noexcept확인) - 소멸자에서 무거운 I/O 작업을 피했는가?
- 멤버 변수 선언 순서가 소멸 순서에 영향을 주는가?
- 스마트 포인터로 자원 관리를 단순화할 수 있는가? (Rule of 0)
소멸자 실수를 도구로 잡기
호출 순서 위반이나 다형 삭제 실수는 리뷰에서 눈으로 잡기 어렵고, 증상도 누수나 드문 종료 시 크래시처럼 늦게 나타납니다. GCC의 -Wall에 포함된 -Wdelete-non-virtual-dtor는 가상 함수가 있는데 소멸자가 가상이 아닌 클래스를 그 포인터로 delete하는 코드를 경고하고, clang-tidy의 cppcoreguidelines-virtual-class-destructor는 그런 클래스 정의 자체를 짚어 줍니다. 여기에 테스트 빌드를 AddressSanitizer로 돌리면 파생 소멸자가 불리지 않아 생긴 누수와, 이미 소멸한 멤버를 다른 멤버의 소멸자에서 건드리는 use-after-free가 함께 드러납니다. 사람이 매번 기억하기보다 이런 검사를 CI 빌드 옵션에 고정해 두는 편이 확실합니다.
같이 보면 좋은 글
- C++ 가상 소멸자가 없을 때 생기는 누수: virtual·protected 소멸자 선택 기준
- C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
- C++ vtable과 vptr: 가상 함수 호출이 동작하는 방식과 메모리·호출 비용