C++ 이야기 스물한번째: 비가상인터페이스(NVI) 패턴

Posted at 2008. 8. 18. 06:00 // in S/W개발/C++ 이야기 // by 김윤수


C++ 이야기 스물한번째 이야기로 비가상인터페이스(Non-Virtual Interface; NVI) 패턴에 대해 소개드릴까 합니다. 보통 C++에서 자바의 인터페이스와 동일한 역할을 해주는 걸 추상 클래스라고들 말합니다. 추상 클래스를 정의할 때는 순수 가상 함수로 정의를 합니다. 그러니, 인터페이스를 정의할 때는 당연히 가상 메소드를 정의하게 되는데, 가상이 아닌 인터페이스를 정의한다는 게 말이 안되는 것 같지 않으세요 ?

예를 들어, 여러분이 이미지 디코딩 라이브러리를 개발하는데, 여러 가지 종류의 이미지를 지원하려고 이미지 디코더의 인터페이스를 제공한다고 상상해 보시죠. 그럼 당연히 다음과 같이 인터페이스를 정의하게 되지 않을까요 ?

/// @file ImageDecoder.h
class RGBImageBuffer;
class ImageBuffer;

class ImageDecoder {
public:
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage) = 0;
};

그리고 각 이미지 종류에 대한 디코더를 이런식으로 상속받아서 구현하게 될 것입니다.

/// @file JpegImageDecoder.h
#include "ImageDecoder.h"

class JpegImageDecoder : public ImageDecoder {
public:
  // constructor, destructor, 기타 등등 메소드 정의
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage);
};

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

RGBImageBuffer*
JpegImageDecoder::Decode(const ImageBuffer* pImage)
{
  // Implementation
}

/// @file GifImageDecoder.h
#include "ImageDecoder.h"

class GifImageDecoder : public ImageDecoder {
public:
  // constructor, destructor, 기타 등등 메소드 정의
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage);
};

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

RGBImageBuffer*
GifImageDecoder::Decode(const ImageBuffer* pImage)
{
  // Implementation
}

/// @file ImageDecoderFactory.h
#include <string>

class ImageDecoder;

ImageDecoder* createImageDecoder(const std::string& imageMimeType);

이렇게 가상함수로 당연히 선언하게 될텐데... 비가상함수인터페이스라니 말이 돼 ? 별 이상한 사람이 다 있네 하면 그냥 창 닫고 가시려는 분 잠깐만 제 얘기 좀 듣고 가시라구요. 성질 급하시긴... 아무리 그래도 이렇게 떡하니 블로그에 올릴 때는 다 믿는 구석이 그런 거 아니겠어요 ? 이 글 쓴다고 누가 돈 줄 것도 아니구요.

자 먼저 NVI 패턴으로 얘기하자면 그 유명한 Scott Meyers 씨의 Effective C++ 항목 35와 Herb Sutter의 Exceptional C++ Style 항목 18에서 설명하고 있는 패턴입니다. 아주 바람직한 패턴으로요.

도대체! 왜! 어떤 목적으로! NVI 패턴을 사용하는 것일까요 ? 그럼 차근 차근 알아보도록 하겠습니다.

자~ 여러분이 만들고 있던 디코딩 라이브러리로 돌아가 보겠습니다. 이미지 디코딩 라이브러리의 설계자이자 개발자로서 여러분은 일단 현 시점에서는 JPEG, PNG 등의 이미지 형식은 잘 알고 있으나, 다른 이미지 형식에 대해서는 지식이 부족하여 다음 번 이미지 디코딩 라이브러리에서 다른 이미지 형식도 지원할 수 있는 구조를, 아니면, 나중에 분명히 계속해서 새로운 이미지 형식이 나오게 될 것이므로 그런 것들을 쉽게 지원할 수 있는 구조를 원할 것입니다. 그러면서도 이미지 디코딩 라이브러리 사용자들은 정의된 인터페이스만 사용하면 이미지 형식에 상관없이 디코딩할 수 있도록 하고 싶을 것입니다.

이런 두 가지 관점, 즉, 하나는 개발자가 확장할 수 있는 인터페이스, 또 하나는 사용자가 사용하는 인터페이스라는 관점을 가지고 다음 인터페이스를 살펴 보겠습니다.

class ImageDecoder {
public:
  virtual RGBImageBuffer* decode(const ImageBuffer* pImage) = 0;
};


위 인터페이스는 이미지 디코딩 라이브러리 사용자를 위한 인터페이스일까요 아니면 이미지 디코딩 라이브러리 개발자를 위한 인터페이스일까요 ? 그렇죠, 두 가지 목적을 모두 갖고 있는 인터페이스입니다. 문제는 거기서 시작하게 됩니다. 개발자용 인터페이스 성격도 있게 되므로, 사용자도 이 인터페이스를 볼 때, 뭔가 개발을 해 넣어야 되는 걸로 착각하기 쉽다는 것이죠. 개발자 입장에서는 구현 방식을 바꾸고 싶어도 나중에 바꾸기가 무척 어렵다는 문제가 있기도 합니다.

예를 들어, 이미지 디코딩 라이브러리를 구현하다보면 입력인자로 주어진 ImageBuffer 가 유효한 버퍼인지를 검사해야 할 것입니다. 그런데 제일 처음 릴리즈할 때, 위와 같이 public 가상 인터페이스로 선언을 했다면 어떻게 될까요 ? 다음과 같이 순수 가상함수로 두지 않고, 기본 구현을 해 놓으면 될까요 ?

class ImageDecoder {
public:
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage)
  {
    if (IsValidImageBuffer(pImage)) {
      // decoding logic here
      return pBuffer;
    }
    else {
      return 0;
    }
  }

private:
  bool IsValidImageBuffer(const ImageBuffer* pImage);
}

그렇지 않죠. ImageDecoder 에서 상속받아서 구체 디코더를 개발하는 사람이 가상 함수를 override 하면서 IsValidImageBuffer()를 부르리란 보장이 없습니다. 게다가 IsValidImageBuffer() 는 이미지 종류별로 그 구현이 달라질 수 있기 때문에 위와 같이 그냥 일반 메소드로 구현해 놓는다면 새로운 이미지 형식을 지원해야 할 때마다 IsValidImageBuffer를 수정해야 할 것입니다. 따라서 IsValidImageBuffer 도 가상함수로 만들어 놓아야 겠군요. 그렇다면 다음과 같이 수정하면 되겠네요.

class ImageDecoder {
public:
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage)
  {
   if (IsValidImageBuffer(pImage)) {
     // decoding logic here
     return pBuffer;
   }
   else {
     return 0;
   }
  }

  virtual bool IsValidImageBuffer(const ImageBuffer* pImage) = 0;
}

어때요 ? 맘에 드세요 ? 저는 정확히는 모르겠지만 자꾸 제 맘속에서 위 코드가 정말 맘에 들지 않는다는 생각이 꾸물 꾸물 올라오는 걸 주체할 수가 없네요. 왜냐구요 ? 잘 안 보이세요 ? 그렇다면 라이브러리 사용자 입장에서 위 인터페이스를 다시 한 번 살펴 보시겠어요 ?

사용자 입장이라면 이미지 디코딩하려면 단순히 Decode 만 호출하면 되는데, IsValidImageBuffer() 라는 인터페이스가 있으니 사용자가 어떻게 느낄까요 ? IsValidImageBuffer에 대해 개발자 매뉴얼이 아닌 사용자 매뉴얼에 도대체 어떻게 써 놓아야 할까요 ? 이것은 단순히 사용해서는 안된다라고 써 놓을까요 ? 아니면 개발자들이 구현하기 위한 인터페이스로서 사용자들은 신경쓰지 않아도 된다라고 써 놓으면 될까요 ? 구현을 위한 인터페이스라면 public 이 아니라 private이 되어야 하는 것 아닐까요 ? 다음과 같이 말이죠.

class ImageDecoder {
public:
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage)
  {
   if (IsValidImageBuffer(pImage)) {
     // decoding logic here
     return pBuffer;
   }
   else {
     return 0;
   }
  }

private:
  virtual bool IsValidImageBuffer(const ImageBuffer* pImage) = 0;

}

이렇게 하니 의미가 명확해 지는 군요. Decode 는 사용자가 사용하기 위한 인터페이스이고, IsValidImageBuffer 는 개발자가 구현해줘야 하는 이미지 종류별 customize 가 가능한 인터페이스인 것이죠. 이제 문제점이 보이지 않으세요 ? 실상 이런 혼란은 public 가상 함수가 두 가지 성격을 한 데 합쳐 놓기 때문에 발생하는 것입니다. 사용자를 위한 인터페이스와 개발자를 위한 구현 인터페이스를 한 데 합쳐 놓은 것이지요. 이런 문제점을 해결하기 위한 idiom 이 NVI idiom 입니다. NVI idiom을 적용한다면 class ImageDecoder 를 다음과 같이 정의할 수 있습니다.

class ImageDecoder {
public:
 
RGBImageBuffer* Decode(const ImageBuffer* pImage)
  {
    if (IsValidImageBuffer(pImage)) {
      return DoDecode(pImage);
    }
    else {
      return 0;
    }
  }

private:
  virtual RGBImageBuffer* DoDecode(const ImageBuffer* pImage) = 0;
  virtual bool IsValidImageBuffer(const ImageBuffer* pImage) = 0;

}

NVI idiom 은 template method 패턴을 C++로 표현해 놓은 것이라고 볼 수도 있습니다. 그렇지만 NVI idiom은 template method 패턴을 좀 더 확장해서 사용자가 사용하게 될 인터페이스와 개발자가 customize 해야할 인터페이스를 분명하게 분리해 놓은 idiom 이기 때문에 좀 더 다양한 경우에 사용될 수 있을 것입니다. NVI idiom에서는 template method 패턴에서처럼 알고리즘의 스텝을 명시하는 method 만 template method 가 될 수 있는 것은 아니기 때문입니다. 그보다는 사용자에게 제공해야할 인터페이스와 개발자가 customize 해야할 인터페이스를 분리할 필요가 있을 때는 언제라도 NVI idiom을 들이댈 수 있는 것이지요. 예를 들어, ImageDecoder 의 처음 인터페이스-IsValidImageBuffer() 를 추가하기 전-를 설계할 때도 NVI idiom 을 사용할 수 있을 것입니다.

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

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


처음부터 ImageDecoder 클래스를 이런식으로 정의해 놓을 경우 사용자용 인터페이스와 개발자용 인터페이스가 깔끔하게 분리되는 장점도 있지만 ImageDecoder 의 개발자용 인터페이스를 나중에 수정하기가 수월하게 됩니다. 이런 경우는 이미 이 글 안에서도 제시되었었죠. 나중에 보니 사용자들이 잘못된 ImageBuffer 를 넘겨줬는데도(예를 들어, GifImageDecoder 에 JpegImageBuffer 를 넘겨 줬다던지하는 경우 말입니다), 이미지를 억지로 디코딩 하려다 중간에 라이브러리가 죽는 일이 자주 발생하다 보니 본격적으로 디코딩을 시작하기 전에 IsValidImageBuffer 를 실행하여 이미지 버퍼의 유효성을 검사하는 루틴을 추가한 경우죠. 처음부터 NVI idiom 을 사용하여 ImageDecoder 를 정의했다면 ImageDecoder 인터페이스를 사용하던 Application 은 최소한의 영향만 받게 됩니다. 즉, ImageDecoder 를 다음과 같이 정의한다면,

class ImageDecoder {
public:
  RGBImageBuffer* Decode(const ImageBuffer* pImage) {
    if (IsValidImageBuffer(pImage))
      return DoDecode(pImage);
    else
      return 0;
  }

private:
  virtual RGBImageBuffer* DoDecode(const ImageBuffer* pImage) = 0;
  virtual bool IsValidImageBuffer(const ImageBuffer* pImage) = 0;
};


ImageDecoder 인터페이스를 사용했던 Application 은 소스 코드의 수정 없이 재컴파일만 하면 되는 것이죠. 아무래도 Application 에 대한 영향이 최소화된다면 Image Decoder 의 구현 인터페이스는 수정하기가 쉬울 것입니다. 구현 인터페이스들이 너덜 너덜 public 인터페이스로 나와 있는 경우보다는 훨씬 쉽게 수정할 수 있게 되는 거죠. 처음부터 다음과 같이 정의한 경우와 한 번 비교해 보시기 바랍니다.

class ImageDecoder {
public:
  virtual RGBImageBuffer* Decode(const ImageBuffer* pImage) = 0;
  virtual bool IsValidImageBuffer(const ImageBuffer* pImage) = 0;
};


위와 같이 인터페이스를 정의하고, 사용자에게 반드시 IsValidImageBuffer 를 사용해서 이미지 버퍼의 유효성을 검증한 후에, Decode를 호출해야 한다라고 사용자 매뉴얼에 쓰는 거죠. 예~ 별로 바람직하지 않습니다. 내부적인 작동으로 감춰야 할 부분을 사용자에게 전가하는 꼴이 되어 버립니다.

또 다른 예로, NVI idiom을 사용하였다면 나중에 ImageDecoder 안에 IsValidImageBuffer 라는 구현용 인터페이스를 둔 게 마음에 걸려서 ImageValidator 라는 인터페이스를 분리하게 되는 경우에도 Application 에는 영향을 미치지 않도록 비교적 쉽게 수정할 수 있을 것입니다.

/// @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;
};


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

struct ImageValidator {
  virtual IsValid(const ImageBuffer* pImage) = 0;
};

/// @file ImageDecoder.cpp
#include "ImageDecoder.h"
#include "ImageValidator.h"

RGBImageBuffer* ImageDecoder::Decode(const ImageBuffer* pImage)
{
  if (GetValidator()->IsValid(pImage))
    return DoDecode(pImage);
  else
    return 0;
}

왜냐면 Application에서 사용하는 인터페이스는 Decode() 로만 한정되어 있기 때문에 내부적으로 IsValidImageBuffer()가 GetValidator()->IsValid()로 바뀌더라도 Application은 전혀 영향을 받지 않는 것이죠.

이 정도면 왜 NVI idiom을 사용해야 하는지에 대해 감을 잡으셨으리라 생각합니다. 지금까지 모든 가상함수는 public 으로 선언해야 한다라고 생각하셨다면 맘을 고쳐 잡수시고, 때때로 아니 틈만 나면 가상 함수를 정의해야할 때마다 NVI idiom 이 적합할지 들이대 보는 것도 좋은 습관이 아닐까 생각합니다. 이번 글을 정리하자면

"가상 함수를 정의할 때는 NVI idiom을 들이대 보자"

가 되겠습니다. ^^

그나 저나 제가 중간에 ImageDecoder의 내부 구현 인터페이스를 바꾸게 되면, Application 재컴파일이 필요하다고 말씀드렸는데, ImageDecoder의 내부 구현 인터페이스를 바꾸더라도 Application 재컴파일이 필요 없게 만들 수는 없을까요 ? 이렇게 되야 하는 경우가 종종 있죠. 예를 들어, 이미지 디코딩 라이브러리를 shared library로 배포했는데, 버그로 인해 내부 구현을 수정하게 됐는데... 그 shared library를 이용해서 A1 이라는 업체, A2 라는 업체가 Application을 각각 개발해서 배포하였고, 이번 라이브러리 수정으로 인해 Application 재컴파일이 필요하게 됐다고 해 보죠. 그런 상태에서 A1 이라는 업체는 재빨리 대응하여 Application 을 재컴파일하여 Application 과 library 를 배포하였는데, A2 라는 업체는 늑장 대응을 하였다고 가정해 보겠습니다. 이런 상태에서 어떤 한 사용자가 A1, A2를 모두 쓰고 있었고 A1을 원격 Update 하고 나면 평소에는 잘 돌던 A2 Application 이 갑자가 죽는 것이죠. 영문도 모르는 애꿎은 사용자만 피해를 보는 것이지요. 이런 문제를 아마 DLL hell 이라고 불렀지요.

다음 글에서는 이런 문제를 해결할 수 있는 방법에 대해 생각해 보도록 하겠습니다.

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

제 글이 유익하셨다면 오른쪽 버튼을 눌러 제 블로그를 구독하세요. ->
블로그를 구독하는 방법을 잘 모르시는 분은 2. RSS 활용을 클릭하세요.
RSS에 대해 잘 모르시는 분은 1. RSS란 무엇인가를 클릭하세요.