[Effective C++]예외가 소멸자를 떠나지 못하도록 붙들어 놓자

생성자, 소멸자 및 대입 연산자, 네 번째 이야기

Posted by SungBeom on November 26, 2019 · 10 mins read

소멸자에서 예외가 발생하는 경우

소멸자로부터 예외가 터져 나가는 경우를 C++ 언어에서 막는 것은 아니지만, 실제 상황을 들춰보면 사용자가 막아주어야 합니다. 아래의 예를 봅시다.

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
    ...
    ~Widget() { ... }  // 이 함수로부터 예외가 발생된다고 가정합니다.
};
 
void doSomething()
{
    std::vector<Widget> v;
    ...
}                      // v는 여기서 자동으로 소멸됩니다.
cs

vector 타입의 객체 v, 다시 말해 벡터 v가 소멸될 때, 자신이 거느리고 있는 Widget들 전부를 소멸시킬 책임은 바로 이 벡터에게 있습니다. v에 들어 있는 Widget이 열 개인데, 첫 번째 것을 소멸시키는 도중에 예외가 발생되었다고 가정합시다. 나머지 아홉 개는 여전히 소멸되어야 하므로(그렇지 않으면 이들이 가지고 있을지 모를 자원이 누출됩니다), v는 이들에 대해 소멸자를 호출해야 할 것입니다. 그런데 이 과정에서 문제가 또 터졌다고 가정합시다. 활성화된 예외가 동시에 두 개나 만들어진 상태이고, C++의 입장에서는 감당하기에 버겁습니다.

위 두 예외가 동시에 발생한 조건이 어떤 미묘한 조건이냐에 따라 프로그램 실행이 종료되든지 아니면 정의되지 않은 동작을 보이게 될 텐데, 이 경우에는 프로그램이 정의되지 않은 동작을 보입니다. 다른 라이브러리 컨테이너(이를테면 list나 set)라든지 TR1의 컨테이너를 쓰더라도 결과는 마찬가지이며, 심지어 배열을 써도 마찬가지입니다. 완전치 못한 프로그램 종료나 미정의 동작의 원인은 컨테이너나 배열의 문제가 아니라, 예외가 터져 나오는 것을 내버려 두는 소멸자에게 있습니다. C++은 예외를 내보내는 소멸자를 좋아하지 않습니다!

데이터베이스 연결을 나타내는 클래스를 쓰고 있다고 가정하고 이야기를 계속하겠습니다.

1
2
3
4
5
6
7
class DBConnection {
public:
    ...
    static DBConnection create();  // DBConnection 객체를 반환하는 함수
                                   // 매개변수는 편의상 생략합니다.
    void close();                  // 연결을 닫습니다.
};                                 // 연결이 실패하면 예외를 던집니다.
cs

보다시피 사용자가 DBConnection 객체에 대해 close를 직접 호출해야 하는 설계입니다. DBConnection에 대한 자원 관리 클래스를 만들어서 그 클래스의 소멸자에서 close를 호출하게 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
class DBConn {  // DBConnection 객체를 
public:
    ...
    ~DBConn()   // 데이터베이스 연결이 항상 닫히도록
    {           // 확실히 챙겨주는 함수
        db.close();
    }
 
private:
    DBConnection db;
};
cs

위 클래스를 활용해 다음과 같은 프로그래밍이 가능해집니다.

1
2
3
4
5
6
7
8
9
{
    // DBConnection 객체를 생성하고
    // 이것을 DBConn 객체로 넘겨서 관리를 맡깁니다.
    DBConn dbc(DBConnection::create());
 
    // DBConn 인터페이스를 통해 그 DBConnection 객체를 사용합니다.
    ...
}   // DB 객체가 여기서 소멸됩니다.
    //DBConnection 객체에 대한 close 함수의 호출이 자동으로 이루어집니다.
cs

close 호출만 일사천리로 성공하면 아무 문제될 것이 없는 코드입니다. 그런데 close를 호출했는데 여기서 예외가 발생했다고 가정하면 DBConn의 소멸자는 분명히 이 예외를 전파할 것입니다. 바로 이것이 문제입니다. 예외를 던지는 소멸자는 곧 '걱정거리'를 의미하기 때문입니다.

예외 발생 시 끝내거나 삼키거나

걱정거리를 피하는 방법은 두 가지입니다.
첫 번째로 close에서 예외가 발생하면 프로그램을 바로 끝냅니다. 대개 abort를 호출합니다.

1
2
3
4
5
6
7
8
DBConn::~DBConn()
{
    try { db.close(); }
    catch (...) {
        close 호출 실패 로그 작성;
        std::abort();
    }
}
cs

객체 소멸이 진행되다가 에러가 발생한 후에 프로그램 샐행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택입니다. 소멸자에서 생긴 예외를 그대로 흘려 내보냈다가 정의되지 않은 동작에까지 이를 수 있다면, 그런 불상사를 막는다는 의미에서 어느 정도 장점도 있습니다.

두 번째로 close를 호출한 곳에서 일어난 예외를 삼켜 버립니다.

1
2
3
4
5
6
7
DBConn::~DBConn()
{
    try { db.close(); }
    catch (...) {
        close 호출 실패 로그 작성;
    }
}
cs

대부분의 경우에서 예외 삼키기는 무엇이 잘못됐는지를 알려주는 중요한 정보가 묻혀 버리기에 그리 좋은 발상이 아닙니다. 하지만 때에 따라서는 불완전한 프로그램 종료 혹은 미정의 동작으로 인해 입는 위험을 감수하는 것보다 그냥 예외를 먹어버리는 게 나을 수도 있습니다. 단, '예외 삼키기'를 선택한 것이 제대로 빛을 보려면, 발생한 예외를 그냥 무시한 뒤라도 프로그램이 신뢰성 있게 실행을 지속할 수 있어야 합니다.

둘 다 문제점이 있기 때문에 어느 쪽을 택하는 특별히 좋을 건 없어 보입니다. 중요한 것은 close가 최초로 예외를 던지게 된 요인에 대해 프로그램이 어떤 조치를 취할 수 있는가인데, 이런 부분에 대한 대책이 전무한 상태이니까요. 더 좋은 전략으로 발생할 소지가 있는 문제에 대처할 기회를 사용자가 가질 수 있도록 인터페이스를 설계하여 해결할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DBConn {
public:
    ...
 
    void close()
    {
        db.close();
        closed = true;
    }
 
    ~DBConn()
    {
        if (!closed) {
            try { db.close(); }  // 사용자가 연결을 안 닫았으면 여기서 닫아 봅니다.
            catch (...) {        // 연결을 닫다가 실패하면, 실패를 알린 후에
                                 // 실행을 끝내거나 예외를 삼킵니다.
                close 호출 실패 로그 작성;
            }
        }
    }
 
private:
    DBConnection db;
    bool closed;
};
 
cs

이는 무책임한 책임 전가로 보일 수도 있습니다. 제대로 쓰기에 쉬운 인터페이스를 만들지 않은 것으로 느낄 수도 있습니다. 여기서 포인트는 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것입니다. 예외를 일으키는 소멸자는 시한폭탄이나 마찬가지라서 프로그램의 불완전 종료 혹은 미정의 동작의 위험을 내포하고 있기 때문입니다. 위의 예제 코드는 사용자가 호출할 수 있는 close 함수를 두긴 했지만 사용자에게 에러를 처리할 수 있는 기회를 주는 것이죠. 이것마저 없다면 사용자는 예외에 대처할 기회를 못 잡게 됩니다.


정리

소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.
어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.