소멸자에서 예외가 발생할 때 문맥에 따라 어떤 일이 벌어지는지, `noexcept`, 스택 언와인딩, `std::terminate`의 동작을 통해 살펴봅니다.
최근에 저는 The Dev Ladder에서 우리 일이 주는 즐거움을 찾는 것의 중요성에 대해 글을 썼습니다. 숙련도와 깊은 이해는 그 즐거움을 찾는 핵심 요소이며, 특히 이제는 코드 생성 비용이 저렴해졌고 점점 더 자주 AI가 우리보다 더 잘 해내는 지금 더욱 그렇습니다.
그러다 한 가지 기억이 떠올랐습니다. 저는 면접에서 — 코드 리뷰 과제의 일부로 — 소멸자가 예외를 던지면 무슨 일이 일어나는지 자주 묻습니다. 너무 많은 지원자들, 심지어 시니어 포지션 면접자들조차도 이 질문에 답하지 못합니다. 대부분은 그게 나쁜 관행이라고 말하지만 왜 그런지는 설명하지 못합니다. 어떤 사람들은 프로그램이 종료될 수 있다고 말합니다. 자세한 답변을 듣는 일은 드뭅니다.
이게 결정적인 탈락 사유라고 말하는 것은 아니지만, 분명 도움이 되지는 않습니다.
실제로 무슨 일이 일어나는지 살펴봅시다.
소멸자는 RAII 관용구를 구현하는 핵심입니다. RAII가 중요한 이유는 리소스를 획득한 뒤에 상황이 나빠질 수 있기 때문입니다. 함수가 일찍 반환해야 할 수도 있고, 예외를 던질 수도 있습니다. 리소스가 해제되도록 보장하는 일은 번거롭고, 이를 가장 깔끔하게 달성하는 방법은 획득과 해제를 모두 자동으로 처리하는 객체로 감싸는 것입니다.
하지만 해제 자체가 성공하지 못한다면 어떨까요?
소멸자에는 반환값이 없기 때문에 오류 보고 방식은 제한적입니다. 일반적인 선택지는 로깅, 오류 상태 저장, 또는 (권장되지 않는) 예외 던지기입니다.
왜 예외를 던지는 것을 권장되지 않는다고 표시했을까요?
예외가 던져지면 런타임 스택 언와인딩이 시작됩니다.
먼저 현재 스코프의 자동 객체들이 역순으로 파괴되고, 그 소멸자들이 실행됩니다.
언와인딩 도중 또 다른 예외가 던져지면 std::terminate가 호출됩니다.
일치하는 예외 처리기가 발견되면 그곳에서 실행이 계속됩니다.
먼저 예외 처리가 진행 중이 아닌 단순한 예제부터 보겠습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// https://godbolt.org/z/zn9b19jao
#include <iostream>
struct A {
~A() {
std::cout << "Destructor\n";
throw std::runtime_error("boom");
}
};
int main() {
try {
A a;
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << "\n";
}
}
단계별로 살펴보면 다음과 같습니다:
try 블록에 진입합니다A a가 생성됩니다a의 스코프가 끝나고 ~A()가 호출됩니다A::~A()가 예외를 던집니다그리고 나서…
여기서 잠시 멈추고 중요한 규칙 하나를 떠올려야 합니다:
C++11 이후로 소멸자는 별도로 선언하지 않거나 기반 클래스 또는 멤버의 소멸자가 예외를 던질 수 있는 경우가 아닌 한 암묵적으로
noexcept(true)입니다.
예외가 우리의 noexcept 소멸자 밖으로 빠져나가려 하면 noexcept 보장이 위반되므로 std::terminate가 호출됩니다. catch 블록에는 절대 도달하지 못합니다.
소멸자가 예외를 던질 수 있도록 허용하고 싶다면 어떻게 해야 할까요?
예제를 조금 수정해서 소멸자에 noexcept(false)를 붙여 예외 가능하도록 표시해봅시다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// https://godbolt.org/z/KKhErsv6Y
#include <iostream>
struct A {
~A() noexcept(false) {
std::cout << "Destructor\n";
throw std::runtime_error("boom");
}
};
int main() {
try {
A a;
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << "\n";
}
}
이 경우에는 예외가 정상적으로 전파되고 catch 블록이 이를 받아냅니다.
즉, 소멸자는 명시적으로 noexcept(false)로 표시되어 있는 한 예외를 던질 수 있습니다.
저는 _할 수 있다_고 했지, _해야 한다_고 하지는 않았습니다. 그리고 여기에는 치명적인 단서가 있습니다.
스택 언와인딩 중에 소멸자가 예외를 던지면 어떨까요? 예제를 조금만 바꿔보겠습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://godbolt.org/z/a1n75Tb4c
#include <iostream>
struct A {
~A() noexcept(false) {
std::cout << "Destructor throwing\n";
throw std::runtime_error("boom");
}
};
int main() {
try {
A a;
throw std::runtime_error("original");
} catch (...) {
std::cout << "caught\n";
}
}
무슨 일이 일어나는지 단계별로 살펴보겠습니다:
try 블록에 진입합니다throw std::runtime_error("original")이 던져지고 스택 언와인딩이 시작됩니다A::~A()가 호출됩니다A::~A()가 두 번째 예외를 던지고 — std::terminate()가 호출됩니다이 종료는 C++ 표준에 의해 강제되며 어떤 catch 블록에도 도달하지 못합니다.
이 규칙이 존재하는 이유는 그렇지 않으면 런타임이 여러 개의 동시 전파를 추적해야 하고, 이는 복잡할 뿐 아니라 거의 확실하게 모호해지기 때문입니다.
소멸자가 예외를 던질 때 결과는 문맥에 따라 달라집니다.
다른 활성 예외가 없고 소멸자가 명시적으로 noexcept(false)로 표시되어 있다면, 예외는 정상적으로 전파되며 잡을 수도 있습니다. 하지만 이것은 말 그대로 예외적인 경우입니다. C++11 이후로 소멸자는 암묵적으로 noexcept(true)이므로, 예외를 던지는 소멸자는 기본적으로 std::terminate를 호출하고 어떤 catch 블록도 완전히 우회합니다.
진짜 위험한 경우는 두 번째 시나리오입니다. 즉, 이미 스택 언와인딩이 진행 중인 동안 소멸자가 예외를 던지는 경우입니다. noexcept(false)가 붙어 있더라도 이 경우는 항상 std::terminate를 호출합니다. 언와인딩 중인 소멸자에서 예외가 빠져나가도록 둘 수는 없습니다 — 표준이 그것을 허용하지 않기 때문입니다.
이것이 바로 통념이 유지되는 이유입니다. 소멸자는 예외를 던지지 말아야 합니다. 리소스 해제가 실패한다면, 대안인 오류 로깅, 오류 플래그 설정, 또는 나중에 점검할 수 있도록 실패 상태를 저장하는 방식이 소멸자 밖으로 예외를 전파하는 것보다 훨씬 안전합니다. noexcept(false)라는 옵트아웃은 존재하지만, 신중한 처리가 필요하며 소멸자가 스택 언와인딩 중에는 절대 호출되지 않는다고 보장할 수 있을 때에만 사용해야 합니다.
의도적으로 소멸자에서 예외를 던지는 코드베이스를 본 적이 있나요? 오류 처리는 어떤 구조로 되어 있었나요?
이 글이 마음에 드셨다면,