예쁜 배경그림을 깔고 나오는 GUI 메뉴를 구현하기 위해 클래스를 하나 만든다고 가정합시다. 이 클래스는 스레딩 환경에서 동작할 수 있도록 설계되었기 때문에, 병행성 제어를 위해 뮤텍스(mutex)를 갖고 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 | class PrettyMenu { public: ... void changeBackground(std::istream& imgSrc); // 배경그림을 ... // 바꾸는 멤버 함수 private: Mutex mutex; // 이 객체 하나를 위한 뮤텍스 Image *bgImage; // 현재의 배경그림 int imageChanges; // 배경그림이 바뀐 횟수 }; | cs |
여기서 PrettyMenu의 changeBackground 함수가 다음과 같이 구현되었다고 생각해보세요.
1 2 3 4 5 6 7 8 9 10 | void PrettyMenu::changeBackground(std::istream& imgSrc) { lock(&mutex); // 뮤텍스를 획득합니다. delete bgImage; // 이전의 배경그림을 없앱니다. ++imageChanges; // 그림 변경 횟수를 갱신합니다. bgImage = new Image(imgSrc); // 새 배경그림을 깔아 놓습니다. unlock(&mutex); // 뮤텍스를 해제합니다. } | cs |
예외 안전성이라는 측면에서 볼 때 이 함수는 상당히 좋지 않은 함수입니다. 일반적으로 예외 안전성을 확보하려면 두 가지의 요구사항을 맞추어야 합니다. 예외 안전성을 가진 함수라면 예외가 발생할 때 다음과 같이 동작해야 합니다.
자원이 새도록 만들지 않습니다.
그런데 위의 코드는 자원이 샙니다.
왜냐하면 "new Image(imgSrc)" 표현식에서 예외를 던지면 unlock 함수가 실행되지 않게 되어 뮤텍스가 계속 잡힌 상태로 남기 때문입니다.
자료구조가 더럽혀지는 것을 허용하지 않습니다.
그런데 위의 코드에서 "new Image(imgSrc)"가 예외를 던지면 bgImage가 가리키는 객체는 이미 삭제된 후입니다.
또한 새 그림이 제대로 깔린 게 아닌데도 imageChanges 변수는 이미 증가되었을 것입니다.
위 함수의 자원 누출 문제는 객체를 써서 자원 관리를 전담케 하는 방법과 뮤텍스를 적절한 시점에 해제하는 방법을 구현한 Lock 클래스를 사용하는 것으로 마무리됩니다. Lock 등의 자원관리 전담 클래스를 쓰면 좋은 점 중 하나는 함수의 코드 길이가 짧아진다는 것입니다. 늘 그런 건 아니겠지만, 대개 코드는 적을수록 좋습니다. 어긋날 일도 적어질 것이고, 뭔가를 바꿨을 때 잘못 이해할 것도 적어질 테니까요.
1 2 3 4 5 6 7 8 | void PrettyMenu::changeBackground(std::istream& imgSrc) { Lock ml(&mutex); // 뮤텍스를 대신 획득하고 이것이 필요 없어질 // 시점에 바로 해제해 주는 객체입니다. delete bgImage; ++imageChanges; bgImage = new Image(imgSrc); } | cs |
자원 누출 문제는 해결했으나, 자료구조 오염 문제가 남았습니다. 여기서는 선택이 필요한데, 일단 그전에 고를 수 있는 것이 무엇인지를 파악하기 위해 용어를 알아야 합니다. 예외 안전성을 갖춘 함수는 다음 세 가지 보장(guarantee) 중 하나를 제공합니다.
기본적인 보장(basic guarantee)
함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장입니다.
어떤 객체나 자료구조도 더렵혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지하고 있습니다(즉, 모든 클래스 불변속성이 만족된 상태입니다).
하지만 프로그램의 상태가 정확히 어떠한지는 예측이 안 될 수도 있습니다.
예를 들어, changeBackground 함수가 동작하다가 예외가 발생했을 때 PrettyMenu 객체는 바로 이전의 배경그림을 그대로 계속 그릴 수도 있고, 아니면 처음부터 마련해 둔 기본 배경그림을 사용할 수도 있을 것입니다.
이 부분은 전적으로 함수를 만들 사람에 달려 있지만, 사용자 쪽에서는 어떻게 될지 예측할 수 없습니다.
강력한 보장(strong guarantee)
함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장입니다.
이런 함수를 호출하는 것은 원자적인(atomic) 동작이라고 할 수 있습니다.
호출이 성공하면(예외가 발생하지 않으면) 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아갑니다.
예측할 수 있는 프로그램의 상태가 두 개밖에 안 되기 때문에, '쓰기 편한가'의 측면에서 보면 강력한 보장을 제공하는 함수가 기본 보장을 제공하는 함수보다 더 쉽습니다.
예외불가 보장(nothrow guarantee)
예외를 절대로 던지지 않겠다는 보장입니다.
약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이죠.
기본제공 타입(int, 포인터 등)에 대한 모든 연산은 예외를 던지지 않게 되어 있습니다(즉, 예외불가 보장이 제공됩니다).
하지만 어떤 예외도 던지지 않게끔 예외 지정이 된 함수가 예외불가 보장을 제공하는 것은 아닙니다.
이는 만약 해당 함수에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자인 unexpected 함수가 호출되어야 한다는 뜻입니다.
예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 합니다. 따라서 여러분이 '선택'해야 하는 것은 '어떤 보장을 제공할 것인가'이겠습니다. 예외 안전성의 관점에서 보면 예외불가 보장이 가장 훌륭하겠지만, 현실적으로는 대부분의 함수에 있어서 기본적인 보장과 강력한 보장 중 하나를 고르게 됩니다.
changeBackground 함수의 경우엔 강력한 보장을 거의 제공하는 것은 그다지 어렵지 않습니다. 우선 첫째로, PrettyMenu의 bgImage 데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담용 포인터로 바꿉니다. 둘째로, changeBackground 함수 내의 문장을 재배치해서 배경그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만듭니다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우에는 해당 동작이 실제로 일어날 때까지 그 객체의 상태를 바꾸지 않는 편이 일반적으로 좋다고 하지요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class PrettyMenu { ... std::tr1::shared_ptr<Image> bgImage; ... }; void PrettyMenu::changeBackground(std::istream& imgSrc) { Lock ml(&mutex); bgImage.reset(new Image(imgSrc)); // bgImage의 내부 포인터를 // "new Image" 표현식의 실행 결과로 // 바꿔치기합니다. ++imageChanges; } | cs |
changeBackground 함수에서 강력한 예외 안전성 보장을 제공하려면 현재 상태로 거의 충분합니다. 문제는 매개변수인 imgSrc입니다. Image 클래스의 생성자가 실행되다가 예외를 일으킬 때, 그 시점에 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분히 있을 테고, 이 표시자의 이동이 전체 프로그램의 나머지에 영향을 미칠 수 있는 어떤 변화로 작용할 수도 있을 것입니다. 따라서 엄밀히 말하면 changeBackground가 제공하는 예외 안전성 보장은 기본적인 보장입니다. 이 문제는 매개변수 타입으로 istream을 쓰지 않고, 배경그림 파일의 이름을 나타내는 타입 같은 것으로 바꾸면 출분히 해결될 문제입니다.
이번에는 예외에 속수무책인 함수를 탈바꿈시켜 강력한 예외 안전성 보장을 제공하는 함수로 거듭나게 만드는 일반적인 설계 전략을 하나 알아보도록 하죠. 이 전략은 '복사-후-맞바꾸기(copy-and-swap)'라는 이름으로 알려져 있는데, 원리적으로 무척 간단합니다. 어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고 그 사본을 수정하는 것입니다. 이렇게 하면 수정 동작 중에 실행되는 연산에서 예외가 던져지더라도 원본 객체는 바뀌지 않은 채로 남는 거죠. 필요한 동작이 전부 성공적으로 완료되고 나면 수정된 객체를 원본 객체와 맞바꾸는데, 이 작업을 '예외를 던지지 않는' 연산 내부에서 수행합니다. 이 전략은 대개 '진짜' 객체의 모든 데이터를 별도의 구현(implementation) 객체에 넣어두고, 그 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 하는 식으로 구현합니다. 흔히 'pimpl 관용구'라고 부르는 구현 방법입니다.
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 | struct PMImpl { std::tr1::shared_ptr<Image> bgImage; int imageChanges; }; class PrettyMenu { ... private: Mutex mutex; std::tr1::shared_ptr<PMImpl> pImpl; }; void PrettyMenu::changeBackground(std::istream& imgSrc) { using std::swap; Lock ml(&mutex); // 뮤텍스를 획득합니다. std::tr1::shared_ptr<PMImpl> // 객체의 데이터 부분을 복사합니다. pNew(new PMImpl(*pImpl)); pNew->bgImage.reset(new Image(imgSrc)); // 사본을 수정합니다. ++pNew->imageChanges; swap(pImpl, pNew); // 새 데이터로 바꿔 넣어 // 진짜로 배경그림을 바꿉니다. } // 뮤텍스를 놓습니다. | cs |
'복사-후-맞바꾸기' 전략은 객체의 상태를 '전부 버꾸거나 혹은 안 바꾸거나(all-or-nothing)' 방식으로 유지하려는 경우에 아주 그만입니다. 그러나 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다는 것이 일반적인 정설입니다. changeBackground 함수의 전체 흐름을 추상화해 놓은 someFunc()를 한번 살펴봅시다. '복사-후-맞바꾸기' 수법을 쓰되, f1 및 f2라는 다른 함수의 호출문이 들어 있는 형태로 말이죠.
1 2 3 4 5 6 7 | void someFunc() { ... // 이 함수의 현재 상태에 대해 사본을 만들어 놓습니다. f1(); f2(); ... // 변경된 상태를 바꾸어 넣습니다. } | cs |
f1 혹은 f2에서 보장하는 예외 안전성이 '강력'하지 못하면, 위의 구조로는 someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들어집니다. 예를 들어 f1이 기본적인 보장만 제공한다고 가정해 봅시다. someFunc 함수에서 강력한 보장을 제공하게 만들려면, f1을 호출하기 전에 프로그램 전체의 상태를 결정하고, f1에서 발생하는 모든 예외를 잡아낸 후에, 원래의 상태로 되돌리는 코드를 작성해야 합니다. f1 및 f2 모두가 강력한 예외 안전성을 보장한다고 해도 사실 별로 나아지는 것은 없습니다. 예를 들어 어차피 f1이 끝까지 실행되고 나면 프로그램 상태는 f1에 의해 어떻게든 변해 있을 것이고, 그 다음에 f2가 실행되다가 예외를 던지면 그 프로그램의 상태는 f2에서 아무것도 바꾸지 않았더라도 someFunc가 호출될 때의 상태와 아예 달라져 있을 것이니까요.
여기서 불거지는 문제가 바로 함수의 부수효과(side effect)입니다. 자기 자신에만 국한된 것들의 상태를 바꾸며 동작하는 함수의 경우(예를 들어 someFunc는 이 함수의 내부에서만 사용하는 객체의 상태에만 영향을 주고 있죠)에는 강력한 보장을 제공하기가 비교적 수월합니다. 그렇지만 비지역 데이터에 대해 부수효과를 주는 함수는 이렇게 하기가 무척 까다롭습니다. 예를 들어 f1을 호출하고 나서 생기는 부수효과로서 데이터베이스가 변경되기라도 하면, someFunc 쪽에서 어떻게 손을 쓸 수가 없습니다.
강력한 예외 안전성 보장을 제공하고 싶어도 이런 문제 때문에 발목 잡힐 수 있다는 사실을 알고 있어야 합니다. 또한 '복사-후-맞바꾸기' 방법은 수정하고 싶은 객체를 복사해 둘 공간과 복사에 걸리는 시간을 감수해야 하기에, 효율 문제도 무시할 수 없습니다. 이런 이유 때문에 꺼림칙하긴 하나 예외 안전성 보장 중에는 강력한 보장이 가장 좋습니다. 실용성이 확보되는 경우라면 반드시 제공하는 게 맞습니다. 하지만 효율 혹은 복잡성에서 생기는 비용이 문제가 되는 경우에는 기본적인 보장이 우선입니다.
소프트웨어 시스템은 예외에 안전하거나 예외에 뚫려 있거나 둘 중 하나입니다. 일부만 예외 안전성을 갖춘 시스템 같은 것은 없다는 말입니다. 예외 안전성이 없는 함수가 한 개라도 쓰이고 있으면 그 시스템은 전부가 예외에 안전하지 않은 시스템입니다.
앞으로는 새로운 함수를 만들거나 기존의 코드를 고칠 때 '어떻게 하면 예외에 안전한 코드를 만들까'를 진지하게 고민해야 합니다. 자원 관리가 필요할 때 자원 관리용 객체를 사용하는 것부터가 시작입니다. 그리고 예외 안전성 보장 세 가지 중에 여러분이 만드는 함수에서 실용적으로 제공할 수 있는 보장은 어떤 것일지를 결정합니다. 여러분이 내린 결정은 반드시 문서로 남겨서, 여러분이 만든 함수의 사용자 및 나중의 인수인계자가 파악할 수 있도록 하세요. 예외 안전성 보장은 함수의 인터페이스에서 외부에 노출되는 아주 중요한 부분이므로, 함수 인터페이스의 다른 부분을 결정할 때와 같은 마음으로 신중하게 결정해야 합니다.
예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다.
이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다.
강력한 예외 안전성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.
어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.