1. 서론: 컴퓨터실과 교실 빔프로젝터의 영원한 악몽 — 사라진 창(Off-Screen Window)

초·중·고 교실과 군 브리핑실에서 가장 빈번하게 발생하는 기술 지원 요청 중 하나는 "프로그램을 실행했는데 작업 표시줄에는 켜져 있다고 뜨지만 화면에는 창이 전혀 보이지 않는다"는 현상입니다.

교사가 교실 빔프로젝터나 보조 모니터(확장 화면)에 창을 띄워 수업을 진행한 뒤 HDMI 케이블을 뽑으면, Windows OS는 이전에 종료된 창의 좌표를 기억하고 있다가 다음번 실행 시 지금은 존재하지도 않는 가상의 확장 모니터 위치로 창을 띄워버립니다. 사용자는 Alt + Space를 누르고 M(이동) 키와 방향키를 더듬거리며 창을 메인 화면으로 끌고 와야 하는 극심한 불편을 겪게 됩니다.

ZeliDesk의 창 상태 거버넌스 모듈인 WindowKit은 생성자에서 단 1줄(WindowKit.Attach(this, "key"))의 호출만으로 72종 모든 데스크톱 유틸리티 창의 위치와 크기를 완벽하게 영속화하면서도, 빔프로젝터가 분리되었을 때는 0.001초 만에 이를 감지하여 안전하게 주 모니터로 복귀시키는 무결점 기하학 아키텍처를 구축했습니다.

2. Windows 가상 데스크톱(Virtual Screen) 음수 좌표계와 토폴로지 변경의 함정

Windows의 다중 디스플레이 좌표계는 주 모니터의 좌상단을 (0, 0)으로 정의합니다. 만약 보조 모니터나 빔프로젝터가 주 모니터의 왼쪽에 배치되면, 해당 영역의 좌표는 X = -1920과 같은 음수 좌표(Negative Coordinates)를 갖게 됩니다.

단순한 좌표 기억 로직은 X < 0인 좌표를 "비정상 화면 밖"으로 오판하여 좌측 모니터 배치를 거부하거나, 반대로 빔프로젝터 연결이 해제되어 VirtualScreen의 바운딩 박스가 축소되었음에도 무조건 저장된 이전 음수/양수 좌표로 창을 이동시켜 화면 밖으로 창을 유실시키는 치명적인 결함을 유발합니다.

3. DisplayArea.GetFromPoint와 Fallback.None: 디스플레이 존재 유무의 0ms 기하학 검증

WindowKit은 WinUI 3의 최신 창 관리 API인 Microsoft.UI.Windowing.DisplayArea를 활용하여, 복원하려는 창의 중심점(Center Point)이 현재 물리적으로 연결된 어떤 모니터 영역에라도 걸치는지 수학적으로 검증합니다:

Services/WindowKit.cs — DisplayArea 기하학 검증 및 안전 폴백 C#
// 저장된 위치가 현재 어떤 디스플레이에도 걸치지 않으면 (모니터 구성 변경)
// 위치는 버리고 크기만 복원한다!
var centerPoint = new Windows.Graphics.PointInt32(x + w / 2, y + h / 2);
var area = Microsoft.UI.Windowing.DisplayArea.GetFromPoint(
    centerPoint, 
    Microsoft.UI.Windowing.DisplayAreaFallback.None);

if (area != null)
{
    // 현재 활성화된 모니터 범위 내 존재 확인 -> 위치와 크기 모두 완벽 복원
    appWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h));
}
else
{
    // 빔프로젝터가 뽑혀 화면 밖으로 날아갈 위험 감지!
    // 위치 이동은 취소하고, 사용자가 설정했던 창 크기만 주 모니터에서 복원
    appWindow.Resize(new Windows.Graphics.SizeInt32(w, h));
}

핵심은 DisplayAreaFallback.None입니다. 기본 폴백을 사용하면 가장 가까운 모니터를 억지로 반환하지만, None을 지정하면 대상 점이 현재 어떤 디스플레이에도 포함되지 않을 때 즉시 null을 반환하므로, 모니터 토폴로지 변경을 0ms 만에 정확하게 감지할 수 있습니다.

4. 최소화 상태(-32000, -32000) 저장 방어: OverlappedPresenterState 가드

Windows Win32 커널은 사용자가 창을 최소화(Minimize)하면 내부적으로 창의 위치를 (-32000, -32000)이라는 특수한 오프스크린 좌표로 밀어냅니다. 만약 창이 최소화된 상태에서 작업 표시줄 우클릭 [창 닫기]를 수행하면, 순진한 창 복원 로직은 -32000 좌표를 정상 위치로 저장해버려 다음 부팅 시 창이 영원히 사라지는 대참사를 겪게 됩니다.

WindowKit은 TrySave 시점에 appWindow.Presenter의 상태를 정밀 감사하여, 최소화 상태일 경우 좌표 저장을 즉시 중단(Abort)하는 안전 방어벽을 구축했습니다:

Services/WindowKit.cs — 최소화 좀비 좌표 저장 원천 차단 C#
// 최소화 상태에서 닫으면 비정상 좌표(-32000)가 저장되므로 건너뛴다
if (appWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter p &&
    p.State == Microsoft.UI.Windowing.OverlappedPresenterState.Minimized)
{
    return;
}

5. 언패키지드(Unpackaged) WinUI 3 크래시 극복: 순수 로컬 JSON 스레드 안전 영속화

공식 Windows SDK 문서에서는 창 상태를 저장할 때 Windows.Storage.ApplicationData.Current.LocalSettings를 사용하도록 권장합니다. 그러나 이는 MSIX 패키지 컨테이너 안에서 실행될 때만 유효하며, 개발 디버깅 모드나 공교육용 포터블 배포(Unpackaged) 환경에서는 InvalidOperationException(패키지 ID를 찾을 수 없음)을 던지며 애플리케이션 전체가 즉시 크래시됩니다.

WindowKit은 UWP 레거시 API에 의존하지 않고, %LOCALAPPDATA%\ZeliDesk\window_state.json 파일에 순수 C# System.Text.Json 직렬화와 프로세스 내 lock (_lock) 동기화를 적용하여 72개 유틸리티가 동시에 열리고 닫혀도 단 1건의 I/O 충돌이나 크래시 없이 100% 원자적으로 상태를 보존합니다.

6. Activated 라이프사이클 훅: 기본 생성자 Resize를 이기는 원자적 타이밍

WinUI 3 유틸리티 창의 생성자(Constructor) 내부에는 통상 AppWindow.Resize(new SizeInt32(1150, 800))와 같은 기본 권장 크기가 하드코딩되어 있습니다. 만약 생성자에서 즉시 이전 위치를 복원해버리면, 그 뒤에 실행되는 기본 생성자 코드가 저장된 크기를 덮어써버리는 타이밍 레이스(Timing Race)가 발생합니다.

WindowKit은 복원 시점을 window.Activated 이벤트의 최초 1회 발생 시점으로 늦추는 스마트 디스패치(Lazy Activation Hook)를 채택했습니다:

WindowKit.Attach — 완벽한 실행 우선순위 보장 C#
bool restored = false;
window.Activated += (s, e) =>
{
    if (restored) return;
    restored = true;
    TryRestore(window, key); // 각 창 생성자의 기본 Resize()가 모두 완료된 후 적용!
};
window.Closed += (s, e) => TrySave(window, key);

이로써 개발자가 유틸리티 창의 생성자 어느 라인에 WindowKit.Attach(this, "key")를 배치하더라도, 사용자가 마지막으로 조절했던 창의 위치와 크기가 항상 최종 승자(Last Winner)가 됩니다.

7. 결론: 단 1줄(WindowKit.Attach)로 72종 유틸리티를 수호하는 데스크톱 엔지니어링

복잡한 다중 모니터 기하학 연산, 프로젝터 분리 감지, 최소화 상태 가드, 언패키지드 JSON 영속화라는 고난이도의 기술적 난제들을 WindowKit은 단 119줄의 우아한 단일 클래스로 완벽하게 추상화했습니다.

ZeliDesk 생태계에 새로운 73번째, 74번째 유틸리티가 추가되더라도, 개발자는 복잡한 윈도우 좌표계를 고민할 필요 없이 WindowKit.Attach(this, "myutility") 단 한 줄만 선언하면 됩니다. 이것이 바로 공교육 현장의 혹독한 다중 디스플레이 환경에서도 ZeliDesk가 단 한 번의 화면 실종 없이 최고의 안정성을 발휘하는 이유입니다.