증권사 대규모 HTS(홈트레이딩시스템)는 20년 이상 누적된 C++/MFC 코드베이스 위에서 작동합니다. 최근 실시간 시세 데이터 처리를 위해 도입한 신규 캐시 모듈을 탑재한 후, "화면을 아무것도 열지 않고 HTS 메인 창을 닫았는데도 작업 관리자에 프로세스가 영구 잔류하는 기이한 좀비 프로세스(Zombie Process)" 버그가 발생했습니다.
이 문제는 단순한 메모리 릭이 아니라, MFC Extension DLL(AFX_EXTENSION_MODULE, new CDynLinkLibrary)과 MFC Regular DLL 간의 모듈 상태(AFX_MODULE_STATE) 오염 및 Loader Lock 종료 시퀀스 충돌이 빚어낸 치명적인 아키텍처 결함이었습니다.
본 글에서는 화면을 열지도 않았는데 프로세스가 종료되지 않던 원인을 역추적하고, 불필요한 MFC 의존성을 걷어내어 순수 Win32 DLL 구조로 리팩토링함으로써 0.3초 만에 프로세스를 정상 종료시킨 실전 트러블슈팅 과정을 상세히 기록합니다.
1. 문제 현상: 화면을 켜지도 않고 껐는데 왜 프로세스가 살아있을까?
기능 테스트 도중 다음과 같은 당혹스러운 버그 보고가 접수되었습니다:
"어? 이상하다... 관심종목이나 차트 화면을 띄웠다가 닫은 것도 아니고, 그냥 HTS 메인(
HtsMain.exe)을 켰다가 아무 화면도 안 띄우고 바로 껐는데도 작업 관리자에 HtsMain.exe가 계속 남아 있어요!"
실제로 작업 관리자를 확인해보니 HtsMain.exe 프로세스가 CPU 0% 상태로 영원히 종료되지 않은 채 백그라운드 프로세스 목록에 잔류하고 있었습니다. 이 상태에서 사용자가 HTS를 다시 실행하려 하면 "이미 실행 중인 프로그램이 있습니다"라는 중복 실행 방지 알림이 뜨며 실행이 차단되었고, 매번 명령 프롬프트나 작업 관리자에서 강제 종료(taskkill)를 해야만 했습니다.
2. 시스템 아키텍처와 의혹 추적
해당 HTS의 핵심 모듈 구성은 다음과 같았습니다:
HtsMain.exe: HTS 프레임워크의 메인 실행 프로세스.CommDriver.dll: 네트워크 소켓 통신 및 패킷 송수신을 전담하는 MFC Regular DLL.ScreenWatchList.dll: 관심종목 및 호가 화면을 렌더링하는 개별 화면 UI DLL.DataManager.dll(신규): 기존 HTS의 병목인 화면별 중복 TR 조회와 UI 프리징을 방지하기 위해 설계된 초고속 메모리 풀 & 시세 스냅샷 캐시 모듈.CommDriver.dll과ScreenWatchList.dll이 이 모듈을 참조하여 메모리 데이터를 직접 읽고 씁니다.
2.1 가설 1: 백그라운드 워커 스레드가 종료 대기에 걸렸는가?
DataManager 내부에는 수신된 패킷을 비동기로 정렬하고 스냅샷을 갱신하는 워커 스레드가 존재했습니다. 하지만 화면을 열지 않았을 때는 수신 큐로 유입되는 패킷이 0건이므로 스레드는 대기 이벤트 상태였습니다. 또한 명시적인 Stop() 호출을 추가해도 프로세스는 여전히 종료되지 않았습니다.
2.2 가설 2: 화면을 열지도 않았는데 왜 DataManager가 로드되는가?
추적 결과, 통신 드라이버인 CommDriver.dll이 DataManager.lib를 암시적 링킹(Implicit Linking)하고 있었습니다. 즉, HtsMain.exe가 실행될 때 CommDriver.dll이 로드되면서 DataManager.dll도 프로세스 가상 메모리 공간에 무조건 매핑되었고, DllMain 역시 화면 실행 여부와 무관하게 프로세스 시작/종료 시 무조건 실행되고 있었습니다.
3. 원인 규명: MFC Extension DLL의 모듈 상태 체인 오염과 Loader Lock
결정적인 원인은 DataManager/dllmain.cpp의 기존 코드에 있었습니다.
// [수정 전 기존 dllmain.cpp - MFC Extension DLL]
#include "pch.h"
#include <afxdllx.h>
static AFX_EXTENSION_MODULE DataManagerDLL = { NULL, NULL };
extern "C" int APIENTRY
DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved)
{
UNREFERENCED_PARAMETER(lpReserved);
if (dwReason == DLL_PROCESS_ATTACH)
{
// 1. MFC 확장 DLL 초기화
if (!AfxInitExtensionModule(DataManagerDLL, hInstance))
return 0;
// 2. MFC 내부 전역 모듈 체인에 이 DLL을 강제 등록! (문제의 근원)
new CDynLinkLibrary(DataManagerDLL);
}
else if (dwReason == DLL_PROCESS_DETACH)
{
CDataManager::GetInstance().Stop();
// 3. MFC 확장 DLL 종료 정리
AfxTermExtensionModule(DataManagerDLL);
}
return 1;
}
왜 이것이 좀비 프로세스를 유발했는가?
- Regular DLL과 Extension DLL 간의 모듈 상태(
AFX_MODULE_STATE) 충돌:
메인 프로세스(HtsMain.exe)와 통신 모듈(CommDriver.dll)은 각자 독립된 모듈 상태를 갖는 MFC Regular DLL 구조입니다. 그런데DataManager.dll이new CDynLinkLibrary를 호출하면서 MFC 프레임워크의 전역 리소스 체인과 스레드 로컬 스토리지(_AFX_THREAD_STATE)에 강제로 편입되었습니다. - 비정상/급격한 종료 시퀀스의 파괴:
화면들을 정상적으로 띄우고 사용하다가 닫을 때는 표준 윈도우 메시지(WM_CLOSE,WM_DESTROY)를 거치며 순차적인 MFC 객체 정리가 이루어집니다. 하지만 메인 창만 열렸다가 아무 화면도 없이 곧바로 닫히면, 프레임워크 레벨에서 프로세스 종료가 급격히 진행됩니다.
이 과정에서CDynLinkLibrary가 여전히 활성 체인에 걸려 있는 상태로 CRT(C Runtime) 소멸자가 진입하거나, OS Loader Lock을 쥔 채AfxTermExtensionModule내부의 모듈 캐시 정리 루틴이 블로킹되어 메인 스레드가 영구적인 데드락에 빠진 것입니다. - 가장 근본적인 설계 모순:
DataManager.dll은 윈도우, 다이얼로그, 폰트, 메뉴 등 UI 요소를 단 1픽셀도 다루지 않는 순수 메모리 풀 및 시세 데이터 연산 모듈입니다. 애초에 MFC Extension DLL 구조를 취할 이유가 전혀 없었던 것입니다.
4. 해결 방법: 불필요한 MFC를 걷어낸 순수 Win32 DLL로의 완전한 전향
해결책은 명확했습니다. 불필요한 MFC 모듈 매크로와 CDynLinkLibrary를 완전히 제거하고, 윈도우 표준 순수 Win32 DLL 구조로 재작성하는 것입니다.
4.1 dllmain.cpp 전면 리팩토링
// [수정 후 완전 무결한 순수 Win32 DllMain]
#include <windows.h>
#include "DataManager.h"
// MFC 확장 DLL 매크로(AFX_EXTENSION_MODULE, CDynLinkLibrary)를 완전 제거하고
// 순수 Win32 표준 DllMain으로 전환하여 모듈 상태 오염 및 좀비 잔류 원인을 원천 차단함.
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved)
{
UNREFERENCED_PARAMETER(hModule);
UNREFERENCED_PARAMETER(lpReserved);
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// 불필요한 스레드 생성/종료 알림을 비활성화하여 성능 및 안정성 확보
::DisableThreadLibraryCalls(hModule);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
// 프로세스 종료 시 백그라운드 워커 스레드를 명시적으로 안전하게 정지
CDataManager::GetInstance().Stop();
break;
}
return TRUE;
}
4.2 프로젝트 빌드 환경 정리
- MFC 헤더 격리:
dllmain.cpp에서<afxdllx.h>와pch.h를 걷어내고<windows.h>만 포함하도록 정리했습니다. - 인코딩 무결성: 한국어 주석과 ANSI 문자열 호환을 위해 CP949(Windows-949) 파일 인코딩을 그대로 보존했습니다.
DisableThreadLibraryCalls적용: 프로세스 내에서 다른 스레드가 생성/소멸될 때마다DllMain이 호출되는 오버헤드를 차단하여 고빈도 시세 처리 환경에서의 락 경합을 사전에 방지했습니다.
5. 검증 및 결과: 0.3초 만에 깨끗하게 사라지는 프로세스
빌드 파이프라인과 실서버 런타임 환경에서 전수 검증을 진행했습니다.
5.1 6개 타겟 전수 빌드 검증
DataManager(Debug / Release)CommDriver(Debug / Release)ScreenWatchList(Debug / Release)- MSBuild 결과: 오류 0개, 경고 0개로 완벽한 클린 빌드 달성.
5.2 런타임 테스트
실제 HTS 실행 환경에 신규 DLL을 배포한 후 테스트를 수행했습니다:
HtsMain.exe실행 후 로그인 완료.- 어떠한 화면(주식 주문, 차트, 관심종목)도 열지 않고 우측 상단
X버튼 클릭. - 작업 관리자 모니터링.
클릭 즉시 0.3초 만에 작업 관리자 백그라운드 프로세스 목록에서
HtsMain.exe가 완전히 소멸되었습니다.수십 차례 연속 실행 및 즉시 종료를 반복해도 좀비 프로세스는 단 한 번도 발생하지 않았습니다.
6. 대규모 레거시 C++ 시스템을 위한 핵심 교훈
이번 버그 해결을 통해 대규모 레거시 아키텍처에서 반드시 기억해야 할 3가지 원칙을 재확인할 수 있었습니다:
| 비교 항목 | 기존 MFC Extension DLL | 리팩토링 후 순수 Win32 DLL |
|---|---|---|
| 모듈 체인 등록 | new CDynLinkLibrary로 전역 체인 오염 |
등록 없음 (완전 격리) |
| 종료 안정성 | Loader Lock 시점 데드락/좀비 프로세스 유발 | Stop() 명시 호출 후 0.3초 즉시 종료 |
| 스레드 오버헤드 | 스레드 생성 시마다 DllMain 호출 | DisableThreadLibraryCalls로 0% 달성 |
| 적용 권장 영역 | MFC UI 클래스를 직접 익스포트할 때만 | 데이터 캐시, 통신, 수학 연산 등 순수 로직 모듈 |
- "UI가 없는 DLL이라면 MFC 종속성을 무조건 버려라"
MFC Extension DLL은 MFC UI 컨트롤이나 다이얼로그 클래스를 모듈 간에 상속/공유하기 위한 특수 목적 규격입니다. 데이터 처리나 메모리 캐시 모듈은 가능한 한 순수 Win32 DLL로 작성해야 다른 Regular DLL이나 메인 프로세스의 런타임 상태와 충돌하지 않습니다. CDynLinkLibrary는 양날의 검이다
호출 즉시 MFC의 전역 리소스 및 모듈 체인에 진입하므로, 복합 DLL 환경에서는 언로드 시점이나 프로세스 급격 종료 시 심각한 데드락을 유발할 수 있습니다.DisableThreadLibraryCalls의 생활화
DLL 내에서 스레드별 초기화/정리가 필요 없다면DLL_PROCESS_ATTACH에서 반드시 호출하여 불필요한 OS 락 오버헤드를 차단해야 합니다.
7. 자주 묻는 질문 (FAQ)
CWinApp 인스턴스와 독립된 모듈 상태(AFX_MODULE_STATE)를 가지는 반면, Extension DLL은 자신만의 모듈 상태가 없고 자신을 호출한 실행 파일이나 DLL의 모듈 상태 체인(CDynLinkLibrary)에 편입되어 작동합니다.
__declspec(dllexport))는 Win32 표준 규격이므로, 클라이언트가 MFC 기반이든 순수 Win32 기반이든 완벽하게 링크되고 호출됩니다.
WM_DESTROY)을 거쳐 MFC 객체들이 단계적으로 해제되지만, 창을 켜자마자 즉시 닫으면 프레임워크 종료와 CRT 소멸자 호출이 급격히 진행되면서 CDynLinkLibrary와 Loader Lock 간의 데드락 조건이 촉발되었기 때문입니다.