예외가 소멸자를 떠나지 못하도록 붙잡아 놓자
내용
예외 발생시 소멸자의 입장
소멸자에서 예외가 터져 나가는 경우를 C++ 언어에서 막는 것은 아니지만, 실제 상황을 들춰보면 확실히 프로그래머가 직접 막을 수 밖에 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget {
public:
~Widget() { ... }
...
};
void doSomething() {
std::vector<Widget> v;
...
}
vector 타입의 객체 v, 다시 말해 벡터 v가 소멸될 때, 자신이 가지고 있는 Widget들 전부를 소멸시킬 책임은 이 벡터에게 있다.
v에 들어 있는 Widget이 10개 일 때, 첫번째 것을 소멸시키는 도중에 예외가 발생했다고 가정한다. 나머지 9개는 여전히 소멸되어야 하므로(그렇지 않으면 이들이 가지고 있을지 모르는 자원이 누출된다), v는 이들에 대해 소멸자를 호출해야 할 것이다. 그런데 이 과정에서 문제가 또 발생했다고 했을때, 두 번째 Widget에 대해 호출된 소멸자에서 예외가 던져지면 어떻게 되는가? 현재 활성화된 예외가 동시에 두개나 만들어진 상태이고, C++의 입장에서는 감당하기에 버거워지는 문제가 발생하게 되는 것이다. 이 두 예외가 동시에 발생한 조건이 어떤 미묘한 조건이냐에 따라 프로그램 실행이 종료되는지 아니면 정의되지 않은 동작을 보이게 될 것인데, 이 경우에는 프로그램이 정의되지 않은 동작을 보이게 될 것이다.
이는 다른 STL 컨테이너 라든지 TR1 컨테이너, 심지어 배열을 써도 결과는 마찬가지 이다. 그러나 이러한 완전하지 못한 프로그램 종료나 미정의 동작이 발생하는 원인은, 컨테이너나 배열을 썻기 때문에 발생한 문제가 아니라 예외가 터져나오는 것을 내버려 두는 소멸자에게 있다.
사용자측에서의 예외처리와 소멸자에서의 예외처리
1
2
3
4
5
6
7
8
9
class DBConnection {
public:
...
static DBConnection create(); // DBConnection 객체를 반환
void close(); // 연결을 끊음. 이때, 연결이 실패하면 예외 발생
};
위 예제는 사용자가 DBConnection 객체에 대해 close를 직접 호출해야 하는 설계이다. 사용자의 실수를 사전에 차단하는 좋은 방법이라면 DBConnection에 대한 자원 관리 클래스를 만들어서 그 클래스의 소멸자에서 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
27
28
// DBConnection 객체의 관리 클래스
class DBConn {
public:
...
// 연결이 항상 닫히도록 확실히 챙기는 소멸자
~DBConn() { db.close(); }
private:
DBConnection db
};
...
{ // 블록 시작
DBConn dbc(DBConnection::create()) // DBConnection 객체를 생성하고,
// 이를 DBConn 객체로 넘겨 관리를 위임
... // DBConn 인터페이스를 통하여
// DBConnection 객체를 사용
} // 블록의 끝
// DBConn 객체가 소멸하고,
// DBConnection 객체에 대한
// close 함수의 호출이 자동으로 이루어짐
위 예제에서, close 일사천리로 성공하면 아무 문제될 것이 없는 코드이다. 그러나 close를 호출 했는데 여기서 예외가 발생했다고 가정하면 어떻게 될 것인가? DBConn의 소멸자는 분명히 이 예외를 전파할 것이다. 즉, 그 소멸자에서 예외가 나가도록 내버려 둔다는 것이다. 바로 이것이 문제이다. 예외를 던지는 소멸자는 곧 ‘걱정거리’를 의미하기 때문이다.
이를 피하는 방법은 두가지가 있다. DBConn의 소멸자는 이 두가지 방법중 하나를 선탱할 수 있을 것이다.
- close에서 예외가 발생하면 프로그램을 바로 종료한다. 대개 abort를 호출한다.
1 2 3 4 5 6 7 8 9 10 11
DBConn::~DBConn() { try { db.close(); } catch( ... ) { ... // close 호출이 실패했다는 로그 작성 std::abort(); } }
- close를 호출한 곳에서 일어난 예외를 삼켜 버린다.
1 2 3 4 5 6 7 8 9 10
DBConn::~DBConn() { try { db.close(); } catch( ... ) { ... // close 호출이 실패했다는 로그 작성 } }
대부분의 경우 예외 삼키기는 좋은 발상이 아니다. 무엇이 잘못되었는지를 알려주는 정보와 같이 중요한 정보가 묻혀 버리기 때문이다. 그러나 때에 따라서는 불완전한 프로그램 종요 혹은 미정의 동작으로 인해 입는 위험을 감수하는 것 보다 그냥 예외를 먹어버리는 게 나을 수도 있다. 단, 이 ‘예외 삼키기‘가 제대로 빛을 보려면, 발생 한 예외를 그냥 무시한 뒤라도 프로그램이 쇤뢰성 있게 실행을 지속할 수 있어야 한다.
위의 두가지 선택방법 모두 문제점이 보이는 방법들이다. 중요한 것은 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
27
28
class DBConn {
public:
void close() { // 사용자 호출을 위한 인터페이스 제공
db.close;
closed = true;
}
void ~DBConn() {
if(!closed) { // 사용자가 연결을 닫지 않았으면,
// 소멸자에서 닫는다
try {
db.close();
}
catch(...) { // 연결을 닫다가 실패하면,
// 실패를 알린후에
// 실행을 끝내거나 예외를 삼킨다
... // close 호출이 실패했다는 로그 작성
}
}
}
private:
DBConnection db;
bool closed;
};
close 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 떠넘기는 아이디어는 무책임한 책임전가로 보일 수 있다. 그러나, 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다 라는 것이 포인트 이다. 이유는 위에서 설명한 바와 같이 예외를 일으키는 소멸자는 시한폭탄이나 마찬가지라서 프로그램의 불완전 종료 혹은 미정의 동작의 위험을 내포하고 있기 때문이다.
위 예제에서 사용자가 호출할 수 있는 close 함수를 두고 있기는 하지만 부담을 떠넘기는 모양새가 아니다. 사용자에게 에러를 처리할 수 있는 기회를 주는 것이다. 이것 마저 없다면 사용자는 예외에 대처할 기회를 못잡게 된다. 물론 이 에러를 미리 처리할 것인지, 그냥 무시하여 DBConn의 소멸자에서 에러를 처리할 것인지 선택하는 것은 사용자의 몫이다.
요점
소멸자에서는 예외가 빠져나가면 안된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지, 프로그램을 끝내든지 해야 한다.
어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수 즉, 소멸자가 아닌 함수 이여야 한다.
참고. Effective C++ 3/E - Scott Meyers