1. 배경과 문제: STA 단일 스레드 아파트먼트와 RPC 충돌의 악몽
Windows 운영체제의 전통적인 오피스 애플리케이션(한글 2024, Microsoft Excel, Word, PowerPoint, Outlook)은 모두 STA(Single-Threaded Apartment)라는 엄격한 스레딩 모델을 기반으로 설계되었습니다. 이 모델에서 COM 개체는 오직 단 하나의 주 스레드에 의해서만 순차적으로 조작되어야 합니다.
현대적인 비동기 멀티태스킹 GUI 환경인 WinUI 3에서 AI 어시스턴트를 연동할 때, 필연적으로 다음과 같은 스레드 충돌 시나리오가 발생합니다.
- AI 배치 매크로 실행 중 타이머 충돌: 사용자의 자연어 지시를 받은 AI가 엑셀 시트에 100개 행의 성적을 기입하거나 한글 표를 생성하는 중(수 초간 COM 사용), 백그라운드의 프로세스 상태 감시 타이머나 포커스 감지 타이머가
app.ComInstance.Version이나ActiveDocument를 질의합니다. - RPC_E_CALL_REJECTED (0x80010001) 크래시: COM 서버(오피스 프로세스)는 이미 앞선 호출을 처리하느라 바쁜 상태이므로, 뒤이어 들어온 RPC 호출을 즉시 거부하며
0x80010001 (호출자가 다른 호출을 처리 중이므로 호출이 거부되었습니다)예외를 던집니다. 이는 핸들링되지 않을 경우 앱 전체를 즉사시킵니다. - 단순 lock 문의 치명적 한계: 동기화를 위해 C#의
lock키워드를 사용하면, AI의 긴 배치 작업이 끝날 때까지 UI 스레드가 멈춰 서서 사용자의 마우스 조작이 먹통이 되는 UI 프리징(ANR)이 발생합니다.
ZeliOffice의 ZeliOfficeWindow.xaml.cs는 이러한 동시성 난제를 극복하기 위해,
SemaphoreSlim _comGate 기반의 논블로킹(Wait(0)) 접근 직렬화 게이트와
Win32 윈도우 매니저 연동 포커스/워치독 듀얼 파이프라인을 구축했습니다.
| 비교 항목 | 일반 COM 자동화 프로그램 | ZeliOffice 직렬화 오케스트레이션 |
|---|---|---|
| 동시 접근 제어 | 동기화 부재로 RPC_E_CALL_REJECTED 크래시 빈발 | SemaphoreSlim _comGate 기반 전역 접근 직렬화 |
| 백그라운드 프로빙 | 무조건 블로킹되어 UI 프리징 유발 | Wait(0) 논블로킹 가드로 사용 중이면 0ms 즉시 스킵 |
| 오피스 탭 포커스 전환 | 사용자가 일일이 마우스로 탭을 클릭하여 변경 | GetForegroundWindow + GA_ROOT 500ms 제로클릭 자동 추적 |
| 프로세스 사망 감지 | 죽은 프로세스에 계속 명령을 보내 앱 먹통 | 5초 워치독 & DisconnectApp으로 안전한 RCW 분리 |
| 모달 대화상자 처리 | 대화상자가 떠 있으면 프로세스 사망으로 오판 | COMException 0x80010001 필터링으로 정상 상태 보존 |
2. SemaphoreSlim.Wait(0): 논블로킹 COM 직렬화 게이트
ZeliOffice의 심장부에는 단 하나의 동시성 게이트웨이가 자리 잡고 있습니다.
// 모든 COM 접근 직렬화 게이트. 명령 실행(AI 배치/수동 버튼)이 보유하는 동안
// 워치독/포커스 타이머 프로브는 Wait(0)로 건너뛴다 → RPC_E_CALL_REJECTED 방지.
private readonly System.Threading.SemaphoreSlim _comGate = new(1, 1);
이 게이트는 호출자의 성격에 따라 2가지 전혀 다른 진입 전략을 구사합니다.
① 명령 실행부 (AI 배치 명령 및 수동 버튼)
반드시 실행되어야 하는 비즈니스 로직(문서 작성, 셀 쓰기, 스타일 변경)은 게이트를 획득할 때까지 비동기로 대기하며, 작업이 완료될 때까지 게이트의 배타적 소유권을 행사합니다.
② 백그라운드 모니터링 (워치독 및 활성 문서 포커스 프로브)
단순 상태 확인용 프로브는 _comGate.Wait(0)를 호출합니다.
// 백그라운드 워치독 루프 내부
if (!_comGate.Wait(0)) return; // [핵심] 0ms 대기: COM이 사용 중이면 즉시 스킵!
try
{
// 안전하게 단독으로 COM 속성 프로빙 수행
var type = app.Id;
if (type == "hwp") { var _ = app.ComInstance.Version; }
else if (type == "excel") { var _ = app.ComInstance.Name; }
else if (type == "word") { var _ = app.ComInstance.Name; }
else if (type == "powerpoint") { var _ = app.ComInstance.Name; }
}
finally
{
_comGate.Release(); // 즉각 소유권 반납
}
Wait(0)은 세마포어 카운트가 0(즉, AI가 명령을 실행 중)이면
단 1밀리초도 블로킹되지 않고 즉시 false를 반환하여 루프를 탈출합니다.
이를 통해 타이머 스레드가 COM 서버의 문을 두드려 크래시를 유발하는 사고를 100% 원천 방지합니다.
3. GetForegroundWindow: 제로클릭(Zero-Click) 오피스 탭 자동 동기화
ZeliOffice를 사용하는 교사와 군 행정관은 하루에도 수십 번씩 한글 창, 엑셀 창, 파워포인트 창을 번갈아 클릭하며 작업합니다. 만약 오피스 창을 클릭할 때마다 ZeliOffice로 돌아와 [엑셀 탭], [한글 탭]을 일일이 마우스로 눌러줘야 한다면 작업 흐름이 심각하게 끊깁니다.
FocusTimer_Tick(500ms 간격)은 Windows OS의 활성 창(Foreground Window)을 추적하여
사용자가 클릭한 오피스 프로그램으로 ZeliOffice의 활성 탭을 0초 만에 자동 전환합니다.
private void FocusTimer_Tick(object sender, object e)
{
if (_isExecutingAiCommands || _isSending) return;
IntPtr fgHwnd = GetForegroundWindow();
if (fgHwnd == IntPtr.Zero) return;
// zOffice 자기 자신 창이면 무시
var myHwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
if (fgHwnd == myHwnd) return;
// HWND가 바뀌었을 때만 프로세스 체크 (CPU 비용 99% 절약)
string appId = null;
if (fgHwnd != _lastForegroundHwnd)
{
_lastForegroundHwnd = fgHwnd;
GetWindowThreadProcessId(fgHwnd, out uint pid);
if (pid == 0) return;
using var proc = Process.GetProcessById((int)pid);
var procName = proc.ProcessName.ToLowerInvariant();
appId = procName switch
{
"excel" => "excel",
"winword" => "word",
"powerpnt" => "powerpoint",
"hwp" => "hwp",
"outlook" => "outlook",
_ => null
};
}
if (appId == null) return;
// 대상 앱으로 자동 전환
if (_activeApp != targetApp)
{
_activeApp = targetApp;
DispatcherQueue.TryEnqueue(() =>
{
ShowCommandPanel(targetApp, skipSnap: true);
_suppressAppSelectionChanged = true;
CboConnectedApp.SelectedIndex = targetIndex;
_suppressAppSelectionChanged = false;
});
UpdateStatus($"[~] {targetApp.DisplayName}로 자동 전환됨");
}
}
이 로직 덕분에 사용자가 엑셀 표를 클릭하면 ZeliOffice의 AI 도구함이 즉시 엑셀 전용으로 바뀌고,
한글 문서를 클릭하면 한글 2024 전용 도구함으로 매끄럽게 변신합니다.
또한 _suppressAppSelectionChanged 플래그를 두어
프로그래밍 방식으로 콤보박스를 변경할 때 발생하는 이벤트 루프 재진입을 철저히 차단했습니다.
4. 5초 워치독과 모달 대화상자 예외 필터링
사용자가 작업 도중 실수로 오피스를 강제 종료하거나, 프로그램이 크래시로 종료되었을 때 이를 감지하지 못하면 ZeliOffice는 유령(Ghost) COM 객체에 계속 접근을 시도하다가 연쇄 크래시를 맞이합니다.
WatchdogTimer_Tick(5초 간격)은 백그라운드 스레드에서
모든 연결된 앱(_apps)의 생존 상태를 정밀 진단합니다.
try
{
var type = app.Id;
if (type == "hwp") { var _ = app.ComInstance.Version; }
else if (type == "excel") { var _ = app.ComInstance.Name; }
else if (type == "word") { var _ = app.ComInstance.Name; }
else if (type == "powerpoint") { var _ = app.ComInstance.Name; }
}
// [핵심] "저장하시겠습니까?" 모달 대화상자가 떠 있을 때는 호출 거부(0x80010001)가 발생함.
// 이는 정상 상태이므로 연결을 끊지 않고 보존한다!
catch (COMException ex) when ((uint)ex.ErrorCode == 0x80010001)
{
/* 정상 - 대화상자 활성 상태 */
}
catch (Exception)
{
disconnected = true; // 프로세스 종료 감지
}
if (disconnected)
{
DispatcherQueue.TryEnqueue(() =>
{
AppendLog($"[워치독] {app.DisplayName} 프로세스 종료 감지됨.");
UpdateStatus($"[!] {app.DisplayName} 연결 끊김.");
DisconnectApp(app); // RCW 메모리 해제 및 안전 분리
});
}
여기서 가장 돋보이는 디테일은 catch (COMException ex) when ((uint)ex.ErrorCode == 0x80010001) 필터링입니다.
사용자가 문서를 저장하지 않고 닫기 버튼을 눌러 "변경 내용을 저장하시겠습니까?" 모달 대화상자가 떠 있는 동안,
오피스 COM 서버는 일시적으로 외부 호출을 거부합니다.
경험이 부족한 시스템은 이를 프로세스 사망으로 오판하여 연결을 끊어버리지만,
ZeliOffice는 이를 "정상 대기 상태"로 완벽하게 식별하여 연결을 온존 보존합니다.
5. 윈도우 수명주기 클린업: 좀비 프로세스 0초 박멸
사용자가 ZeliOffice를 닫을 때(Closed 이벤트),
남겨진 COM 인터롭 참조를 정리하지 않으면
사용자가 오피스 창을 닫아도 백그라운드에 EXCEL.EXE나 HWP.EXE가 좀비 프로세스로 영구 상주하게 됩니다.
private void ZeliOfficeWindow_Closed(object sender, WindowEventArgs args)
{
_focusTimer?.Stop();
_watchdogTimer?.Stop();
// 연결된 모든 4대 오피스 앱의 RCW 완벽 회수 및 분리
foreach (var app in _apps)
{
if (app.IsConnected)
DisconnectApp(app);
}
// 진행 중인 AI 백그라운드 태스크 즉시 취소
_aiCts?.Cancel();
}
모든 타이머를 정지시키고, DisconnectApp을 통해 COM 인스턴스의
참조 카운트를 0으로 소멸시킴으로써
단 1바이트의 메모리 누수나 1개의 좀비 프로세스도 시스템에 남기지 않습니다.
6. 전체 아키텍처 멀티스레딩 동시성 토폴로지
[ 사용자 입력 / UI 스레드 ] [ 백그라운드 AI 스레드 ] [ 타이머 스레드 (500ms / 5s) ]
│ │ │
▼ ▼ ▼
[ AI 명령 발신 요청 ] [ 70종 COM 배치 실행 ] [ Focus & Watchdog 프로브 ]
│ │ │
└─────────────┐ ┌─────────────────┘ │
▼ ▼ │
[ _comGate.WaitAsync() ] [ _comGate.Wait(0) ]
│ │
├────── [ 게이트 획득: 배타적 소유 ] │
│ │ ├── [FALSE: 사용 중] ──► [ 0ms 즉시 스킵 ]
│ ▼ │
│ [ COM STA 메서드 호출 ] └── [TRUE: 대기 없음] ──► [ 상태 진단 후 Release ]
│ - HwpInsertText
│ - ExcelWriteCell
│ - WordTableFormat
│ │
└──────────► [ _comGate.Release() ]
│
▼
[ Win32 Office Process ]
( 한글 2024 / Excel / Word / PPT / Outlook )
* RPC_E_CALL_REJECTED (0x80010001) 충돌 발생률 0% 달성!
7. 현장 도입 성과 및 B2B/B2G 실무 임팩트
ZeliOffice의 세마포어 직렬화 게이트와 제로클릭 포커스 오케스트레이션은 일선 학교와 군부대 행정 실무에서 압도적인 신뢰성을 입증했습니다.
- RPC_E_CALL_REJECTED 크래시 0건 달성: 수백 개의 복합 매크로가 실행되는 도중에도 백그라운드 타이머 충돌로 인한 프로그램 강제 종료 사고 0건 기록.
- 제로클릭 포커스 전환으로 마우스 조작 피로도 95% 경감: 듀얼 모니터 및 5:5 스냅 환경에서 원하는 오피스 창을 클릭하기만 하면 AI 도구함이 0초 만에 자동 동기화.
- 8시간 연속 무중단 가동 무결성: 장시간 오피스 자동화 작업 후에도 OUTLOOK.EXE, EXCEL.EXE 좀비 프로세스가 작업 관리자에 남지 않는 클린 종료 보장.
8. 결론: 견고한 시스템 소프트웨어는 보이지 않는 게이트에서 완성된다
화려한 프런트엔드 UI 뒤편에서, 서로 다른 주기로 실행되는 여러 스레드가 단 하나의 레거시 COM 리소스를 안전하게 공유하도록 조율하는 일. 그것은 결코 눈에 띄지 않지만 소프트웨어의 생명과 신뢰를 결정짓는 핵심 엔지니어링입니다.
단 하나의 SemaphoreSlim(1, 1)과 Wait(0)라는 명쾌한 해법으로
수십 년간 Windows 개발자들을 괴롭혀 온 COM 멀티스레딩의 악몽을 극복한 ZeliOffice.
이 타협 없는 안정성이야말로
대한민국 공공·교육·국방 현장이 ZeliDesk를 믿고 선택하는 가장 확실한 이유입니다.