수정 이력(7건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635192)
- 3층 증거의 역할 분담, 크래시 핸들러의 4단계, WER LocalDumps와 PDB 보관, watchdog에 의한 감지 등을 그림으로도 따라갈 수 있도록 Mermaid 그림 22개를 추가했습니다(본문 500~750자당 그림 1개의 규약에 맞춘 것입니다). 기존 전체 관계도에도 캡션을 달았습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하면 됩니다.
- 3층 구조의 전체 관계도를 추가했습니다. C# 최소 구현(다중 진입 방지, `Flush(true)`, `FailFast`를 포함하고 호출 쪽도 함께 실었습니다)을 새로 넣었고, `UnhandledException` 안에서 `FailFast`를 호출하면 덤프의 원인이 바뀌는 점을 주석으로 적었습니다. 용어표, 덤프를 받은 뒤 WinDbg에서 처음 하는 절차, JSON Lines 실제 데이터 예를 추가했습니다.
- 본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635191)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「Windows 앱 크래시 시 로그와 덤프를 남기는 설계」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635191 https://comcomponent.com/ko/blog/windows-app-crash-logging-best-practices/
- DOI(최신 버전)
- 10.5281/zenodo.21635191
- DOI(이 버전)
- 10.5281/zenodo.22217467
Windows 앱 장애 조사에서 가장 힘든 상태는 죽었다는 사실만 알고, 왜 죽었는지는 남아 있지 않은 경우입니다.
특히 다음 같은 사례에서는 이 문제가 꽤 커집니다.
- 고객 환경에서만 죽는다
- 장시간 운전 끝에만 죽는다
- WPF / WinForms / Windows 서비스 / 상주 앱에서 재현률이 낮다
- COM, P/Invoke, native DLL, vendor SDK가 얽힌다
- 「예외 메시지만」은 잡히지만 직전 맥락이 없다
다만 처음에 솔직히 말하면, 죽는 쪽 프로세스만으로 「반드시」 로그를 남길 수는 없습니다. 스택 손상, 메모리 파괴, fast fail, 강제 종료, 전원 차단까지 포함하면 in-process의 마지막 로그는 본질적으로 best effort입니다.
flowchart TB
accTitle: in-process의 마지막 로그는 best effort
accDescr: 스택 손상, 메모리 파괴, fast fail, 강제 종료, 전원 차단까지 포함하면, 죽는 쪽 프로세스만으로 로그를 반드시 남길 수는 없고, in-process의 마지막 로그는 본질적으로 best effort임을 나타내는 그림입니다.
a1["스택 손상·메모리 파괴"] --> a4["마지막 in-process 로그"]
a2["fast fail·강제 종료"] --> a4
a3["전원 차단"] --> a4
a4 --> a5["본질적으로 best effort"]
a5 -.-> a6["「반드시 남긴다」는 불가능"]
그림1: 죽는 쪽 프로세스만으로는 마지막 로그를 「반드시」 남길 수 없습니다.
실무에서 목표로 해야 할 것은 죽는 프로세스 안에만 기대하지 않는 구성입니다. 즉,
- 평상시의 시계열 로그
- 죽는 순간의 최종 크래시 마커
- OS 또는 별도 프로세스 쪽에서 남기는 크래시 증거
의 3층으로 생각합니다.
이 글에서는 Windows 데스크톱 앱, 상주 앱, Windows 서비스, 장치 연계 도구를 전제로, 프로그램 실수에 의한 예외로 죽어도 조사 가능성을 잃지 않기 위한 베스트 프랙티스를 정리합니다.
1. 먼저 결론
먼저 결론만 나열합니다.
- 「마지막 로그」를 in-process 핸들러 하나에 걸지 않는 것이 가장 중요합니다.
- 실무에서 가장 무난한 것은 통상 로그 + 최종 크래시 마커 + WER LocalDumps의 조합입니다.
- 장시간 운전, 장치 연계, 플러그인, native SDK 혼재라면 감시 프로세스(watchdog / launcher / service)를 더하면 꽤 강해집니다.
- 크래시 핸들러에서는 무거운 처리를 하지 않는 것이 철칙입니다. 압축, HTTP 전송, DI에서 가져오기, UI 대화상자, 복잡한 JSON 생성은 뺍니다.
- 크래시 때는 로컬에 짧게 남기기만 하고, 압축·업로드·알림은 다음 기동 후나 별도 프로세스로 넘깁니다.
- WinForms의
ThreadException이나 WPF의DispatcherUnhandledException을 써서 겉보기로 계속 살리는 설계는, 프로그램 실수를 상대로는 위험합니다. - .NET에서도 native에서도 손상 상태를 의심하는 예외는 「복구」보다 「기록하고 종료」를 기본으로 하는 편이 안전합니다.
- 덤프를 남긴다면 PDB와 배포 바이너리 보관을 같이 하지 않으면 나중에 읽을 수 없습니다.
요컨대 베스트 프랙티스는 「죽는 순간에 전부 하려고 하지 않는다. 죽기 전·죽는 순간·죽은 후로 역할을 나눈다」 입니다.
flowchart TB
accTitle: 죽기 전·순간·후의 역할 분담
accDescr: 죽는 순간에 전부 하려고 하지 않고, 죽기 전의 통상 로그, 죽는 순간의 짧은 로컬 기록, 죽은 후의 압축·업로드·알림으로 역할을 나눈다는 베스트 프랙티스를 나타내는 그림입니다.
b1["죽기 전: 통상 로그를 남긴다"] --> b2["죽는 순간: 로컬에 짧게 남기기만 한다"]
b2 --> b3["죽은 후: 압축·업로드·알림"]
b3 -.-> b4["다음 기동 후나 별도 프로세스에서 한다"]
그림2: 죽는 순간에 전부 하지 않고, 전·순간·후 세 시점으로 역할을 나눕니다.
1.1 이 글에서 쓰는 용어
이후 설명 없이 쓰는 말을 먼저 정리합니다.
| 용어 | 전개·읽기 | 의미 |
|---|---|---|
| WER | Windows Error Reporting, Windows 오류 보고 | 앱의 비정상 종료를 OS 쪽에서 잡아 기록하는 Windows 메커니즘입니다. 로컬에 덤프를 남기는 설정이 LocalDumps입니다 |
| 덤프 / minidump | crash dump | 죽은 순간의 프로세스 메모리 내용을 저장한 파일입니다. 스레드, 스택, 모듈을 나중에 볼 수 있습니다 |
| PDB | Program Database | 빌드 때 생성되는 심볼 파일입니다. 없으면 덤프를 열어도 함수 이름이나 행 번호가 나오지 않습니다 |
| in-process | 프로세스 안 | 죽고 있는 그 프로세스 자신 안에서 처리하는 것입니다. 반대말은 별도 프로세스입니다 |
| best effort | 최선의 노력 | 「잘되면 남고, 보증하지는 않는다」는 성질입니다. 죽는 순간의 in-process 로그가 여기에 해당합니다 |
fast fail / __fastfail |
고속 장애 종료 | 상태가 깨졌다고 판단했을 때 뒷정리를 하지 않고 최소한의 절차로 즉시 종료하는 메커니즘입니다. native에서는 __fastfail, .NET에서는 Environment.FailFast가 해당합니다 |
| watchdog | 감시 프로세스 | 본체 프로세스의 기동·종료·생존을 밖에서 감시하는 별도 프로세스입니다. launcher나 부모 서비스로 만들기도 합니다 |
| heartbeat | 생존 신호 | 「아직 살아 있다」를 watchdog에 주기적으로 알리는 신호입니다 |
| UNC 경로 | Universal Naming Convention | \\server\share\... 형식의 네트워크 공유 경로입니다. 크래시 때 쓰면 순간 끊김이나 자격 증명 때문에 기다리게 됩니다 |
| ACL | Access Control List, 액세스 제어 목록 | 그 폴더에 누가 읽고 쓸 수 있는지의 설정입니다. 덤프나 로그가 「허탕」이 되는 원인의 단골입니다 |
| SEH | Structured Exception Handling, 구조화 예외 처리 | Windows의 네이티브 예외 메커니즘입니다. SetUnhandledExceptionFilter는 여기에 올라갑니다 |
| CRT | C Runtime, C 런타임 | C / C++ 표준 라이브러리 구현입니다. SEH와 별도로 고유한 종료 경로를 가집니다 |
| session | 세션 ID | 「어느 기동 인스턴스 이야기인지」를 식별하는 값입니다. 로그, 덤프, watchdog 기록을 대조하는 열쇠가 됩니다 |
이 글의 지식 맵
Windows 앱이 프로그램 실수로 인한 예외로 떨어져도 원인을 추적할 수 있게 하려면, 떨어지는 프로세스 자신의 로그에만 의존하지 않고 통상 시계열 로그·떨어지는 순간의 최종 크래시 마커·WER LocalDumps에 의한 덤프라는 3층으로 증적을 분담하는 설계가 효과적입니다. AppDomain.UnhandledException이나 WinForms의 ThreadException, WPF의 DispatcherUnhandledException을 겉보기의 연명에 사용하면 위험하며, 기록의 입구로 사용한 뒤 Environment.FailFast 같은 즉시 종료계 API로 종료시키는 편이 안전하지만, UnhandledException 안에서 FailFast를 호출하면 덤프의 원인이 바뀌어 버립니다. native C++에서는 SEH에 더해 CRT의 종료 경로도 잡아야 하며, 24시간 가동이나 장치 제어가 얽힌 경우는 감시 프로세스를 추가하면 exit code나 재기동 횟수까지 외부에서 검지할 수 있게 됩니다.
flowchart LR
accTitle: Windows 앱 크래시 시 로그·덤프 설계 지식 맵
accDescr: 떨어지는 프로세스 안에만 증적을 기대하지 않는 설계가, 통상 로그·최종 크래시 마커·WER LocalDumps라는 3층의 역할 분담과, AppDomain.UnhandledException이나 FailFast 등 각종 예외 핸들러의 구분 사용, 감시 프로세스에 의한 외부에서의 검지로 성립함을 보여주는 그림
crash_time_logging_design["크래시 시점 로그·증적 설계"]
wer_localdumps["WER LocalDumps"]
application_log["일반 로그(시계열 로그)"]
fatal_crash_marker["최종 크래시 마커"]
seh["SEH(구조화된 예외 처리)"]
crt_termination_handler["CRT/C++ 런타임 종료 경로"]
watchdog_process["감시 프로세스(watchdog)"]
high_reliability_operation_requirement["24/7 운전·장치 제어 등 강도 높은 요구 사항"]
post_restart_processing["다음 시작 후 후처리"]
session_id_correlation["session ID를 통한 기록 대조"]
dotnet_unhandledexception["AppDomain.UnhandledException"]
winforms_threadexception["Application.ThreadException(WinForms)"]
unexpected_exception_continuation["프로그램 결함에서 비롯된 예상치 못한 예외에서의 처리 지속"]
wpf_dispatcherunhandledexception["Application.DispatcherUnhandledException(WPF)"]
unobserved_task_exception["TaskScheduler.UnobservedTaskException"]
environment_failfast["Environment.FailFast"]
windows_application_event_log["Windows 애플리케이션 이벤트 로그"]
dump_folder_acl["덤프 저장 폴더의 ACL"]
crash_dump["크래시 덤프"]
pdb["PDB(프로그램 데이터베이스)"]
windbg["WinDbg"]
minidumpwritedump["MiniDumpWriteDump"]
wer_file_registration["WerRegisterFile을 통한 로그 첨부 등록"]
crash_time_logging_design -->|"전제로 한다"| application_log
crash_time_logging_design -->|"전제로 한다"| fatal_crash_marker
crash_time_logging_design -->|"전제로 한다"| wer_localdumps
crash_time_logging_design -.->|"전제로 한다"| seh
crash_time_logging_design -.->|"전제로 한다"| crt_termination_handler
watchdog_process -->|"권장되는 대응"| high_reliability_operation_requirement
fatal_crash_marker -->|"보다 먼저 해야 한다"| post_restart_processing
fatal_crash_marker -.->|"전제로 한다"| session_id_correlation
fatal_crash_marker -->|"권장되는 대응"| dotnet_unhandledexception
winforms_threadexception -->|"사용은 비권장"| unexpected_exception_continuation
wpf_dispatcherunhandledexception -->|"사용은 비권장"| unexpected_exception_continuation
unobserved_task_exception -->|"사용은 비권장"| fatal_crash_marker
environment_failfast -->|"이용한다"| windows_application_event_log
fatal_crash_marker -->|"권장되는 대응"| seh
fatal_crash_marker -->|"권장되는 대응"| crt_termination_handler
wer_localdumps -.->|"전제로 한다"| dump_folder_acl
crash_dump -.->|"전제로 한다"| pdb
crash_dump -->|"에서 확인할 수 있다"| windbg
minidumpwritedump -->|"구현을 담당한다"| crash_dump
watchdog_process -.->|"이용한다"| minidumpwritedump
wer_file_registration -->|"이용한다"| application_log
wer_localdumps -->|"보다 먼저 해야 한다"| wer_file_registration
wer_localdumps -->|"구현을 담당한다"| crash_dump
fatal_crash_marker -->|"권장되는 대응"| winforms_threadexception
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 in-process만으로는 「확실」히 할 수 없는가
여기를 애매하게 두면 설계가 흔들립니다.
2.1 죽은 스레드의 맥락 자체가 깨져 있는 경우가 있다
미처리 예외 훅이나 최상위 예외 필터는 깨진 쪽 스레드 맥락에서 동작하는 경우가 있습니다. 이 시점에서,
- 스택이 이미 위험하다
- 힙 파괴로 추가 확보가 위험하다
- 예외 발생 때 잡고 있던 락 때문에 대기하면 멈춘다
- logger 자체가 의존하는 객체가 이미 깨져 있다
는 일이 흔합니다.
그래서 마지막 핸들러는 「무엇이든 할 수 있는 장소」가 아니라 「할 수 있는 일이 꽤 적은 장소」로 보는 편이 안전합니다.
flowchart TB
accTitle: 마지막 핸들러에서 할 수 있는 일은 적다
accDescr: 미처리 예외 훅은 깨진 쪽 스레드 맥락에서 동작하는 경우가 있어, 스택이나 힙이 위험하고, 락 대기로 멈출 수 있으며, logger 의존도 깨져 있으므로 할 수 있는 일이 꽤 적은 장소로 봐야 함을 나타내는 그림입니다.
c1["깨진 스레드 맥락에서 동작한다"] --> c2["스택이나 힙이 위험하다"]
c1 --> c3["락 대기로 멈출 수 있다"]
c1 --> c4["logger의 의존도 깨질 수 있다"]
c2 --> c5["할 수 있는 일이 꽤 적은 장소"]
c3 --> c5
c4 --> c5
그림3: 마지막 핸들러는 「무엇이든 할 수 있는 장소」가 아니라 제약투성이인 장소가 됩니다.
2.2 fast fail이나 손상 상태 예외는 「최소한의 in-process 동작」이 전제다
메모리 파괴나 치명적인 상태에서는 통상 예외 처리에 기대하지 않는 편이 좋습니다.
특히 native 쪽 __fastfail 계열이나 손상 상태를 의심하는 이상은 「가능한 한 적은 오버헤드로 즉시 종료한다」 방향으로 설계되어 있습니다.
즉 마지막 in-process 로그는 쓸 수 있으면 다행이고, 주 증거는 OS / 별도 프로세스 쪽이라는 생각이 자연스럽습니다.
2.3 .NET의 미처리 예외 이벤트도 「무거운 복구 처리」의 장이 아니다
.NET의 AppDomain.UnhandledException은 편리하지만,
여기서 해도 되는 일은 짧은 기록까지로 생각하는 편이 좋습니다.
- 예외 발생 때 잡고 있던 락의 영향을 받을 수 있다
- 손상 상태 예외까지 무엇이든 안전하게 잡을 수 있는 것은 아니다
- 여기서 계속 방침을 억지로 만들면 반쯤 깨진 상태로 살아남기 쉽다
「미처리 예외 이벤트 = 마지막 알림」이지 「안전한 복구 지점」이 아니다고 보는 것이 현실적입니다.
flowchart TB
accTitle: 미처리 예외 이벤트는 마지막 알림
accDescr: AppDomain.UnhandledException에서 해도 되는 일은 짧은 기록까지이며, 손상 상태 예외까지 안전하게 잡을 수 있는 것은 아니고, 미처리 예외 이벤트는 마지막 알림이지 안전한 복구 지점이 아님을 나타내는 그림입니다.
d1["미처리 예외 이벤트"] --> d2["해도 되는 일은 짧은 기록까지"]
d2 --> d3["마지막 알림으로 쓴다"]
d1 -.-> d4["안전한 복구 지점이 아니다"]
d4 -.-> d5["계속 방침을 억지로 만들면 반쯤 깨진 채로 살아남는다"]
그림4: 미처리 예외 이벤트는 기록의 입구이지, 복구의 장소가 아닙니다.
3. 권장 아키텍처 - crash-time과 after-restart를 나눈다
가장 정리하기 쉬운 방법은 크래시 때 할 일과 재시작 후에 할 일을 나누는 것입니다.
먼저 3층이 각각 어느 프로세스의 책임이고, 어디에 남는가를 한 장으로 둡니다.
flowchart TD
subgraph APP["앱 프로세스"]
L1["통상 로그<br/>append-only 시계열"]
L2["최종 크래시 마커<br/>한 줄만 쓰고 종료"]
end
subgraph WIN["Windows 쪽"]
WER["WER LocalDumps<br/>프로세스 밖에서 덤프를 저장"]
end
subgraph WD["watchdog 프로세스"]
EX["exit code와 종료 시각을 기록<br/>재시작 판단"]
end
subgraph NEXT["다음에 기동한 정상 프로세스"]
POST["압축 / 업로드 / 알림<br/>이전 비정상 종료 감지"]
end
DISK[("로컬의 고정 폴더")]
L1 --> DISK
L2 --> DISK
WER --> DISK
EX --> DISK
DISK --> POST
L1 -. 예외 발생 .-> L2
L2 -. 프로세스 종료 .-> WER
L2 -. 프로세스 종료 .-> EX
그림5: 3층 증거의 전체 모습입니다. 죽는 프로세스가 직접 쓰는 것은 두 가지뿐이고, 주 증거는 밖에 있습니다.
포인트는 세 가지입니다.
- 죽는 프로세스가 직접 쓰는 것은 「앱 프로세스」 틀 안에 있는 두 가지뿐입니다. 게다가 최종 크래시 마커는 「한 줄 쓰고 끝」까지입니다.
- 주 증거는 프로세스 밖에 있습니다. WER 덤프와 watchdog 기록은 앱이 깨져 있어도 남습니다.
- 대조의 열쇠는 공통 session ID와 PID입니다. 여기가 맞지 않으면 세 증거가 별개의 사건처럼 보입니다.
| 단계 | 목적 | 어디서 돌리는가 | 할 일 |
|---|---|---|---|
| 평상시 | 시계열을 남긴다 | 앱 안 | 구조화 로그, heartbeat, 경계 이벤트 |
| 크래시 때 | 최소한의 증거를 남긴다 | 앱 안 + OS | 최종 크래시 마커, WER 덤프 |
| 종료 직후 | unexpected exit를 감지한다 | 별도 프로세스 | exit code 기록, 재시작 판단, 알림 |
| 다음 기동 후 | 무거운 후처리를 한다 | 새로운 정상 프로세스 | 압축, 업로드, 사용자 알림, 오래된 로그 정리 |
이렇게 나누면 설계가 꽤 안정됩니다.
3.1 최소 구성
작은 업무 도구나 사내용 WPF / WinForms라면 우선 이 정도로 충분한 경우가 많습니다.
- 통상 로그: 로컬의 append-only 파일
- 최종 크래시 마커: 전용의 짧은 파일
- 덤프: WER LocalDumps
- 다음 기동 시: 「이전에 비정상 종료했습니다. 진단 정보가 있습니다」를 보여 준다
3.2 강한 구성
한 단계 강하게 하는 편이 좋은 것은 이런 요건일 때입니다.
- 24/7 운전
- 장치 제어, 감시, 상주
- COM / P/Invoke / native SDK가 많다
- 자식 프로세스, 플러그인, 스크립트 실행이 있다
- 고객 환경에서 「멈춘 채로 방치」가 허용되지 않는다
이 경우에는
- worker 프로세스: 본체 처리
- launcher / watchdog / service: 기동 감시, exit 기록, 재시작
- WER LocalDumps: worker 쪽
- 다음 기동 또는 watchdog: 진단 정보 회수
로 나누면 꽤 실무에 맞습니다.
flowchart TB
accTitle: 강한 구성의 분담
accDescr: 24시간 운전이나 장치 제어에서는 본체 처리를 맡는 worker 프로세스와 기동 감시·exit 기록·재시작을 맡는 launcher나 watchdog으로 나누고, WER LocalDumps를 worker 쪽에 설정하며, 다음 기동 또는 watchdog이 진단 정보를 회수하는 구성을 나타내는 그림입니다.
e0["강한 요건(24/7·장치 제어 등)"] --> e1["worker: 본체 처리"]
e0 --> e2["watchdog: 기동 감시·exit 기록·재시작"]
e1 -.-> e3["WER LocalDumps는 worker 쪽에 설정"]
e2 -.-> e4["다음 기동이나 watchdog이 진단 정보를 회수"]
그림6: 강한 구성에서는 본체와 감시를 별도 프로세스로 나누고 역할을 고정합니다.
4. 통상 로그의 베스트 프랙티스
크래시 때의 마지막 한 줄만으로 싸우려 하면 대체로 집니다. 진짜로 효력이 있는 것은 직전까지의 통상 로그입니다.
4.1 로그는 「사람용 문장」보다 「나중에 상관할 수 있는 정보」
통상 로그에 최소한 넣고 싶은 항목을 듭니다.
- UTC 타임스탬프
- 프로세스 시작부터의 경과 시간
- PID / TID
- 앱 이름, 버전, 빌드 번호, 커밋 식별자
- 세션 ID
- 조작 ID / 잡 ID / 상관 ID
- 모듈 이름 / 화면 이름 / 워커 이름
- 직전의 외부 작용
- 파일 쓰기
- DB 갱신
- 장치 명령 송신
- 통신 요청
- 예외 형식, HRESULT / Win32 오류 / 예외 코드
- 주요 입력 파라미터의 요약
- 기밀을 포함하지 않는 범위의 대상 ID
추천은 한 줄 한 이벤트의 JSON Lines 또는 key=value 형식입니다.
JSON Lines라면 한 이벤트가 이 정도 단위가 됩니다.
{"ts":"2026-03-18T01:15:33.412Z","up_ms":184213,"pid":1234,"tid":9,"app":"MyApp","ver":"3.2.1.884","commit":"9f1c2ab","session":"4f1c","level":"Warning","module":"DeviceWorker","op":"JOB-20260318-0042","event":"ExternalCommandSent","target":"COM3","cmd":"SEQ_START","timeout_ms":3000}
길지만, 이것으로 한 줄만 보면 「언제·어느 기동 인스턴스의·어느 버전이·어느 조작 도중에·무엇을 했는지」가 보입니다. 덤프 쪽과는 pid와 session, 빌드 쪽과는 ver와 commit으로 이어집니다.
key=value 형식이라면 같은 내용이 이렇게 됩니다.
ts=2026-03-18T01:15:33.412Z up_ms=184213 pid=1234 tid=9 app=MyApp ver=3.2.1.884 commit=9f1c2ab session=4f1c level=Warning module=DeviceWorker op=JOB-20260318-0042 event=ExternalCommandSent target=COM3 cmd=SEQ_START timeout_ms=3000
사람용 장문을 남기는 것보다 「나중에 파일 세 개를 대조할 수 있는 것」이 더 중요합니다.
flowchart TB
accTitle: 한 줄에서 세 증거가 이어진다
accDescr: 통상 로그의 한 줄은 pid와 session으로 덤프 쪽과, ver와 commit으로 빌드 쪽과 이어지며, 나중에 파일 세 개를 대조할 수 있는 것이 사람용 장문보다 중요함을 나타내는 그림입니다.
f1["통상 로그의 한 줄"] --> f2["pid / session으로 덤프 쪽으로"]
f1 --> f3["ver / commit으로 빌드 쪽으로"]
f2 --> f4["파일 세 개를 대조할 수 있다"]
f3 --> f4
그림7: 로그의 한 줄은 pid·session·ver·commit으로 다른 증거와 이어집니다.
4.2 크리티컬 이벤트는 동기적으로 남긴다
통상 로그를 전부 동기 쓰기로 하면 무거워집니다. 다만 전부를 비동기 버퍼에만 맡기면 죽는 순간에 한꺼번에 사라집니다.
그래서 실무에서는 레벨에 따라 취급을 바꾸는 것이 현실적입니다.
Information의 세세한 이벤트: 버퍼해도 된다Warning이상: 빨리 flush- 중요한 경계 이벤트: 동기적으로 남긴다
- ProcessStart
- ConfigLoaded
- WorkerStarted
- ExternalCommandSent
- TransactionCommitted
- RecoveryStarted
- FatalPathEntered
요점은 업무상의 경계만은 제대로 디스크에 쓴다는 것입니다.
flowchart TB
accTitle: 레벨에 따른 쓰기 구분
accDescr: Information의 세세한 이벤트는 버퍼해도 되고, Warning 이상은 빨리 flush하며, 업무상 경계 이벤트는 동기적으로 남긴다는, 레벨에 따른 취급 구분을 나타내는 그림입니다.
g0["로그 쓰기"] --> g1["Information: 버퍼 가능"]
g0 --> g2["Warning 이상: 빨리 flush"]
g0 --> g3["경계 이벤트: 동기로 남긴다"]
g3 -.-> g4["업무상의 경계만은 디스크에 쓴다"]
그림8: 전부 동기도, 전부 버퍼도 아니고, 레벨로 쓰기를 구분합니다.
4.3 「지금 쓰고 있는 통상 로그」와 「마지막 크래시 마커」는 나눈다
이것은 꽤 중요합니다.
rolling log 하나에 전부 넣으려 하면,
- 로테이션 중이었던
- 비동기 큐에 남아 있던
- 예외 발생 직후 logger 자체가 죽은
- 로그 줄 중간에서 잘린
일이 일어납니다.
그래서 최소한 두 개로 나눠 두는 것을 권합니다.
app-<session>.jsonl평상시 시계열 로그fatal-last.log또는fatal-<session>.log최종 크래시 마커 전용
「마지막 한 줄을 어디에 남기는가」가 명확하기만 해도 현장에서 꽤 도움이 됩니다.
flowchart TB
accTitle: 통상 로그와 fatal 마커를 나눈다
accDescr: rolling log 하나에 전부 넣으면 로테이션 중이나 비동기 큐 잔여, logger 자체의 사망으로 마지막 줄이 사라지므로, 평상시 시계열 로그와 최종 크래시 마커 전용 파일 두 개로 나눔을 나타내는 그림입니다.
h1["rolling log 하나에 전부 넣는다"] --> h2["로테이션 중·큐 잔여·logger 사망으로 사라진다"]
h2 -.->|"대신에"| h3["통상 로그와 fatal 마커 두 개로 나눈다"]
h3 --> h4["마지막 한 줄의 위치가 명확해진다"]
그림9: 마지막 한 줄의 위치는 통상 로그와 다른 파일에 고정합니다.
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를 만드는 장소가 아닙니다. 한 번만, 짧게, 확실한 쪽에 가깝게 남기는 장소입니다.
5.1 목적은 「원인의 상세」가 아니라 「입구의 고정」
최종 크래시 마커에 넣을 정보는 줄일수록 강해집니다.
- 발생 UTC
- PID / TID
- 세션 ID
- 버전 / 빌드 번호
- 어느 훅에서 왔는지
AppDomain.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledExceptionSetUnhandledExceptionFilter_set_invalid_parameter_handlerset_terminate
- 예외 형식 또는 예외 코드
- 가능하면 간단한 메시지
- 직전의 조작 ID
- 통상 로그의 파일 이름
- dump 예정 폴더
이것만으로 충분합니다.
flowchart TB
accTitle: 마커의 목적은 입구의 고정
accDescr: 최종 크래시 마커는 원인의 상세가 아니라, 어느 훅에서 어느 예외로 죽었는지와 통상 로그 파일 이름이나 dump 예정 폴더라는 조사 입구를 고정하기 위해 정보를 줄여 남김을 나타내는 그림입니다.
i1["최종 크래시 마커"] --> i2["어느 훅·예외 형식·session"]
i1 --> i3["통상 로그 이름과 dump 폴더"]
i2 --> i4["조사 입구가 고정된다"]
i3 --> i4
i4 -.-> i5["원인의 상세는 덤프와 통상 로그에 맡긴다"]
그림10: 마커의 역할은 원인의 상세가 아니라, 조사 입구를 고정하는 것입니다.
5.2 크래시 핸들러에서 해서는 안 되는 일
어느 것이든 꽤 높은 확률로 함정이 됩니다.
- DI 컨테이너에서 logger를 가져온다
- async / await를 쓴다
- Task를 던진다
- 락을 기다린다
- 복잡한 JSON을 조립한다
- COM 객체를 건드린다
- UI 대화상자를 띄운다
- 압축한다
- HTTP / SMTP / Slack / Teams 전송
- dump를 분석해 요약한다
- 예외를 삼키고 계속한다
크래시 핸들러는 보통 처리 흐름의 연장이 아닙니다. 「최소한의 로컬 쓰기만 하고 끝낸다」로 모읍니다.
5.3 크래시 핸들러에서 할 일
반대로 할 일은 꽤 단순합니다.
- 다중 진입을 막는다
- 한 줄만 쓴다
- flush한다
- 종료한다
이 순서입니다.
가능하면
- 미리 만들어 둔 전용 폴더
- 미리 존재 확인을 끝낸 경로
- ACL을 확인한 저장 위치
를 씁니다.
통상 로그에서는 flush를 너무 하면 무겁지만, fatal 마커는 건수가 매우 적으므로 여기만은 세게 flush해도 됩니다.
.NET이라면 FileStream.Flush(true), native라면 FlushFileBuffers처럼 「이 한 줄만은 지금 바로 디스크에 쓴다」 취급으로 모으면 설계하기 쉬워집니다.
flowchart TB
accTitle: 크래시 핸들러의 4단계
accDescr: 크래시 핸들러에서 할 일은 다중 진입을 막고, 한 줄만 쓰고, flush하고, 종료하는 네 가지뿐이며, fatal 마커는 건수가 매우 적으므로 강한 flush로 디스크까지 써도 됨을 나타내는 그림입니다.
j1["1. 다중 진입을 막는다"] --> j2["2. 한 줄만 쓴다"]
j2 --> j3["3. flush한다"]
j3 --> j4["4. 종료한다"]
j3 -.-> j5["이 한 줄만은 지금 바로 디스크에 쓴다"]
그림11: 핸들러에서 할 일은 4단계뿐이고, 이 순서를 깨지 않습니다.
5.4 계속시키려 하지 않는다
프로그램 실수에서 비롯된 unexpected 예외라면, 최종 핸들러는 복구 장치가 아니라 기록 장치로 생각하는 편이 안전합니다.
특히 「계속하지 않는다」를 기본으로 하고 싶은 것은 이 부근입니다.
NullReferenceException이나InvalidOperationException이라도 공유 상태 갱신 도중이었다- UI 스레드의 unexpected 예외
- 감시 루프나 부모 루프에서 새어 나온 unexpected 예외
AccessViolationExceptionStackOverflowException- native 경계의 이상
- CRT의 invalid parameter / purecall / terminate
「죽이고 싶지 않다」는 마음은 이해하지만, 반쯤 깨진 채로 살아남는 편이 진단도 운영도 더 힘든 경우가 많습니다.
종료시킬 때는 .NET이라면 Environment.FailFast, native라면 RaiseFailFastException이나 __fastfail 같은 즉시 종료 계열 API를 검토하고, finally나 통상 뒷정리에 기대하지 않는 설계가 더 안전합니다.
flowchart TB
accTitle: 복구 장치가 아니라 기록 장치
accDescr: 프로그램 실수에서 비롯된 예상 밖 예외에서는 최종 핸들러를 복구 장치가 아니라 기록 장치로 보고, 반쯤 깨진 채로 살아남기보다 기록한 뒤 즉시 종료 계열 API로 종료하는 편이 안전함을 나타내는 그림입니다.
k1["프로그램 실수에서 비롯된 예외"] --> k2["반쯤 깨진 채로 살아남는다"]
k1 --> k3["기록하고 종료한다"]
k2 -.-> k4["진단도 운영도 힘들다"]
k3 --> k5["FailFast 등 즉시 종료 계열 API를 검토"]
그림12: 최종 핸들러는 복구 장치가 아니라, 기록하고 종료하기 위한 장치가 됩니다.
5.5 C#에서의 최소 구현
여기까지의 방침을 그대로 .NET 6 이후 C#에 옮기면 이 정도 분량이 됩니다. DI도 logger도 쓰지 않습니다.
using System;
using System.IO;
using System.Text;
using System.Threading;
internal static class FatalMarker
{
// 미리 만들어 두고, 쓰기 테스트까지 끝낸 로컬 고정 경로로 한다
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "Logs");
// 통상 로그·덤프·watchdog 기록과 대조하기 위한 열쇠
private static readonly string SessionId = Guid.NewGuid().ToString("N").Substring(0, 8);
// 다중 진입을 막는 플래그. 0이면 아직 쓰지 않음
private static int _written;
/// <summary>앱 기동 직후에 한 번만 호출한다.</summary>
public static void Install()
{
// 크래시 때 CreateDirectory를 호출하고 싶지 않으므로 여기서 만들어 둔다
Directory.CreateDirectory(LogDirectory);
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
}
/// <summary>「더 이상 계속하면 안 된다」고 판단한 곳에서 호출한다.</summary>
public static void FailNow(string reason)
{
Write("FailNow", null, reason);
Environment.FailFast(reason);
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Write("AppDomain.UnhandledException", e.ExceptionObject as Exception, null);
// 여기서는 종료시키지 않는다. 미처리 예외이므로, 이 뒤에 CLR이 기본 종료 처리로 진행한다.
// 여기서 FailFast를 호출하면 WER에 남는 덤프의 원인이
// 원래 예외가 아니라 FailFast로 바뀌어 버린다.
}
private static void Write(string hook, Exception ex, string note)
{
// 두 번째 이후는 아무것도 하지 않는다
if (Interlocked.Exchange(ref _written, 1) != 0)
{
return;
}
try
{
string fileName = string.Format(
"MyApp_fatal_{0:yyyyMMdd_HHmmss}_pid{1}_session-{2}.log",
DateTime.UtcNow, Environment.ProcessId, SessionId);
string path = Path.Combine(LogDirectory, fileName);
// 시리얼라이저를 호출하지 않고, 문자열 연결만으로 한 줄을 조립한다
string line = string.Join("\t",
"ts=" + DateTime.UtcNow.ToString("O"),
"pid=" + Environment.ProcessId,
"tid=" + Environment.CurrentManagedThreadId,
"session=" + SessionId,
"hook=" + hook,
"type=" + (ex != null ? ex.GetType().FullName : "none"),
"message=" + Flatten(ex != null ? ex.Message : note),
"log=" + LogDirectory);
using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
byte[] bytes = new UTF8Encoding(false).GetBytes(line + Environment.NewLine);
stream.Write(bytes, 0, bytes.Length);
// true를 넘기면 OS 캐시가 아니라 디스크까지 쓴다
stream.Flush(true);
}
}
catch
{
// 여기서 실패하면 더 할 수 있는 일은 없다. 삼키고 끝낸다
}
}
private static string Flatten(string s)
{
return string.IsNullOrEmpty(s) ? string.Empty : s.Replace("\r", " ").Replace("\n", " ");
}
}
호출 쪽은 기동 직후에 Install()을 호출하기만 하면 됩니다. 여기를 잊으면 위의 코드는 1바이트도 효력이 없습니다.
internal static class Program
{
private static void Main(string[] args)
{
FatalMarker.Install();
// 이후 통상 앱 기동 처리
RunApplication(args);
}
private static void RunApplication(string[] args)
{
// 예: 공유 상태가 깨졌다고 판단하면 계속하지 않고 종료한다
// FatalMarker.FailNow("device state inconsistent");
}
}
이 60행이 지키는 것은 5.3에서 든 네 가지뿐입니다.
Interlocked.Exchange로 다중 진입을 막는다- 문자열 연결만으로 한 줄을 쓴다
Flush(true)로 디스크까지 쓴다UnhandledException에서는 계속시키지 않는다
Environment.FailFast는 메시지를 Windows 애플리케이션 이벤트 로그에 쓴 뒤 프로세스를 즉시 종료시키고, 그 내용을 오류 보고에도 포함합니다. 즉 FailFast 자체도 증거를 하나 더 늘립니다. 다만 앞에서 말했듯 미처리 예외 경로에서 이것을 호출하면 덤프가 보이는 방식이 바뀌므로, 호출 위치는 가려서 정합니다.
flowchart TB
accTitle: FailFast를 호출하는 장소를 고른다
accDescr: Environment.FailFast는 애플리케이션 이벤트 로그에 쓴 뒤 즉시 종료해 증거를 하나 늘리지만, 미처리 예외 경로에서 호출하면 WER에 남는 덤프의 원인이 원래 예외에서 FailFast로 바뀌므로 호출 장소를 고름을 나타내는 그림입니다.
l1["Environment.FailFast"] --> l2["이벤트 로그에 쓰고 즉시 종료"]
l2 --> l3["증거가 하나 늘어난다"]
l1 -.-> l4["미처리 예외 경로에서 호출하면"]
l4 -.-> l5["덤프의 원인이 FailFast로 바뀐다"]
그림13: FailFast는 증거를 늘리지만, 미처리 예외 안에서 호출하면 원인이 바뀝니다.
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에서도 계속을 위해 겉으로만 살리는 장치로 쓰지 말고, 기록의 입구로 쓰는 편이 무난합니다.
flowchart TB
accTitle: UI 이벤트로 겉보기로 살리지 않는다
accDescr: WinForms의 ThreadException이나 WPF의 DispatcherUnhandledException은 겉보기로 계속할 수 있지만, 프로그램 실수를 상대로는 화면 상태와 내부 상태가 어긋나므로, 기록의 입구로 쓰고 종료해 덤프와 로그를 남기는 편이 안전함을 나타내는 그림입니다.
m1["UI 스레드의 미처리 예외"] --> m2["겉보기에는 계속할 수 있다"]
m2 -.-> m3["화면과 내부 상태가 어긋난다"]
m2 --> m4["기록의 입구로 쓴다"]
m4 --> m5["종료하고 덤프와 로그를 남긴다"]
그림14: WinForms / WPF의 UI 이벤트는 겉보기로 살리는 장치가 아니라 기록의 입구로 씁니다.
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_handlerset_terminate
이 계통은 C 런타임이나 C++ 런타임에서 비롯된 「종료 경로」를 잡기 위한 것입니다.
실무에서는
- 이들 핸들러에서도 최종 크래시 마커를 쓴다
- 다만 무거운 복구 처리는 하지 않는다
- 확실히 종료시킨다
- 주 증거는 WER / dump에 맡긴다
가 무난합니다.
flowchart TB
accTitle: SEH만으로는 빠진다
accDescr: native C++에서는 SetUnhandledExceptionFilter만 보고 있으면 CRT나 C++ 런타임에서 비롯된 종료 경로를 놓치므로, invalid parameter·purecall·terminate 핸들러에서도 최소 기록을 남기고 주 증거는 WER와 dump에 맡김을 나타내는 그림입니다.
n1["SetUnhandledExceptionFilter만"] --> n2["CRT의 종료 경로를 놓친다"]
n2 --> n3["invalid parameter / purecall / terminate도 잡는다"]
n3 --> n4["최소 기록만 쓰고 확실히 종료"]
n4 -.-> n5["주 증거는 WER / dump에 맡긴다"]
그림15: native C++는 SEH에 더해 CRT 쪽 종료 경로도 잡아야 비로소 빠지지 않습니다.
7. WER LocalDumps를 토대로 둔다
여기가 실무에서는 꽤 강합니다.
7.1 우선 추천은 WER LocalDumps
「죽은 뒤에 최소한의 증거를 확실한 쪽에 가깝게 남긴다」는 의미에서는 우선 WER LocalDumps가 가장 다루기 쉽습니다.
이유는 단순합니다.
- OS 쪽에서 덤프를 남길 수 있다
- 추가 도구 없이 넣기 쉽다
- 앱 단위로 설정할 수 있다
- 크래시 때의 주 증거를 in-process 밖으로 돌릴 수 있다
로그만으로는 알 수 없는
- 어느 스레드가 죽었는지
- 어느 스택에서 죽었는지
- 어느 모듈 경계였는지
- managed / native / COM / SDK 어디가 수상한지
를 나중에 볼 수 있는 점이 강합니다.
flowchart TB
accTitle: WER LocalDumps가 강한 이유
accDescr: WER LocalDumps는 OS 쪽에서 덤프를 남길 수 있고 앱 단위로 설정할 수 있으며, 주 증거를 in-process 밖으로 돌릴 수 있고, 어느 스레드가 어느 스택에서 죽었는지, 어느 모듈 경계였는지를 나중에 볼 수 있음을 나타내는 그림입니다.
p1["WER LocalDumps"] --> p2["OS 쪽에서 덤프를 남긴다"]
p1 --> p3["앱 단위로 설정할 수 있다"]
p2 --> p4["주 증거를 in-process 밖으로 돌릴 수 있다"]
p3 --> p4
p4 -.-> p5["스레드·스택·모듈 경계가 보인다"]
그림16: WER LocalDumps는 주 증거를 죽는 프로세스 밖으로 돌리는 토대가 됩니다.
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 또는 2 |
7.3 dump 저장 위치의 ACL은 반드시 확인한다
로그든 덤프든 같지만, 쓸 수 없는 폴더에 설정해도 의미가 없습니다.
특히
- Windows 서비스
- 권한을 분리한 자식 프로세스
- 현장 머신의 제한 계정
- UAC가 얽힌 경우
에서는 저장 위치 ACL이 허탕의 주원인이 됩니다.
저장 위치는
- 사전 생성
- 쓰기 테스트
- 보관 개수 제한
- 운영 담당이 찾아갈 수 있는 곳인가
까지 확인합니다.
flowchart TB
accTitle: 저장 위치 ACL의 허탕을 막는다
accDescr: Windows 서비스나 제한 계정에서는 쓸 수 없는 폴더에 덤프 저장 위치를 설정해 허탕이 되는 것이 전형적인 실패이므로, 저장 위치는 미리 만들고 쓰기 테스트와 보관 개수 제한, 운영 담당이 찾아갈 수 있는 곳인지도 확인함을 나타내는 그림입니다.
q1["서비스·제한 계정"] --> q2["쓸 수 없는 폴더로 설정해 버린다"]
q2 --> q3["덤프가 허탕이 된다"]
q3 -.->|"막으려면"| q4["사전 생성과 쓰기 테스트"]
q4 --> q5["보관 개수와 찾아갈 수 있는 곳인지도 확인"]
그림17: 덤프가 허탕이 되는 주원인은 저장 위치 ACL이며, 사전 쓰기 테스트로 막습니다.
7.4 WER 리포트에 현재 로그를 첨부하고 싶을 때
Microsoft로의 WER 리포트나 독자 WER 운영을 쓰는 경우에는, WerRegisterFile로 현재 로그 파일을 오류 리포트에 포함하기 위한 등록을 하는 방법도 있습니다.
다만 여기는 로컬 저장의 대체가 아니라 추가 경로로 생각하는 편이 안전합니다. 크래시 때 정말 필요한 것은 우선 해당 PC에 확실한 쪽에 가깝게 남는 것이기 때문입니다.
순서로는
- 로컬 통상 로그
- 로컬 fatal 마커
- 로컬 dump
- 필요하면 WER 송신 경로에서도 관련 파일을 등록
이 실무에 맞습니다.
7.5 덤프만이 아니라 버전 관리도 남긴다
덤프를 받아도 나중에
- 그때의 EXE / DLL이 없다
- PDB가 없다
- 어느 커밋의 빌드인지 모른다
가 되면 꽤 약해집니다.
최소한 이것만은 남겨 둡니다.
- 배포한 바이너리
- 대응하는 PDB
- 버전
- 빌드 일시
- 커밋 식별자
- 설치 프로그램 버전
덤프 수집과 PDB 보관은 한 세트입니다.
flowchart TB
accTitle: 덤프 수집과 PDB 보관은 한 세트
accDescr: 덤프를 받아도 그때의 EXE나 DLL, PDB, 어느 커밋의 빌드인지가 남아 있지 않으면 나중에 읽을 수 없게 되므로, 배포 바이너리·PDB·버전·커밋 식별자 보관을 덤프 수집과 세트로 함을 나타내는 그림입니다.
r1["덤프만 남긴다"] --> r2["PDB나 그때의 바이너리가 없다"]
r2 --> r3["나중에 읽을 수 없게 된다"]
r3 -.->|"그래서"| r4["배포 바이너리와 PDB를 보관한다"]
r4 --> r5["버전과 커밋 식별자도 남긴다"]
그림18: 덤프는 같은 빌드의 PDB와 바이너리가 남아 있어야 비로소 읽을 수 있습니다.
7.6 받은 덤프를 처음 열기까지
「덤프는 쌓여 있는데 아무도 열어 본 적이 없다」는 정말 흔합니다. 깊은 추적은 전문 글에 맡기고, 처음 10분만 여기에 둡니다.
준비는 두 가지입니다.
- 그 dump와 같은 빌드의 PDB와 배포 바이너리를 한 폴더로 모은다
- Windows SDK의 Debugging Tools for Windows에 들어 있는 WinDbg를 준비한다
그다음은 WinDbg에서 .dmp를 연 뒤 순서대로 칩니다.
.sympath srv*C:\symbols*https://msdl.microsoft.com/download/symbols;C:\myapp\pdb
.reload /f
.ecxr
!analyze -v
~*k
lm v
각각의 역할은 이렇습니다.
| 명령 | 무엇을 하는가 |
|---|---|
.sympath |
심볼 탐색 위치를 설정합니다. Microsoft 심볼 서버와 자신의 PDB 위치를 둘 다 지정합니다 |
.reload /f |
심볼을 강제로 다시 읽습니다 |
.ecxr |
예외 발생 때의 레지스터 컨텍스트로 이동합니다. 이것을 잊으면 죽은 곳이 아니라 WER 쪽 대기 스택을 보게 됩니다 |
!analyze -v |
현재 예외를 자동 분석해 자세히 표시합니다. 우선 여기를 읽습니다 |
~*k |
모든 스레드의 콜 스택을 냅니다. 죽은 스레드 이외가 무엇을 하고 있었는지가 보입니다 |
lm v |
로드된 모듈과 버전을 냅니다. 배포물과 빌드 번호 대조에 씁니다 |
여기까지 와서 「함수 이름이 안 나온다」「행 번호가 안 나온다」면 원인은 대체로 PDB가 다른 빌드의 것입니다. 7.5의 버전 관리로 돌아갑니다.
flowchart TB
accTitle: 덤프를 여는 처음 10분
accDescr: 같은 빌드의 PDB와 배포 바이너리를 모으고, WinDbg로 덤프를 연 뒤 예외 발생 컨텍스트로 이동해 분석을 읽고, 함수 이름이 안 나오면 PDB가 다른 빌드이므로 버전 관리로 돌아간다는 처음 흐름을 나타내는 그림입니다.
s1["같은 빌드의 PDB와 바이너리를 모은다"] --> s2["WinDbg로 덤프를 연다"]
s2 --> s3["예외 발생 컨텍스트로 옮겨 분석"]
s3 -.-> s4[".ecxr를 잊으면 WER 쪽 대기 스택을 본다"]
s3 --> s5{"함수 이름이나 행 번호가 나오는가"}
s5 -->|"안 나온다"| s6["PDB가 다른 빌드, 버전 관리로 돌아간다"]
그림19: 덤프 분석은 준비·열기·예외 컨텍스트 이동 순으로 시작합니다.
더 깊이 들어가는 수집과 분석 이야기는 끝의 관련 글에 정리되어 있습니다.
8. MiniDumpWriteDump나 독자 크래시 리포터를 쓸 때의 생각
독자 구현이 필요해지는 장면도 있습니다.
- UI에서 「진단 정보 저장」 버튼을 내고 싶다
- 로그나 설정 파일도 묶고 싶다
- 자식 프로세스 무리를 한데 다루고 싶다
- 자동 업로드 전에 독자 마스킹을 넣고 싶다
다만 여기서 가장 중요한 것은 dump를 받는 처리도 죽는 쪽에 너무 짊어지지 않는 것입니다.
8.1 self-dump보다 별도 프로세스
MiniDumpWriteDump는 강력하지만,
크래시한 그 프로세스 안에서 호출하는 것보다 별도 프로세스에서 호출하는 편이 안전합니다.
전형적으로는 이런 구성이 됩니다.
- worker 본체가 이상을 감지
- 가능하면 이벤트나 named pipe로 helper에 알림
- helper가 worker의 dump를 받는다
- helper가
tail로그나 설정 파일을 묶는다 - helper가 종료 후 업로드 큐에 둔다
이렇게 하면 worker가 깨져 있어도 helper 쪽은 아직 정상입니다.
sequenceDiagram
accTitle: 별도 프로세스 helper에 의한 덤프 취득
accDescr: worker 본체가 이상을 감지하면 이벤트나 named pipe로 helper에 알리고, 정상인 helper가 worker의 덤프를 받으며, 로그나 설정 파일을 묶어 종료 후 업로드 큐에 두는 흐름을 나타내는 그림입니다.
participant W as worker 본체
participant H as helper
W->>H: 이상을 이벤트나 파이프로 알림
H->>W: worker의 dump를 받는다
H->>H: tail 로그나 설정 파일을 묶는다
H->>H: 종료 후 업로드 큐에 둔다
Note over H: worker가 깨져 있어도 helper는 정상
그림20: dump 취득은 죽는 쪽이 아니라 정상인 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 당했는지
- 몇 번 재시작 루프했는지
가 꽤 보입니다.
flowchart TB
accTitle: watchdog 기록으로 구별할 수 있는 것
accDescr: 감시 프로세스가 exit code나 종료 시각, heartbeat 마지막 수신, restart 횟수를 남기면, 정말 크래시했는지, OS 종료인지, 사용자 종료인지, hang에서의 kill인지, 재시작 루프인지를 밖에서 구별할 수 있음을 나타내는 그림입니다.
t1["exit code와 종료 시각"] --> t4["밖에서 구별할 수 있다"]
t2["heartbeat의 마지막 수신"] --> t4
t3["restart 횟수"] --> t4
t4 -.-> t5["크래시인지 OS 종료인지 사용자 종료인지"]
그림21: watchdog 기록이 있으면 종료 종류를 밖에서 가려낼 수 있습니다.
9.2 특히 맞는 케이스
분리를 적극 검토해도 되는 것은 이런 케이스입니다.
- vendor SDK를 안은 worker
- 이미지 처리 / 동영상 처리 / device I/O
- 감시나 폴링의 부모 루프
- 스크립트나 플러그인 실행
- COM / ActiveX 기존 자산의 호스트
- 64bit / 32bit 브리지나 상호 운용
위험한 처리를 worker 하나에 가두면 로그 설계도 복구 설계도 편해집니다.
10. 흔한 NG
여기는 설계 수준의 함정을 모았습니다. 「크래시 핸들러 안에서 무엇을 하면 안 되는가」라는 절차 수준의 이야기는 5.2에 정리되어 있으므로, 구현 중인 분은 그쪽을 보면 됩니다. 겹쳐 보이는 항목(10.3의 HTTP 전송 등)은, 5.2가 「왜 핸들러 안에서 하면 안 되는가」, 이쪽이 「그 결과 운영에서 무엇이 일어나는가」를 적습니다.
10.1 catch (Exception)으로 로그만 찍고 계속한다
가장 흔하고, 가장 위험합니다.
- 중간 변경이 남는다
- 공유 상태가 깨진다
- 후속 장애가 늘어난다
- 진짜 원인 지점이 흐려진다
로그가 하나 늘어나는 대신 사고가 길어지는 경우가 많습니다.
10.2 async logger의 큐만 믿는다
비동기 로그 자체는 나쁘지 않습니다. 문제는 fatal path에서도 같은 큐에 쌓고 끝나는 것입니다.
죽는 순간에 워커가 멈추면 그 큐째 사라집니다.
fatal path만은 직접 쓰는 탈출구를 두는 편이 안전합니다.
10.3 크래시 핸들러에서 HTTP 전송한다
구현하고 싶어지지만 꽤 위험합니다.
- DNS
- TLS
- proxy
- 인증
- 타임아웃
- 재전송 대기
전부가 죽은 맥락에 올라탑니다.
보내는 것은 재시작 후로 합니다.
10.4 dump는 있는데 통상 로그와 이어지지 않는다
이것은 많습니다.
- 덤프 파일 이름에 session이 없다
- 로그 쪽에 PID / session이 없다
- watchdog 쪽에 PID가 없다
- build 번호가 일치하지 않는다
결과적으로 세 증거가 별개 이야기처럼 보이게 됩니다.
flowchart TB
accTitle: 증거가 이어지지 않는 실패
accDescr: 덤프 파일 이름에 session이 없고, 로그 쪽에 PID나 session이 없으며, build 번호가 일치하지 않으면, 덤프·로그·watchdog 기록 세 증거가 별개 사건처럼 보임을 나타내는 그림입니다.
u1["덤프 이름에 session이 없다"] --> u4["세 증거가 별개 이야기처럼 보인다"]
u2["로그에 PID / session이 없다"] --> u4
u3["build 번호가 일치하지 않는다"] --> u4
그림22: 열쇠가 되는 ID가 빠지면, 애써 남긴 증거끼리 이어지지 않게 됩니다.
10.5 WinForms / WPF의 미처리 예외 이벤트로 겉보기로 살린다
겉보기에는 「안 죽게 되므로」 처음에는 환영받습니다. 하지만 실제로는
- 화면만 남는다
- 워커는 죽어 있다
- 버튼 활성만 남는다
- 저장됐는지 모른다
는 좀비 상태를 만들기 쉽습니다.
10.6 native 쪽 종료 경로를 보지 않는다
SetUnhandledExceptionFilter만으로 안심하면
- invalid parameter
- purecall
- terminate
- fast fail
쪽을 놓칩니다.
native C++에서는 SEH만이 아니라 CRT / C++ 런타임 쪽 종료 경로도 의식하는 편이 좋습니다.
11. 최소한의 도입 체크리스트
아래를 충족하면 꽤 실전적입니다.
- 통상 로그가 한 줄 한 이벤트로 남는다
- 모든 로그에 UTC, PID, TID, version, session이 있다
ProcessStart와ProcessExit가 남는다- 중요 경계 이벤트는 동기적으로 flush된다
- 최종 크래시 마커 전용 파일이 있다
- fatal path에서는 async logger를 거치지 않는다
- WER LocalDumps가 앱 단위로 설정되어 있다
- dump 저장 위치 ACL을 검증했다
- PDB와 배포 바이너리를 보관하고 있다
- 다음 기동 때 이전 비정상 종료를 감지할 수 있다
- 압축 / 업로드 / 알림은 재시작 후 또는 별도 프로세스에서 한다
- native C++에서는 invalid parameter / purecall / terminate도 정리했다
- 검증 머신에서 의도적으로 죽여 보고, 정말 남는지를 확인했다
마지막 한 줄이 특히 중요합니다. 설계만으로는 의미가 없고, 반드시 「받아내는 시험」을 해야 합니다.
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를 토대로 둔다
- 계속보다 기록하고 종료를 기본으로 한다
결국 「마지막 한 줄을 애쓴다」보다 「마지막 한 줄이 없어도 추적할 수 있는 구성을 만든다」 가 더 강합니다.
flowchart TB
accTitle: 마지막 한 줄에 의존하지 않는 구성
accDescr: 마지막 한 줄을 애쓰는 것보다 마지막 한 줄이 없어도 추적할 수 있는 구성을 만드는 편이 강하고, 그래도 최종 크래시 마커는 별도 파일에 짧게 남기며, 주 증거는 WER 덤프와 직전까지의 통상 로그에 둠을 나타내는 그림입니다.
v0["목표하는 형태"] --> v2["마지막 한 줄이 없어도 추적할 수 있는 구성"]
v2 --> v3["마커는 별도 파일에 짧게 남긴다"]
v2 --> v4["주 증거는 덤프와 통상 로그"]
그림23: 마지막 한 줄을 애쓰기보다, 그것이 없어도 추적할 수 있는 구성을 만듭니다.
그래도 마지막 한 줄은 필요하므로, 최종 크래시 마커는 별도 파일에 짧게 남긴다. 그리고 진짜 주 증거는 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
- Microsoft Learn: FileStream.Flush(Boolean) https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush
- Microsoft Learn: !analyze (WinDbg) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-analyze
- Microsoft Learn: .ecxr (Display Exception Context Record) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-ecxr–display-exception-context-record-
- Microsoft Learn: Symbol path for Windows debuggers https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/symbol-path
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 크래시 덤프 수집 입문 - WER/ProcDump/WinDbg
재현하기 어려운 Windows 앱 크래시를 추적하기 위해, WER LocalDumps, ProcDump, MiniDumpWriteDump, WinDbg의 용도 구분과 운영상 주의점을 정리합니다.
절전에서 재개하면 깨지는 앱 ── 전원 이벤트의 구조와 재개에 강한 업무 앱을 만드는 방법
노트북을 열었더니 업무 앱의 통신이 끊어져 있었다――원인은 절전을 전제하지 않은 설계입니다. WM_POWERBROADCAST에 의한 알림의 흐름, Modern Standby의 동작, 끊김·재연결 설계, 절전 억제와 조사 명령까지를 1차 정보로 설...
「응답 없음」의 정체 ── Windows가 앱을 「멈췄다」고 판단하는 구조와, 멈추지 않는 설계
Windows의 「응답 없음」은 창이 5초 동안 메시지를 꺼내지 않으면 OS가 판단해 고스트 창으로 교체하는 구조입니다. 판단의 내부 동작부터 멈추는 전형적인 원인, UI 스레드에서 무거운 처리를 빼내는 설계, hang 조사 절차까지 설명합니다.
WPR/WPA 실무 ── 「PC 전체가 느리다」를 시스템 전체에서 조사하는 성능 조사 입문
「PC 전체가 느리다」「부팅이 느리다」처럼 작업 관리자로는 따라갈 수 없는 성능 문제는 OS 전체 ETW 트레이스를 모아 읽는 WPR/WPA로 조사합니다. wpr.exe 수집 절차부터 WPA에서 CPU·대기·디스크 I/O를 읽는 법까지 설명합니다.
Windows 오류 코드 읽기 ── Win32·HRESULT·NTSTATUS 삼층 구조
0x80004005가 나오면 검색하기 전에 먼저 분해합니다. Win32 오류·HRESULT·NTSTATUS의 삼층 구조, 0x8007xxxx가 Win32 오류를 감싼 형태라는 가장 중요한 패턴, err.exe와 PowerShell로 코드를 찾는 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
장애 조사 & 장기 가동 장애
간헐적 장애, 통신 진단, 장기 가동 크래시, 실패 경로 테스트 기반을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
장애 조사 & 원인 분석
고객 환경에서만 발생하는 크래시, 재현률이 낮은 비정상 종료, 덤프와 로그를 대조하는 원인 분석은 장애 조사·원인 분석과 잘 맞는 주제입니다.
Windows 앱 개발
WPF, WinForms, 상주 앱, Windows 서비스에서 통상 로그, WER, watchdog을 어떻게 설계할지는 Windows 앱 개발 자체와 직결됩니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 크래시하는 앱 스스로 로그를 확실히 남길 수 있나요?
- 없습니다. 스택 손상, 메모리 파괴, fast fail, 강제 종료, 전원 차단까지 포함하면 in-process의 마지막 로그는 본질적으로 best effort입니다. 실무에서는 평상시의 시계열 로그, 죽는 순간의 최종 크래시 마커, OS 또는 별도 프로세스 쪽에서 남기는 크래시 증거를 3층으로 나누고, 죽는 프로세스 안에만 기대하지 않는 구성으로 합니다. 가장 무난한 조합은 통상 로그+최종 크래시 마커+WER LocalDumps입니다.
- 크래시 핸들러 안에서 해서는 안 되는 일은 무엇인가요?
- 무거운 처리 전반입니다. DI 컨테이너에서 logger를 가져오기, async/await, 락 대기, 복잡한 JSON 생성, COM 객체 조작, UI 대화상자, 압축, HTTP/SMTP/Slack 전송 등은 높은 확률로 함정입니다. 할 일은 다중 진입을 막기, 한 줄만 쓰기, flush하기, 종료하기, 이 네 가지로 모읍니다. 압축·업로드·알림 같은 무거운 후처리는 다음 기동 후나 별도 프로세스로 넘깁니다.
- WER LocalDumps는 어떻게 설정하면 되나요?
- 레지스트리의 HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\앱이름.exe 아래에 DumpFolder(전용 폴더), DumpCount(5~10 정도), DumpType(개발 머신은 2, 현장은 용량과 기밀 요건을 보고 1 또는 2)을 설정합니다. 중요한 것은 저장 위치 ACL 확인이며, Windows 서비스나 제한 계정에서는 쓸 수 없는 폴더로 설정해 허탕이 되는 것이 전형적인 실패입니다. 또한 덤프 수집과 PDB·배포 바이너리 보관은 한 세트이며, 어느 한쪽이 빠지면 나중에 읽을 수 없습니다.
- WPF의 DispatcherUnhandledException에서 예외를 삼키고 계속해도 되나요?
- 프로그램 실수에서 비롯된 unexpected 예외를 상대로는 위험합니다. Handled=true로 겉보기에는 계속할 수 있지만, 화면만 남고 워커는 죽어 있거나, 버튼 활성만 남고 저장됐는지 모르는 좀비 상태를 만들기 쉽습니다. 미처리 예외 이벤트는 복구 장치가 아니라 기록의 입구로 쓰고, 최종 크래시 마커를 쓴 뒤에는 계속하지 말고 종료시키며, 주 증거는 WER 덤프와 직전까지의 통상 로그에 두는 편이 안전합니다. 종료에는 Environment.FailFast 같은 즉시 종료 계열 API를 검토합니다.