오늘의 삽질: boost::shared_ptr<T> 이렇게 쓰면 망한다!

강력하고 C++ 패러다임에 잘 부합하기로 유명한 라이브러리로 Boost의 일련의 라이브러리들이 있다. 그 중에서도 유명한 것을 꼽으라면 STL의 generic programming 패러다임 중 특히 함수 객체(함수자; function object; functor)를 잘 활용하게 해주는 boost::lambda 라거나, 정규식 팩키지로 사용되는 boost::regex, 그리고 이 포스팅의 주인공인 boost::shared_ptr 를 포함한 스마트 포인터 팩키지가 있다. 물론 이외에도 C++ TR1 에 포함된 많은 라이브러리들이 있으니 직접 https://boost.org 를 방문해 볼 것을 권한다 :)

Boost의 메모리 관련 라이브러리 중에서도 포인터의 참조 카운트에 기반해서 동작하는 스마트 포인터인 shared_ptr<T>는 그 편리함과 거의 포인터에 유사한 동작을 제공하며, 별도의 라이브러리를 빌드할 필요도 없고 단순히 헤더파일만 추가해주면 되서 쓰기가 편하다. 용법도 지극히 단순하다.

class ResourceType; // 이런 타입이 있다고 치자.
shared_ptr<ResourceType> ptr( new ResourceType( blah, blah, blah ) );

이제 이 ptr 라는 변수는 다른 shared_ptr<ResourceType> 에 복사하거나 하면서 총 참조된 횟수를 내부적으로 관리하게 된다. 사실 이것만 보면 별 것 없는 것 같지만, 이 스마트 포인터는 정확히 같은 shared_ptr<T> 타입의 변수(가리키는 주소가 아니라 껍데기인 shared_ptr<T>의 주소가 같은 경우만)에 대한 대입 연산을 빼고 모든 연산에 대해 thread-safe 하다1. 그리고 스마트 포인터 자체에 대한 연산(가리키는 주소 말고)들은 모두 lock-free한 연산을 사용한다 – 물론 가능한 플랫폼의 범위 내에서지만, 우리가 많이 사용하게 되는 환경은 대부분 포함된다.

스마트 포인터이기 때문에 그 수명 주기를 파악하기가 매우 힘든 서버 내 객체들의 **유효성을 보장하는 방식**으로 이용되기도 쉽다. 즉, 특정 객체가 지워지지 않는다는 것이 보장되려면 해당 객체에 대한 shared_ptr<T> 변수가 살아있기만 하면 된다†. 이를 이용해서 특정 서버 기능이 동작하는 동안 해당 객체가 살아있다는 것을 손쉽게 보장할 수 있게 된다.

사실 이런 용도 때문에 도입된 것인데 – 이전에도 home-made 버젼의 스마트 포인터가 있었는데 좀 부족한 부분이 있어서 라이브러리 단에 해당하는 일부분에 시험적으로 도입하는 중이다 – 월요일 오후에 작업했던 부분에서 _살아있어야 하는 객체가 미리 죽어버리는(메모리에서 지워지는) 현상_이 일어나서 이를 디버깅했다.

문제는 멀티스레딩과 관련없는 곳에서 발생했다 – 그래서 그나마 찾기 쉬웠다. 사실 이건 바보짓의 결과였지만(…). 버그를 일으킨 코드를 간단히 쓰면 이렇다.

// m_x는 수명주기가 매우 긴 어떤 객체의 멤버 변수
m_x = shared_ptr(new X());

// blah blah blah

// X type의 멤버 함수 코드 안에서,
foobar(shared_ptr(this)); // X의 어떤 멤버 함수 안이므로 this는 X* const 타입이됨

바로 감이 오는가? 이런 짓을 하면 안된다.

foobar라는 함수에 스마트 포인터를 넘기면서 해당 함수 안에서 참조 카운트 1인 어떤 shared_ptr가 생겼는데, 이 shared_ptr는 foobar 함수의 스코프가 끝나는 순간 소멸되면서 참조 카운트가 0인 걸로 인식하고 객체를 지운다. 그리고 _실제로 사용되고 있던 m_x_는 그걸 포함한 객체가 소멸되는 순간 혹은 m_x를 참조해서 뭔가를 하려던 순간에 쓰레기 메모리에 한 발을 내딛게 되고 사망하는 것이다.

뭐 이런 초보적인 실수를.

하나의 포인터에 대한 여러 개의 shared_ptr<T> 객체들이 하나의 참조 카운터를 사용하려면 shared_ptr<T>의 복사를 통해 생성되야 한다. 따라서 위 코드처럼 쓰려면 foobar에다가 this 포인터를 가지고 shared_ptr 객체를 만들어내는게 아니라 미리 m_x복사본을 가지고 있다가 넘겨 줘야 한다.

그렇지만 복사본을 가지고 있는 것은 더더욱 안될 말이다. shared_ptr<T>의 구현상 _순환 참조(cyclic reference)_를 발견할 수 없기 때문에, 절대로 m_x에 해당하는 메모리가 해제되지 않는다. 따라서 weak_ptr<T>라는 boost에서 제공하는 또다른 스마트 포인터를 사용해서 m_x에 저장되는 shared_ptr<T>weak_ptr<T>버젼을 전달할 수 있게 – 그리고 X타입 변수의 멤버에 보관 – 해서 해결했다.2


  1. 사실 이 부분은 int 와 같다 라고 생각하면 이해하기 쉽다. 어떤 동작이 가능한가 하면,

    shared_ptr<X> a (new X());
    
    shared_ptr<X> b (a);
    
    // 이 이후에 a, b에 대해 동시에 reset()을 부른다거나(NULL로 셋팅됨), 하나만 다른 포인터로 바꾼다거나하는 것을 동시에 진행해도 아무런 문제가 없다.
    

    자세한 사항은 shared_ptr의 스레드 안정성에 관한 boost 문서를 보자. ↩︎

  2. weak_ptr<T>shared_ptr<T> 하나에 대한 포인터다. 다만 shared_ptr<T>와는 달리 참조 카운터를 증가시키지 않아서 순환 참조 문제가 발생하지 않는다. weak_ptr<T>::lock() 을 통해 가리키고 있던 shared_ptr사라지지 않았다면 가리키고 있던 shared_ptr<T>를 얻어 올 수 있다. ↩︎