본문 바로가기

2.분석 및 설계

MFC 메시지맵 구조

MFC에서 윈도우 메시지 처리하는 구조는 독특해보였다. C++이지만 전혀 OOP형태가 아닌 구조였다. 이는 처음 MFC을 접하면서 느낀 부분이였다. 그리고 이 구조는 C++의 가상함수에 의한 성능하락과 메모리 사용 증가 문제점을 극뽁(?)하고자 대안으로 사용한 방법임을 알게되었다.
언젠가 이 메시지 맵 구조를 파악해보리라했는데 시간이 너무 많이 지나게 되었다. 늦었지만, 최근 나름 이를 정리해서 올리려고 한다.
작성:http://ospace.tistory.com/(ospace114@empal.com) 2012.01.04

기본구조

아래 구조는 나름대로 단순화하고 필요한 부분만 추가하였다. MFC와 비교하기 어려울 수 있지만 이해를 돕기위해서 수정하였다.

MessageTarget 이 핵심 부분이다. 그리고 각 구현체에서 MessageMap과 MessageEntry가 들어간다. MessageTarget에는 두개 메소드가 있다. Message()은 메시지를 발생하는 부분으로 원하는 처리를 요청한다. OnMessage()은 요청한 메시지를 처리한다. OnMessage은 MessageTarget을 다른 용도로 사용할 경우 재구현할 수 있도록 하였다. 여기서는 큰 의미가 없다. 확장성을 위한 부분이다.
지금 MessageTarget에는 가상함수가 2개가 생겼다. OnHello()와 OnBye()에 대한 가상함수는 없다. 단순 멤버함수로 정의하고 있다. 현재는 메시지 종류가 2개이지만, 100여개가 생겼다면 100여개에 대한 가상함수와 이에 대한 가상테이블이 생성되어한다. 늘어나면 늘어날 수록 커진다. 물론 현재는 많은 최적화가 되어서 사용하지 않은 가상함수라면 리소스가 할당되지 않을 수도 있다.

typedef void (MessageTarget::*MessageHandler)();
struct MessageEntry {
    int code;
    MessageHandler fun;
}
struct MessageMap {
    const MessageMap *parent;
    const MessageEntry *entities;
};

이 정도면 딱 감이 오시는 분도 있을 것이다. 정말 단순화해서 핵심 부분을 정리한 부분이다.

메시지 디스페처

실제 메시지를 처리하는 OnMessage()의 로직을 살펴보겠다. 이부분을 알면 전체 흐름을 쉽게 이해할 수 있다.

virtual void MessageTarget::OnMessage(int code) {
    //이 부분은 특정 메시지는 우선적으로 처리하여 지정된 가상함수로 호출할 수도 있음.
    //if(MSG_INIT == code) {
    //    OnInit();
    //}
    for(const Message *msg = GetMessageMap(); msg != NULL; msg = msg->parent) {
        const MessageEntry *entry = msg->entries;
        while (NULL != entry->fun ) {
            if (entry->code != code) {
                ++entry;
                continue;
            }

            (this->*(entry->fun))();
            return;
        }
    }
}

아래를 샘플 코드이다.

MessageTarget *target = new MsgImpl3();
target->Message(MSG_BYE);

간단히 흐름을 설명하면;

  1. for문의 GetMessageMap()에 의해서 현재 객체의 MessageMap을 받는다.
  2. 위의 예에서는 MsgImpl3객체의 messageMap을 획득한다.
  3. 그리고 그 곳에서 MessageEntry의 목록을 얻는다.
  4. 그 엔트리 목록에서 원하는 메시지 코드와 비교하여 해당 멤버 함수 포인터를 획득한다. 즉 OnHello()가 될 것이다.
  5. 해당 멤버함수 포인터로 호출한다.

엔트리 목록에서 원하는 메시지 코드를 찾을 수 없다면, 현재 MessageMap에서 parent 포인터를 획득하여 해당 MessageMap애서 다시 검색한다.

구현체 내용

실제 메시지 처리하는 구현체를 보자. MsgImpl2을 살펴 보겠다. 아래 bold체 부분이 핵심이다.

class MsgImpl2 : public MessageTarget {
public:
    virtual const MessageMap* GetMessageMap() { return &messageMap; }
    static MessageEntry messageEntries[];
    static MessageMap messageMap;

public:
    void OnInit() {
        cout << "초기화" << endl;
    }

    void OnBye() {
        cout << "잘가" << endl;
    }

    void OnHello() {
        cout << "안녕" << endl;
    }
};

MessageMap MsgImpl2::messageMap = {&MessageTarget::messageMap, MsgImpl2::messageEntries};

MessageEntry MsgImpl2::messageEntries[] = {
    {MSG_BYE, static_cast(&MsgImpl2::OnBye)},
    {MSG_HELLO, static_cast(&MsgImpl2::OnHello)},
    {0, 0}
};

messageEntries 에 메시지에 대한 핸들러가 등록된다. 여기서는 두 개 핸들러가 등록된다. MessageMap은 linked list 구조로 단일 연결로 구성되어 있다. 자신에게 처리할 핸들러가 없다면 상위 객체로 가서 검색하는 구조이다.
만약 다중 상속된다고 하면 문제가 발생하는 구조이다. 예를 들어, 다음과 같은 구조가 있다고 하자.

class MsgImpl1 : public MessageTarget {};
class MsgImpl2 : public MessageTarget {};
class MsgImpl3 : public MsgImpl1, public MsgImpl2 {};

MsgImpl3 에서 parent를 MsgImpl1으로할지 MsgImpl2로 할지 결정해야 한다. 이미 MsgImpl1와 MsgImpl2의 parent는 MessageTarget으로 고정된 상태이다. MsgImpl3에서 parent가 MsgImpl1이라면 MsgImpl2에 있는 핸들러는 호출할 수 없게 된다. 다중 상속되도록 만들면 안된다. 다중 상속은 특수한 목적을 위해서가 아니라면 지양해야한다.
어쩔 수 없이 다중 상속하게 된다면, 어느 한쪽은 포기해야하고 그 경우에 핸들러 호출 관계를 확인해야 한다.
메 시지 식별자와 그때 호출하는 멤버함수 명은 등록 시점에 결정이 된다. 즉, 고정된 멤버함수를 사용하지 않는다. 즉 사용자가 원하는 형태의 핸들러 명을 사용할 수 있다는 의미이다. 나름 장점이라고 할 수 있다. 그리고, 전체 구조의 큰 변경 없이 새로운 메시지에 대한 처리를 할 수 있다.

다양한 변형

특수 목적 핸들러

핸들러 중에 특수 목적으로 별도 처리하는 경우가 있다. 예를 들어 객체 시작과 종료할 경우 호출하는 핸들러가 있다고 하면 해당 함수를 고정으로 호출 할 수 있을 것이다.

class MessageTarget {
    //...
    virtual void OnInit() {}
    virtual void OnRelease() {}
};

class MsgImpl : public MessageTarget {
    //...
    virtual void OnInit() {
      //...
    }
};

MessageTarget의 OnMessage()에 수정이 필요하다.

void MessageTarget::OnMessage(int code) {
    if (MSG_INIT == code) {
        OnInit();
        return;
    }

    if (MSG_RELEASE == code) {
        OnRelease();
        return;
    }
    //...
}

OnMessage의 재정의

OnMessage을 목적에 맞게 재정의할 수 있다. 물론 전체 구조를 해쳐서는 안될 것이다.
예를 들어 Window라는 클래스가 있을 경우 Windows의 자체에 대한 메시지 처리할 경우를 보자.

class Window : public MessageTarget {};
void Window::OnMessage(int code) {
    //... 원하는 처리
    MessageTarget::OnMessage(code); //처리 못할 경우
}

만약에 대비해서 처리못할 경우 MessageTarget을 처리를 위임할 수 있다. Windows가 자체적으로 많은 MessageTarget객체를 가지고 있다면, 이들을 모두 처리하게 만들 수도 있을 것이다.

다양한 형태의 핸들러 사용

현 재 예제에서 사용한 멤버함수는 인자가 없다. 그러나 인자가 필요한 경우가 대부분이다. 이럴 경우 고정된 형식을 갖는 인자를 사용하는게 가장 좋지만, 항상 그럴 수 없다. 인자와 반환 값을 필요따라 사용하면 더 유용할 것이다. 그럼 어떻게 할까?
MFC에서는 사용 중인 모든 핸들러 시그니처에 식별자를 부여하고 등록할에 해당 멤버함수가 맞는 식별자를 등록한다.
예를 들어 인자가 없는 경우와 인자가 정수형 한 개인경우, 식별자를 TYPE_VOID, TYPE_BOOL_INT형태로 한다고 하자. 앞에 BOOL은 리턴값, 뒤의 INT는 인자이다.

enum {
    TYPE_VOID,
    TYPE_BOOL_INT
};

MessageEntry MsgImpl::messageEntries[] = {
    {MSG_BYE, static_cast<messagehandler>(&amp;MsgImpl2::OnBye)}, TYPE_VOID,
    {MSG_HELLO, static_cast<messagehandler>(&amp;MsgImpl2::OnADD), TYPE_BOOL_INT},
    {0, 0}
};

MessageTarget의 OnMessage()에서 핸들러 타입을 확인하고 적절하게 멤버 함수 포인터를 변형해서 호출한다. 그럼 넘겨줄 값은 어디에 있는가? 이를 위해 MessageTarget의 Message()을 변경할 필요가 있다. 일반적으로 Windows에서 사용하는 함수 형태는 아래와 같다.

LRESULT WINAPI SendMessage(
  __in  HWND hWnd,
  __in  UINT Msg,
  __in  WPARAM wParam,
  __in  LPARAM lParam
);

hWnd는 메시지를 처리할 핸들 객체이고, Msg가 앞에서 사용한 code에 해당하며, wParam과 lParam이 넘겨지는 값이다. 이 부분의 값이 앞에 TYPE_BOOL_INT에 넘겨지는 인자 값으로 사용 가능하다.
실제 호출하는 측에서는 아래처럼 처리할 수 있다. 이는 _AfxDispatchCmdMsg()를 참고한 내용이다.

union MessageMapFunction {
    CmdHandler func;
    bool (MessageTarget::*func_bool_int)(int);
};

bool DispatchHandler(MessageTarget *target, int type, CmdHandler handler, WPARAM wParam, LPARAM lParam) {
    MessageMapFunction funcMap;
    funcMap.handler = handler;
    bool result = false;
    switch(type) {
    case TYPE_VOID:
        (target->*funcMap.handler)();
        break;
    case TYPE_BOOL_INT:
        result = (target->*funcMap.func_bool_int)((int)wParam);
        break;
    }

    return result; //false는 처리 실패했다는 의미
}

전역 메시지 맵 처리

특 별 한 것은 없다. 앞의 SendMessage()을 보았으면 힌트를 얻었을 것이다. 무수히 많은 MessageTarget 객체에서 어떤 객체가 처리되는지 알 수 없다. 윈도우즈에서는 HWND로 모든 객체를 표현하는데, 이 것이 MessageTarget이 된다.
MFC에서는 HWND을 통해서 CWnd객체를 획득한다. 이는 CWnd::FromHandlePermant()를 통해서 획득한다. 이말은 Windows를 통해서 MessageTarget을 호출해서 사용하는 구조로 만들 수 있다는 의미이다.
이를 위해서 전역변수로 이를 CWnd과 HWND간에 맵을 생성해서 관리를 한다. 그러면 언제 어디든지 HWND만 알고 있으면 바로 해당 객체에 메시지를 호출할 수 있게된다.

매크로를 통해 간편하게 사용

앞에 작업은 매우 복잡한 추가 코드가 필요하다. 이를 간단하게 도와줄 수 있는게 매크로이다. 매크로 사용하는 것을 좋아하지는 않지만 이 경우 매크로를 사용하면 코드를 많이 단순화할 수 있어서 유용하다.
먼저 아래와 같은 매크로가 필요하다. MFC를 참고하여 위의 예제에 맞게 수정하였다.

#define DECL_MESSAGE_MAP() \
    private:\
        static const MessageEntry messageEntries[];\
    protected:\
        static const MessageMap messageMap;\
        virtual const MessageMap* GetMessageMap() const;

#define BEGIN_MESSAGE_MAP(theClass, baseClass) \
    const MessageMap* theClass::GetMessageMap() const \
        { return &theClass::messageMap; }\
    const MessageMap theClass::messageMap = \
        { &baseClass::messageMap, theClass::messageEntries };\
    const MessageEntry theClass::messageEntries[] = {

#define END_MESSAGE_MAP() \
    {0, 0} };

#define ON_MESSAGE(id, memberFun) \
    { id, static_cast<messagehandler>(&memberFun) },
class MsgImpl2 : public MsgImpl1 {
DECL_MESSAGE_MAP()
public:
    void OnHello() {}
    void OnBye() {}
};
BEGIN_MESSAGE_MAP(MsgImpl2, MsgImpl1)
    ON_MESSAGE(MSG_HELLO, MsgImpl2::OnHello)
    ON_MESSAGE(MSG_BYE, MsgImpl2::OnBye)
END_MESSAGE_MAP()

많이 단순해졌다. 단지 새로운 메시지 핸들러 시그니처가 나오면 새로운 매크로 정의가 필요하다. 물론 직접 초기화할 수 있지만, 차후에 새로운 구조체 형식으로 변경될 경우 호환성을 위해 매크로를 사용하는 더 좋을 것이라 판단된다.

매크로에 한걸음 더

이 번에는 매크로를 좀더 발전시킬려고 한다. 기존에 멤버변수를 만들어서 그 곳에 매시지 맵을 초기화하는 방식이다. 싱글톤에서 보면 멤버변수를 싱글톤 객체를 만들었다고 보면 된다. 이번에는 조금 방식을 다르게해서 싱글톤에서 멤버함수 내에 정적 객체를 만들어 싱글톤 객체를 만들었다고 보면 된다. 앞의 방식은 실행 시점에 초기화되는 반면, 이번은 함수 호출 시점에 초기화되는 시점이 틀리다.
기본 구조는 다음과 같이 변경된다.

class MsgImpl2 : public MessageTarget {
public:
    virtual const MessageMap* GetMessageMap();
public:
    void OnInit() {}
    void OnBye() {}
    void OnHello() {}
};
const MessageMap* MsgImpl2::GetMessageMap() {
    static const MessageEntry messageEntries[] = {
        {MSG_BYE, static_cast<messagehandler>(&amp;OnBye)},
        {MSG_HELLO, static_cast<messagehandler>(&amp;OnHello)},
        {0, 0}
    };
    static const MessageMap messageMap = {&amp;MessageTarget::messageMap, messageEntries};
    return &amp;messageMap;
}

위처럼 조금 간단하게 변경된다. 그리고 OnBye(), OnHello을 메시지 맵에 추가할 때 사용하는 한정자(MsgImpl2)도 없어졌다. 이를 매크로로 변경하면 다음처럼 될 것이다.
이를 매크로 정의로 변경하면

#define DECL_MESSAGE_MAP() \
    protected:\
        virtual const MessageMap* GetMessageMap() const;
#define BEGIN_MESSAGE_MAP(theClass, baseClass) \
const MessageMap* theClass::GetMessageMap() {\
static const MessageEntry messageEntries[] = {
#define END_MESSAGE_MAP() \
{0, 0} };\
static const MessageMap messageMap = \
{ &amp;baseClass::messageMap, messageEntries };\
return &amp;messageMap;\
}
#define ON_MESSAGE(id, memberFun) \
    { id, static_cast<messagehandler>(&amp;memberFun) },

이를 예제에 적용하면

class MsgImpl2 : public MsgImpl1 {
DECL_MESSAGE_MAP()
public:
    void OnHello() {}
    void OnBye() {}
};
BEGIN_MESSAGE_MAP(MsgImpl2, MsgImpl1)
    ON_MESSAGE(MSG_HELLO, OnHello)
    ON_MESSAGE(MSG_BYE, OnBye)
END_MESSAGE_MAP()

ON_MESSAGE 부분이 많이 간소화되었다.

메시지 처리 성능 향상

어 플리케이션이 실행되는 동안 윈도우 핸들이 변경되지 않을 것이다. 즉 한 메시지에 대해서 같은 객체에서는 같은 핸들러가 수행된다고 할 수 있다. 즉, 어떤 메시지가 한번 호출했다면 다시 검색할 필요 없이 바로 실행할 수 있다면 성능 향상에 좋을 것이다. MFC에서는 이를 캐시를 활용하여 구현하고 있다.
캐시 값을 저장할 구조체

struct MessageCache {
    int message;
    MessageEntry *entry;
    MessageMap  *msgMap;
};

이를 사용하여 캐시를 적용한다면, 아래와 같이 수정할 수 있을 것이다.

#define MAX_HASH 256

MessaeCache msgCache[MAX_HASH];

virtual void MessageTarget::OnMessage(int code) {
    for(const Message *msg = GetMessageMap(); msg != NULL; msg = msg->parent) {
        int hash = (LOWORLD((DWORD)msg)^code) & (MAX_HASH-1);
        if (message == msgCache[hash].message && msg == msgCache[hash].msgMap) {
            (this->*(msgCache[hash].entry->fun))();
            return;
        }
        const MessageEntry *entry = msg->entries;
        while (NULL != entry->fun ) {
            if (entry->code != code) {
                ++entry;
                continue;
            }
            msgCache[hash].message = code;
            msgCache[hash].entry = entry;
            (this->*(entry->fun))();
            return;
        }
    }
}

즉, 캐시 테이블에 있다면 바로 호출 하는 것이고, 없다면 새로 캐시에 등록하고 호출하게 된다. 처리는 간단하다.

결론

지금까지 MFC의 메시지 처리 방식을 간략하게 살펴보았다. 단순히 가상함수를 사용한 구조보다 복잡해졌고, 해야할 작업도 많아졌다. 실제 성능도 측정해보지는 않았다. 얼마나 효용성이 있는지데 대한 부분은 검증이 필요하다.
아마도 기존 MFC와 일부 명칭이 상이한 부분이 있어서 이해하는데 어려울 수도 있지만, 최대한 쉽게 이해하기위해 풀어서 명칭을 사용하였다.
귀동량에 의하면 유연한 메시지 처리 구조와 빠른 속도와 적은 리소스 사용을 장점을 갖는 메시지맵 구조를 살펴보았다.
기나긴 일의 결실을 맺었다. 너무 짧고 간단하게 끝나서 그동안 이걸 가지고 고민한게 허무하기도 하다.
그래도, 목표 하나를 끝냈다.
모두 즐프

~

2012.ospace

참조

[1] http://msdn.microsoft.com/en-us/library/windows/desktop/ms644950(v=vs.85).aspx

[2] Micorsoft, MFC source, Visual Studio

[3] 권영근, airen@soar.snu.ac.kr, 고급 MFC, MFC1.ppt

[4] ospace@empal.com, 본문 Sample source code, test_message_handle.cpp

[4] hash, http://internet512.chonbuk.ac.kr/datastructure/hash/hash1.htm

반응형

'2.분석 및 설계' 카테고리의 다른 글

YUV 포멧  (0) 2021.11.15
Mina로 본 네트웍 프레임워크  (0) 2012.07.27
errno같은 리턴 에러값 프레임워크  (0) 2012.07.26
함수 호출과정 분석  (0) 2011.01.12
코드 문서화  (0) 2011.01.07