C++ 이야기 스물세번째: pImpl idiom

Posted at 2008. 9. 30. 07:00 // in S/W개발/C++ 이야기 // by 김윤수


2008/08/18 - [S/W개발/C++ 이야기] - C++ 이야기 스물한번째: 비가상인터페이스(NVI) 패턴

이 글을 쓴 것이 8월 18일 이니까 벌써 한 달 이상이 지났네요. 역시 글을 쓰는 게 쉬운 일은 아니다라는 걸 새삼 느낍니다.개인적으로 회사일도 많이 바빴었구요. 회사일로 너무 달리다보니 피로감이 누적돼서 다른 일로 머리를 식힐 필요가 있었는데, 결국은 블로그 앞에 앉게 됐네요. ^^

이글에서 비가상인터페이스(NVI)를 활용하여 사용자용 인터페이스와 개발자용 인터페이스를 분리하여 관점을 잘 분리하는 방법을 설명해 드렸습니다. 그리고, 글 마지막에는 다음 글로 "특정 클래스 내부 구현을 바꾸더라도 그 클래스를 사용한 Application 재컴파일이 필요 없게 만들 수는 없을까요 ?"라는 화두를 던졌었지요.

그럼 이제부터 그 화두에 대한 답변을 해 보도록 하지요.

지난글 예제로 제시됐던 ImageDecoder 헤더 파일을 다시 한 번 살펴 보도록 하겠습니다.

/// @file ImageDecoder.h
// forward declaration
class ImageValidator;
class ImageBuffer;
class RGBImageBuffer;

class ImageDecoder {
public:
  RGBImageBuffer* Decode(const ImageBuffer* pImage);

private:
  virtual RGBImageBuffer* DoDecode(const ImageBuffer* pImage) = 0;
  virtual ImageValidator* GetValidator() = 0;
};


DoDecode()와 GetValidator()를 private virtual 함수로 선언하여 내부 구현 변경이 Application 에 미치는 영향을 최소화하는데 성공하였습니다. 이제 시간은 흘러 흘러 이 ImageDecoder 라이브러리를 사용하는 사람들이 많아졌고, ImageDecoder 라이브러리의 성능이 별로 좋지 않다는 보고를 많이 듣게 됐습니다. ImageDecoder 개발자는 한참을 고민하고서는 다음과 같은 해결 방안에 도달하게 됐습니다.

class ImageDecoder {
public:
  ImageDecoder();
  virtual ~ImageDecoder();
  RGBImageBuffer* Decode(const ImageBuffer* pImage);

private:
  virtual RGBImageBuffer* DoDecode(const ImageBuffer* pImage) = 0;
  virtual ImageValidator* GetValidator() = 0;

  RGBImageBuffer* m_imgCache[MAX_IMG_CACHE_SZ];
  int m_idx;
};

즉, 내부적으로 디코딩된 이미지들을 캐슁하기로 한 것이지요. 그리고, 캐슁 데이터를 초기화하고 정리하기 위해 constructor와 destructor도 추가했구요. 다음과 같이 구현도 수정을 했습니다.

/// @file ImageDecoderV2.cpp
ImageDecoder::ImageDecoder() :

  m_idx(0)
{
  cout << "ImageDecoder::ImageDecoder() V2 called" << endl;
  memset(m_imgCache, 0, sizeof(m_imgCache));
}

ImageDecoder::~ImageDecoder()
{
  cout << "ImageDecoder::~ImageDecoder() V2 called" << endl;
  for (int i = 0; i < MAX_IMG_CACHE_SZ; ++i)
  {
    if (m_imgCache[i])
    {
      delete m_imgCache[i];
      m_imgCache[i] = 0;
    }
  }
}

RGBImageBuffer*
ImageDecoder::Decode(const ImageBuffer* pImage)
{
  cout << "ImageDecoder::Decode() V2 called" << endl;
  if (GetValidator()->IsValid(pImage))
  {
    RGBImageBuffer* r = DoDecode(pImage);
    if (r)
    {
      if (m_imgCache[m_idx])
        delete m_imgCache[m_idx];
      m_imgCache[m_idx] = r;
      ++m_idx, m_idx %= MAX_IMG_CACHE_SZ;
    }
    return 0;
  }
  else
    return 0;
}

/// @file JpegImageDecoderV2.cpp
JpegImageDecoder::JpegImageDecoder() :
  ImageDecoder()
{
  cout << "JpegImageDecoder::JpegImageDecoder() V2 called" << endl;
}

JpegImageDecoder::~JpegImageDecoder()
{
  cout << "JpegImageDecoder::~JpegImageDecoder() V2 called" << endl;
}

자~ 이렇게 구현을 수정하고 나서 디코딩 라이브러리를 다시 릴리즈하게 됐습니다. 이전 디코딩 라이브러리를 사용하던 Application은 재컴파일하지 않아도 잘 동작하리라는 기대를 품고 릴리즈를 하게 된 것이죠. 이전에 다음과 같은 응용 프로그램이 있었다고 상상해 보시겠습니다.

/// @file decapp.cpp
#include <iostream>

#include "ImageDecoderFactory.h"
#include "ImageDecoder.h"

using namespace std;

int
main()
{
  ImageDecoder* dec = ImageDecoderFactory::Create("image/jpeg");

  RGBImageBuffer* buf = dec->Decode(new ImageBuffer);
  // application logic here
  delete dec;

  return 0;
}

마지막에 delete dec를 호출하므로 위 예에서는 JpegImageDecoder 의 destructor가 호출되어야 할 것입니다. virtual ~ImageDecoder()로 선언해 놓았기 때문이겠지요. 왜 ~ImageDecoder를 virtual로 선언해야 하는지는 Effective C++의 항목 #7을 참고하시기 바랍니다. 그런데 이게 왠 일입니까? application을 재컴파일하지 않고 실행시켰더니 JpegImageDecoder의 destructor가 호출되질 않네요. 다음은  이전 버전 application을 새로운 라이브러 버전과 함께 실행해 본 예제 화면입니다.

$ ./decapp
ImageDecoder::ImageDecoder() V2 called
JpegImageDecoder::JpegImageDecoder() V2 called
ImageDecoder::Decode() V2 called
JpegImageDecoder::GetValidator() called
JpegImageValidator::DoIsValid() called
JpegImageDecoder::DoDecode() called

JpegImageDecoder의 destructor도 호출되지 않고, ImageDecoder의 destructor도 호출되지 않습니다. 오~ 이런 그렇다면 cache에 쌓인 데이터들이 그냥 날라가 버리니 이거 꼼짝없이 memory leak이 생기겠는데요 ? Application 개발자에게 재컴파일하라고 알려줘야 하나요? 그럼 다시 컴파일 해 본 후에 실행시켜 보겠습니다.

다음은 재컴파일한 후 실행시켜본 실행예입니다.

$ ./decapp
ImageDecoder::ImageDecoder() V2 called
JpegImageDecoder::JpegImageDecoder() V2 called
ImageDecoder::Decode() V2 called
JpegImageDecoder::GetValidator() called
JpegImageValidator::DoIsValid() called
JpegImageDecoder::DoDecode() called
JpegImageDecoder::~JpegImageDecoder() V2 called
ImageDecoder::~ImageDecoder() V2 called

이 번에는 확실히 JpegImageDecoder의 destructor가 호출되는 게 보이는 군요. 아~ 그렇다면 재컴파일을 해야한다는 얘기인데... Application 개발자가 한 두 사람이 아니라 수백, 수천이라면 그 수많은 Application 개발자들에게 다 알려 주려면 장난이 아니겠군요. 그리고, 그런 Application을 쓰던 사용자들은 다시 재컴파일된 Application을 다시 다운받아서 설치해야겠군요. Application 재컴파일이 필요 없을 거라 생각했던 순진한 상상이 완전히 산산조각나고 말았습니다. 이런 문제를 해결할 방법이 있을까요? 도대체 Decode() 라는 멤버 함수 하나 쓰는 Application들이 내부 구현 조금 바꿨다고 이렇게나 영향을 받아야 한다니 뭔가 문제가 있지 않나요 ? 그렇죠. 뭔가 문제가 있는 건 사실입니다만 여러분이 C++로 개발하기로 또는 여러분 보스가  어떤 이유에서건 C++로 개발하기로 했다면 여러분이 감당해야할 짐이니 프로그래밍언어탓은 그만하고 실질적인 해결책이 무엇인지 알아보는게 빠른 길이겠지요.

그렇담 왜 이런 일이 벌어지는지부터 한 번 생각해보죠. Application 코드에서 delete dec 에 대해 컴파일러는 어떤 코드를 생성할까요? 원래의 헤더파일에 따르면  ImageDecoder에 대해 destructor 도 선언되어 있질 않고, virtual도 선언되어 있질 않으니 당연히 컴파일러에 의해 자동 생성되는 destructor 코드가 inline으로 삽입되었을 것입니다. 그건 당연히 아무런 일도 하지 않는 destructor 였을 것이구요. 그러니 당연히 새로운 library를 시스템에 설치한다고 하더라도 decapp는 JpegImageDecoder의 destructor를 부를 리가 만무합니다. 이렇게 새로운 멤버 함수를 추가하거나, 멤버 변수를 하나 추가할 때마다 실은 Object Layout이라는 것이 바뀌게 되고, 이 Object Layout은 라이브러리의 Binary Compatibility에 영향을 미치게 됩니다. 왜냐면 C++ 컴파일러는 멤버를 Access할 때,  멤버의 이름보다는 위치(offset)를 기준으로 Access하게 되는데, 멤버를 추가하면 이런 위치들이 다 바뀌어 버리기 때문입니다. 또 다른 실험을 한 번 해볼까요 ? 다음과 같은 클래스가 있고(이 클래스는 libint.so 로 제공된다고 가정하겠습니다), 이 클래스를 사용하는 intapp가 있다고 가정해 보겠습니다.

/// @file Integer.h
class Integer {
public:
  Integer(int val = 0);
  int Get();
  void Set(int val);

private:
  int m_val;
};

/// @file Integer.cpp
#include "Integer.h"

Integer::Integer(int val) :
  m_val(val)
{
}

int Integer::Get()
{
  return m_val;
}

void Integer::Set(int val)
{
  m_val = val;
}

/// @file intapp.cpp
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm>
#include "Integer.h"

typedef void (Integer::*MemFuncPtr)(void);

extern "C" unsigned MemFuncPtr2Int(MemFuncPtr pmf);

using namespace std;

int
main()
{
  vector<Integer> v(10);

  for(int i = 0; i < 10; ++i)
  {
    v[i] = i;
  }

  vector<int> vi(v.size());
  transform(v.begin(), v.end(), vi.begin(),
    mem_fun_ref(&Integer::Get));

  copy(vi.begin(), vi.end(), ostream_iterator<int>(cout, " "));
  cout << endl;

  for_each(v.begin(), v.end(),
    bind2nd(mem_fun_ref(&Integer::Set), 3));

  transform(v.begin(), v.end(), vi.begin(),
    mem_fun_ref(&Integer::Get));

  copy(vi.begin(), vi.end(), ostream_iterator<int>(cout, " "));
  cout << endl;

  cout << "&Integer::Get offset = "
       << MemFuncPtr2Int((MemFuncPtr)&Integer::Get) << endl;

  cout << "&Integer::Set offset = "
       << MemFuncPtr2Int((MemFuncPtr)&Integer::Set) << endl;


  return 0;
}

이 프로그램을 컴파일하면 다음과 같은 결과가 나옵니다.

$ ./intapp
0 1 2 3 4 5 6 7 8 9
3 3 3 3 3 3 3 3 3 3
&Integer::Get offset = 36182
&Integer::Set offset = 36196

이 상태에서 Integer.h에 멤버 변수를 하나 추가해 보겠습니다.

/// @file Integer.h
class Integer {
public:
  Integer(int val = 0);
  int Get();
  void Set(int val);

private:
  int m_other;
  int m_val;
};

그런 다음,  intapp는 새로 컴파일하지 않고, libint.so 만 재컴파일한 채로 intapp를 수행해 보겠습니다.

$ make libint.so
$ ./intapp
9 9 9 9 9 9 9 9 9 0
3 3 3 3 3 3 3 3 3 3
&Integer::Get offset = 36178
&Integer::Set offset = 36192

음... 신기한 결과가 나오는군요. 0 1 2 3 4 5 6 7 8 9 가 나와야 하는 부분에서 9 9 9 9 9 9 9 9 9 0 이 출력되고 있습니다. 그럼 이번에는 변수 하나만 추가하지 말고 아예 array를 추가해 보도록 하겠습니다.

/// @file Integer.h
class Integer {
public:
  Integer(int val = 0);
  int Get();
  void Set(int val);

private:
  int m_arr[1000];
  int m_val;
};

이번에도 라이브러리만 재컴파일하고 Application은 재컴파일하지 않은 채로 실행해 보도록 하겠습니다.

$ make libint.so
$ ./intapp
Segmentation fault

허걱! 이번에는 상태가 훨씬 더 심각해졌군요. 이 실험에서 확인할 수 있는 것처럼 멤버 변수를 추가하거나 멤버 함수를 추가할 경우 Object Layout이 바뀌어 버리므로 기존의 Application을 재컴파일하지 않고는 정상 동작을 보장할 수가 없습니다. 이런 문제를 Fragile Binary Interface Problem이라고 하여 오래전부터 알려진 C++의 문제입니다. 그렇다면 문제의 원인은 알았는데, 이런 문제를 어떻게 피할 수 있을까요 ? 문제의 원인을 알았다면 해결책은 그 안에 답이 있기 마련이지요. 바로 멤버 함수나 멤버 변수를 추가하더라도 Object Layout에 변화가 없도록 만들면 될 것입니다.

멤버 함수나 멤버 변수를 추가하더라도 Object Layout에 변화가 없도록 할 수 있는 방법이 이글 제목에서 제시됐던 pImpl idiom 입니다. 또는 관점에 따라서 또는 프로그래밍언어에 따라서 Compiler Firewall 또는 Opaque Pointer라고도 합니다. 그럼 이 pImpl idiom이라는 녀석이 어떻게 하길래 멤버 함수나 멤버 변수를 추가하더라도 Object Layout 에 변화가 없도록 할 수 있느냐 ? 자세히 한 번 살펴 보겠습니다.

1) Foo(foo.h 에 정의되어 있다고 가정하겠습니다)라는 클래스의 모든 private 멤버들을 포함할 클래스로 FooImpl 이라는 클래스(또는 struct)를 정의하고, 모든 private 멤버들을 FooImpl로 모두 옮깁니다. FooImpl 은 fooImpl.h 에 선언하셔도 되고, 아니면 foo.cpp 안에서 선언하셔도 됩니다.

2) 그리고 나서, foo.h 에서는 class FooImpl; 이라고 forward declaration 해줍니다. 절대 fooImpl.h 를 include 해서는 안됩니다. 그렇게 하면 pImpl idiom을 사용하는 의미가 없어집니다.

3) Foo 클래스의  private section은 다음과 같이 오로지 FooImpl 포인터를 가리키는 멤버 변수 하나만 존재하도록 수정합니다.

class FooImpl;

class Foo {
public:
  ......

private:
  FooImpl* m_pImpl;
};

4) 기존의 Foo의 멤버 함수들은 FooImpl의 멤버 함수 또는 멤버 변수를 access 구현하는 것으로 수정합니다.

5) Foo의 constructor는  다음과 같이 FooImpl 인스턴스를 생성해야 하고, destructor는 FooImpl 인스턴스를 삭제해야 합니다. constructor와 destructor를  foo.h에 구현하게 되면 FooImpl 클래스의 Object Layout 정보를 필요로 하므로 반드시 foo.cpp에서 구현해야 합니다. 만약 shared_ptr 을 사용한다면 exception safety 도 챙기면서 destructor에서 별다른 일을 하지 않아도 됩니다.

이상을 합쳐서 표현한다면 다음과 같을 것입니다(shared_ptr 을 썼을 때의 패턴을 작성해 봤습니다).

/// @file foo.h
#include <boost/shared_ptr.hpp>

class FooImpl;

class Foo {
public:
  Foo();
  ......

private:
  boost::shared_ptr<FooImpl> m_pImpl;
};

/// @file foo.cpp
#include <boost/shared_ptr.hpp>
#include "foo.h"

struct FooImpl {
  // member variables
  // private member functions
};

Foo::Foo() :
  m_pImpl(new FooImpl)
{
}

......

이렇게 할 경우 public 멤버 함수를 추가하는 경우가 아니라면 모두 struct FooImpl 내부 변경으로 끝나게 되므로 Foo 에는 전혀 변경이 일어나지 않게 되고, Foo를 사용하는 Application에는 전혀 영향을 미치지 않게 됩니다. 재컴파일할 필요도 없으므로 Binary Compatibility도 확보할 수 있게 됩니다. 즉, shared library만 살짝 바꿔치기 하면 기존 Application은 아무런 문제 없이 수행될 수 있다는 것이죠.

이 pImpl idiom은 한편으로는 Binary Compatibility 를 달성하기 쉽게 해줄뿐만 아니라, FooImpl 의 내부 구현이 바뀌더라도 재컴파일이 필요 없게 해주므로 Compiler Firewall 으로도 불립니다. KDE 프로젝트에서는 pImpl idiom을 D-pointers 라고도 부르나 봅니다.

오~ 이렇게 좋은 idiom이 있었다니 하면서 "그럼 모든 클래스 설계할 때 이 idiom을 따르면 되겠네"라고 생각하시면서 눈알을 열심히 굴리시는 분이 계시군요. 워~워~ 바로 적용하시기 전에 잠깐 숨 좀 고르시구요. No free lunch 라는 말이 있듯이 항상 benefit이 있다면 그에 따르는 cost가 있기 마련이지요. 그렇다면 pImpl idiom의 cost는 뭘까요 ? 이 cost가 뭔지 정확히 알 때까지는 잠깐 적용하는 것을 미루셔도 될 것 같습니다. cost가 뭔지, 그리고 그 cost를 피할 방법은 있는지 pImpl idiom을 NVI idiom에 적용하면 어떤 효과가 있을지 등등에 대해 앞으로 좀 더 알아 보도록 하겠습니다.

그럼 다음글도 기대해 주시기 바랍니다.

그리고, 뭔가 말이 안된다 싶거나 틀린 부분이 있다 싶으시면 언제라도 댓글 좀 남겨 주세요. 저는 토론을 무척 즐기는 사람입니다. 여러분이 알고 계신 지식을 저에게도 조금 나눠 주세요~ ^^