(29) 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
GUI 메뉴 구현을 위한 클래스
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 배경그림을 바꾸는 멤버함수
...
private:
Mutex mutex; // 이 객체 하나를 위한 뮤텍스
Image* bgImage; // 현재의 배경그림
int imageChaged; // 배경그림이 바뀐횟수
};
|
PrettyMenu의 changeBackground의 구현
void Pretty::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 뮤텍스 획득(14)
delete bgImage; // 이전의 배경그림을 없앱니다.
++imageChanges; // 그림 변경 횟수를 갱신합니다.
bgImage = new Image(imgSrc); // 새 배경그림을 깔아 놓습니다.
unlock(&mutex); / 뮤텍스 해제.
}
|
예외 안전성이라는 측면에서 볼 때, 이 함수는 “이보다 더 나쁠 수는 없다”.일반적으로 예외 안전성을 확보하려면 2가지의 요구사항을 맞추어야 하는데, 이 함수는 어느 요구사항에도 맞지 않는, 위험천만의 함수입니다.
예외 안전성을 가진 함수라면, 예외가 발생할 때 이렇게 동작해야 합니다.
l 자원이 새도록 만들지 않습니다.위의 코드는 자원이 샙니다.왜냐하면 “new Image(imgSrc)” 표현식에서 예외를 던지면,
unlock 함수가 실행되지 않게 되어, 뮤텍스가 계속 잡힌 상태로 남기 때문입니다.
unlock 함수가 실행되지 않게 되어, 뮤텍스가 계속 잡힌 상태로 남기 때문입니다.
l 자료구조가 더럽혀지는 것을 허용하지 않습니다.1. “new Image(imgSrc)”가 예외를 던지면 bgImage가 가리키는 객체는 이미 삭제된 후입니다.
2. 새 그림이 제대로 깔린 게 아닌데도, imageChanges 변수는 이미 증가되었습니다.
2. 새 그림이 제대로 깔린 게 아닌데도, imageChanges 변수는 이미 증가되었습니다.
자원 누출 문제 해결방법은, 객체를 써서 자원 관리를 전담케 하는 방법(13)과, 뮤텍스를 적절한 시점에 해제하는 방법을 구현한 Lock 클래스(14)를
그대로 따라하면 마무리 된다.
그대로 따라하면 마무리 된다.
void Pretty::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex); // 항목(14): 뮤텍스를 대신 획득하고, 이것이 필요 없어질 시점에 바로 해제해 주는 객체
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex); / 뮤텍스 해제.
}
|
Lock 등의 자원관리 전담 클래스를 쓰면 가장 좋은 점 중 하나는, 함수의 코드 길이가 짧아진다는 것입니다. Unlock을 호출할 필요가 없습니다.
자료구조 오염 문제.
예외 안전선을 갖춘 함수는 다음 3가지 보장(guarantee) 중 하나를 제공해야 합니다..
l 기본적인 보장(basic guarantee)함수 동작 중에 예외가 발생하면, 실행중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장입니다.어떤 객체나 자료구조도 더럽혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지하고 있습니다. (즉, 모든 클래스 불변속성이 만족된 상태입니다).하지만 프로그램의 상태가 정확히 어떠한지는 예측이 안될 수도 있습니다.예를 들어, changeBackground 가 동작하다가, 예외가 발생했을 때 PrettyMenu 객체는 바로 이전의 배경그림을 그대로 계속 그릴 수도 있고, 아니면 처음부터 마련해 둔 기본 배경그림을 사용할 수도 있을 것입니다.이 부분은 전적으로 함수를 만든 사람에 달려 있지요.하지만 사용자 쪽에서는 어떻게 될지 예측할 수 없습니다 (알아내려면, 현재의 배경그림이 무엇인지를 알려 주는 다른 멤버 함수를 호출하든지 해야겠지요).
l 강력한 보장(string guarantee): 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장입니다. 이런 함수를 호출하는 것은 원자적(atomic) 동작이라고 할 수 있습니다. 호출이 성공하면 (예외가 발생하지 않으면) 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다는 면에서 말이죠.
‘쓰기 편한가’의 측면에서 보면 강력한 보장을 제공하는 함수가 기본 보장을 제공하는 함수보다 더 쉽습니다. 예측할 수 있는 프로그램의 상태가 2개 밖에 안 되기 때문입니다. 그러니까 함수가 성공적으로 실행을 마친 후의 상태,아니면 함수가 호출될 때의 상태만 존재하는 거죠. 이와 대조적으로 함수가 기본 보장을 제공하는 경우에는, 예외 발생 시에 프로그램이 있을 수 있는 상태가 그냥 유효하기만 하면, 어떤 상태도 될 수 있습니다.
‘쓰기 편한가’의 측면에서 보면 강력한 보장을 제공하는 함수가 기본 보장을 제공하는 함수보다 더 쉽습니다. 예측할 수 있는 프로그램의 상태가 2개 밖에 안 되기 때문입니다. 그러니까 함수가 성공적으로 실행을 마친 후의 상태,아니면 함수가 호출될 때의 상태만 존재하는 거죠. 이와 대조적으로 함수가 기본 보장을 제공하는 경우에는, 예외 발생 시에 프로그램이 있을 수 있는 상태가 그냥 유효하기만 하면, 어떤 상태도 될 수 있습니다.
l 예외불가 보장(nothrow guarantee)예외를 절대로 던지지 않겠다는 보장입니다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이죠. 기본 제공 타입(int, 포인터 등)에 대한 모든 연산은 예외를 던지지 않게 되어 있습니다 (즉, 예외불가 보장이 제공됩니다). 예외에 안전한 코드를 만들기 위한 가장 기본적이며 핵심적인 요소가 아닐까 싶네요.어떤 예외도 던지지 않게끔 예외 지정이 된 함수는, 예외불가 보장을 제공한다고 생각해도 일견 맞을 것 같지만, 잘못 생각하신 겁니다.
Int doSomething() throw(); // 비어 있는 예외 지정.
|
위의 함수 선언이 전하는 메시지는 doSomething이 절대로 예외를 던지지 않겠다는 말이 아닙니다. 만약 doSomething에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자는 unexpected 함수가 호출되어야 한다는 뜻입니다. 사실 doSomething은 어떤 예외 안전성 보장도 제공하지 않을 수도 있습니다. 함수 선언문에는(예외 지정이 붙어 있으면 이것도 포함됩니다) 해당 함수가 맞는지,이식성이 있는지, 아니면 효율적인지 알려 주는 기능 같은 것이 없습니다. 예외 안전성 보장을 제공하는지도 당연히 알려 주지 않습니다. 함수가 어떤 특성을 갖느냐 하는 부분은 ‘구현’이 결정하는 것입니다. ‘선언’은 그냥 선거공약 같은 거라고요.
앞에서 말했지만, 예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 합니다. 아무 보장도 제공하지 않으면 예외에 안전한 함수가 아닙니다. 따라서 여러분이 ‘선택’해야 하는 것은 ‘어떤 보장을 제공할 것인가’이겠습니다.예외 안전성이 없는 재래식(legacy) 코드를 사용해서 작업할 때를 제외하면(이 부분에 대한 이야기는 이번 항목의 뒷부분에서 따로 설명하겠습니다),
위의 3가지 보장 중에 하나를 고르라면 아무래도 실용성이 있는 강력한 보장이 괜찮아 보일 것입니다. 예외 안정성의 관점에서 보면 예외불가 보장이 가장 훌륭하겠지만, 예외를 던지는 함수를 호출하지 않고 C++의 C 부분으로부터 벗어나오기란 힘들거든요.일단 동적 할당 메모리를 사용하는 쪽(STL 컨테이너가 실제로 그렇습니다)만 보아도, 요청에 맞는 메모리를 확보할 수 없으면 bad_alloc 예외를 던지도록 구현되어 있지 않습니까(항목49).뭐 할 수 있으면 예외불가 보장을 제공하세요. 하지만 현실적으로는 대부분의 함수에 있어서 기본적인 보장과 강력한 보장 중 하나를 고르게 됩니다.
changeBackground를 다시 보자, 이 함수의 경우엔 강력한 보장을 거의 제공하는 것은 그다지 어렵지 않습니다.첫째로, PrettyMenu의 bgImage 데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담용 포인터(항목13)로 바꿉니다. 자원 누출을 막는 대책으로 본다면 이렇게 가는 게 딱 맞습니다.사용자에게 강력한 예외 안전성 보장을 제공할 수 있게 만든 것뿐인데 ‘객체(스마트 포인터 등)를 써서 자원을 관리하는 것이 좋은 설계의 첫걸음(항목13)아래 코드에서 자원관리용 객체로 tr1::shared_ptr을 쓸 겁니다. ato_ptr도 있긴 하지만, 복사될 때의 동작이 더 직관적이라 사용하기가 더 좋거든요.
둘째로, changeBackground 함수 내의 문장을 재배치해서 배경그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만듭니다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우에는, 해당 동작이 실제로 일어날 때까지 그 객체의 상태를 바꾸지 않는 편이 좋다고 하지요.
class PrettyMenu {
public:
...
std::tr1::shared_ptr<Image> bgImage;
...
};
|
void Pretty::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex);
bgImage.reset(new Image(imgSrc)); // bgImage의 내부 포인터를 “new Image” 표현식의 실행 결과로 바꿔치기합니다.
++imageChanges;
}
|
이제는 이전의 배경그림(Image 객체)을 프로그래머가 직접 삭제할 필요가 없게 되었습니다. 지금은 배경그림이 스마트 포인터의 손에서 관리되고 있기 때문입니다.게다가, 새로운 배경그림이 제대로 만들어졌을 때만 이전 배경그림의 삭제 작업이 이루어지도록 바뀐 점도 눈에 들어옵니다.다시 말해, 이제는 tr1::shared_ptr::reset 함수가 호출되려면 이 함수의 매개변수(“new Image(imgScr)”의 결과)가 제대로 생성되어야 한다는 것입니다.
delete 연산자는 reset 함수 안에 쏙 들어 있기 때문에, reset이 불리지 않는 한 delete도 쓰일 일이 없을 것입니다.또, 객체(tr1::shared_ptr)를 써서 자원(동적 할당된 Image 객체)을 관리하게 하니까 changeBackground 함수의 길이까지 줄어들었습니다.
delete 연산자는 reset 함수 안에 쏙 들어 있기 때문에, reset이 불리지 않는 한 delete도 쓰일 일이 없을 것입니다.또, 객체(tr1::shared_ptr)를 써서 자원(동적 할당된 Image 객체)을 관리하게 하니까 changeBackground 함수의 길이까지 줄어들었습니다.
매개변수 imgScr, Image 클래스의 생성자가 실행되다가 예외를 일으킬 때, 그 시점에 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분히 있을 테고, 이 표시자의 이동이 전체 프로그램의 나머지에 영향을 미칠 수 있는 어떤 변화로 작용할 수도 있을 것입니다.따라서 엄밀히 말하면 changeBackground가 제공하는 예외 안전성 보장은 기본적인 보장입니다.
예외 안전성 보장을 제공하는 함수로 거듭나게 만드는 일반적인 설계 전략을 하나 알아보도록 하죠. 이 전략은 ‘복사 후 – 맞바꾸기(copy-and-swap)’라는 이름으로 알려져 있는데, 원리적으로 무척 간단합니다. 어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고 그 사본을 수정하는 것입니다. 이렇게 하면 수정 동작 중에 실행되는 연산에서 예외가 던져지더라도 원본 객체는 바뀌지 않은 채로 남는 거죠. 필요한 동작이 전부 성공적으로 완료되고 나면 수정된 객체를 원본 객체와 맞바꾸는데, 이 작업을 ‘예외를 던지지 않는’ 연산 내부에서 수행합니다.
이 전략은 대개 ‘진짜’ 객체의 모든 데이터를 별도의 구현(implementation) 객체에 넣어두고, 그 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 하는 식으로 구현합니다.
‘pimp 관용구’라고들 부르는 이 구현 방법은 항목31에서 …
‘pimp 관용구’라고들 부르는 이 구현 방법은 항목31에서 …
이 방법을 PrettyMenu에 적용한 코드가 다음이다.
struct PMImpl { // PMImpl = “PrettyMenuImpl”
std::tr1::shared_ptr<Image> bgImage; // PMImpl이 struct로 선언된 데에는 이유가 있습니다. 아래에서 확인하세요.
int imageChanges;
};
class PrettyMenu {
…
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgScr)
{
using std::swap; // 항목25.
Lock m1(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(&pImpl)); // 객체의 데이터 부분을 복사합니다.
pNew->bgImage.reset(new Image(imgScr)); // 사본을 수정합니다.
++pNeww->imageChanges;
Swap(pImpl, pNew); // 새 데이터로 바꿔 넣어 진짜로 배경그림을 바꿉니다.
}
|
‘복사-후-맞바꾸기’ 전략은 객체의 상태를 ‘전부 바꾸거나 혹은 안 바꾸거나(all-or-nothing)’ 방식으로 유지하려는 경우에 아주 그만입니다. 그러나 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다는 것이 일반적인 정설입니다.
왜 그럴까요? changeBackground 함수의 전체 흐름을 추상화해 놓은 someFunc()를 한번 살펴봅시다. ‘복사-후-맞바꾸기’ 수법을 쓰되, f1 및 f2라는 다른 함수의 호출문이 들어 있는 형태로 말이죠. 다음과 같은 형태로 나올 겁니다.
void somdFunc()
{
… // 이 함수의 현재 상태에 대해 사본을 만들어 놓습니다.
f1();
f2();
… // 변경된 상태를 바꾸어 넣습니다.
}
|
f1 혹은 f2 에서 보장하는 예외 안전성이 ‘강력’하지 못하면, 위의 구조로는 someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들어집니다. 예를 들어 f1이 기본적인 보장만 제공한다고 가정하면, someFunc 함수에서 강력한 보장을 제공하게 만들려면 (1) f1을 호출하기 전에 프로그램 전체의 상태를 결정하고 (2) f1에서 발생하는 모든 예외를 잡아낸 후에 (3) 원래의 상태로 되돌리는 코드를 작성해야 합니다.
f1 및 f2 모두가 강력한 예외 안전성을 보장한다고 해도 사실 별로 나아지는 것은 없습니다. 예를 들어 어차피 f1이 끝까지 실행되고 나면, 프로그램 상태는 f1에 의해 어떻게든 변해 있을 것이고, 그 다음에 f2가 실행되다가 예외를 던지면 그 프로그램의 상태는 someFunc가 호출될 때의 상태와 아예 달라져 있을 것이니까요. f2에서 아무것도 바꾸지 않았더라도 말입니다.
여기서 불거지는 문제가 바로 함수의 부수효과(side effect)입니다.자기 자신에만 국한된 것들의 상태를 바꾸며 동작하는 함수의 경우(예를 들어 someFunc는 이 함수의 내부에서만 사용하는 객체의 상태에만 영향을 주고 있죠)에는 강력한 보장을 제공하기가 비교적 수월합니다. 그렇지만 비지역 데이터에 대해 부수효과를 주는 함수는 이렇게 하기가 무척 까다롭습니다.
강력한 예외 안전성 보장을 제공하게 하고 싶어서 아무리 열을 내더라도 이런 문제 때문에 발목을 잡힐 수 있다는 사실을 알고 계셨으면 좋겠습니다. 참, 효율 문제도 무시할 수 없습니다. ‘복사-후-맞바꾸기’ 방법의 요체는 객체의 데이터에 대해 사본을 만들어 놓고 그 사본을 변경한 후에, 사본과 원본의 바꿔치기 작업을 예외를 던지지 않는 함수 내부에서 하자는 아이디어 입니다. 때문에 수정하고 싶은 객체를 복사해 둘 공간과 복사에 걸리는 시간을 감수해야 하겠지요. 이런 부분에 여유가 없거나 왠지 꺼림칙한 분이 분명히 있을 것입니다. 어쨌든 예외 안전성 보장 중에는 강력한 보장이 가장 좋습니다. 실용이 확보되는 경우라면 반드시 제공하는 게 맞고요. 그러나 언제나 실용적인 것은 아니랍니다.
l 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적보장, 강력한보장, 예외금지보장이 있습니다.
l 강력한 예외 안전성 보장은 ‘복사-후-맞바꾸기’ 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.
l 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.