[Effective C++] 5. 구현 [2/3]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 28 : 내부에서 사용하는 객체에 대한 ‘핸들’을 반환하는 코드는 되도록 피하자
사각형을 사용하는 어떤 응용 프로그램을 만들고 있다. 사각형은 좌측 상단 및 우측 하단을 꼭짓점 두 개로 나타낼 수 있다, 이것을 추상화한 Rectangle 클래스를 만들었는데, 이 클래스의 객체를 썼을 때의 메모리 부담을 최대한 줄이고 싶다는 생각이 우리 머리를 스쳤다. 사각형의 영역을 정의하는 꼭짓점을 Rectangle 자체에 넣으면 안될 것 같고, 이것들을 별도의 구조체에 넣은 후에 Rectangle이 이 구조체를 가리키도록 하면 어떨까 하는 생각이 들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Point
{
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData
{
Point ulhc; // ulhc = 좌측 상단(upper left-hand corner)
Point lrhc; // lrhc = 우측 하단(lower right-hand corner)
};
class Rectangle
{
...
private:
std::tr1::shared_ptr<RectData> pData;
};
Rectangle 클래스의 사용자는 분명히 영역정보를 알아내어 쓸 떄가 있을 것이므로, Rectangle 클래스에는 upperLeft 및 lowerRight 함수가 멤버 함수로 들어있다. 그런데 그러고 보니 Point가 사용자 정의 타입인 것이 눈에 들어오면서 항목 20에서 읽었던 내용이 떠오르기 시작한다. 사용자 정의 타입을 전달할 때는 값에 의한 전달보다 참조에 의한 전달방식을 쓰는 편이 더 효율적이라고 누군가가 부르짖었던 것 같다. 그래서 이들 두 멤버 함수는 포인터로 물어둔 Point 객체에 대한 참조자를 반환하는 형태로 만들어졌다.
1
2
3
4
5
6
7
8
class Rectangle
{
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
};
컴파일은 잘 된다. 그런데 결정적으로 틀렸다. 조금만 들여다보면 자기모순적인 코드 임을 알 수 있다. 우선 upperLeft 함수와 lowerRight 함수가 어떻게 선언되어 있는지 보자. 상수 멤버 함수이다. 원래 Rectangle의 꼭짓점 정보를 알아낼 수 있는 방법만 사용자에게 제공하고, Rectangle 객체를 수정하는 일은 할 수 없도록 설계되었다. 그런데 이 함수들이 반환하는 게 어떤 건지 보자. private 멤버인 내부 데이터에 대한 참조자이다. 호출부에서 이 참조자를 써서 내부 데이터를 맘대로 수정해도 좋다는 뜻이 되어버린다! 그러니까 다음과 같이 쓰면,
1
2
3
4
5
6
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rect(coord1, coord2); // (0,0) ~ (100,100)
rect.upperLeft().setX(50); // (50, 0) ~ (100, 100)
upperLeft를 호출한 쪽은 rec의 은밀한 곳에 숨겨진 Point 데이터 멤버를 참조자로 끌어와 척척 바꿀 수 있다는 것이다. 하지만 rec은 상수 객체로 선언된 것 아닌가?
우리는 여기서 두 가지 교훈을 얻을 수 있다.
첫째, 클래스 데이터 멤버는 아무리 숨겨봤자 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다는 점이다. 말이 좀 어렵지만, 지금의 경우를 놓고 설명하면 ulhc와 lrhc는 private으로 선언되어 있는데, 실질적으로는 public 멤버이다. 왜냐하면 이들의 참조자를 반환하는 함수들이 public으로 선언되어 있기 때문이다.
둘째, 어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다는 점이다.
지금은 참조자를 반환하는 멤버 함수만 붙들고 열심히 이야기하고 있는데, 만약 이들이 포인터나 반복자를 반환하도록 되어 있었다 해도 마찬가지 이유로 인해 마찬가지 문제가 생긴다. 참조자, 포인터 및 반복자는 어쨌든 모두 핸들(handle, 다른 객체에 손을 댈 수 있게 하는 매개자)이고, 어떤 객체의 내부요소에 대한 핸들을 반환하게 만들면 언제든지 그 객체의 캡슐화를 무너뜨리는 위험을 무릅쓸 수 밖에 없다. 우리도 보았지만, 이것 때문에 상수 멤버 함수조차도 객체 상태의 변경을 허용하는 지경에까지 이를 수 있다.
어떤 객체의 ‘내부요소’라고 하면 흔히들 데이터 멤버만 생각하는 사람들이 많은데, 일반적인 수단으로 접근이 불가능한 (protected이나 private으로 선언된) 멤버 함수도 객체의 내부요소에 들어간다. 그러니 어떻겠는가? 이들에 대한 핸들도 반환하지 말아야 한다. 즉, 외부 공개가 차단된 멤버 함수에 대해, 이들의 포인터를 반환하는 멤버 함수를 만드는 일이 절대로 없어야 한다는 말이다. 이런 함수가 하나라도 들어가는 순간부터 실질적인 접근 수준이 바뀐다. 당연히, 멤버 함수 포인터를 반환하는 함수의 접근도에 맞춰지는 것이다. protected 혹은 private 멤버로 선언된 함수라 해도 사용자 측면에서는 얼마든지 이들의 포인터를 얻어내어 호출해 버릴 수 있다.
하지만 멤버 함수의 포인터를 반환하는 함수가 그렇게 흔치 않은 것이 사실이므로, 다시 Rectangle 클래스와 그의 멤버 함수인 upperLeft 및 lowerRight의 이야기로 돌아가자. 이들 멤버 함수가 가진 문제 두 개는 위에서 함께 봤는데, 이렇게 하면 둘 다 간단히 해결된다. 반환 타입에 const를 붙여주자.
1
2
3
4
5
6
7
8
class Rectangle
{
public:
...
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
...
};
이렇게 설계하면 사용자는 사각형을 정의하는 꼭짓점 쌍을 읽을 수는 있지만 쓸 수는 없게 된다. 말하지만 upperLeft와 lowerRight에 const를 붙여 선언한 게 이젠 거짓말이 아니라는 이야기다. 호출부에서 객체의 상태를 바꾸지 못하도록 컴파일러 수준에서 막고 있다. 그리고 캡슐화 문제를 보면, 사용자들이 Rectangle을 구성하는 Point를 들여다보도록 하자는 것은 처음부터 알고 시작한 설계이기 때문에, 이 부분은 의도적인 캡슐화 완화라고 할 수 있다. 이보다 더 중요한 부분은 느슨하게 만든 데에도 제한을 두었다는 것이다. 읽기 접근만 주어지고, 쓰기 접근은 여전히 금지다.
뭔가 한 것 같지만 그래도 찝찝하다. upperLeft와 lowerRight 함수를 보면 내부 데이터에 대한 핸들을 반환하고 있는 부분이 남아 있다. 이것을 남겨두면 다른 쪽에서 문제가 될 수 있다. 가장 큰 문제가 무효참조 핸들(dangling handle)로서, 핸들이 있기는 하지만 그 핸들을 따라 갔을 때 실제 객체의 데이터가 없는 것이다. 핸들이 물고 있는 객체가 기약도 없이 어디론가 증발하는 현상은 함수가 객체를 값으로 반환할 경우에 가장 흔하게 발생된다. 에를 하나 들어보자. 어떤 GUI 객체의 사각 테두리 영역을 Rectangle 객체로 반환하는 함수가 있다고 가정하자.
1
2
class GUIObject { ... };
const Rectangle boudingBox(const GUIObject& obj);
이 상태에서 어떤 사용자가 이 함수를 사용한다고 생각해 보자.
1
2
3
GUIObject* pgo; // pgo를 써서 임의의 GUIObject를 가리키도록 한다.
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
가장 마지막 문장을 보면, boundingBox 함수를 호출하면 Rectangle 임시 객체가 새로 만들어진다. 이 객체는 겉으로 드러나는 이름 같은 것이 없으므로 일단 temp라고 부르자. 다음엔 이 temp에 대해 upperLeft가 호출될 텐데, 이 호출로 인해 temp의 내부 데이터, 정확히 말하면 두 Point 객체 중 하나에 대한 참조자가 나온다. 마지막으로 이 참조자에 & 연산자를 건 결과 값(주소)이 pUpperLeft 포인터에 대입되는 것이다. 여기까지는 대충 행복하지만, 아직 끝나기엔 이르다. 이 문장이 끝날 무렵, boundingBox 함수의 반환 값(임시 객체인 temp)이 소멸된다는 사실을 넘어가면 안 된다. temp가 소멸되니, 그 안에 들어 있는 Point 객체들도 덩달아 없어질 것이다. 자 그러면, pUpperLeft 포인터가 가리키는 객체는 이제 날아가고 없게 된다. 다시 말해 이 문장은 pUpperLeft에게 객체를 달아 줬다가 주소 값만 남기고 몽땅 빼앗아 간 거란 말이다.
객체의 내부에 대한 핸들을 반환하는 함수는 어떻게든 위험하다는 말이 이래서 나오는 것이다. 핸들이 무엇이냐는 상관없다. 포인터/참조자/반복자 모두 위험하기는 마찬가지다. 핸들에 const를 붙였느냐 안 붙였느냐도 상관없다. 핸들을 반환하는 함수가 상수 멤버냐 아니냐도 상관없다. 핸들을 반환하는 함수라는 사실, 그것 빼고는 아무것도 중요하지 않다. 일단 바깥으로 떨어져 나간 핸들은 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문이다.
그렇다고 해서 핸들을 반환하는 멤버 함수를 절대로 두지 말라는 이야기가 아니다. 이번 항목의 제목을 보면, ‘피하자’이다. 어쩌다 보면 필요할 때도 있다 예를 들어 operator[] 연산자는 string이나 vector 등의 클래스에서 개개의 원소를 참조할 수 있게 만드는 용도로 제공되고 있는데, 실제로 이 연산자는 해당 클래스에 들어 있는 개개의 원소 데이터에 대한 참조자를 반환하는 식으로 동작한다. 물론 이 원소 데이터는 컨테이너가 사라질 때 같이 사라지는 데이터다. 하지만 이런 함수는 예외적인 것이고 일반적인 규칙은 아니다.
어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하자. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있다.
항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
예외 안전성을 확보하는 작업은 상당히 힘들다.
예쁜 배경 그림을 깔고 나오는 GUI 메뉴를 구현하기 위해 클래스를 하나 만든다고 가정하자. 이 클래스는 스레딩 환경에서 동작할 수 있도록 설계되었기 때문에, 병행성 제어를 위해 뮤텍스를 가지고 있다. 자, 일단 보자.
1
2
3
4
5
6
7
8
9
10
11
class PrettyMenu
{
public:
...
void changeBackground(std::istream& imgSrc); // 배경그림을 바꾸는 멤버 함수
...
private:
Mutex mutex;
Image *bgImage;
int imageChanges;
};
여기서 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);
}
예외 안전성이라는 측면에서 볼 때 이 함수는 “이보다 더 나쁠 수는 없다”고 말할 수 있다. 일반적으로 예외 안전성을 확보하려면 두 가지의 요구사항을 맞추어야 하는데, 이 함수는 어느 요구사항에도 맞지 않는, 위험한 함수이다.
예외 안전성을 가진 함수라면 예외가 발생할 때 이렇게 동작해야 한다.
자원이 새도록 만들지 않는다.
그런데 위의 코드는 자원이 샌다. 왜냐하면 “new Image(imgSrc)” 표현식에서 예외를 던지면 unlock 함수가 실행되지 않게 되어 뮤텍스가 계속 잡힌 상태로 남기 때문이다.
자료구조가 더렵혀지는 것을 허용하지 않는다.
그런데 위의 코드에서 “new Image(imgSrc)”가 예외를 던지면 bgImage가 가리키는 객체는 이미 삭제된 후이다. 그뿐인가? 새 그림이 제대로 깔린 게 아닌데도 imageChanges 변수는 이미 증가되었을 것이다.
자원 누출 문제는 맞서 싸우기가 그렇게 까다로운 것은 아니다. 객체를 써서 자원 관리를 전담케 하는 방법을 항목 13에서 읽고, 항목 13의 아이디어를 살려 뮤텍스를 적절한 시점에서 해제하는 방법을 구현한 Lock 클래스를 항목 14에서 접했으면, 그대로 따라하는 것으로 마무리된다.
1
2
3
4
5
6
7
8
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex); // 항목 14에서 가져왔다. 뮤텍스를 대신 획득하고 이것이 필요 없어질 시점에 바로 해제해 주는 객체이다.
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
Lock 등의 자원관리 전담 클래스를 쓰면 가장 좋은 점 중 하나는 함수의 코드 길이가 짧아진다는 것이다. 지금은 unlock을 호출할 필요가 없다. 늘 그런 건 아니겠지만, 대개 코드는 적으면 적을 수록 좋다. 어긋날 일도 적어질 것이고, 뭔가를 바꿨을 때 잘못 이해할 것도 적어질 것이다.
이렇게 해서 일단 자원 누출 문제는 꼬리를 내렸다. 이제 다음 판에서 만날 상대는 자료구조 오염 문제이다. 여기서는 선택을 좀 해야 한다. 일단 그 전에 우리가 고를 수 있는 것들이 무엇인지를 제대로 파악하기 위해 용어 공부가 필요할 것 같다.
예외 안전성을 갖춘 함수는 아래의 세 가지 보장 중 하나를 제공한다.
기본적인 보장(basic guarantee)
함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장이다. 어떤 객체나 자료구조도 더럽혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지하고 있다. 즉, 모든 클래스 불변속성이 만족된 상태이다. 하지만 프로그램의 상태가 정확히 어떠한지는 예측이 안 될 수도 있다. 예를 들어, changeBackground 함수가 동작하다가 예외가 발생했을 때 PrettyMenu 객체는 바로 이전의 배경 그림을 그대로 계속 그릴 수도 있고, 아니면 처음부터 마련해 둔 기본 배경그림을 사용할 수도 있을 것이다. 이 부분은 전적으로 함수를 만든 사람에 달려 있다. 하지만 사용자 쪽에서는 어떻게 될지 예측할 수 없다.
강력한 보장(strong guarantee)
함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장이다. 이런 함수를 호출하는 것은 원자적인 동작이라고 할 수 있다. 호출이 성공하면(예외가 발생하지 않으면) 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다는 면에서 말이다.
예외불가 보장(nothrow guarantee)
예외를 절대로 던지지 않겠다는 보장이다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이다. 기본제공 타입에 대한 모든 연산은 예외를 던지지 않게 되어 있다. 예외에 안전한 코드를 만들기 위한 가장 기본적이며 핵심적인 요소가 아닐까 싶다.
어떤 예외도 던지지 않게끔 예외 지정이 된 함수는 예외불가 보장을 제공한다고 생각하면 안된다. 아래 예를 보자.1
int doSomething() throw(); // 비어 있는 예외 지정
위의 함수 선언이 전하는 메세지는 doSomething이 절대로 예외를 던지지 않겠다는 말이 아니다. 만약 doSomething에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자인 unexpected 함수가 호출되어야 한다는 뜻이다. 사실 doSomething은 어떤 예외 안전성 보장도 제공하지 않을 수도 있다. 함수 선언문에는 해당 함수가 맞는지, 이식성이 있는지, 아니면 효율적인지 알려 주는 기능 같은 것이 없다. 예외 안전성 보장을 제공하는지도 당연히 알려 주지 않다. 함수가 어떤 특성을 갖느냐 하는 부분은 ‘구현’이 결정하는 것이다. ‘선언’은 그냥 공약 같은 것이다.
앞에서 말했지만, 예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 한다. 아무 보장도 제공하지 않으면 예외에 안전한 함수가 아니다. 따라서 우리가 선택해야 하는 것은 ‘어떤 보장을 제공할 것’인가이다. 예외 안전성이 없는 레거시 코드를 사용해서 작업할 때를 제외하면, 예외 안전성을 제공하지 않는 쪽으로 선택해도 되는 경우는 거의 없다.
위의 세 가지 보장 중에 하나를 고르라면 아무래도 실용성이 있는 강력한 보장이 괜찮아 보일 것이다. 예외 안전성의 관점에서 보면 예외불가 보장이 가장 훌륭하겠지만, 예외를 던지는 함수를 호출하지 않고 C++의 C 부분으로부터 벗어나오기란 힘들다. 일단 동적 메모리 할당을 사용하는 쪽만 보아도, 요청에 맞는 메모리를 확보할 수 없으면 bad_alloc 예외를 던지도록 구현되어 있다. 뭐 할 수 있다면 예외불가 보장을 제공해도 된다. 하지만 현실적으로는 대부분의 함수에 있어서 기본적인 보장과 강력한 보장 중 하나를 고르게 된다.
changeBackground 함수를 다시 들여다 보자. 이 함수의 경우엔 강력한 보장을 거의 제공하는 것은 그다지 어렵지 않다. 우선 첫째로, PrettyMenu의 bgImage 데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담용 포인터로 바꾼다. 정말이지 자원 누출을 막는 대책으로 본다면 이렇게 가는 게 딱 맞다. 사용자에게 강력한 예외 안전성 보장을 제공할 수 있게 만든 것뿐인데 ‘객체를 써서 자원을 관리하는 것이 좋은 설계의 첫걸음’이라고 말한 항목 13의 내용이 다시 나오는 것이다. 뒤에서 볼 코드에서 shared_ptr를 쓸 것이다. auto_ptr도 있긴 하지만, 복사될 떄의 동작이 더 직관적이라 사용하기가 더 좋다.
둘째로, changeBackground 함수 내의 문장을 재배치해서 배경그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만든다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우에는 해당 동작이 실제로 일어날 때까지 그 객체의 상태를 바꾸지 않는 편이 일반적으로 좋다고 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PrettyMenu
{
...
std::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex);
bgImage.reset(new Image(imgSrc)); // bgImage의 내부 포인터를 "new Image" 표현식의 실행 결과로 바꿔치기 한다.
++imageChanges;
}
자, 이제는 이전 배경그림을 프로그래머가 직접 삭제할 필요가 없게되었다. 지금은 배경그림이 스마트 포인터의 손에서 관리되고 있기 때문이다. 게다가, 새로운 배경그림이 제대로 만들어졌을 때만 이전 배경그림의 삭제 작업이 이루어지도록 바뀐 점도 눈에 들어온다. 다시 말해, 이제는 shared_ptr::reset 함수가 호출되려면 이 함수의 매개변수가 제대로 생성되어야 한다는 것이다. delete 연산자는 reset 함수 안에 들어가 있기 때문에, reset이 불리지 않는 한 delete도 쓰일 일이 없을 것이다. 그리고 객체를 써서 자원을 관리하게 하니까 함수의 길이까지 줄어들었다.
그렇다. changeBackground 함수에서 강력한 예외 안전성 보장을 제공하려면 앞에서 말한 대로 두 가지만 바꾸면 거의 충분하다. ‘거의’라 한 것은 바로 매개변수인 imgSrc 때문이다. Image 클래스의 생성자가 실행되다가 예외를 일으킬 때, 그 시점에 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분히 있을 테다. 이 표시자의 이동이 전체 프로그램의 나머지에 영향을 미칠 수 있는 어떤 변화로 작용할 수도 있을 것이다. 따라서 엄밀히 말하면 changeBackground가 제공하는 예외 안전성 보장은 기본적인 보장이다. 이 문제까지 해소될 때까지는 말이다.
어쨌든 이 문제는 접어두자. changeBackground 함수가 강력한 보장을 제공한다고 가정하고 갈 길 계속 가자. 우리 능력이라면 해결 방법을 충분히 생각해 낼 수 있을 것이다. 매개변수 타입을 istream을 쓰지 말고, 배경그림 파일의 이름을 나타내는 타입 같은 것으로 바꿔보자. 이번에는 예외에 속수무책인 함수를 탈바꿈시켜 강력한 예외 안전성 보장을 제공하는 함수로 거듭나게 만드는 일반적인 설계 전략을 하나 알아보도록 하자. 이 전략은 ‘복사 후 맞바꾸기(copy-and-swap)’라는 이름으로 알려져 있는데, 원리적으로 무척 간단하다. 어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고, 그 사본을 수정하는 것이다. 이렇게 하면 수정 동작 중에 실행되는 연산에서 예외가 던져지더라도 원본 객체는 바뀌지 않은 채로 남는거다. 필요한 동작이 전부 성공적으로 완료되고 나면 수정된 객체를 원본 객체와 맞바꾸는데, 이 작업을 ‘예외를 던지지 않는’ 연산 내부에서 수행한다.
이 전략은 대개 ‘진짜’ 객체의 모든 데이터를 별도의 구현 객체에 넣어두고, 그 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 하는 식으로 구현한다. 많은 사람들이 ‘pimpl 관용구’라고들 부르는 이 구현 방법은 항목 31에서 자세히 다루고 있다. 어쨌든 이 방법을 PrettyMenu에 적용하면 다음과 같은 형태의 코드가 나올 수 있다.
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
struct PMIPImpl // PMIPImpl = PrettyMenu Implementation
{
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu
{
...
private:
Mutex mutex;
std::shared_ptr<PMIPImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap; // 항목 25 참조
Lock m1(&mutex);
std::shared_ptr<PMIPImpl> pNew(new PMIPImpl(*pImpl)); // 객체의 데이터 부분을 복사
pNew->bgImage.reset(new Image(imgSrc)); // 사본 수정
++pNew->imageChanges;
swap(pImpl, pNew); // 새 데이터로 바꾸어 넣는다.
}
PMImpl이 클래스가 아니라 구조체로 만들어져 있는게 보일 것이다. 저자가 그렇게 정한 것인데, PrettyMenu 클래스에서 pImpl이 private 멤버로 되어 있어서 구현 객체의 데이터가 바로 캡슐화되기 때문이다. 사실 클래스로 만들어 놓았어도 그다지 나쁠 건 없다. 조금 불편해진다는 점만 빼면 말이다. 취향에 따라서는 PMImpl을 PrettyMenu 안에 중첩시킬 수도 있지만, 이부분은 코드의 예외 안전성 문제와 관련 없는 패키징 문제이므로 여기서는 넘어가자.
‘복사 후 맞바꾸기’ 전략은 객체의 상태를 ‘전부 바꾸거나 아니면 전혀 바꾸지 않거나(all or nothing)’ 방식으로 유지하려는 경우에 어울린다. 그러나 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다는 것이 일반적인 정설이다. 왜 그럴까? changeBackground 함수의 전체 흐름을 추상화해 놓은 someFunc()를 한번 살펴보자. ‘복사 후 맞바꾸기’ 수법을 쓰되, f1 및 f2라는 다른 함수의 호출문이 들어 있는 형태로 말이다.
1
2
3
4
5
6
7
void someFunc()
{
... // 이 함수의 현재 상태에 대해 사본을 만들어 놓는다.
f1();
f2();
... // 변경된 상태를 바꾸어 넣는다.
}
f1 혹은 f2에서 보장하는 예외 안전성이 ‘강력’하지 못하면, 위의 구조로는 someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들어진다. 예를 들어 f1이 기본적인 보장만 제공한다고 가정해 보자. someFunc 함수에서 강력한 보장을 제공하게 만들려면,
- f1을 호출하기 전에 프로그램 전체의 상태를 결정하고
- f1에서 발생하는 모든 예외를 잡아낸 후에
- 원래의 상태로 되돌리는 코드를 작성해야 한다.
f1 및 f2 모두가 강력한 예외 안전성을 보장한다고 해도 사실 별로 나아지는 것은 없다. 예를 들어 어차피 f1이 끝까지 실행되고 나면 프로그램 상태는 f1에 의해 어떻게든 변해 있을 것이고, 그 다음에 f2가 실행되다가 예외를 던지면 그 프로그램의 상태는 someFunc가 호출될 때의 상태와 아예 달라져 있을 것이다. f2에서 아무것도 바꾸지 않았더라도 말이다.
눈치 챘을 수도 있지만 여기서 불거지는 문제가 바로 함수의 부수효과(side effect)이다. 자기 자신에만 국한된 것들의 상태를 바꾸며 동작하는 함수의 경우에는 강력한 보장을 제공하기가 비교적 수월하다. 그렇지만 비지역 데이터에 대해 부수효과를 주는 함수는 이렇게 하기가 무척 까다롭다. 예를 들어 f1을 호출하고 나서 생기는 부수효과로서 데이터베이스가 변경되기라도 한다면, someFunc 쪽에서 어떻게 손을 쓸 수가 없다. 특이한 경우가 아닌 한, 이미 확정(commit)되어 버린 데이터베이스 변경사항을 되돌릴 수 있는 방법은 없다. 그 데이터베이스의 다른 사용자가 변경사항을 벌써 봤을 수 있으니까.
강력한 예외 안전성 보장을 제공하게 하고 싶어서 아무리 열을 내더라도 이런 문제 때문에 발목을 잡힐 수 있다는 사실을 알고 있어야 한다. 참, 효율 문제도 무시할 수 없다. 복사 후 맞바꾸기 방법의 핵심은 객체의 데이터에 대해 사본을 만들어 놓고 그 사본을 변경한 후에, 사본과 원본의 바꿔치기 작업을 예외가 던지지 않는 함수 내부에서 하자는 아이디어이다. 때문에 수정하고 싶은 객체를 복사해 둘 공간과 복사에 걸리는 시간을 감수해야 한다. 이런 부분에 여유가 없거나 불편한 사람이 분명히 있을 것이다. 어쨌든 예외 안전성 보장 중에는 강력한 보장이 가장 좋다. 실용성이 확보되는 경우라면 반드시 제공하는 게 맞다. 그러나 언제나 실용적인 것은 아니다.
강력한 보장을 제공할 수 없다면 기본적인 보장 쪽으로 눈을 돌릴 수밖에 없을 것이다. 실제로 현업을 뛰다 보면 어떤 함수들은 강력한 보장을 제공할 수 있지만 그외의 많은 함수들에 대해서는 효율 혹은 복잡성 때문에 생기는 비용 때문에 어쩔 수 없이 강력한 보장을 접어야 하는 경우를 경험하게 된다. 앞에서 말한 대로 실용성이 확보될 때만 강력한 보장을 제공하는 데 힘쓰자. 이렇게만 하면, 우리가 기본적인 보장만 제공한다고 할 때 뭐라고 할 사람 아무도 없을 것이다. 대다수의 함수에 있어서 무리 없는 선택을 한다면 뭐니 뭐니 해도 기본적인 보장이 우선이다.
예외 안전성 보장을 아예 제공하지 않는 함수를 만든다고 하면 생각을 좀 다르게 해야 한다. 이런 함수를 만들 땐 일단 예외에 안전한 코드를 써야 하는 것이 기본이지만, 어쩔 수 없이 방어막을 많이 세워야 한다. someFunc가 어떻게 구현되어 있는지 다시 떠올려 보자. f1과 f2를 호출하고 있다. 여기서 f2가 예외 안전성 보장을 전혀 제공하지 않는다고 가정하자. f2는 기본적인 보장조차 해 주지 않는다. 무슨 뜻인고 하니, f2에서 예외가 터지면 f2 안에서 자원이 누출될 수 있다는 뜻이다. 그러니까, f2 때문에 프로그램의 자료구조가 망가질 수 있다는 말도 되는 것이다. 정렬되어 있던 배열이 헝클어질지도 모르고, 어떤 자료구조에서 다른 자료구조로 전송되던 객체가 날아가 버릴지도 모른다는 것이다. 이런 문제가 터진다 해도 someFunc 차원에서는 손 쓸 방법이 없다. someFunc가 호출하는 함수가 예외 안전성 보장을 전혀 제공하지 않으면, someFunc도 역시 어떤 보장도 제공할 수 없게 된다.
소프트웨어 시스템은 예외에 안전하거나, 예외에 뚫려 있거나 둘 중 하나이다. 일부만 예외 안전성을 갖춘 시스템은 없다. 예외 안전성이 없는 함수가 한 개라도 쓰이고 있으면 그 시스템은 전부가 예외에 안전하지 않은 시스템이다. 호출 즉시 자원이 누출되고 자료구조가 더러워질 것이다. 우울한 이야기지만, C++로 작성된 상당수의 레거시 코드가 예외 안전성 자체를 고려하지 않고 만들어진 것이 사실이다. 그래서 요즘 나오는 시스템에서도 예외 안전성 확보 여부에서 자유롭지 않은 경우를 많이 볼 수 있는 것이다. 예외에 안전하지 않은 방법으로 작성된 코드를 사용하고 있으니 당연하다.
요즘에도 그렇다고 계속 그렇게 놔둘 이유는 없다. 앞으로는 새로운 함수를 만들거나 기존의 코드를 고칠 때 ‘어떻게 하면 예외에 안전한 코드를 만들까’를 진지하게 고민하는 버릇을 들이자. 자원 관리가 필요할 때 자원 관리용 객체를 사용하는 것부터가 시작이다. 우선 자원 누출만큼은 확실히 막아 줄 것이다. 그리고 이번 항목에서 공부한 예외 안전성 보장 세 가지 중에 우리가 만드는 함수에서 실용적으로 제공할 수 있는 보장이 어떤 것일지 결정하자. 정말 어쩔 수 없이 레거시 코드를 호출해야 할 경우에만 ‘예외 안전성 무보장’에 머물도록 하자. 우리가 내린 결정은 반드시 문서로 남겨서, 우리가 만든 함수의 사용자 및 나중의 인수인계자가 파악할 수 있도록 하자. 예외 안전성 보장은 함수의 인터페이스에서 외부에 노출되는 아주 중요한 부분이므로, 함수 인터페이스의 다른 부분을 결정할 때와 같은 마음으로 신중하게 결정해야 한다.
40년 전에는 코드에 goto 문으로 도배하는 것이 아주 좋은 프로그래밍 방법이라고들 했다. 그런 시절도 있었다. 요즘은 제어 흐름을 구조화하는 데 모든 프로그래머들이 혈안이 되어 있다. 전역적으로 접근할 수 있는 데이터를 두는 것이 완벽한 프로그래밍 방법이라고 생각되던 때가 20년 밖에 안 된다. 요즘은 데이터 캡슐화에 목숨을 거는 시대이다. 예외의 영향을 생각하지 말고 함수를 만드는 것이 최고라는 이야기는 불과 10년 전에 유행했었다. 지금 우리는 이번 항목에서 코드의 예외 안전성을 위해 싸우자는 이야기를 하고 있다.
시간은 계속 흐른다. 우리는 안 죽었고, 계속 배워나가는 것이다.
예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않는다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있다.
강력한 예외 안전성 보장은 ‘복사 후 맞바꾸기’ 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아니다.
어떤 함수가 제공하는 예외 안전성 보장 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다.
참고