2장. 생성자, 소멸자 및 대입연산자 🎺

안녕하세요! 두두코딩 입니다 ✋
오늘은 Effective C++의 항목 8 예외가 소멸자를 떠나지 못하도록 하는 개념에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

소멸자의 예외발생

소멸자로부터 예외가 터져 나가는 경우를 C++언어 에서 막은 것은 아니지만, 우리가 실제 상황을 겪어보게 될 경우 막을 수 밖에 없다. 아래의 예를 통해 알아보자.

class Widget {
public:
	...
	~Widget() {...}	// 소멸하는 과정에서 예외가 발생된다고 생각하자.
};

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

doSomething() 함수에서는 vector를 사용하고, 컨테이너 내에 존재하는 Widget 객체들을 전부 소멸 시킨다. vector에 들어있는 객체가 총 10개임으로 10개를 소멸시켜야하는데, 첫 번째 객체를 소멸시키는 경우 예외가 발생했다고 해보자. 그래도 컨테이너는 나머지 9개에 대해 예외를 처리시켜줘야한다. 하지만, 두 번째 객체를 소멸시키는 과정에서 또 예외가 발생했다고 해보자😱 이 때 C++ 입장에서는 현재 활성화된 예외가 2개기 때문에 감당하기가 버거워진다.

두 예외가 동일한지 아닌지도 잘 모르고, 해당 처리에 대해 프로그램을 종료할 것인지 아니면 정의되지 않은 상태 즉, 미정의 상태의 동작을 취하게 된다. (우리가 앞에서 배웠지만, 미정의 상태는 아주 큰 재앙을 불러오고.. 프로그래머는 또 밤을 세야한다.. 😥)

해당 문제는 vector 뿐 아니라 다른 표준 라이브러리 (e.g. list, set)을 사용해도 동일하게 발생하고, 심지어 배열에서도 이런 “미정의 동작”을 만날 수 있다. 조금 더 생각해보자. 이런 다양한 예외를 처리하지 못하는 컨테이너의 문제일까?

그건아니다. 해당 문제는 완전하지 못한 프로그램 종료나 미정의 동작의 원인은 바로 예외를 처리하지 못한 소멸자에게 있는 것이다. 예외를 처리하지 못하고 vector에게 모든걸 넘겨버리게 때문이다.

그렇다면, 소멸자 내 예외를 던질만한 코드를 안넣으면 되는거아니야? 라고 생각할 수 있다. 하지만 일상생활에서 우리는 소멸자를 종종 활용하고 있고, 본인도 모르게 예외가 발생할 수 있는 코드를 넣고있다.

실상황에서 소멸자의 예외 발생

우리는 실제상황에서 “소멸자”를 활용해 자원을 관리하곤 하는데, 해당 부분에서 문제가 발생할수도 있다. 예를 들어, 데이터베이스 연결을 나타내는 클래스를 사용하고 있다고 생각해보자.

class DBConeection {
public:
	...
	// 편의상 매게변수는 제외함
	static DBConnection create();	// DBConnection의 객체를 반환함.

	void close();	// 연결을 닫는다. 이떄, 실패하면 예외를 던짐.
};

해당 클래스의 설계는, DB 연결위해서 DBConnection이라는 객체를 만들어 Create()를 한다. 다 사용한 후 Close()를 통해 DB 연결을 안전하게 제거하는 설계이다. 하지만 우리는 어떤 민족인가? 망각의 민족💩 이다. 따라서, 사전에 자원 낭비를 제거하기 위해 이런 DBConnection을 관리하는 객체를 둬 클래스 소멸자에 close()를 호출하도록 보통 설계한다.

/* DB connection을 관리하는 클래스 */
class DBConn {
public:
	...
	// DB가 항상 닫히도록 챙겨주는 함수이다.
	~DBConn() {
		db.close();
	}

private:
	DBConnection db;
};

우리는 위의 DBConn의 설계로 아래와 같은 시나리오가 발생해도 잘 처리할 수 있다.

{
	1. DB 사용 블록 시작
	2. DBConn dbc (DBConnection::create()); // DBConnection 객체를 넘김
	3. DBConn을 생성해 DB를 관리함.

	4. DBConn 인터페이스를 통해 DBConnection에 접근함. 이후 데이터 사용
	5. 객체 사용 중..

	...

	6. 블록 끝에, 도달함.
	7. DBConnection에 대한 close()를 호출하지 않음
	8. 블록 나가면서 소멸자로 close() 가 자동 호출 됨.
	9. DB가 안전하게 연결 해제 됨.
}

위와 같은 시나리오로 해당 프로그램의 설계의 이상적인 방향을 그려볼 수 있다. 이런 이상적인 그림은 close() 호출이 예외 없이 잘 처리 돼 성공했다는 전제하이다. 그러나, close()를 호출 했는데 예외가 발생했다고 가정해보자.

DBConn의 소멸자는 분명히 예외를 던질 것이다. 쉽게 말해, 소멸자에서 예외가 나가도록 허용한다. 따라서 C++ 입장에서는 어떤 처리를해야하는지 모르게 된다. 즉, 예외를 던지는 소멸자는 걱정거리를 하나 더 던지는 것이다. 그렇다면, 이런 예외가 나가는 것을 막기 위한 방법은 무엇일까?

소멸자에서 발생하는 예외가 나가는 것을 막는 법

DBConn에서는 이런 문제를 막기 위해, 아래의 두 가지 방법 중 하나를 택할 수 있다.

🌱 close에서 예외가 발생하면 프로그램을 바로 종료

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

위와같이, abort()를 활용해 “프로그램을 바로 종료” 하도록 한다. 객체 소멸이 진행되다가 에러가 발생한 후 프로그램 실행을 계속할 수 없는 상황이라면, 꽤 괜찮은 선택이다. 소멸자에서 생긴 예외를 그대로 흘려보내서 정의되지 않은 동작을 하도록 하는 것보다는 “종료” 하는 것이 나을 수 있다.

abort()를 사용한다는 건 이런 예외는 못볼 꼴이니 아예 보지마라 하고, 안보여주겠다는 의미이다.

🌱 close를 호출한 곳에서 일어난 예외를 삼킴

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

대부분의 경우에서 예외를 삼키는 것은 그리 좋은 방법은 아니다. “무엇이 잘못됐는지를 알려주는 정보”를 얻지 못하게 때문이다. 하지만, 때에 따라 불완전한 프로그램을 종료 혹은 미정의 동작으로 인해 입는 위험을 감수하는 것보다 그냥 예외를 먹어버리는게 나을 수도 있다.

예를들어, 해당 클래스는 크게 중요하지 않고, 다음 시나리오에 돈을 옮기는 것과 같은 무조건 수행해야하는 경우에 “프로그램을 종료” 할 수 는 없고, 미정의 동작을 처리하면 안되는 상황이 발생했을 때는 “예외에 대한 로그만 남기고 삼키는 것이” 더 좋은 설계일 수도 있다는 것이다.

여기서 중요한 점은, 예외를 삼켜 버렸다면 예외를 무시한 뒤라도 프로그램이 신뢰성 있게 실행을 지속해야한다.

여기서 소멸자에서 예외가 처리는 2가지 방법을 제시하긴 하지만, 어느 쪽을 택하든 리스크가 존재한다. 중요한 것은 close가 최초로 예외를 던지게 된 요인에 대해 프로그램이 어떤 조치를 취할 수 있나? 이런 부분에 대한 대책이 전무한 상태이기 때문이다. 즉, 소멸자에서는 close가 뭐 때문에 예외를 던졌는지 알 수 없고, 던졌다고 해서 그 예외에 대한 조치를 취할수도 없다.

조금 더 나은 전략

우리가 DBConn의 인터페이스를 잘 설계헤서, 발생할 소지가 있는 문제에 대처할 기회를 사용자에게 제공해줄 수 있다는 어떨까?

DBConnclose()라는 인터페이스를 제공한다면 함수 실행 중에 발생하는 에외를 사용자가 직접 처리할 수 있을 것이다. DBConn::close()라는 함수를 호출 할경우 DBConnection에 대한 close()를 호출할 것이다. 또한 여기서 닫혔는지 여부를 기록해뒀다가 만약 사용자가 DBConn::close()를 호출하지 않는다면 소멸자에서 DBConnection에 대한 close를 호출해 주도록 하면 깔끔하게 처리된다.

하지만, 소멸자에서 DB를 제거하는 과정에서 close()를 실패한다면 결국 “끝내거나 혹은 삼켜버리거나” 하는 방법을 택할 수 밖에없다.

class DBConn {
public:
	...

	void close() {
		db.close();
		isClosed = true;
	}

	~DBConn() {
		if (!isClosed)
		try {
			db.close();
		}
		catch(...) {
			// close 에 대한 호출 실패 로그
			...
		}
	}

private:
	DBConnection db;
	bool isClosed = false;
};

이건 사용자에게 close()를 니가 호출해라 떠넘기는건데? 이게 왜 더 향상된 기법이라고 보는 건가??

해당 설계의 포인트는 다음과 같다.

❗ 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고, 그 예외를 처리해야할 필요가 있다면 그 예외는 소멸자가 아닌 다른 함수에서 비롯되어야한다

예외를 일으키는 소멸자는 프로그램의 불완전 종료 혹은 미정의 동작의 위험이 있기 때문에 “시한폭탄” 같은 존재이다. 따라서, 소멸자에서 만큼은 예외가 발생하는 것을 막아야한다.

위의 코드를 보면, close()함수를 만들어 두긴 했지만, 사용자가 무조건 사용해야되는 것은 아니다. 왜냐면 안하더라도 소멸자에서 처리가 가능하기 때문이다. 해당 방법은 인터페이스를 열어 사용자에게 예외를 처리할 수 있는 기회를 제공하는 것이다. 해당 기회마저 없다면 소멸자에서 예외를 일으켜 무조건 실패 혹은 미정의 동작을 만나기 때문이다.

Appendix

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

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