이 글은 Dr. Dobb's Journal 의 'Object Registration and Validation - Making your C++ Ironclad' 라는 기사를 보고 썼습니다.

오늘 참 재미있는 디자인 패턴 하나를 보았습니다. 이 패턴을 제안한 Eric Gufford 라는 사람은 ANSI C++ 기술 위원회 소속이고, Pure C++ 이라는 책을 썼다고 합니다. 그만큼 C++ 기술에서는 권위자라는 이야기이겠지요.

이 패턴이 태어난 이유는 아래와 같은 상황 때문입니다.
AClass * pObj;
pObj->method();

위 코드의 문제가 무었인지는 C++을 대충 알고 있는 사람도 다 알 것입니다. 바로 객체가 생성되지 않았는데, 참조를 한다는 것이지요. 만일, 위의 두 문장 사이에 수 십 줄의 다른 코드가 있다면? 알아채기 쉽지 않을 것입니다.
컴파일러는 알 수 없습니다. 객체가 생성되는 것은 실행을 시켜봐야 아는데, 컴파일러가 알게 뭡니까?
실행해봐도 왜 문제인지 모를 수 있습니다. 디버거를 실행해서 pObj가 가지는 값이 무엇인지 확인해보고 기대한 값이 들어있지 않아서 코드를 거슬러 올라가다 보면 객체가 생성되지 않은 채 사용되어 발생한 문제라는 것을 알겠지요. 게다가 위의 코드는 실행될 때 에러를 발생시키지 않을 수도 있습니다. 가상 함수가 아닌 한 method() 함수는 어쨌든 실행될테고 이 함수가 건드리는 메모리 주소가 가용한 곳이라면 뭐 어쨌든 위의 코드가 실행될 때 에러가 발생하지는 않겠지요.
만일 이 코드가 중요하지 않은 기능에 들어있고, 평소에 잘 사용하지는 않는 기능이지만 언젠가는 쓸 그런 기능(예를 들어 웹브라우저 간의 북마크 내보내고 가져오기 같은 기능)이라면?

'Object Registration and Validation' 패턴은 생성되지 않은 객체의 메소드가 호출되는 것을 막아줍니다. 위 기사에서 언급된 전제는 다음과 같습니다.
  • 클래스의 모든 데이터 멤버는 private이고, 이들은 항상 (심지어 같은 클래스의 method들에서 조차도) Accessor(데이터 멤버의 값을 읽어들이는 method)와 Mutator(데이터 멤버의 값을 변경하는 method)에 의해서만 접근된다.
이런 전제 조건이 없으면 이 패턴은 의미가 없어집니다. 이 패턴이 적용되는 클래스는 다음과 같은 멤버를 가집니다.
class  Class : public Base {
private:
    static const long sm_clSerialNumber = 0x13F039AC;
    mutable long m_mlSerialNumber;
    void SetSanity() const throw();
    void ClearSanity() const throw();
    void SanityCheck(int,string const&) const throw(EXSanity_c);
 };
여기에 보면 두 개의 멤버 변수가 있습니다. 하나는 static으로 선언이 되어 있군요. 이 값은 클래스의 모든 인스턴스들이 가지는 고유한 값으로, 객체가 생성되면 객체의 생성자에서 이 값이 m_mlSerialNumber로 복사됩니다. 이 m_mlSerialNumber 값을 설정하고 제거하는 메소드가 SetSanity()와 ClearSanity()입니다. 여기서 중요한 것은 SanityCheck()라는 메소드입니다. 클래스의 생성자를 제외한 모든 메소드들은 항상 이 메소드를 가장 먼저 호출하여야 합니다. 이 메소드는 다음 두 가지 판단을 하고, 이것이 틀리면 위의 코드에서 명시한 것처럼 어떠한 '예외'를 발생시킵니다.
  1. 객체의 this 포인터가 0인가? 0이라면 객체가 생성되지 않은 것이라고 확언할 수 있지요.
  2. 객체의 this 포인터가 0이 아니라면 sm_clSerialNumber와 m_mlSerialNumber가 같은가. 즉, 생성자가 호출되었는지(따라서 SetSanity() 메소드가 호출되었는 지) 확인하는 것입니다.
    단순히 생성자가 호출되는 것을 확인할 것이라면 어째서 bool 데이터와 같은 것을 사용하지 않았을까요? 클래스 포인터 변수가 지역 변수이고, 여기에 0을 대입하지 않았고, 메모리 할당도 하지 않았다면 this 포인터는 포인터 변수가 가진 어떠한 값을 (가상) 메모리 주소로 여기게 될 것입니다. 우연히도 이 값이 0이면 좋겠지만, 그렇지 않다면...
    또, 객체의 메모리를 해제하기 위해 delete 연산자를 사용했는데, 0을 대입하지 않았다면...
    위의 두 가지 경우는 객체가 생성되었는 지 확인하기 위해 단순한 flag가 아닌 좀처럼 우연히 만나지 않을 특정한 값을 사용할 필요가 있다는 것을 보여줍니다.
만일 위의 두 가지 경우 중 하나 이상에 해당되면, SanityCheck() 메소드는 예외를 발생시킵니다. 그런데, 클래스의 어떤 메소드도 이 예외를 처리하지 않으므로, 객체를 사용하는 곳에서 예외를 처리해야 합니다. 그렇지 않으면 저자의 기사에 의하면 운영체제에 의해 예외 발생된 것이 기록된다고 하네요.

아무튼, 이러한 방법을 쓰면 분명 객체가 생성되었는지 확인하기가 매우 쉬워지고, 문제를 미연에 방지할 수 있습니다. 이 패턴은 이해하기 어려운 것도 아니고, 제가 이 패턴에 대해 설명드리려고 하는 것도 아니니 직접 기사를 참조하시면 되겠습니다. 기사에 소개된 예제만 보셔도 이해하실 수 있을 정도로 명확한 패턴입니다.

저는 이 패턴의 세 가지 문제를 지적하고 싶습니다.

우선 SanityCheck()라는 메소드가 초래하는 overhead입니다. 메소드가 호출될 때마다 매번 this 포인터가 0인지 검사하고 일련 번호를 검사합니다. 비록, 이 함수가 inline으로 사용된다고 하더라도, 이 모든 코드가 기계어로 번역될 때는 최소한 10개의 instruction은 될 것입니다. 게다가, if 문을 가지고 있습니다. 분기문은 프로그램 제어의 일관성을 해치기 때문에 성능 저하를 초래할 수 있는 부분이지요. 뭐 최근의 데스크탑용 프로세서는 이런 것을 효율적으로 처리할 수 있는 기능이 있겠지만..
물론 긴 메소드의 경우에는 이 overhead가 별 큰 영향이 없습니다. 하지만, 이 패턴은 accessor와 mutator에서 항상 SanityCheck()를 호출할 것을 요구하고 있습니다. SanityCheck()가 객체의 데이터 멤버를 사용하기 전에 그것을 사용할 수 있는지 확인하는 것이기 때문에 당연한 것이지요. 사실 이 패턴에서 같은 클래스의 메소드들 조차 데이터 멤버를 사용하려면 accessor와 mutator를 쓰라고 하기 때문에, 다른 메소드들에서는 SanityCheck() 메소드를 호출할 필요 조차 없습니다.
일반적으로 accessor는 설정할 값 하나만을 인자로 받고, 리턴값이 없는 inline 메소드로 정의됩니다. 메소드의 코드도 대입 연산 단 하나가 대부분일 것입니다. mutator는 인자 없이 멤버 변수 하나의 값을 리턴만 하는 메소드가 대부분입니다. 이런 초간단 코드에 두 세번의 비교와 분기문이 있는 코드가 들어가면 그 overhead는 두 세배나 된다고 볼 수 있습니다.
아무래도 저자는 이 overhead를 과소평가하고 있거나 제가 모르는 어떠한 테크닉을 염두에 두고 있는 것이 분명합니다.

두번째로는 가상함수가 사용될 때는 이 패턴이 무용지물이 됩니다. 가상 함수가 호출되기 위해서는 먼저 객체의 vtbl, 즉 가상함수 테이블을 참조해야 합니다. 그런데, 객체가 생성되지 않았다면 vtbl이 유효하지 않겠지요. 그러면 SanityCheck() 함수가 호출되기도 전에 메모리 참조 오류가 발생(UNIX 계열은 Sagmentation fault, Windows 계열은 Access violation)합니다. 운이 좋아 포인터 변수가 가리키는 (물론 메모리 할당이 되지 않은 임의의 숫자)주소에 어떤 함수의 시작접이 지정되어 있다면 오류가 발생하지 않겠지만, 프로그래머가 의도한 바는 아니겠지요.

마지막으로 이 패턴 자체가 가지고 있는 논리적 문제입니다. 만일 메모리 할당이 일어나지 않았는데도 포인터 변수, 즉 this 멤버의 값이 0이 아니고(초기화하지 않은 지역변수가 이렇게 되지요), this가 가진 값이 접근되어서는 안될 주소라면? SanityCheck() 메소드가 실행되고 this와 0을 비교할 때는 괜찮겠지만, 멤버 변수를 사용할 때, 즉 m_mlSerialNumber를 접근할 때 잘못된 메모리 접근 오류가 발생합니다. 이건 뭐 예외를 발생시킬 수도 없습니다.

저의 지식이 짧아서인지는 모르겠으나, 이 패턴이 유용하게 사용될 만한 곳이 분명히 있을 것이라 생각하지만 너무나 많은 문제를 가지고 있어서 효용이 있을지는 의문입니다. 혹시 누군가 저에게 이해시켜주실 분 계신가요??

-- 7월 2일 --
패턴의 저자가 제가 질문한 것에 대해 답변을 해주었네요.

Phillip, yes you're right that there are a few of instructions in each function, but they are simple assignment and cmp operations. THe only expensive operation is the actual throw.

Inlining, though, removes any need for stack push/pop, which are also sizeable operations. And any good optimizing compiler can reduce the footprint further, possibly down to 2 cmp ops and a short jmp (over the throw) into the heart of the function.

Re virtual functions, the vtbl resolution will already have been performed prior to the function being called - it is an operation required to find the correct entry point into the function in the first place. And inlining will have removed the function call overhead and instead embedded the SanityCheck function's instructions directly into the parent function (down to the 2 cmp and 1 jmp instruction referred to above). Thus, there is no problem with virtual functions that I can see.


함수가 Inline 되고, 컴파일러가 최적화를 잘 하면, SanityCheck() 메소드는 두 개의 비교 명령과 하나의 점프 명령으로 될 수 있을 것이라고 하는군요.
또한, vtbl 접근도 문제가 없을 것이라고 하는데.. 이것은 다시 질문해보렵니다.

Trackbacks  0 | Comments 

풀리비’s Blog is powered by Daum & Tattertools.com