재진입 함수의 의미

@codemaru · May 12, 2010 · 8 min read

‘윈도우 메시지 프로시저는 재진입 가능한 함수다.’

그렇게도 많은 책에서 위와 같은 말들을 하고 있다. 하지만 딱 한 줄뿐 저 말이 무엇을 의미하는지를 자세히 설명하고 있는 책은 드물다. 그래서 그런지 저 말을 쓰면서도 저 말이 무슨 의미인지 모르는 경우도 많고, 자신의 의미대로 해석해버리는 경우도 있다. 그렇게 많이들 오해하는 이 재진입 함수의 의미에 대해 한번 제대로 이해해 보자.

재진입이라는 말은 말 그대로 함수 내부로 다시 진입하는 것을 의미한다. ‘다시’라는 말이 의미하듯이 함수 코드가 처리되는 와중에도 다시 함수 내부로 진입됨을 의미한다. 여기까지 설명하면 대부분의 윈도우 개발자는 다음과 같이 잘못된 생각을 가진다. <리스트 1>과 같은 간단한 메시지 처리 루틴에서 WM_PAINT 메시지가 처리되는 동안 Sleep 상태일 때에도 키보드가 눌리면 다시 MyWindowProcedure가 실행되어 WM_KEYDOWN 이벤트가 처리된다고 생각하는 것이다.

<리스트 1> 간단한 메시지 처리 루틴

LRESULT CALLBACK MyWindowProcedure(HWND Window, UINT MsgId, WPARAM W, LPARAM L)
{
    switch(MsgId)
    {
    case WM_PAINT: Sleep(1000); break;
    case WM_KEYDOWN: OutputDebugStringA("WM_KEYDOWN"); break;
    }

    return DefWindowProc(Window, MsgId, W, L);
}

뭔가 이벤트 드리븐 개념에도 부합하는 게 그럴듯해 보인다. 하지만 이는 완전 엉터리 설명이다. 실제로 테스트해 보면 알겠지만 Sleep 상태인 동안에 키를 아무리 눌러본들 디버그 메시지는 출력되지 않는다. 이유는 무엇일까? 답은 메시지를 처리하는 방법에 있다. 많은 윈도우 프로그래밍 책들이 마치 메시지 프로시저를 운영체제가 알아서 호출해 주는 것처럼 설명하지만 실제로 운영체제는 메시지 프로시저를 호출하는 일 따위는 하지 않는다. 해당 일은 <리스트 2>에 나타난 것과 같은 메시지 처리 루틴에 의해 이뤄진다. DispatchMessage 함수 내부에서 해당 윈도우에 맞는 메시지 프로시저를 찾아서 호출되는 일이 진행된다. 따라서 해당 메시지 처리가 완료되어서 다음 GetMessage가 호출되기 전까지 메시지 프로시저는 새로운 것을 처리하고 싶어도 할 수 없는 상태인 것이다.

<리스트 2> 메시지 처리 루틴

wehile(GetMessage(&Msg, NULL, 0, 0))
{
    TranslateMessage(&Msg);
    DispatchMessage(&Msg);
}

그렇다면 다시 본론으로 돌아가서 재진입이란 말이 내포하고 있는 실제 의미에 대해 살펴보자. 이 말이 실제로 하고자 했던 이야기는 윈도우 프로시저는 스레드에 안전하며 병렬적으로 호출이 가능하도록 작성되어야 한다는 것을 나타낸다. 다시 설명한 내용도 무슨 말인지 선뜻 이해하기가 쉽지 않다. <리스트 3>과 <리스트 4>를 보면 무슨 의미인지가 분명해진다. <리스트 3>의 코드는 한 스레드에서 윈도우가 실행될 때와 두 스레드에 윈도우가 실행될 때 동작이 달라진다. 윈도우 의존적으로 처리되어야 하는 데이터가 정적 변수로 되어 있어서 다른 윈도우의 동작에까지 영향을 미치기 때문이다. 이를 제대로 처리하기 위해서는 윈도우 의존적인 변수는 윈도우 프로퍼티로 설정해서 참조하도록 변경해야 한다. <리스트 4>의 코드는 윈도우 프로시저가 글로벌 크리티컬 섹션에 묶여 있어서 메시지가 처리되는 과정이 서로 다른 스레드에 있는 윈도우의 동작에까지 영향을 미치게 된다. 다시 말하면 서로 다른 스레드의 윈도우는 상호 동작에 간섭을 받지 않아야 함에도 이 메시지 프로시저는 한 스레드의 메시지 프로시저가 완료되기 전까지는 다른 스레드의 메시지 프로시저가 처리될 수 없게 됨으로써 서로 원활하게 동시 실행이 처리되지 못하는 문제가 있다는 것이다. 이는 윈도우 운영체제가 의도하는 바가 아니기 때문에 이러한 식의 메시지 프로시저를 작성하는 것은 옳지 않다.

<리스트 3> 스레드에 안전하지 않은 메시지 프로시저

LRESULT CALLBACK MyWindowProcedure(HWND Window, UINT MsgId, WPARAM W, LPARAM L)
{
    static int Count = 0;
    switch(MsgId)
    {
  case WM_KEYDOWN:
    ++Count;
    if(Count % 2)
      OutputDebugStringA(“WM_KEYDOWN”);
      break;
    }

    return DefWindowProc(Window, MsgId, W, L);
}

<리스트 4> 병렬적으로 호출이 불가능한 메시지 프로시저

LRESULT CALLBACK MyWindowProcedure(HWND Window, UINT MsgId, WPARAM W, LPARAM L)
{
    EnterCriticalSection(&GlobalCs);
    switch(MsgId)
    {
  case WM_KEYDOWN: OutputDebugStringA(“WM_KEYDOWN”); break;
    }
    LeaveSection(&GlobalCs);

    return DefWindowProc(Window, MsgId, W, L);
}

아직도 무슨 상황에 문제가 생긴다는 건지 잘 이해가 되지 않을 수도 있다. 그렇다면 위와 같은 메시지 핸들러를 가지고 다음과 같은 세 가지 경우를 모두 테스트해 보도록 하자.

  1. 한 스레드에서 해당 메시지 핸들러를 사용하는 윈도우를 하나만 생성한 경우

  2. 한 스레드에서 해당 메시지 핸들러를 사용하는 윈도우를 여러 개 생성한 경우

  3. 여러 스레드에서 해당 메시지 핸들러를 사용하는 윈도우를 생성한 경우

올바로 디자인된, 다시 말하면 재진입 가능하도록 만들어진 메시지 프로시저라면 1, 2, 3의 경우에 모두 동일한 동작을 보장해야 한다. 만약 세 가지 중에 한 가지 경우라도 동작이 다르다면 해당 메시지 핸들러는 잘못 작성된 것으로 범용적으로 사용할 수 없음을 의미한다.

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중