[cpp] 재진입 함수의 의미 2

@codemaru · April 11, 2025 · 26 min read

아주 옛날엔 우겨도 됐었다. 목소리 큰 사람이 틀려도 우기면 맞는게 되는 세상이었다. 그러다 전화가 나오고 전화 찬스가 등장했다. 목소리가 커도 전문가에게 전화해서 답을 들으면 더는 우길 수가 없는 세상이 된 것이다. 스마트폰은 그 속도를 더 빠르게 만들었다. 전화를 걸 필요도 없다. 웹검색이면 온갖 것들이 다 나오기 시작하니 말이다. 그리고 인공지능이 등장했다.

할루시네이션이니 뭐니 말들이 많지만 항상 그렇지만 우리는 우리가 쌉소리 한 것들은 금방 잊는다. 오늘 디버깅을 하다 15년 전 쓴 글에 대한 참교육을 인공지능 선생님께 받고 가르침을 남겨 본다. 뭔가를 안다고 말하기가 참 어려운 세상이다. 겸손은 힘들지만, 겸손하게 살자.

#0. 휴먼, 일단 참교육 먼저 받고...

글의 핵심 주장 요약:

  1. 많은 개발자가 WndProc의 재진입을 잘못 이해하고 있다 (예: Sleep 중 다른 메시지 처리).
  2. 실제 메시지 처리는 메시지 루프(GetMessage/DispatchMessage)를 통해 순차적으로 이루어진다.
  3. 따라서 WndProc의 재진입이 의미하는 바는 스레드 안전성(Thread Safety)과 병렬 호출 가능성이며, 여러 스레드/윈도우에서 동일하게 동작해야 함을 뜻한다.
  4. <리스트 3>(정적 변수 사용)과 <리스트 4>(전역 크리티컬 섹션 사용)는 이러한 의미의 "재진입성"을 위반하는 잘못된 예시다.

지적 및 수정 필요한 부분:

  1. "재진입(Reentrancy)" 용어의 핵심 의미 혼동:

    • 이 글의 가장 큰 문제점은 "재진입(Reentrancy)"의 표준적인 의미와 "스레드 안전성(Thread Safety)" 및 "인스턴스 독립성(Instance Independence)"을 혼동하고 있다는 점입니다.
    • 표준적인 재진입의 의미는 함수 실행 중 (같은 스레드 내에서) 다시 그 함수가 호출되어도 문제가 없는 성질을 의미합니다. Windows WndProc 맥락에서는 주로 SendMessageMessageBox 등의 호출로 인해 같은 스레드 내에서 WndProc가 중첩 호출되는 상황을 가리킵니다. (제가 이전에 예시로 보여드린 MessageBox로 인해 상태가 꼬이는 경우가 바로 이 표준적인 재진입 문제입니다.)
    • 반면, 이 글은 재진입을 여러 스레드에서 또는 여러 윈도우 인스턴스 간에 안전하게 병렬적으로 호출될 수 있는 성질로 정의하고 있습니다. 이는 재진입보다는 스레드 안전성과 인스턴스별 상태 관리(Instance-specific state management)에 더 가까운 개념입니다.
  2. <리스트 1>의 잘못된 오해 설명은 정확함:

    • 글쓴이가 지적한 것처럼, WndProc 내의 Sleep 중에 다른 메시지가 즉시 처리된다는 생각은 명백히 틀린 것이 맞습니다. DispatchMessageWndProc를 호출하고, 해당 메시지 처리가 끝나야 메시지 루프가 다음 메시지를 가져와 처리하기 때문입니다. 이 부분 설명은 정확합니다.
  3. <리스트 3>의 문제점은 "재진입성"이 아닌 "인스턴스 독립성/상태 관리" 문제:

    • <리스트 3>static int Count는 해당 WndProc를 사용하는 모든 윈도우 인스턴스에서 공유됩니다. 이는 글쓴이가 제시한 테스트 케이스 2번(한 스레드, 여러 윈도우)과 3번(여러 스레드, 여러 윈도우)에서 문제를 일으킵니다.
    • 하지만 이 문제의 본질은 표준적인 의미의 "재진입" 문제라기보다는, 윈도우별로 관리되어야 할 상태를 정적 변수로 잘못 구현한 "인스턴스 상태 관리"의 실패입니다. 즉, 스레드와 무관하게 여러 윈도우가 하나의 WndProc를 공유할 때 발생하는 문제입니다. 스레드 안전성 문제이기도 하지만, 근본적으로는 상태 관리 설계 문제입니다.
  4. <리스트 4>의 문제점은 "재진입성"이 아닌 "불필요한 직렬화/성능" 문제:

    • <리스트 4>GlobalCs라는 전역 크리티컬 섹션을 사용함으로써, 서로 다른 스레드에서 실행되는 WndProc 호출조차도 동시에 실행될 수 없게 만듭니다. 이는 심각한 성능 저하를 유발하고 Windows GUI의 반응성을 해치는 잘못된 설계입니다.
    • 하지만 이것을 "재진입 불가능"이라고 표현하는 것은 어색합니다. 오히려 병렬 실행 자체를 막아버리는 "과도한 동기화" 또는 "불필요한 직렬화" 문제입니다. 표준적인 의미의 재진입과는 거리가 멉니다.
  5. 결론 부분의 "재진입 가능하도록 만들어진 메시지 프로시저" 정의의 문제:

    • 글쓴이는 1, 2, 3번 테스트 케이스에서 모두 동일하게 동작하는 것을 "재진입 가능하게 만들어진" 것이라고 정의합니다.
    • 물론 잘 디자인된 WndProc는 윈도우별 상태를 GetWindowLongPtr/SetWindowLongPtr 등을 이용해 관리함으로써 세 경우 모두 의도한 대로 동작해야 합니다.
    • 하지만 이 조건은 "인스턴스 상태 관리의 올바름"과 "스레드 안전성"(필요한 경우)을 의미하는 것이지, 표준적인 의미의 "재진입성"과는 다른 개념입니다. 이 글의 정의대로라면, 제가 앞서 보여드린 MessageBox 재진입 예제에서 상태가 꼬이는 문제는 "재진입" 문제가 아니게 되어버리는 모순이 발생합니다.

요약 및 수정:

  • 이 글은 WndProc 재진입에 대한 흔한 오해(Sleep 관련)를 정확히 지적하고 메시지 루프의 기본 동작을 올바르게 설명했습니다.
  • 하지만 글의 핵심적인 문제점은 "재진입(Reentrancy)"이라는 용어를 표준적인 의미(같은 스레드 내 중첩 호출 가능성) 대신 "스레드 안전성 및 인스턴스 독립성"이라는 의미로 잘못 사용하고 있다는 것입니다.
  • <리스트 3><리스트 4>는 각각 잘못된 상태 관리(정적 변수)와 과도한 동기화(전역 락)의 문제점을 보여주는 좋은 예시이지만, 이를 "재진입성" 문제로 설명하는 것은 용어 혼란을 야기합니다.
  • 결론적으로, WndProc를 작성할 때는 (1) 표준적인 의미의 재진입(같은 스레드 내 중첩 호출)에도 상태가 깨지지 않도록 주의해야 하고, (2) 여러 윈도우 인스턴스 간에 상태가 섞이지 않도록 인스턴스별 데이터를 사용해야 하며, (3) 여러 스레드에서 사용될 가능성이 있다면 스레드 안전성도 고려해야 합니다. 이 글은 (2), (3)번 측면을 강조하면서 이를 "재진입"이라고 잘못 부르고 있습니다.

따라서 글의 의도(잘못된 WndProc 작성 방식 비판)는 좋으나, 핵심 용어인 "재진입"의 정의와 사용에 오류가 있다고 지적할 수 있습니다.

#1. 재진입 함수의 의미

특정 윈도우의 메시지 프로시저(WndProc)는 해당 윈도우를 소유한 단일 스레드에 의해 순차적으로 처리되는 것이 기본 원칙입니다. 하지만 여기서 '재진입 가능하다(Reentrant)'는 의미는 다음과 같은 상황을 설명합니다.

재진입(Reentrancy)의 의미:

어떤 함수(여기서는 메시지 프로시저)의 실행이 아직 끝나지 않은 상태에서, 같은 스레드 내에서 해당 함수가 다시 호출될 수 있다는 뜻입니다. 즉, 함수의 실행 흐름 중간에 끼어들어 다시 처음부터 실행될 수 있다는 것입니다.

메시지 프로시저에서 재진입이 발생하는 경우:

메시지 프로시저가 단일 스레드에서 실행됨에도 불구하고 재진입이 발생하는 대표적인 경우는 다음과 같습니다.

  1. 메시지 프로시저 내에서 SendMessage 호출:

    • WndProc 안에서 다른 윈도우(또는 자기 자신)에게 SendMessage를 호출하면, 이 SendMessage는 즉시 대상 윈도우의 WndProc를 호출하고 그 처리가 끝날 때까지 대기합니다. 만약 이 대상 윈도우가 같은 스레드 소유라면, 현재 실행 중인 WndProc 코드가 잠시 멈추고 새로운 메시지 처리를 위한 WndProc 코드가 같은 스레드에서 실행됩니다. 원래의 WndProc 실행은 아직 끝나지 않았는데, 다시 WndProc가 호출된 것이므로 재진입이 발생한 것입니다.
  2. 메시지 프로시저 내에서 모달 대화상자 또는 메시지 박스 표시:

    • WndProc 안에서 MessageBox, DialogBox 같은 함수를 호출하면, 이 함수들은 내부적으로 자체 메시지 루프를 돌립니다. 이 내부 메시지 루프는 현재 스레드의 메시지 큐에 있는 다른 메시지들(예: WM_PAINT, 다른 WM_COMMAND 등)을 처리할 수 있습니다. 만약 이 메시지들이 현재 윈도우로 보내진 것이라면, MessageBoxDialogBox가 반환되기 전에 WndProc가 다시 호출될 수 있습니다. 이것 역시 재진입입니다.
  3. 기타 동기적 콜백: COM 호출 등 특정 동기적 작업 중 시스템이 메시지 펌핑을 강제하는 경우에도 유사한 재진입이 발생할 수 있습니다.

중요한 점:

  • 재진입은 같은 스레드 내에서 발생하는 중첩 호출입니다. 여러 스레드가 동시에 WndProc를 실행하는 스레드 안전성(Thread-Safety) 문제와는 다릅니다. (윈도우 프로시저는 기본적으로 특정 스레드에 귀속되므로, 일반적인 의미의 스레드 안전성은 고려 대상이 아닙니다.)
  • 재진입 문제: 만약 WndProc가 전역 변수나 정적 변수를 사용하는데, 재진입 호출 시 이 변수들의 상태가 이전 호출과 꼬이게 되면 문제가 발생할 수 있습니다. 따라서 재진입 가능성을 염두에 두고 WndProc를 작성해야 합니다. 주로 지역 변수나 윈도우 인스턴스별 데이터(SetWindowLongPtr/GetWindowLongPtr 등)를 사용하여 상태를 관리하는 것이 안전합니다.

결론:

메시지 프로시저는 단일 스레드에서 순차 처리되는 것이 맞지만, 특정 함수 호출(SendMessage, 모달 대화상자 등)로 인해 실행 흐름이 중단되고 같은 스레드에서 다시 호출될 수 있기 때문에 "재진입 가능하다"고 말하는 것입니다.

#2. 케이스 스터디

재진입 시 문제가 발생할 수 있는 간단한 예시를 보여드리겠습니다.

여기서는 메시지 프로시저(WndProc) 내에서 정적(static) 변수를 사용하여 상태를 관리하다가, MessageBox 호출로 인해 재진입이 발생했을 때 상태가 꼬이는 상황을 만들어 보겠습니다.

시나리오:

  1. 왼쪽 마우스 버튼(WM_LBUTTONDOWN)을 클릭하면 특정 작업을 시작합니다.
  2. 작업은 여러 단계로 이루어지며, 현재 작업 중임을 나타내기 위해 static bool isProcessing 플래그를 사용합니다.
  3. 작업 중간에 사용자 확인을 위해 MessageBox를 띄웁니다.
  4. MessageBox가 떠 있는 동안 다른 메시지(예: 오른쪽 마우스 버튼 클릭, WM_RBUTTONDOWN)가 처리될 수 있습니다.
  5. 오른쪽 마우스 버튼 클릭 처리기(WM_RBUTTONDOWN)가 작업 상태와 관련된 정적 변수(sharedBuffer)를 수정한다고 가정합니다.
  6. MessageBox가 닫히고 원래의 왼쪽 버튼 클릭 작업으로 돌아왔을 때, 정적 변수(sharedBuffer)의 내용이 예상과 달라 문제가 발생합니다.

예시 코드 (WndProc 부분):

#include <windows.h>
#include <wchar.h> // for wcscpy_s, wcscat_s

//WndProc 함수 내부에 선언된 정적 변수는 해당 함수가 여러 번 호출되어도 값을 유지합니다.
//하지만 재진입 시 문제가 될 수 있습니다.
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
    static bool isProcessing = false;      // 현재 작업 중인지 나타내는 플래그
    static wchar_t sharedBuffer[100] = {0}; // 여러 호출 간에 공유되는 버퍼 (문제 발생 지점)

    switch (message) {
        case WM_LBUTTONDOWN:
            // 이미 다른 작업이 진행 중이면 무시 (간단한 락)
            if (isProcessing) {
                OutputDebugString(L"WndProc [LBUTTON]: 이미 작업 중입니다!\n");
                return 0;
            }

            // 작업 시작
            isProcessing = true;
            OutputDebugString(L"WndProc [LBUTTON]: 작업 시작...\n");

            // 단계 1: 공유 버퍼 준비
            wcscpy_s(sharedBuffer, _countof(sharedBuffer), L"왼쪽 버튼 작업 데이터");
            OutputDebugString(L"WndProc [LBUTTON]: 버퍼 준비 완료.\n");
            Sleep(500); // 약간의 작업 시간 시뮬레이션

            // *** 문제 발생 가능 지점: MessageBox 호출 ***
            // 이 MessageBox는 내부 메시지 루프를 돌기 때문에,
            // 떠 있는 동안 다른 메시지(예: WM_RBUTTONDOWN)가 처리될 수 있습니다.
            MessageBox(hWnd, L"왼쪽 버튼 작업 1단계 완료. 확인을 누르면 2단계 진행.", L"진행 중", MB_OK);
            // 만약 여기서 WM_RBUTTONDOWN이 처리되면 아래 코드는 예상과 다르게 동작할 수 있습니다.

            // 단계 2: 공유 버퍼에 내용 추가 (원래 의도)
            // *** 재진입으로 인해 sharedBuffer 내용이 바뀌었을 수 있음 ***
            wcscat_s(sharedBuffer, _countof(sharedBuffer), L" - 2단계 완료");
            OutputDebugString(L"WndProc [LBUTTON]: 최종 버퍼 내용: ");
            OutputDebugString(sharedBuffer);
            OutputDebugString(L"\n");

            // 작업 완료
            isProcessing = false;
            OutputDebugString(L"WndProc [LBUTTON]: 작업 완료.\n");
            break;

        case WM_RBUTTONDOWN:
            // 이 메시지는 LBUTTON의 MessageBox가 떠 있는 동안 처리될 수 있습니다.
            OutputDebugString(L"WndProc [RBUTTON]: 오른쪽 버튼 클릭! (중단 발생)\n");

            // *** 문제 발생: LBUTTON 작업 중에 공유 버퍼를 수정함 ***
            wcscpy_s(sharedBuffer, _countof(sharedBuffer), L"!!! 오른쪽 버튼에 의해 덮어쓰여짐 !!!");
            OutputDebugString(L"WndProc [RBUTTON]: 공유 버퍼를 수정했습니다.\n");

            // RBUTTON 작업도 MessageBox를 띄운다고 가정 (추가 재진입 가능성)
            // MessageBox(hWnd, L"오른쪽 버튼 작업 완료.", L"RBUTTON", MB_OK);
            break;

        case WM_DESTROY:
            PostQuitMessage(0);
            break;

        default:
            return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

문제 상황 시뮬레이션:

  1. 프로그램을 실행하고 창을 왼쪽 클릭합니다.
    • OutputDebugString에 "작업 시작...", "버퍼 준비 완료."가 출력됩니다.
    • "왼쪽 버튼 작업 1단계 완료..." 메시지 박스가 나타납니다.
  2. 메시지 박스가 떠 있는 상태에서 창을 오른쪽 클릭합니다.
    • OutputDebugString에 "오른쪽 버튼 클릭! (중단 발생)", "공유 버퍼를 수정했습니다."가 출력됩니다. sharedBuffer의 내용이 바뀝니다.
  3. 이제 확인(OK) 버튼을 눌러 왼쪽 버튼 메시지 박스를 닫습니다.
    • 원래의 WM_LBUTTONDOWN 처리가 MessageBox 다음부터 재개됩니다.
    • wcscat_s 함수는 "왼쪽 버튼 작업 데이터" 뒤에 이어붙이려고 했지만, 실제로는 "!!! 오른쪽 버튼에 의해 덮어쓰여짐 !!!" 뒤에 이어붙이게 됩니다.
    • OutputDebugString에 "최종 버퍼 내용: !!! 오른쪽 버튼에 의해 덮어쓰여짐 !!! - 2단계 완료" 와 같이 예상과 다른 결과가 출력됩니다.
    • "작업 완료."가 출력됩니다.

왜 문제가 되는가?

WM_LBUTTONDOWN 처리 루틴은 isProcessing = trueisProcessing = false 사이 구간에서 sharedBuffer에 대한 암묵적인 독점권을 가정하고 작성되었습니다. 하지만 MessageBox 호출로 인해 메시지 펌핑이 발생하면서 다른 메시지(WM_RBUTTONDOWN)가 처리될 기회가 생겼고, 이 메시지 처리기가 공유 자원인 sharedBuffer를 수정하면서 원래 작업의 상태를 깨뜨린 것입니다.

해결 방안:

  • 메시지 프로시저 내에서 시간이 오래 걸리는 작업이나 상태를 복잡하게 변경하는 작업을 피합니다.
  • 재진입을 유발하는 함수(SendMessage, MessageBox 등)를 호출할 때는 그 사이에 상태가 변경될 수 있음을 인지하고 방어적으로 코드를 작성합니다.
  • 상태 저장을 위해 정적 변수나 전역 변수 사용을 최소화하고, 필요하다면 윈도우 인스턴스별 데이터(SetWindowLongPtr, GetWindowLongPtr 등)나 스레드 로컬 스토리지(TLS) 등을 고려합니다. (단, WndProc는 기본적으로 단일 스레드에서 실행되므로 TLS보다는 윈도우 인스턴스 데이터가 더 적합할 수 있습니다.)
  • 정말로 임계 영역(Critical Section)이 필요하다면, 해당 구간 전후로 상태를 저장하고 복원하거나, 더 정교한 동기화 기법(같은 스레드 내 재진입 방지용 플래그 등)을 사용해야 할 수도 있습니다.

이 예시는 재진입이 어떻게 공유 상태를 망가뜨릴 수 있는지 보여주는 간단한 사례입니다. 실제 애플리케이션에서는 더 복잡한 형태로 문제가 발생할 수 있습니다.

#3. 메시지 펌프

MessageBox 함수 내부에는 자체적인 메시지 펌프(Message Pump) 또는 메시지 루프(Message Loop)가 존재합니다.

이것이 바로 WndProcMessageBox 호출에서 아직 반환하지 않았음에도 불구하고, 같은 스레드에서 다른 메시지를 처리하기 위해 WndProc가 다시 호출될 수 있는 이유입니다.

동작 과정:

  1. WndProc 실행: 스레드의 메인 메시지 루프(while(GetMessage...))가 메시지(예: WM_LBUTTONDOWN)를 받아 DispatchMessage를 호출하고, 이로 인해 WndProc가 실행됩니다.
  2. MessageBox 호출: WndProc 코드 내에서 MessageBox(...) 함수를 호출합니다.
  3. MessageBox의 내부 메시지 루프 시작: MessageBox 함수는 단순히 대화상자만 표시하고 대기하는 것이 아니라, 자체적인 메시지 루프를 시작합니다. 이 내부 루프는 대화상자가 활성화된 동안 현재 스레드로 들어오는 메시지들을 처리합니다. 이 루프는 대략 다음과 유사하게 동작합니다.
    // MessageBox 내부 (개념적인 코드)
    DisplayTheMessageBoxWindow();
    while (MessageBoxIsStillActive()) {
        MSG msg;
        // 현재 스레드의 메시지 큐에서 메시지를 가져옴
        if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { // 또는 GetMessage
            // 표준 메시지 처리 (키보드 입력 변환 등)
            TranslateMessage(&msg);
            // *** 중요: 해당 메시지를 처리할 윈도우 프로시저로 보냄 ***
            DispatchMessage(&msg);
        } else {
            // 처리할 메시지가 없으면 잠시 대기
            WaitMessage();
        }
        // + MessageBox 자체 로직 처리 (버튼 클릭 감지 등)
    }
    HideTheMessageBoxWindow();
    ReturnResult();
  4. 새 메시지 도착 및 처리: MessageBox의 내부 메시지 루프가 실행되는 동안, 사용자가 오른쪽 마우스 버튼을 클릭하면 WM_RBUTTONDOWN 메시지가 스레드의 메시지 큐로 들어옵니다. MessageBox의 내부 루프에 있는 PeekMessage 또는 GetMessage가 이 메시지를 가져옵니다.
  5. DispatchMessage 호출: 내부 루프의 DispatchMessage는 이 WM_RBUTTONDOWN 메시지를 해당 윈도우(hWnd)로 보냅니다.
  6. WndProc 재호출(Re-entry): DispatchMessage는 결국 우리의 WndProc 함수를 다시 호출하여 WM_RBUTTONDOWN 메시지를 처리하도록 합니다. 이 시점에서 호출 스택에는 메인 메시지 루프 -> WndProc(LBUTTON) -> MessageBox -> 내부 메시지 루프 -> DispatchMessage -> WndProc(RBUTTON) 와 같이 쌓이게 됩니다. 즉, WndProc가 중첩되어 호출된 것입니다.
  7. MessageBox 종료 및 반환: 사용자가 MessageBox의 확인 버튼 등을 누르면, MessageBox의 내부 메시지 루프가 종료되고, MessageBox 함수 호출이 드디어 반환(return)됩니다.
  8. 원래 WndProc 실행 재개: 이제 제어가 원래의 WndProc ( WM_LBUTTONDOWN 처리 중이던) 코드로 돌아와 MessageBox 호출 다음 라인부터 실행을 계속합니다.

결론:

WndProc 자체는 단일 스레드에서 순차적으로 실행되지만, MessageBox, DialogBox 등 모달(Modal) 상태를 만들고 내부적으로 메시지 루프를 돌리는 함수를 호출하면, 해당 함수가 반환하기 전에도 같은 스레드에서 다른 메시지가 처리되어 WndProc가 재진입(re-entrant) 또는 중첩 호출(nested call)될 수 있습니다. 이는 Windows GUI 프로그래밍에서 매우 흔하며 중요한 개념입니다.

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