Windows 앱이 프로그램 실수에 의한 예외로 떨어져도 확실히 로그를 남기려면 - in-process에 걸지 않는 설계와 WER / 최종 로그 / 감시 프로세스의 베스트 프랙티스

· · 예외 처리, 로그, WER, 크래시 덤프, 불구 조사

Windows 앱의 불구 조사에서 가장 힘든 것은 떨어진 것만은 알 수 있는데, 왜 떨어졌는지가 남아 있지 않은 상태입니다.

특히 다음과 같은 안건에서는 이 문제가 꽤 무거워집니다.

  • 고객 환경에서만 떨어진다
  • 장시간 운전 끝에만 떨어진다
  • WPF / WinForms / Windows 서비스 / 상주 앱에서 재현률이 낮다
  • COM, P/Invoke, native DLL, vendor SDK가 얽힌다
  • 「예외 메시지만」은 얻어지지만 직전의 문맥이 없다

다만, 처음에 정직하게 말하면 떨어지는 측의 프로세스만으로 「반드시」 로그를 남길 수는 없습니다. 스택 파손, 메모리 파괴, fast fail, 강제 종료, 전원 단절까지 포함하면, in-process의 마지막 로그는 본질적으로 best effort입니다.

실무에서 목표로 해야 할 것은 떨어지는 프로세스 속에만 기대지 않는 구성으로 하는 것입니다. 즉,

  1. 통상시의 시계열 로그
  2. 떨어지는 순간의 최종 크래시 마커
  3. OS 또는 별도 프로세스 측에서 남기는 크래시 증적

의 3층으로 생각합니다.

이 글에서는 Windows 데스크톱 앱, 상주 앱, Windows 서비스, 장치 연계 도구를 전제로, 프로그램 실수에 의한 예외로 떨어져도 조사 가능성을 잃지 않기 위한 베스트 프랙티스를 정리합니다.

1. 먼저 결론

먼저 결론만 나열합니다.

  • 「최후의 로그」를 1개의 in-process 핸들러에 걸지 않는 것이 가장 중요합니다.
  • 실무에서 가장 무난한 것은 통상 로그 + 최종 크래시 마커 + WER LocalDumps의 조합입니다.
  • 장시간 운전, 장치 연계, 플러그인, native SDK 혼재라면, 감시 프로세스(watchdog / launcher / service)를 추가하면 꽤 강해집니다.
  • 크래시 핸들러에서는 무거운 처리를 하지 않는 것이 철칙입니다. 압축, HTTP 송신, DI 해결, UI 다이얼로그, 복잡한 JSON 생성은 뺍니다.
  • 크래시 시는 로컬에 짧게 남기기만 하고, 압축・업로드・알림은 다음 회 기동 후나 별도 프로세스로 돌립니다.
  • WinForms의 ThreadException이나 WPF의 DispatcherUnhandledException을 사용해 외견상 연명하는 설계는, 프로그램 실수 상대로는 위험합니다.
  • .NET에서도 native에서도 파손 상태를 의심하는 예외는 「회복」보다 「기록해서 종료」를 기본으로 하는 편이 안전합니다.
  • 덤프를 취한다면 PDB와 배포 바이너리의 보관을 동시에 하지 않으면 나중에 읽을 수 없습니다.

요컨대 베스트 프랙티스는 「떨어지는 순간에 전부 하려고 하지 않는다. 떨어지기 전・떨어지는 순간・떨어진 후로 역할 분담한다」 입니다.

2. 왜 in-process만으로는 「확실」히 할 수 없는가

여기를 애매하게 하면 설계가 흔들립니다.

2.1 떨어진 스레드의 문맥 그 자체가 망가져 있는 경우가 있다

미처리 예외의 훅이나 최상위 예외 필터는 망가진 측의 스레드 문맥에서 동작하는 경우가 있습니다. 이 시점에서,

  • 스택이 이미 위험
  • 힙 파괴로 추가 확보가 위험
  • 예외 발생 시에 취하고 있던 락 때문에 대기하면 멈춘다
  • logger 자체가 의존하고 있는 오브젝트가 이미 망가져 있다

는 것이 평범하게 있습니다.

그래서 최후의 핸들러는 「뭐든 할 수 있는 장소」가 아니라 「할 수 있는 것이 꽤 적은 장소」로 보는 편이 안전합니다.

2.2 fast fail나 파손 상태 예외는 「최소한의 in-process 동작」 전제

메모리 파괴나 치명적인 상태에서는 통상의 예외 처리에 기대지 않는 편이 좋습니다. 특히 native 측의 __fastfail 계나 파손 상태를 의심하는 이상은 「가능한 한 적은 오버헤드로 즉시 종료한다」 방향으로 설계되어 있습니다.

즉, 최후의 in-process 로그는 쓸 수 있으면 운이 좋은, 주 증적은 OS / 별도 프로세스 측이라는 사고방식이 자연스럽습니다.

2.3 .NET의 미처리 예외 이벤트도 「무거운 회복 처리」의 장이 아니다

.NETAppDomain.UnhandledException은 편리하지만, 여기서 해도 좋은 것은 짧은 기록까지라고 생각하는 편이 좋습니다.

  • 예외 발생 시에 보유하고 있던 락의 영향을 받을 수 있다
  • 파손 상태 예외까지 뭐든 안전하게 취할 수 있는 것은 아니다
  • 여기서 지속 방침을 무리하게 만들면 반쯤 망가진 상태로 연명하기 쉽다

요컨대 「미처리 예외 이벤트 = 최후의 통지」이며 「안전한 회복 지점」은 아닙니다.

3. 추천 아키텍처 - crash-time과 after-restart를 나눈다

가장 정리하기 쉬운 것은 크래시 시에 하는 것재기동 후에 하는 것을 나누는 방법입니다.

페이즈 목적 어디서 동작시키는가 하는 일
통상시 시계열을 남긴다 앱 내 구조화 로그, heartbeat, 경계 이벤트
크래시 시 최저한의 증적을 떨어뜨린다 앱 내 + OS 최종 크래시 마커, WER 덤프
종료 직후 unexpected exit를 검지한다 별도 프로세스 exit code 기록, 재기동 판단, 알림
다음 회 기동 후 무거운 후처리를 한다 새로운 건전한 프로세스 압축, 업로드, 사용자 알림, 오래된 로그 정리

이 분할 방식으로 하면 설계가 꽤 안정됩니다.

3.1 최소 구성

작은 업무 도구나 사내용 WPF / WinForms라면 우선은 다음으로 충분한 경우가 많습니다.

  • 통상 로그: 로컬의 append-only 파일
  • 최종 크래시 마커: 전용의 짧은 파일
  • 덤프: WER LocalDumps
  • 다음 회 기동 시: 「전회 이상 종료했습니다. 진단 정보가 있습니다」를 낸다

3.2 강한 구성

다음과 같은 요건이라면 1 단계 강화하는 편이 좋습니다.

  • 24/7 운전
  • 장치 제어, 감시, 상주
  • COM / P/Invoke / native SDK가 많다
  • 자식 프로세스, 플러그인, 스크립트 실행이 있다
  • 고객 환경에서 「멈춘 채로」가 허용되지 않는다

이 경우는,

  • worker 프로세스: 본체 처리
  • launcher / watchdog / service: 기동 감시, exit 기록, 재기동
  • WER LocalDumps: worker 측
  • 다음 회 기동 또는 watchdog: 진단 정보 회수

로 나누면 꽤 실무 쪽이 됩니다.

4. 통상 로그의 베스트 프랙티스

크래시 시의 마지막 1행만으로 싸우려고 하면 대개 집니다. 정말로 효과적인 것은 직전까지의 통상 로그입니다.

4.1 로그는 「인간용 문장」보다 「나중에 상관시킬 수 있는 정보」

통상 로그에는 최소한 다음을 넣습니다.

  • UTC 타임스탬프
  • 프로세스 개시부터의 경과 시간
  • PID / TID
  • 앱 이름, 버전, 빌드 번호, 커밋 식별자
  • 세션 ID
  • 조작 ID / 작업 ID / 상관 ID
  • 모듈 이름 / 화면 이름 / 워커 이름
  • 직전의 외부 작용
    • 파일 쓰기
    • DB 갱신
    • 장치 커맨드 송신
    • 통신 요구
  • 예외형, HRESULT / Win32 에러 / 예외 코드
  • 주요 입력 파라미터의 요약
  • 기밀을 포함하지 않는 범위에서의 대상 ID

추천은 1행 1이벤트의 JSON Lines이나 key=value 형식입니다.

인간용으로 장문을 남기기보다 「나중에 3 파일을 맞춰 볼 수 있다」는 것이 더 중요합니다.

4.2 크리티컬 이벤트는 동기적으로 남긴다

통상 로그를 전부 동기 쓰기로 하면 무거워집니다. 다만, 전부를 비동기 버퍼에 맡기면 떨어진 순간에 한꺼번에 사라집니다.

그래서 실무에서는 다음 분할이 다루기 쉽습니다.

  • Information의 세세한 이벤트: 버퍼해도 좋다
  • Warning 이상: 일찍 flush
  • 중요한 경계 이벤트: 동기적으로 남긴다
    • ProcessStart
    • ConfigLoaded
    • WorkerStarted
    • ExternalCommandSent
    • TransactionCommitted
    • RecoveryStarted
    • FatalPathEntered

요점은 업무상의 경계만큼은 제대로 지면에 떨어뜨린다는 것입니다.

4.3 「지금 쓰고 있는 통상 로그」와 「최후의 크래시 마커」는 나눈다

이것은 꽤 중요합니다.

1개의 rolling log에만 전부 넣으려고 하면,

  • 로테이션 중이었다
  • 비동기 큐에 남아 있었다
  • 예외 발생 직후에 logger 자체가 죽었다
  • 로그 행의 도중에 끊겼다

는 일이 일어납니다.

그래서 최소한 다음 2개로 나누는 것이 추천입니다.

  • app-<session>.jsonl 통상 시계열 로그
  • fatal-last.log 또는 fatal-<session>.log 최종 크래시 마커 전용

「마지막 1행을 어디에 남길지」가 명확하기만 해도 현장에서 꽤 도움이 됩니다.

4.4 로그 저장처는 로컬 고정, 네트워크처는 사용하지 않는다

크래시 시에 UNC 패스, NAS, HTTP, 클라우드 API에 의존하는 것은 위험합니다.

  • 네트워크 순간 끊김
  • DNS 지연
  • 자격 정보 실효
  • UI 스레드에서의 대기
  • 서비스 계정 권한 부족

이 얽히기 때문입니다.

크래시 시는 먼저 로컬 고정 패스에 떨어뜨립니다. 보내는 것은 다음 회 기동 후나 별도 프로세스입니다.

4.5 파일 이름에는 session을 넣는다

날짜만으로는 부족합니다. 같은 날에 몇 번이나 재기동하기 때문입니다.

추천은 예를 들어 다음입니다.

Logs\
  MyApp_20260318_101530_pid1234_session-4f1c.jsonl
  MyApp_fatal_20260318_101533_pid1234_session-4f1c.log
  MyApp_watchdog_20260318.jsonl

「어느 기동 인스턴스의 이야기인가」가 명확한 것만으로 해석의 속도가 꽤 변합니다.

5. 최종 크래시 마커의 베스트 프랙티스

여기는 풀 기능 logger를 만드는 장소가 아닙니다. 1회만, 짧게, 확실 쪽으로 남기는 장소입니다.

5.1 목적은 「원인의 상세」가 아니라 「입구의 고정」

최종 크래시 마커에 넣어야 할 정보는 좁히는 편이 강합니다.

  • 발생 UTC
  • PID / TID
  • 세션 ID
  • 버전 / 빌드 번호
  • 어느 훅에서 왔는가
    • AppDomain.UnhandledException
    • Application.ThreadException
    • DispatcherUnhandledException
    • SetUnhandledExceptionFilter
    • _set_invalid_parameter_handler
    • set_terminate
  • 예외형 또는 예외 코드
  • 가능하면 간단한 메시지
  • 직전의 조작 ID
  • 통상 로그의 파일 이름
  • dump 상정 폴더

이것만으로 충분합니다.

5.2 크래시 핸들러에서 해서는 안 되는 것

다음은 꽤 높은 확률로 지뢰입니다.

  • DI 컨테이너에서 logger를 해결한다
  • async / await를 사용한다
  • Task를 던진다
  • 락 대기를 한다
  • 복잡한 JSON을 조립한다
  • COM 오브젝트를 만진다
  • UI 다이얼로그를 낸다
  • 압축한다
  • HTTP / SMTP / Slack / Teams 송신
  • dump를 해석해서 요약한다
  • 예외를 쥐어 뭉개고 지속한다

크래시 핸들러는 보통의 처리 플로우의 연속이 아닙니다. 「최소한의 로컬 쓰기만 하고 끝난다」로 기울입니다.

5.3 크래시 핸들러에서 하는 것

반대로 하는 것은 꽤 단순합니다.

  1. 다중 진입을 방지한다
  2. 1행만 쓴다
  3. flush한다
  4. 종료한다

이 순서입니다.

가능하면,

  • 사전에 만들어 둔 전용 폴더
  • 사전에 존재 확인이 끝난 패스
  • ACL을 확인이 끝난 저장처

를 사용합니다.

통상 로그에서는 flush를 너무 하면 무겁지만, fatal 마커는 건수가 극소이므로 여기만은 강하게 flush해도 좋습니다. .NET이라면 FileStream.Flush(true), native라면 FlushFileBuffers처럼 「이 1행만은 지금 즉시 지면에 떨어뜨린다」 취급으로 기울이면 설계하기 쉬워집니다.

5.4 지속시키려고 하지 않는다

프로그램 실수 기점의 unexpected한 예외라면, 최종 핸들러는 회복 장치가 아니라 기록 장치라고 생각하는 편이 안전합니다.

특히 다음은 「지속하지 않는다」가 기본입니다.

  • NullReferenceException이나 InvalidOperationException이라도 공유 상태 갱신의 도중이었다
  • UI 스레드에서의 unexpected한 예외
  • 감시 루프나 부모 루프에서 샌 unexpected 예외
  • AccessViolationException
  • StackOverflowException
  • native 경계의 이상
  • CRT의 invalid parameter / purecall / terminate

「떨어뜨리고 싶지 않다」는 마음은 알지만, 반쯤 망가져 살아남는 편이 진단도 운용도 힘든 경우가 많습니다.

종료시킬 때는, .NET이라면 Environment.FailFast, native라면 RaiseFailFastException이나 __fastfail과 같은 즉시 종료계 API를 검토하고, finally나 통상의 후처리에 기대지 않는 설계 편이 안전합니다.

6. 프레임워크별의 주의점

6.1 .NET 공통: AppDomain.CurrentDomain.UnhandledException

이것은 최후의 통지로서 유용합니다. 다만, 여기서의 무거운 회복 처리는 피합니다.

사용법의 기본은 다음입니다.

  • 최종 크래시 마커를 쓴다
  • 필요하면 Windows Event Log에 최소 메시지를 남긴다
  • 지속하지 않는다
  • 여기서 대기나 재시행을 하지 않는다

UnhandledException은 편리하지만, 여기서 앱을 건강한 상태로 돌릴 수 있다는 전제는 하지 않는 편이 안전합니다.

6.2 WinForms: Application.ThreadException

이것은 UI 스레드의 미처리 예외를 주워 외견상 지속할 수 있는 것이 어려운 점입니다.

업무 입력의 상정 내 에러를 다이얼로그화하는 용도라면 모를까, 프로그램 실수 기점의 unexpected 예외로 지속하는 용도에는 어울리지 않습니다.

정말로 원인 조사를 우선한다면,

  • ThreadException에서 최소 기록만 한다
  • 또는 UnhandledExceptionMode.ThrowException에 기울인다
  • 그 위에서 프로세스를 종료시키고 덤프와 로그를 남긴다

쪽이 안전합니다.

6.3 WPF: Application.DispatcherUnhandledException

WPF에서도 비슷합니다.

  • UI 스레드 위의 예외만이 주 대상
  • Handled = true로 하면 외견상 지속할 수 있다
  • 하지만 프로그램 실수 상대로 그것을 하면 화면 상태와 내부 상태가 어긋나기 쉽다

그래서 WPF에서도 지속을 위한 연명 장치로 사용하지 않고, 기록의 입구로 사용하는 편이 무난합니다.

6.4 TaskScheduler.UnobservedTaskException은 주 경로로 하지 않는다

이것은 「떨어지기 직전의 최후의 보루」가 아닙니다.

Task의 예외 놓침을 검지하는 보조에는 사용할 수 있지만, 크래시 시의 확실한 기록 경로로서는 약합니다.

그래서,

  • 예외의 관측 누락을 조기에 발견한다
  • 개발 중에 Task의 설계 누락을 불거내게 한다

용도에는 사용해도 최종 크래시 핸들러의 주역으로는 하지 않는 편이 좋습니다.

6.5 native Win32 / C++: SetUnhandledExceptionFilter를 과신하지 않는다

native 측에서는 무심코 SetUnhandledExceptionFilter에 기대고 싶어집니다.

다만, 이것은 faulting thread의 문맥에서 동작하므로,

  • 무효 스택
  • 깊은 재귀
  • 이미 망가진 힙
  • 예외 발생 시의 락 보유

의 영향을 받습니다.

따라서 SetUnhandledExceptionFilter최후의 통지를 받는 best effort의 입구라고 생각하는 것이 딱 좋습니다.

6.6 native C++는 CRT의 종료 경로도 잡는다

native C++에서는 미처리 SEH만 보면 샙니다.

봐두고 싶은 것은 예를 들어 다음입니다.

  • _set_invalid_parameter_handler
  • _set_purecall_handler
  • set_terminate

이 계통은 C 런타임이나 C++ 런타임 기점의 「종료 경로」를 잡기 위한 것입니다.

실무에서는,

  • 이들 핸들러에서도 최종 크래시 마커를 쓴다
  • 다만 무거운 회복 처리는 하지 않는다
  • 확실히 종료시킨다
  • 주 증적은 WER / dump에 맡긴다

가 무난합니다.

7. WER LocalDumps를 토대로 한다

여기가 실무에서는 꽤 강합니다.

7.1 먼저의 추천은 WER LocalDumps

「떨어진 후에 최저한의 증적을 확실 쪽으로 남긴다」는 의미에서는, 우선은 WER LocalDumps가 가장 다루기 쉽습니다.

이유는 단순합니다.

  • OS 측에서 덤프를 남길 수 있다
  • 추가 도구 없이 넣기 쉽다
  • 앱 단위로 설정할 수 있다
  • 크래시 시의 주 증적을 in-process 이외로 도망시킬 수 있다

로그만으로는 모르는

  • 어느 스레드가 떨어졌는지
  • 어느 스택에서 떨어졌는지
  • 어느 모듈 경계였는지
  • managed / native / COM / SDK 중 어디가 수상한지

를 나중에 볼 수 있는 것이 강합니다.

7.2 전형 설정

예를 들어 MyApp.exe에 대해 C:\CrashDumps\MyApp에 덤프를 남긴다면 다음과 같이 할 수 있습니다.

reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpFolder /t REG_EXPAND_SZ /d "C:\CrashDumps\MyApp" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpCount /t REG_DWORD /d 10 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpType /t REG_DWORD /d 2 /f

처음에는 다음 사고방식으로 좋습니다.

먼저의 추천
DumpFolder 전용 폴더
DumpCount 5~10
DumpType 개발기는 2, 현장은 용량과 기밀 요건을 보고 1 or 2

7.3 dump 저장처의 ACL을 반드시 확인한다

로그에서도 덤프에서도 마찬가지지만, 쓸 수 없는 폴더에 설정해도 의미가 없습니다.

특히,

  • Windows 서비스
  • 권한 분리한 자식 프로세스
  • 현장기의 제한 계정
  • UAC 관련

에서는 저장처 ACL이 헛스윙의 주 원인이 됩니다.

저장처는,

  • 사전 작성
  • 쓰기 테스트
  • 보유 수 제한
  • 운용 담당이 보러 갈 수 있는 장소인가

까지 확인합니다.

7.4 WER 리포트에 현재 로그를 첨부하고 싶을 때

Microsoft에 대한 WER 리포트나 독자 WER 운용을 사용하는 경우는, WerRegisterFile현재의 로그 파일을 에러 리포트에 포함하기 위한 등록을 하는 방법도 있습니다.

다만 여기는 로컬 저장의 대체가 아닌 추가 도선으로 생각하는 편이 안전합니다. 크래시 시에 정말로 원하는 것은 우선 수중의 단말에 확실 쪽으로 남는 것이기 때문입니다.

순서로서는,

  1. 로컬 통상 로그
  2. 로컬 fatal 마커
  3. 로컬 dump
  4. 필요하면 WER 송신 경로에서도 관련 파일을 등록

쪽이 실무 쪽입니다.

7.5 덤프뿐만 아니라 버전 관리를 남긴다

덤프를 취해도 나중에

  • 그 시점의 EXE / DLL이 없다
  • PDB가 없다
  • 어느 커밋의 빌드인지 모른다

가 되면 꽤 약해집니다.

최저한 다음은 남깁니다.

  • 배포한 바이너리
  • 대응하는 PDB
  • 버전
  • 빌드 일시
  • 커밋 식별자
  • 인스톨러 판

덤프 수집과 PDB 보관은 세트입니다.

8. MiniDumpWriteDump나 독자 크래시 리포터를 사용할 때의 사고방식

독자 구현이 필요해지는 장면도 있습니다.

  • UI에서 「진단 정보를 저장」 버튼을 내고 싶다
  • 로그나 설정 파일도 묶고 싶다
  • 자식 프로세스군을 함께 다루고 싶다
  • 자동 업로드 전에 독자 마스킹을 넣고 싶다

다만 여기서 가장 중요한 것은 dump를 취하는 처리도 떨어지는 측에 너무 짊어지게 하지 않는 것입니다.

8.1 self-dump보다 별도 프로세스

MiniDumpWriteDump는 강력하지만, 크래시한 그 프로세스 속에서 부르기보다 별도 프로세스에서 부르는 편이 안전합니다.

전형 구성은 다음입니다.

  • worker 본체가 이상을 검지
  • 가능하면 이벤트나 이름 있는 파이프로 helper에 통지
  • helper가 worker의 dump를 취한다
  • helper가 tail 로그나 설정 파일을 묶는다
  • helper가 종료 후에 업로드 큐로 놓는다

이것이라면 worker가 망가져도 helper 측은 아직 건전합니다.

8.2 아무래도 in-process라면 전용 스레드로 기울인다

별도 프로세스화할 수 없는 경우라도, 전용 스레드를 dump 전용으로 해 두는 것이 낫습니다.

다만, 그래도 본질은 best effort입니다. 「독자 dump 구현을 넣었으니 100% 안심」이 되지는 않습니다.

8.3 무거운 것은 다음 회 기동 후로 돌린다

독자 리포터에서 하고 싶어지기 쉬운 것이 있습니다.

  • zip 압축
  • symbol 정보와의 대조
  • 서버 업로드
  • 화면 캡처
  • DB에서 추가 정보 취득

이들은 크래시 시가 아니라 재기동 후나 helper 측에 돌립니다.

9. 감시 프로세스를 넣으면 무엇이 바뀌는가

장시간 운전계에서는 감시 프로세스가 꽤 효과적입니다.

9.1 감시 프로세스가 남기는 것

watchdog / launcher / 부모 서비스는 예를 들어 다음을 남길 수 있습니다.

  • 자식 프로세스 개시 시각
  • 기동 인수
  • PID
  • 감시 대상 버전
  • heartbeat의 최종 수신 시각
  • 종료 시각
  • exit code
  • restart 횟수
  • dump의 유무
  • 재기동했는지 여부

이것이 있는 것만으로도,

  • 정말로 크래시했는가
  • OS 셧다운이었는가
  • 사용자가 닫은 것인가
  • hang에서 kill된 것인가
  • 몇 번 재기동 루프한 것인가

가 꽤 보입니다.

9.2 특히 어울리는 케이스

다음이라면 분리를 꽤 적극적으로 생각해도 좋습니다.

  • vendor SDK를 안은 worker
  • 이미지 처리 / 동영상 처리 / device I/O
  • 감시나 폴링의 부모 루프
  • 스크립트나 플러그인 실행
  • COM / ActiveX 기존 자산의 호스트
  • 64bit / 32bit 브리지나 상호 운용

위험한 처리를 1개의 worker에 가두면 로그 설계도 복구 설계도 편해집니다.

10. 자주 있는 NG

10.1 catch (Exception)으로 로그만 내고 지속한다

가장 흔하고 가장 위험합니다.

  • 도중 변경이 남는다
  • 공유 상태가 망가진다
  • 후속 장해가 늘어난다
  • 진정한 원인 지점이 모호해진다

로그가 1개 늘어나는 대신에 사고가 길어지는 경우가 많습니다.

10.2 async logger의 큐만을 믿는다

비동기 로그 자체는 나쁘지 않습니다. 문제는 fatal path에서도 같은 큐에 쌓아 끝내는 것입니다.

떨어진 순간에 워커가 멈추면 그 큐째로 사라집니다.

fatal path만은 직접 쓴다 도망 길을 가지는 편이 안전합니다.

10.3 크래시 핸들러에서 HTTP 송신한다

구현하고 싶어지지만 꽤 위험합니다.

  • DNS
  • TLS
  • proxy
  • 인증
  • 타임아웃
  • 재송 대기

전부가 떨어진 문맥에 탑니다.

보내는 것은 재기동 후에 합니다.

10.4 dump는 있지만 통상 로그와 결합되지 않는다

이것은 많습니다.

  • 덤프 파일 이름에 session이 없다
  • 로그 측에 PID / session이 없다
  • watchdog 측에 PID가 없다
  • build 번호가 일치하지 않는다

결과적으로 3개의 증적이 별개의 이야기로 보이게 됩니다.

10.5 WinForms / WPF의 미처리 예외 이벤트로 연명한다

외견상 「떨어지지 않게」 되므로 처음에는 기뻐됩니다. 하지만 실제로는

  • 화면만 남는다
  • 워커는 죽어 있다
  • 버튼 활성만 남는다
  • 저장할 수 있었는지 모른다

는 좀비 상태를 만들기 쉽습니다.

10.6 native 측의 종료 경로를 보고 있지 않다

SetUnhandledExceptionFilter만으로 안심하면,

  • invalid parameter
  • purecall
  • terminate
  • fast fail

측을 놓칩니다.

native C++에서는 SEH뿐만 아니라 CRT / C++ 런타임 측의 종료 경로도 의식하는 편이 좋습니다.

11. 최저한의 도입 체크리스트

다음을 충족하면 꽤 실전적이 됩니다.

  • 통상 로그가 1행 1이벤트로 남는다
  • 모든 로그에 UTC, PID, TID, version, session이 있다
  • ProcessStartProcessExit가 남는다
  • 중요 경계 이벤트는 동기적으로 flush된다
  • 최종 크래시 마커 전용 파일이 있다
  • fatal path에서는 async logger를 경유하지 않는다
  • WER LocalDumps가 앱 단위로 설정되어 있다
  • dump 저장처의 ACL을 검증 완료
  • PDB와 배포 바이너리를 보관하고 있다
  • 다음 회 기동 시에 전회 이상 종료를 검지할 수 있다
  • 압축 / 업로드 / 알림은 재기동 후 또는 별도 프로세스에서 한다
  • native C++에서는 invalid parameter / purecall / terminate도 정리했다
  • 검증기에서 의도적으로 떨어뜨려 정말로 남는지를 확인했다

마지막 1행이 특히 중요합니다. 설계만으로는 의미가 없고, 반드시 「취하기까지 하는 시험」을 할 필요가 있습니다.

12. 어디까지 시험할까

추천 확인 항목은 다음입니다.

시험 무엇을 확인하는가
managed의 미처리 예외 통상 로그, fatal 마커, dump가 전부 갖춰지는가
UI 스레드 예외 WinForms / WPF의 이벤트 경로가 상정대로인가
worker 스레드 예외 AppDomain.UnhandledException까지 오는가, watchdog이 검지할 수 있는가
native 예외 WER dump가 정말로 취해지는가
invalid parameter / terminate CRT / C++ 런타임 경로에서도 최소 기록이 남는가
강제 kill in-process에서는 무리여도 watchdog 측이 unexpected exit를 기록할 수 있는가
재기동 다음 회 기동 후의 알림, 회수, 업로드가 동작하는가

「예외가 날면 로그가 나올 터」가 아니라 「이 조건에서 이 파일이 남는다」고 확인하는 것이 중요합니다.

13. 정리

Windows 앱이 프로그램 실수에 의한 예외로 떨어져도 조사에 필요한 정보를 남기고 싶다면, 사고방식의 축은 꽤 단순합니다.

  • 떨어지는 측의 프로세스만에 기대지 않는다
  • 통상 로그, 최종 크래시 마커, OS / 별도 프로세스 측의 증적으로 나눈다
  • 크래시 시는 로컬에 짧게 남기기만 한다
  • 무거운 처리는 재기동 후나 별도 프로세스로 돌린다
  • WER LocalDumps를 토대로 한다
  • 지속보다 기록해서 종료를 기본으로 한다

요컨대, 「마지막 1행을 분발한다」보다 「마지막 1행이 없어도 쫓을 수 있는 구성을 만든다」 쪽이 강합니다.

그래도 마지막 1행은 원하므로, 최종 크래시 마커는 별도 파일에 짧게 남긴다. 그리고 진짜 주 증적은 WER의 dump와 직전까지의 통상 로그에 가지게 한다. 이것이 Windows 앱의 실무에서는 꽤 안정된 방식입니다.

관련 기사

참고 자료

  • Microsoft Learn: Collecting User-Mode Dumps https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
  • Microsoft Learn: Using WER https://learn.microsoft.com/en-us/windows/win32/wer/using-wer
  • Microsoft Learn: MiniDumpWriteDump function https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
  • Microsoft Learn: SetUnhandledExceptionFilter function https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setunhandledexceptionfilter
  • Microsoft Learn: System.AppDomain.UnhandledException event https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception
  • Microsoft Learn: Application.ThreadException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.threadexception
  • Microsoft Learn: Application.DispatcherUnhandledException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception
  • Microsoft Learn: TaskScheduler.UnobservedTaskException Event https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception
  • Microsoft Learn: Environment.FailFast https://learn.microsoft.com/en-us/dotnet/api/system.environment.failfast
  • Microsoft Learn: Registering for Application Recovery https://learn.microsoft.com/en-us/windows/win32/recovery/registering-for-application-recovery
  • Microsoft Learn: RegisterApplicationRecoveryCallback https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-registerapplicationrecoverycallback
  • Microsoft Learn: WerRegisterFile https://learn.microsoft.com/en-us/windows/win32/api/werapi/nf-werapi-werregisterfile
  • Microsoft Learn: _set_invalid_parameter_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-invalid-parameter-handler-set-thread-local-invalid-parameter-handler
  • Microsoft Learn: _set_purecall_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-purecall-handler-set-purecall-handler
  • Microsoft Learn: set_terminate https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-terminate-crt
  • Microsoft Learn: __fastfail https://learn.microsoft.com/en-us/cpp/intrinsics/fastfail

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기