아주 옛날엔 우겨도 됐었다. 목소리 큰 사람이 틀려도 우기면 맞는게 되는 세상이었다. 그러다 전화가 나오고 전화 찬스가 등장했다. 목소리가 커도 전문가에게 전화해서 답을 들으면 더는 우길 수가 없는 세상이 된 것이다. 스마트폰은 그 속도를 더 빠르게 만들었다. 전화를 걸 필요도 없다. 웹검색이면 온갖 것들이 다 나오기 시작하니 말이다. 그리고 인공지능이 등장했다.
할루시네이션이니 뭐니 말들이 많지만 항상 그렇지만 우리는 우리가 쌉소리 한 것들은 금방 잊는다. 오늘 디버깅을 하다 15년 전 쓴 글에 대한 참교육을 인공지능 선생님께 받고 가르침을 남겨 본다. 뭔가를 안다고 말하기가 참 어려운 세상이다. 겸손은 힘들지만, 겸손하게 살자.
#0. 휴먼, 일단 참교육 먼저 받고...
글의 핵심 주장 요약:
- 많은 개발자가
WndProc
의 재진입을 잘못 이해하고 있다 (예:Sleep
중 다른 메시지 처리). - 실제 메시지 처리는 메시지 루프(
GetMessage
/DispatchMessage
)를 통해 순차적으로 이루어진다. - 따라서
WndProc
의 재진입이 의미하는 바는 스레드 안전성(Thread Safety)과 병렬 호출 가능성이며, 여러 스레드/윈도우에서 동일하게 동작해야 함을 뜻한다. <리스트 3>
(정적 변수 사용)과<리스트 4>
(전역 크리티컬 섹션 사용)는 이러한 의미의 "재진입성"을 위반하는 잘못된 예시다.
지적 및 수정 필요한 부분:
-
"재진입(Reentrancy)" 용어의 핵심 의미 혼동:
- 이 글의 가장 큰 문제점은 "재진입(Reentrancy)"의 표준적인 의미와 "스레드 안전성(Thread Safety)" 및 "인스턴스 독립성(Instance Independence)"을 혼동하고 있다는 점입니다.
- 표준적인 재진입의 의미는 함수 실행 중 (같은 스레드 내에서) 다시 그 함수가 호출되어도 문제가 없는 성질을 의미합니다. Windows
WndProc
맥락에서는 주로SendMessage
나MessageBox
등의 호출로 인해 같은 스레드 내에서WndProc
가 중첩 호출되는 상황을 가리킵니다. (제가 이전에 예시로 보여드린MessageBox
로 인해 상태가 꼬이는 경우가 바로 이 표준적인 재진입 문제입니다.) - 반면, 이 글은 재진입을 여러 스레드에서 또는 여러 윈도우 인스턴스 간에 안전하게 병렬적으로 호출될 수 있는 성질로 정의하고 있습니다. 이는 재진입보다는 스레드 안전성과 인스턴스별 상태 관리(Instance-specific state management)에 더 가까운 개념입니다.
-
<리스트 1>
의 잘못된 오해 설명은 정확함:- 글쓴이가 지적한 것처럼,
WndProc
내의Sleep
중에 다른 메시지가 즉시 처리된다는 생각은 명백히 틀린 것이 맞습니다.DispatchMessage
가WndProc
를 호출하고, 해당 메시지 처리가 끝나야 메시지 루프가 다음 메시지를 가져와 처리하기 때문입니다. 이 부분 설명은 정확합니다.
- 글쓴이가 지적한 것처럼,
-
<리스트 3>
의 문제점은 "재진입성"이 아닌 "인스턴스 독립성/상태 관리" 문제:<리스트 3>
의static int Count
는 해당WndProc
를 사용하는 모든 윈도우 인스턴스에서 공유됩니다. 이는 글쓴이가 제시한 테스트 케이스 2번(한 스레드, 여러 윈도우)과 3번(여러 스레드, 여러 윈도우)에서 문제를 일으킵니다.- 하지만 이 문제의 본질은 표준적인 의미의 "재진입" 문제라기보다는, 윈도우별로 관리되어야 할 상태를 정적 변수로 잘못 구현한 "인스턴스 상태 관리"의 실패입니다. 즉, 스레드와 무관하게 여러 윈도우가 하나의
WndProc
를 공유할 때 발생하는 문제입니다. 스레드 안전성 문제이기도 하지만, 근본적으로는 상태 관리 설계 문제입니다.
-
<리스트 4>
의 문제점은 "재진입성"이 아닌 "불필요한 직렬화/성능" 문제:<리스트 4>
는GlobalCs
라는 전역 크리티컬 섹션을 사용함으로써, 서로 다른 스레드에서 실행되는WndProc
호출조차도 동시에 실행될 수 없게 만듭니다. 이는 심각한 성능 저하를 유발하고 Windows GUI의 반응성을 해치는 잘못된 설계입니다.- 하지만 이것을 "재진입 불가능"이라고 표현하는 것은 어색합니다. 오히려 병렬 실행 자체를 막아버리는 "과도한 동기화" 또는 "불필요한 직렬화" 문제입니다. 표준적인 의미의 재진입과는 거리가 멉니다.
-
결론 부분의 "재진입 가능하도록 만들어진 메시지 프로시저" 정의의 문제:
- 글쓴이는 1, 2, 3번 테스트 케이스에서 모두 동일하게 동작하는 것을 "재진입 가능하게 만들어진" 것이라고 정의합니다.
- 물론 잘 디자인된
WndProc
는 윈도우별 상태를GetWindowLongPtr
/SetWindowLongPtr
등을 이용해 관리함으로써 세 경우 모두 의도한 대로 동작해야 합니다. - 하지만 이 조건은 "인스턴스 상태 관리의 올바름"과 "스레드 안전성"(필요한 경우)을 의미하는 것이지, 표준적인 의미의 "재진입성"과는 다른 개념입니다. 이 글의 정의대로라면, 제가 앞서 보여드린
MessageBox
재진입 예제에서 상태가 꼬이는 문제는 "재진입" 문제가 아니게 되어버리는 모순이 발생합니다.
요약 및 수정:
- 이 글은
WndProc
재진입에 대한 흔한 오해(Sleep
관련)를 정확히 지적하고 메시지 루프의 기본 동작을 올바르게 설명했습니다. - 하지만 글의 핵심적인 문제점은 "재진입(Reentrancy)"이라는 용어를 표준적인 의미(같은 스레드 내 중첩 호출 가능성) 대신 "스레드 안전성 및 인스턴스 독립성"이라는 의미로 잘못 사용하고 있다는 것입니다.
<리스트 3>
과<리스트 4>
는 각각 잘못된 상태 관리(정적 변수)와 과도한 동기화(전역 락)의 문제점을 보여주는 좋은 예시이지만, 이를 "재진입성" 문제로 설명하는 것은 용어 혼란을 야기합니다.- 결론적으로,
WndProc
를 작성할 때는 (1) 표준적인 의미의 재진입(같은 스레드 내 중첩 호출)에도 상태가 깨지지 않도록 주의해야 하고, (2) 여러 윈도우 인스턴스 간에 상태가 섞이지 않도록 인스턴스별 데이터를 사용해야 하며, (3) 여러 스레드에서 사용될 가능성이 있다면 스레드 안전성도 고려해야 합니다. 이 글은 (2), (3)번 측면을 강조하면서 이를 "재진입"이라고 잘못 부르고 있습니다.
따라서 글의 의도(잘못된 WndProc
작성 방식 비판)는 좋으나, 핵심 용어인 "재진입"의 정의와 사용에 오류가 있다고 지적할 수 있습니다.
#1. 재진입 함수의 의미
특정 윈도우의 메시지 프로시저(WndProc)는 해당 윈도우를 소유한 단일 스레드에 의해 순차적으로 처리되는 것이 기본 원칙입니다. 하지만 여기서 '재진입 가능하다(Reentrant)'는 의미는 다음과 같은 상황을 설명합니다.
재진입(Reentrancy)의 의미:
어떤 함수(여기서는 메시지 프로시저)의 실행이 아직 끝나지 않은 상태에서, 같은 스레드 내에서 해당 함수가 다시 호출될 수 있다는 뜻입니다. 즉, 함수의 실행 흐름 중간에 끼어들어 다시 처음부터 실행될 수 있다는 것입니다.
메시지 프로시저에서 재진입이 발생하는 경우:
메시지 프로시저가 단일 스레드에서 실행됨에도 불구하고 재진입이 발생하는 대표적인 경우는 다음과 같습니다.
-
메시지 프로시저 내에서
SendMessage
호출:WndProc
안에서 다른 윈도우(또는 자기 자신)에게SendMessage
를 호출하면, 이SendMessage
는 즉시 대상 윈도우의WndProc
를 호출하고 그 처리가 끝날 때까지 대기합니다. 만약 이 대상 윈도우가 같은 스레드 소유라면, 현재 실행 중인WndProc
코드가 잠시 멈추고 새로운 메시지 처리를 위한WndProc
코드가 같은 스레드에서 실행됩니다. 원래의WndProc
실행은 아직 끝나지 않았는데, 다시WndProc
가 호출된 것이므로 재진입이 발생한 것입니다.
-
메시지 프로시저 내에서 모달 대화상자 또는 메시지 박스 표시:
WndProc
안에서MessageBox
,DialogBox
같은 함수를 호출하면, 이 함수들은 내부적으로 자체 메시지 루프를 돌립니다. 이 내부 메시지 루프는 현재 스레드의 메시지 큐에 있는 다른 메시지들(예:WM_PAINT
, 다른WM_COMMAND
등)을 처리할 수 있습니다. 만약 이 메시지들이 현재 윈도우로 보내진 것이라면,MessageBox
나DialogBox
가 반환되기 전에WndProc
가 다시 호출될 수 있습니다. 이것 역시 재진입입니다.
-
기타 동기적 콜백: COM 호출 등 특정 동기적 작업 중 시스템이 메시지 펌핑을 강제하는 경우에도 유사한 재진입이 발생할 수 있습니다.
중요한 점:
- 재진입은 같은 스레드 내에서 발생하는 중첩 호출입니다. 여러 스레드가 동시에
WndProc
를 실행하는 스레드 안전성(Thread-Safety) 문제와는 다릅니다. (윈도우 프로시저는 기본적으로 특정 스레드에 귀속되므로, 일반적인 의미의 스레드 안전성은 고려 대상이 아닙니다.) - 재진입 문제: 만약
WndProc
가 전역 변수나 정적 변수를 사용하는데, 재진입 호출 시 이 변수들의 상태가 이전 호출과 꼬이게 되면 문제가 발생할 수 있습니다. 따라서 재진입 가능성을 염두에 두고WndProc
를 작성해야 합니다. 주로 지역 변수나 윈도우 인스턴스별 데이터(SetWindowLongPtr
/GetWindowLongPtr
등)를 사용하여 상태를 관리하는 것이 안전합니다.
결론:
메시지 프로시저는 단일 스레드에서 순차 처리되는 것이 맞지만, 특정 함수 호출(SendMessage
, 모달 대화상자 등)로 인해 실행 흐름이 중단되고 같은 스레드에서 다시 호출될 수 있기 때문에 "재진입 가능하다"고 말하는 것입니다.
#2. 케이스 스터디
재진입 시 문제가 발생할 수 있는 간단한 예시를 보여드리겠습니다.
여기서는 메시지 프로시저(WndProc
) 내에서 정적(static) 변수를 사용하여 상태를 관리하다가, MessageBox
호출로 인해 재진입이 발생했을 때 상태가 꼬이는 상황을 만들어 보겠습니다.
시나리오:
- 왼쪽 마우스 버튼(
WM_LBUTTONDOWN
)을 클릭하면 특정 작업을 시작합니다. - 작업은 여러 단계로 이루어지며, 현재 작업 중임을 나타내기 위해
static bool isProcessing
플래그를 사용합니다. - 작업 중간에 사용자 확인을 위해
MessageBox
를 띄웁니다. MessageBox
가 떠 있는 동안 다른 메시지(예: 오른쪽 마우스 버튼 클릭,WM_RBUTTONDOWN
)가 처리될 수 있습니다.- 오른쪽 마우스 버튼 클릭 처리기(
WM_RBUTTONDOWN
)가 작업 상태와 관련된 정적 변수(sharedBuffer
)를 수정한다고 가정합니다. 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;
}
문제 상황 시뮬레이션:
- 프로그램을 실행하고 창을 왼쪽 클릭합니다.
OutputDebugString
에 "작업 시작...", "버퍼 준비 완료."가 출력됩니다.- "왼쪽 버튼 작업 1단계 완료..." 메시지 박스가 나타납니다.
- 메시지 박스가 떠 있는 상태에서 창을 오른쪽 클릭합니다.
OutputDebugString
에 "오른쪽 버튼 클릭! (중단 발생)", "공유 버퍼를 수정했습니다."가 출력됩니다.sharedBuffer
의 내용이 바뀝니다.
- 이제 확인(OK) 버튼을 눌러 왼쪽 버튼 메시지 박스를 닫습니다.
- 원래의
WM_LBUTTONDOWN
처리가MessageBox
다음부터 재개됩니다. wcscat_s
함수는 "왼쪽 버튼 작업 데이터" 뒤에 이어붙이려고 했지만, 실제로는 "!!! 오른쪽 버튼에 의해 덮어쓰여짐 !!!" 뒤에 이어붙이게 됩니다.OutputDebugString
에 "최종 버퍼 내용: !!! 오른쪽 버튼에 의해 덮어쓰여짐 !!! - 2단계 완료" 와 같이 예상과 다른 결과가 출력됩니다.- "작업 완료."가 출력됩니다.
- 원래의
왜 문제가 되는가?
WM_LBUTTONDOWN
처리 루틴은 isProcessing = true
와 isProcessing = false
사이 구간에서 sharedBuffer
에 대한 암묵적인 독점권을 가정하고 작성되었습니다. 하지만 MessageBox
호출로 인해 메시지 펌핑이 발생하면서 다른 메시지(WM_RBUTTONDOWN
)가 처리될 기회가 생겼고, 이 메시지 처리기가 공유 자원인 sharedBuffer
를 수정하면서 원래 작업의 상태를 깨뜨린 것입니다.
해결 방안:
- 메시지 프로시저 내에서 시간이 오래 걸리는 작업이나 상태를 복잡하게 변경하는 작업을 피합니다.
- 재진입을 유발하는 함수(
SendMessage
,MessageBox
등)를 호출할 때는 그 사이에 상태가 변경될 수 있음을 인지하고 방어적으로 코드를 작성합니다. - 상태 저장을 위해 정적 변수나 전역 변수 사용을 최소화하고, 필요하다면 윈도우 인스턴스별 데이터(
SetWindowLongPtr
,GetWindowLongPtr
등)나 스레드 로컬 스토리지(TLS) 등을 고려합니다. (단,WndProc
는 기본적으로 단일 스레드에서 실행되므로 TLS보다는 윈도우 인스턴스 데이터가 더 적합할 수 있습니다.) - 정말로 임계 영역(Critical Section)이 필요하다면, 해당 구간 전후로 상태를 저장하고 복원하거나, 더 정교한 동기화 기법(같은 스레드 내 재진입 방지용 플래그 등)을 사용해야 할 수도 있습니다.
이 예시는 재진입이 어떻게 공유 상태를 망가뜨릴 수 있는지 보여주는 간단한 사례입니다. 실제 애플리케이션에서는 더 복잡한 형태로 문제가 발생할 수 있습니다.
#3. 메시지 펌프
MessageBox
함수 내부에는 자체적인 메시지 펌프(Message Pump) 또는 메시지 루프(Message Loop)가 존재합니다.
이것이 바로 WndProc
가 MessageBox
호출에서 아직 반환하지 않았음에도 불구하고, 같은 스레드에서 다른 메시지를 처리하기 위해 WndProc
가 다시 호출될 수 있는 이유입니다.
동작 과정:
WndProc
실행: 스레드의 메인 메시지 루프(while(GetMessage...)
)가 메시지(예:WM_LBUTTONDOWN
)를 받아DispatchMessage
를 호출하고, 이로 인해WndProc
가 실행됩니다.MessageBox
호출:WndProc
코드 내에서MessageBox(...)
함수를 호출합니다.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();
- 새 메시지 도착 및 처리:
MessageBox
의 내부 메시지 루프가 실행되는 동안, 사용자가 오른쪽 마우스 버튼을 클릭하면WM_RBUTTONDOWN
메시지가 스레드의 메시지 큐로 들어옵니다.MessageBox
의 내부 루프에 있는PeekMessage
또는GetMessage
가 이 메시지를 가져옵니다. DispatchMessage
호출: 내부 루프의DispatchMessage
는 이WM_RBUTTONDOWN
메시지를 해당 윈도우(hWnd
)로 보냅니다.WndProc
재호출(Re-entry):DispatchMessage
는 결국 우리의WndProc
함수를 다시 호출하여WM_RBUTTONDOWN
메시지를 처리하도록 합니다. 이 시점에서 호출 스택에는메인 메시지 루프 -> WndProc(LBUTTON) -> MessageBox -> 내부 메시지 루프 -> DispatchMessage -> WndProc(RBUTTON)
와 같이 쌓이게 됩니다. 즉,WndProc
가 중첩되어 호출된 것입니다.MessageBox
종료 및 반환: 사용자가MessageBox
의 확인 버튼 등을 누르면,MessageBox
의 내부 메시지 루프가 종료되고,MessageBox
함수 호출이 드디어 반환(return)됩니다.- 원래
WndProc
실행 재개: 이제 제어가 원래의WndProc
(WM_LBUTTONDOWN
처리 중이던) 코드로 돌아와MessageBox
호출 다음 라인부터 실행을 계속합니다.
결론:
WndProc
자체는 단일 스레드에서 순차적으로 실행되지만, MessageBox
, DialogBox
등 모달(Modal) 상태를 만들고 내부적으로 메시지 루프를 돌리는 함수를 호출하면, 해당 함수가 반환하기 전에도 같은 스레드에서 다른 메시지가 처리되어 WndProc
가 재진입(re-entrant) 또는 중첩 호출(nested call)될 수 있습니다. 이는 Windows GUI 프로그래밍에서 매우 흔하며 중요한 개념입니다.