Sonji-log
[Effective C++] Cp 8. 소멸자 밖으로 예외가 빠져나가지 않게 하자 본문
1. 이 항목이 왜 중요한가
소멸자(destructor)는 객체의 마지막 정리 담당자다. 메모리를 해제하고, 파일을 닫고, mutex를 풀고, 데이터베이스 연결을 종료한다. 앞서 Cp 7.에서 소멸자가 제대로 호출되는지가 중요하다고 봤다면, 이번에는 호출된 소멸자가 어떤 식으로 끝나야 하는가를 다룬다.
겉보기에는 이런 코드가 자연스럽다.
class DBConnection
{
public:
static DBConnection create();
void close(); // 실패하면 예외를 던질 수 있다
};
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
~DBConn()
{
db.close(); // 여기서 예외가 날 수 있다
}
private:
DBConnection db;
};
DBConn은 DBConnection을 감싸는 RAII 클래스처럼 보인다. 객체가 사라질 때 데이터베이스 연결도 자동으로 닫아주니 좋아 보인다.
그런데 close()가 실패해서 예외를 던지면 무슨 일이 벌어질까?
평범한 멤버 함수라면 호출자에게 예외를 전달하면 된다. 하지만 소멸자는 사용자가 직접 부르는 경우보다 scope를 벗어나거나 다른 예외를 처리하는 도중에 자동으로 호출되는 경우가 많다. 이때 소멸자 밖으로 예외가 새어 나가면 프로그램은 더 이상 정상적으로 정리 흐름을 이어가기 어렵다.
핵심은 간단하다.소멸자 밖으로 예외가 빠져나가게 두면 안 된다. 소멸자는 예외를 삼키든, 프로그램을 끝내든, 밖으로 흘려보내지 않아야 한다.
2. 문제 상황을 코드로 보자
2-1. 컨테이너가 객체들을 정리하는 경우
소멸자는 우리가 직접 delete를 쓸 때 뿐 아니라, 지역 객체가 scope를 벗어날 때, 컨테이너가 내부 원소를 지울 때, 예외가 발생해서 stack unwinding(이게뭘까? : https://luckygg.tistory.com/372)이 진행될 때도 자동으로 호출된다.
예를 들어 DBConn 객체 여러 개를 std::vector에 넣었다고 해보자.
std::vector<DBConn> connections;
// ... 여러 DBConn 객체 사용 ...
// vector가 파괴될 때, 내부 DBConn들의 소멸자가 차례로 호출된다
vector가 파괴되면서 원소들의 소멸자를 부른다. 그런데 첫 번째 DBConn의 소멸자에서 close()가 예외를 던졌다고 해보자. 이미 컨테이너는 자기 정리 작업 중이다.
더 나쁜 경우도 있다. 어떤 함수가 이미 예외를 던져서 stack unwinding이 진행되는 중인데, 그 과정에서 지역 객체의 소멸자가 호출되고, 그 소멸자까지 또 예외를 던지는 경우다.
void run()
{
DBConn dbc(DBConnection::create());
// ...
throw std::runtime_error("작업 실패");
// dbc는 stack unwinding 중에 파괴된다
// 이때 ~DBConn()에서도 예외가 나오면 상황이 무너진다
}
C++은 동시에 여러 예외가 겹쳐 밖으로 전파되는 상황을 안전하게 이어갈 수 없다. 특히 예외 처리 도중 소멸자에서 또 다른 예외가 빠져나가면, 프로그램은 보통 std::terminate로 끝난다.
C++11 이후에는 여기에 한 가지가 더 붙는다. 소멸자는 대체로 암묵적으로 noexcept로 간주되기 때문에, 소멸자 밖으로 예외가 나가면 그 자체로 std::terminate가 호출될 수 있다.
말하자면 소멸자는 "실패할 수 없는 곳"이 아니라, 실패를 밖으로 보고하기 어려운 곳이다.
3. 해결 방법 1: 소멸자 안에서 끝장을 본다
3-1. 실패하면 프로그램을 종료한다
첫 번째 선택지는 예외를 잡고, 프로그램을 끝내는 것이다.
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
~DBConn()
{
try
{
db.close();
}
catch (...)
{
// 로그를 남길 수 있다면 남긴다
std::abort();
}
}
private:
DBConnection db;
};
이 방식은 거칠어 보인다. 하지만 어떤 시스템에서는 닫기 실패가 프로그램 상태를 더 이상 믿을 수 없게 만든다는 뜻일 수 있다. 그럴 때는 어설프게 계속 진행하는 것보다 명시적으로 완전히 죽는 편이 낫다.
중요한 건 close()의 예외가 소멸자 밖으로 나가지 않는다는 점이다. 소멸자가 책임지고 흐름을 끊는다.
3-2. 실패를 삼킨다
두 번째 선택지는 예외를 잡고 삼키는 것이다.
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
~DBConn()
{
try
{
db.close();
}
catch (...)
{
// 가능하면 로그를 남긴다
// 예외는 소멸자 밖으로 내보내지 않는다
}
}
private:
DBConnection db;
};
이 방법도 완벽하지 않다. close() 실패는 실제 문제일 수 있는데, 그냥 묻어버리면 사용자는 아무 일도 없었던 것처럼 다음 단계로 넘어간다.
그래도 "소멸자에서 예외가 밖으로 나가 프로그램이 갑자기 종료되는 것"보다는 나을 때가 있다. 특히 정리 작업 실패가 치명적이지 않고, 로그만으로 충분히 추적 가능한 경우라면 현실적인 선택이다.
다만 이 방식에는 전제가 있다. 예외를 삼키려면, 정말 삼켜도 되는 실패인지 먼저 판단해야 한다.
4. 해결 방법 2: 실패를 사용자가 처리할 수 있게 한다
4-1. close를 명시적으로 제공하자
소멸자에서 실패를 보고하기 어렵다면, 실패를 보고할 수 있는 일반 멤버 함수에서 일을 하게 만들면 된다.
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
void close()
{
db.close(); // 실패하면 호출자에게 예외를 전달한다
closed = true;
}
~DBConn()
{
if (!closed)
{
try
{
db.close(); // 사용자가 안 닫았을 때의 마지막 안전망
}
catch (...)
{
// 여기서는 예외를 밖으로 내보내지 않는다
}
}
}
private:
DBConnection db;
bool closed = false;
};
이제 사용자는 연결 닫기 실패에 반응하고 싶을 때 직접 close()를 호출할 수 있다.
DBConn dbc(DBConnection::create());
// ... dbc 사용 ...
dbc.close(); // 실패하면 여기서 예외를 처리할 수 있다
이 구조에서는 책임이 더 분명해진다.
- 사용자가 실패를 처리하고 싶다 →
close()를 직접 호출한다. - 사용자가 호출하지 않았다 → 소멸자가 마지막 안전망으로 정리한다.
- 소멸자에서 실패했다 → 예외를 밖으로 내보내지 않는다.
4-2. 소멸자는 진짜 오류 처리 장소가 아니다
소멸자는 객체가 사라지는 마지막 순간에 불린다. 이때는 호출자가 무엇을 하려던 중인지 알기 어렵다. 정상 흐름일 수도 있고, 이미 예외를 처리하는 중일 수도 있다. 그래서 소멸자에서 "실패를 호출자에게 알려주자"는 설계는 대체로 불안하다.
반대로 일반 멤버 함수는 호출자가 의도를 가지고 부른다. close()가 실패하면 그 자리에서 재시도할지, 로그를 남길지, 사용자에게 알릴지 결정할 수 있다.
그러니 실패에 의미 있게 대응해야 하는 작업이라면, 소멸자에만 숨겨두지 않는 편이 좋다.
5. RAII와 모순되는 말은 아니다
여기서 헷갈릴 수 있다. "RAII라면 자원 해제를 소멸자에 맡기라고 하지 않았나? 그런데 이제 소멸자에서 닫지 말라는 건가?"
RAII의 핵심은 자원의 생명주기를 객체 생명주기에 묶는 것이다. 따라서 소멸자는 여전히 마지막 정리 책임을 가져야 한다. 다만 실패를 밖으로 던지는 방식으로 보고하면 안 된다는 뜻이다.
좋은 RAII 클래스는 보통 이렇게 행동한다.
- 소멸자에서 자원을 반드시 정리하려고 시도한다.
- 소멸자 밖으로 예외를 내보내지는 않는다.
- 사용자가 실패를 처리해야 한다면 별도의 명시적 함수(
close,commit,flush등)를 제공한다.
예를 들어 파일 출력 버퍼를 비우는 flush(), 트랜잭션을 확정하는 commit(), 네트워크 연결을 닫는 close() 같은 작업은 실패가 의미를 가진다. 이런 작업은 소멸자에만 맡기면 호출자가 실패를 다룰 기회를 잃는다.
핵심은 "소멸자에서 아무것도 하지 말라"가 아니다. 소멸자는 마지막 안전망이어야지, 예외를 통해 오류를 보고하는 주 통로가 되어서는 안 된다.
6. 현대 C++에서는 더 엄격하다
Effective C++가 쓰이던 시기(3판은 2005년에 집필되고 있었다)에는 소멸자에서 예외가 빠져나갈 때의 위험을 주로 "예외가 겹치면 프로그램이 종료된다"는 관점으로 설명했다.
현대 C++에서는 여기에 noexcept까지 함께 생각해야 한다.
noexcept는 말 그대로 "이 함수는 예외를 밖으로 던지지 않는다"는 약속이다. 함수 선언 뒤에 붙일 수 있다.
void cleanup() noexcept;
이 약속이 깨지면 어떻게 될까? noexcept 함수 안에서 예외가 발생하는 것 자체가 문제는 아니다. 그 예외가 함수 안에서 잡히면 괜찮다. 하지만 예외가 함수 밖으로 빠져나가려는 순간, C++ 런타임은 더 이상 일반적인 예외 전파를 계속하지 않고 std::terminate를 호출한다.
소멸자가 여기에 특히 민감하다. C++11 이후 소멸자는 특별히 지정하지 않으면 대체로 noexcept(true)로 취급된다. 즉, 아래 코드는 보기보다 더 위험하다.
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
~DBConn()
{
db.close(); // close가 던지면 std::terminate가 호출될 수 있다
}
private:
DBConnection db;
};
물론 ~DBConn() noexcept(false)처럼 소멸자가 예외를 던질 수 있다고 선언하는 방법도 있다. 하지만 그건 아주 조심스럽게 써야 한다. 컨테이너, 스마트 포인터, stack unwinding과 만나는 순간 여전히 위험한 설계가 되기 쉽다.
실무적으로는 이쪽이 더 단순하다.
class DBConn
{
public:
explicit DBConn(const DBConnection& connection)
: db(connection)
{}
~DBConn() noexcept
{
try
{
if (!closed)
{
db.close();
}
}
catch (...)
{
// 로그 또는 종료 정책
}
}
void close()
{
db.close();
closed = true;
}
private:
DBConnection db;
bool closed = false;
};
소멸자는 noexcept라는 의도를 분명히 갖고, 실패 처리는 소멸자 내부 정책으로 끝낸다. 사용자가 실패를 직접 다뤄야 하면 일반 멤버 함수를 호출하게 한다.
7. 실무에서는 어떻게 판단하면 좋을까
소멸자에서 어떤 함수를 호출하려고 할 때는 몇 가지를 자문해보면 좋다.
- 이 함수가 예외를 던질 수 있는가?
- 실패했을 때 호출자가 실제로 대응해야 하는가?
- 소멸자에서 실패를 삼켜도 시스템 상태가 괜찮은가?
- 삼키면 안 되는 실패라면, 프로그램을 종료하는 것이 더 정직한가?
- 사용자가 명시적으로 호출할 수 있는
close()나commit()같은 함수를 제공해야 하는가?
내 코드도 그렇지만, "소멸자가 알아서 해주겠지"라는 생각으로 정리 작업을 전부 몰아넣으면 오류 처리 설계가 흐려진다. 자동 정리는 좋다. 하지만 실패까지 자동으로 잘 처리되는 건 아니니 신경을 좀 써줘야 한다.
8. 항목 8 핵심 정리
반드시 기억할 것
- 소멸자 밖으로 예외가 빠져나가게 두면 안 된다. 소멸자는 예외를 잡아서 처리해야 한다.
- 소멸자 안에서 호출하는 함수가 예외를 던질 수 있다면, 소멸자는 그 예외를 잡고 삼키거나 프로그램을 종료해야 한다.
- 사용자가 어떤 작업의 실패에 대응해야 한다면, 그 작업은 소멸자가 아니라 일반 멤버 함수에서 수행할 수 있게 제공해야 한다.
- 소멸자는 자원 정리의 마지막 안전망으로 두고, 오류 보고의 주 통로로 쓰지 않는다.
- 현대 C++에서는 소멸자가 대체로
noexcept라는 점까지 고려해야 한다. 소멸자에서 예외가 나가면std::terminate로 이어질 수 있다.
'책 > Effective C++' 카테고리의 다른 글
| [Effective C++] Cp 7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자 (0) | 2026.05.26 |
|---|---|
| [Effective C++] Cp 6. 컴파일러가 만들어주는 함수를 원치 않으면 사용 금지시키자 (0) | 2026.04.13 |
| [Effective C++] Cp 5. 컴파일러가 만든 함수도 다시보자 (0) | 2026.04.13 |
| [Effective C++] Cp 4. 객체를 사용하기 전 반드시 초기화 (0) | 2026.04.09 |
| [Effective C++] Cp 3. const 사용의 습관화 (0) | 2026.04.08 |
