최근 증권사 HTS(Home Trading System)의 차트 모듈(TeeChart) 버전을 마이그레이션하는 과정에서, C++ 개발자라면 누구나 뒷목을 잡을 만한 기괴한 버그와 맞닥뜨렸습니다.

화면 30~50개를 띄웠다가 '모든 화면 닫기' 기능으로 한꺼번에 종료할 때, 불특정하게 프로그램이 뻗어버리거나 상단 UI가 일그러지는(Tearing) 현상이 발생한 것입니다. 며칠 밤낮을 괴롭혔던 이 악랄한 버그의 원인과 해결 과정을 기록으로 남겨봅니다.


1. 문제의 증상: CResourceException과 GDI 누수

🚨 발생 예외 및 현상:
  • 대량의 차트 화면을 반복해서 열고 닫는 스트레스 테스트 중 디버거가 CClientDC dc(this); 라인에서 중단.
  • 예외 메시지: CResourceException (OS가 Device Context 발급 거절).
  • 작업 관리자 확인 결과, 창을 모두 닫았음에도 불구하고 GDI 개체가 원래 수치로 돌아가지 않고 계단식으로 계속 누적(GDI Leak).
  • GDI 개수가 윈도우 한계치인 10,000개에 도달하지 않았음에도 상단 메뉴와 툴바 UI가 깨지는 현상 동반.

2. 결정적 단서: "마우스 클릭은 되는데, 전체 닫기는 안 샌다?"

코드 곳곳에 GetSafeHwnd() 방어 로직을 치고, 멀티스레드 충돌을 의심하며 COM 객체의 참조 카운트(Ref Count)를 뒤져보았지만 뚜렷한 해결책이 보이지 않았습니다. 그러다 아주 결정적이고 허탈한 단서를 발견했습니다.

💡 동작 비교 단서:
  • 마우스로 창의 'X' 버튼을 눌러 하나씩 닫을 때: GDI 누수 없이 아주 깔끔하게 메모리가 반환됨.
  • '모든 화면 닫기' 버튼으로 한 번에 닫을 때: GDI가 줄줄 새고 UI가 박살 남.

원인은 티차트 컴포넌트 자체의 버그가 아니라, 윈도우 프로그래밍의 심장인 '메시지 펌프(Message Pump) 기아 상태'와 '공용 DC 고갈'에 있었습니다.


3. 원인 분석: 무너진 메시지 펌프와 데스크톱 DC 고갈

기존의 '전체 화면 닫기' 로직은 대략 아래와 같았습니다.

// [문제가 된 기존 코드의 구조]
for (int i = 0; i < nTrNum; i++) {
    ::PostMessage(hwndFrm, WM_CLOSE, 0, 0L); // 30개 창에 닫기 명령 비동기 폭격
}

// ... 하단에서 메시지 처리 ...
result = ::MsgWaitForMultipleObjects(...);

if (::PeekMessage(&msg, 0, 0, 0, PM_REMOVE)) { // ⬅️ 치명적인 함정!
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

이 코드는 두 가지 치명적인 결함을 가지고 있었습니다.

  1. if (PeekMessage)의 배신:
    30개의 창이 파괴되면서 WM_DESTROY를 비롯해 ActiveX(COM) 내부의 GDI 청소 메시지 수천 개가 큐에 쏟아집니다. 하지만 if문을 사용하면 큐에 메시지가 산더미처럼 쌓여있어도 딱 1개만 꺼내서 처리하고 넘어가 버립니다. 결국 청소되지 못한 GDI 해제 메시지들이 큐에 고아처럼 버려져 GDI 누수로 이어졌습니다.
  2. 공용 DC(Desktop DC) 데드락:
    30개의 프레임이 비동기(PostMessage)로 동시에 파괴 절차에 돌입하면서, 마지막 잔상 처리를 위해 윈도우 OS의 공용 프레임 DC를 너도나도 움켜쥐려다 병목(Bottleneck)이 발생했습니다. 한정된 공용 DC 캐시가 바닥나니, HTS의 메인 메뉴나 툴바가 그려지지 못하고 UI가 일그러진 것입니다.

4. 해결책: '직렬화 파괴'와 while 펌핑

해결 방법은 마우스로 하나씩 창을 닫는 환경을 코드상에서 완벽하게 시뮬레이션해 주는 것입니다. 창 하나에 파괴 명령을 내린 뒤, 그 창이 뱉어내는 모든 잔여 메시지를 완전히 소화할 때까지 기다려주는 '직렬화(Serialization)' 방식을 적용했습니다.

// [개선된 코드: 직렬화 파괴 메커니즘]
for (int i = 0; i < nTrNum; i++) {
    hwnd = (HWND)saHandle.at(i);
    if (!::IsWindow(hwnd)) continue;

    HWND hwndFrm = tbc_GetFrameHwnd(hwnd);
    if (hwndFrm && ::IsWindow(hwndFrm)) {
        
        // 1. PostMessage 대신 SendMessage로 파괴 절차 동기화
        ::SendMessage(hwndFrm, WM_CLOSE, 0, 0L);

        // 2. 해당 창이 죽으면서 발생한 모든 청소 메시지를 바닥까지 긁어서 처리 (if -> while)
        MSG msg;
        while (::PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
            if (msg.message == WM_QUIT) {
                ::PostQuitMessage(msg.wParam);
                break;
            }
            ::TranslateMessage(&msg);
            ::DispatchMessage(&msg);
        }
    }
}
🔑 핵심 변경점 요약:
  • PostMessage ➔ SendMessage: 30개가 한 번에 죽는 것을 막고, 1개씩 순서대로 장례식을 치르도록 동기화.
  • if(PeekMessage) ➔ while(PeekMessage): 창 1개가 파괴될 때 큐에 쌓이는 수십 개의 GDI/COM 해제 메시지를 한 번의 루프에서 모조리 소화(Drain) 시킴.

5. 마무리

위 코드를 적용하자 30개의 차트 화면을 동시에 닫아도 작업 관리자의 GDI 개체가 완벽하게 원래 수치로 떨어졌고, UI 일그러짐 현상도 거짓말처럼 사라졌습니다.

C++/MFC 환경에서 무거운 ActiveX 컨트롤을 대량으로 다룰 때는 객체의 생성과 소멸 자체보다 '운영체제의 메시지 큐가 체하지 않게 소화 시간을 벌어주는 것'이 얼마나 중요한지 다시금 뼈저리게 느낀 디버깅이었습니다. 멀티스레딩과 COM 생태계에서 이유 모를 크래시와 누수로 고통받고 있는 누군가에게 이 글이 작은 힌트가 되기를 바랍니다.