컴퓨터공학/Effective C++

8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

Pyxis 2025. 2. 2. 21:40

[ Effective C++ ]

 

class Widget
{
public:
    ...
    ~Widget() { ... } // 이 함수로부터 예외가 발생된다고 가정
};

void doSomething()
{
    std::vector<Widget> v;
    ...
}                    // v는 여기서 자동으로 소멸

 

위 예제에서, Widget들을 소멸시킬 책임은 vector에게 있다.

만약 10개의 Widget이 저장되어 있는데, 첫 번째를 소멸시키는 도중에 예외가 발생되었다고 가정하자.  나머지 9개는 memory leak을 막기 위해 반드시 소멸되어야 하므로, 이들에 대해 소멸자를 호출해야 할 것이다.

만약 두 번째 Widget의 소멸자에서도 예외가 던져지면 어떻게 해야 할까? 이 경우에, Undefined-Behavior에 해당된다.

다른 STL 컨테이너, 정적 배열을 써도 마찬가지인데, 근본적인 원인은 바로 예외를 던지도록 내버려두는 소멸자에게 있다.

class DBConnection
{
public:
    ...
    static DBConnection create(); // DBConnection 객체를 반환하는 함수.
                                  // 매개변수는 생략.
                                  
    void close();                 // 연결을 닫음. 이 때 연결이 실패하면
                                  // 예외를 던짐.
};

class DBConn // DBConnection 객체를 관리하는 클래스
{
public:
    ...
    ~DBConn() // 데이터베이스 연결이 항상 닫히도록 챙겨주는 소멸자
    {
        db.close();
    }
    
private:
    DBConnection db;
};

 

위와 같은 데이터베이스 연결을 나타내는 클래스가 있다고 가정하자.

소멸자에서 close()를 호출해주어 연결을 항상 닫아주기를 기대한다.

{
    DBConn dbc(DBConnection::create());
    
    ...
    
    ...
} // DBConn 객체 소멸, DBConnection의 close() 자동 호출

 

위 사용 예제에서, close()를 호출했는데 여기서 예외가 발생했다고 가정하면 어떻게 될까?

소멸자는 예외를 던질 것이며, 이를 방치할 것이다. 처음 말했던 문제 사항이 생기게 되는 것.

이 경우, 아래와 같은 2 가지 방법을 사용해볼 수 있다.

 

[1] 프로그램을 바로 종료. 보통 std::abort() 호출.

DBConn::~DBConn() // [1]
{
    try { db.close(); }
    catch (...)
    {
        close 호출이 실패했다는 로그 작성;
        std::abort();
    }
}

더 이상 프로그램 실행을 계속할 수 없는 상황이라면, 나쁘지 않은 선택.

 

[2] 예외 삼키기.

DBConn::~DBConn() // [2]
{
    try { db.close(); }
    catch (...)
    {
        close 호출이 실패했다는 로그 작성;
    }
}

 

무엇이 잘못되어서 예외가 발생했는지 알려주지 않아 좋지 않은 선택이지만, 상황에 따라 프로그램 종료 / 미정의 동작으로 인한 위험을 보는 것 보단 예외를 삼키는 것이 나을 수도 있다.

이 때, 예외를 무시하더라도 반드시 프로그램이 신뢰성있게 실행을 지속할 수 있게 해야 한다.

 

하지만 2가지 방식 전부 문제점이 존재하는데, 결국 close()가 최초로 예외를 던지게 된 원인에 대해 프로그램이 조치를 취할 수 없다는 것이다.

더 좋은 방식으로, DBConn의 close() 함수를 직접 제공하게 하고, 안전 장치로 소멸자에서 닫혔는지 확인 후, 열려있다면 다시 한 번 close() 호출을 시도하는 것이다.

class DBConn
{
public:
    ...
    void close()
    {
        db.close();
        closed = true;
    }
    
    ~DBConn()
    {
        if (!closed) // 사용자가 닫지 않았다면 닫기 시도.
        try {
          db.close();
        }
        catch (...) // 실패하면, 종료하거나 예외 삼키기.
        { 
            close 호출이 실패했다는 로그를 작성합니다.
            ...
        }
    }
private:
    DBConnection db;
    bool closed;
};

 

close() 호출 책임을 소멸자에서 사용자로 떠넘기는 이런 방식은 무책임해 보일 수도 있다.

여기서 핵심은, 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고, 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것이다.

이유는, 예외를 일으키는 소멸자는 프로그래의 불완전한 종료, 혹은 Undefined-Behavior의 위험을 내포하고 있기 때문이다.

소멸자에서 예외를 던져서 제어할 수 없는 상황이 생기기 보다는, 사용자에게 직접 에러를 처리할 수 있는 기회를 추가적으로 제공하는 것이다.

 

[ 정리 ]

- 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지, 프로그램을 끝내든지 해야 한다.

- 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 "함수")이어야 한다.