본문 바로가기

Library/C/C++

스마트 포인터 커스터마이즈

C++에서 최대의 단점으로 꼽히는 메모리 관리의 불편함은 스마트 포인터를 사용함으로써 어느 정도 극복할 수 있다. 하지만, 스마트 포인터는 만능이 아니며, 더군다나 연산자 오버로딩만으로 쉽게 사용할 수 있는 것은 더더욱 아니다.

물론, 앞으로 tr1에 포함된 boost::shared_ptr이 std::auto_ptr을 대신해 표준으로 포함될 예정이므로(이것은 std::auto_ptr이 없어진다는 것이 아니라, C++의 새로운 대표적인 스마트 포인터로 자리 매김할 것이라는 뜻이다) 스마트 포인터를 직접 만들어야 하는 일은 여간해서는 없겠지만, 몇몇 상황에서는 커스터마이즈된 스마트 포인터를 사용해야 할 필요성이 있다. 예컨데, 레퍼런스 카운팅 전략이 특정 상황에서는 맞지 않을 수도 있고, 현재 표준에 포함된 std::auto_ptr은 STL 컨테이너와 함께 사용할 수 없는 스마트 포인터이기 때문이다. STL은 기본적으로 복사 중심으로 모든 연산이 이루어지며, 따라서 한번에 하나의 소유권만 가질 수 있는 std::auto_ptr은 STL 컨테이너에 넣는 순간, 넣기 전의 std::auto_ptr은 깡통이 되어버리는 심각한 문제가 발생한다. std::auto_ptr은 특정한 상황에서만 유용하게 쓸 수 있을 뿐이다. 물론 그렇다고 std::auto_ptr이 표준에서 사라진다는 말은 아니다! 경우에 따라서는 std::auto_ptr의 파괴 복사가 적합할 수도 있다.

스마트 포인터를 구현해야 할 때, 스마트 포인터의 형태를 좌우하는 커다란 문제는, 대표적으로 멀티 스레드, 소유권 관리와 같은 것이다. 사실, 멀티 스레드와 관련된 문제는 스마트 포인터 내부에서 완벽하게 해결하기에는 한계가 있으며, 어느 정도 스마트 포인터 외부의 구조적인 대응이 필요하다. 따라서, 완전 복사와 같은 소유권 관리 문제가 더 현실적인 문제일 것이다. 스마트 포인터는 레퍼런스 카운팅을 사용해서 구현될 수도 있으며, 참조 리스트를 사용해서 구현될 수도 있다. 물론 세세한 구현 방법은 각각 트레이드 오프 관계를 가지므로 항상 어느 것이 좋다고는 말할 수 없다. 여기서는, 완전 복사와 관련된 문제에서 레퍼런스 카운팅 전략을 생각해보기로 하자.

어떤 스마트 포인터 개체를 다른 개체 대입한다고 했을 때, 대입되는 스마트 포인터 개체는 자신이 잡고 있는 레퍼런스 카운트를 하나 떨어뜨리고, 이 값이 0이면 힙에서 해제해야 하며, 그렇지 않은 경우 받는 개체를 가리켜야 한다. 여기서, 이 개체를 생성할 때 레퍼런스 카운터와 관련해 개체의 완전 복사 문제는 피할 수 없는 주제이다.

두 방법 중 어느 것이 항상 맞다고 할 수는 없다. 예를 들어, A라는 스마트 포인터가 가리키는 개체를 완전히 복사하여 이 값을 다른 값으로 바꾸고 싶을 경우가 있을 것이다. 특히, 스마트 포인터가 가리키는 개체가 특정한 소멸 동작이 지정되어 있지 않은 타입이나 클래스라고 해보자. 충분히 가능성이 있는 일이다. 이런 경우는, A라는 스마트 포인터를 B 스마트 포인터에 대입할 때 완전 복사가 일어나야 할 것이다.

그렇지만, 이런 경우는 어떨까? A라는 스마트 포인터가 가리키는 클래스는, 이미 힙에서 메모리를 할당 받아서 특정 작업을 하며, 소멸자가 불려질 때 이 힙을 정리하는 코드를 포함하고 있다면 완전 복사는 해결책이 될 수 없다. 즉, A 스마트 포인터가 가리키는 개체를 완전 복사한 B 스마트 포인터는 자신의 소멸자가 불려질 때 자신이 가리키는 개체를 삭제한다. 그러나, A 스마트 포인터는 이 사실을 알지 못한다. A 스마트 포인터 역시 자신의 소멸자가 불려질 때 자신이 가리키고 있는 개체를 삭제하는데, 만약 이 개체가 자체적으로 이미 할당 받은 힙을 가지고 있고, 소멸자를 통해 이 힙을 해제하는 구조라면 문제가 된다. B 스마트 포인터는 해제될 때 이 개체는 자신이 잡고 있던 힙을 해제하며, 완전 복사된 A 스마트 포인터의 포인터 개체 역시 같은 힙을 가리키고 있기 때문에, 이미 해제한 클래스의 힙을 다시 해제하려고 시도한다. 당연히, 프로그램은 정의되지 않은 동작을 할 것이다.

이것은, 완전 복사가 항상 올바른 해결책이 아니라는 것을 보여주는 상황이다. 이 문제에서는 완전 복사보다는 레퍼런스 카운트를 사용하는 것이 바른 답이다. 문제는, 이 두 가지 모두 일반적으로 일어나는 상황이라는 것이다. 즉, 단일한 스마트 포인터 구현 전략으로는 모든 문제에 대처할 수 없으며, 경우에 따라서 전략적으로 선택할 수 있는 스마트 포인터가 존재해야 한다. boost에서 제공하는 스마트 포인터가 shared_ptr, weak_ptr의 두 가지 구현을 제공하는 이유 중 하나이다.


스마트 포인터를 구현하는 것은 참으로 골치 아픈 문제이다. 스레드 안정적인 스마트 포인터를 구현하는 것은 둘째로 하더라도, 일반적인 경우에 적합한 스마트 포인터를 구현하는 것은 그야말로 선택의 연속이기 때문이다. 어떤 것을 멤버 함수로 해야 하는가, 널 값은 인정해야 하는가, 완전 복사로 구현할 것인가 레퍼런스 카운터를 늘릴 것인가, 어디까지 연산자 오버로딩을 허용해야 할 것인가 등등..

하지만, 일단 구현해두면, 메모리 누수에 대해 상당 부분 걱정하지 않고 프로그래밍을 할 수 있을 것이다. Java나 C#처럼 가비지 컬렉션 기능이 제공되어 메모리에 대해 아예 생각하지 않고 프로그래밍 하는 것과 다른 차원의 즐거움이다. boost::shared_ptr이 tr1에 포함되어 앞으로 표준으로 들어올 예정인데, 그래도 손수 만든 스마트 포인터가 필요한가라는 의견이 제기될 수도 있다. 개인적으로는, C++ 라이브러리들은 제한적인 스레드 안정성을 제공하기 때문에, 커스터마이징이 가능한 스마트 포인터를 제작하는 것은 분명히 유용하게 쓰여질 때가 있을거라는 생각이 든다.