예기치 않은 예외가 났을 때 종료할지 계속할지 판단표

· 업데이트: · · Windows 개발, 예외 처리, 설계, C# / .NET, 신뢰성

수정 이력(9건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635163)
계속할지 종료할지 판단 흐름·조건이 갖춰지는 방식·구현 시 역할 분담을 그림으로도 따라갈 수 있도록, Mermaid 그림을 16개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
두 가지를 고쳤습니다. 하나는 `BackgroundService`의 부모 루프에서 `Environment.ExitCode`를 설정하고 `StopApplication`하는 형태였는데, Windows 서비스로 돌릴 때는 이것만으로는 부족합니다. 호스트가 정상 정지하면 SCM에는 `SERVICE_STOPPED`가 보고되고, `ExitCode`는 서비스 종료 상태에 반영되지 않아 설정한 복구 작업이 실행되지 않습니다. 공식 워커 서비스 튜토리얼대로 `Environment.Exit(1)`로 종료시키고, systemd나 컨테이너만 상대라면 이전 형태여도 된다는 점을 함께 적었습니다. 다른 하나는 1건씩 도는 루프에서 `catch (Exception)`이 `NullReferenceException`이나 `OutOfMemoryException`까지 「그냥 불량 데이터」로 삼켜, 같은 글에서 「호스트째 멈춘다」고 정한 예기치 않은 예외가 부모 루프에 닿지 않는 상태였습니다. 1건 실패를 나타내는 예외 형만 잡는 형태로 바꿨습니다.
`BackgroundService`의 부모 루프가 깨졌을 때의 예에 종료 코드 설정을 넣었습니다. `StopApplication`은 정상 종료 요청이라 프로세스는 0으로 끝나고, 실패할 때만 다시 시작하는 감시 쪽(서비스 복구 작업, `Restart=on-failure`, 컨테이너 재시작 정책)에서는 정상 종료와 구분이 안 되어, 워커가 다시 살아나지 못한 채로 남습니다.
WPF 핸들러 등록을 `base.OnStartup(e)` 뒤에 쓰던 것을 앞으로 옮겼습니다. `base.OnStartup`은 Startup 이벤트를 발생시키므로, 그 구독 쪽에서 예외가 나면 뒤에 쓴 핸들러는 아직 등록되지 않아, 시작 시 죽었는데 로그가 한 줄도 남지 않습니다.
코드 예를 추가했습니다. 1건씩 처리하는 루프에서 catch를 실패 단위에 가두는 예, 부모 루프에서는 삼키지 않고 `StopApplication()`으로 멈추는 `BackgroundService` 예, WPF의 기록 전용 핸들러 예입니다. `FailFast`의 성질(`finally`와 finalizer를 실행하지 않음, 이벤트 로그와 dump, 디버거 아래에서의 동작) 절과, 메모리 손상이나 좀비화를 의심할 때의 증상과 확인 위치 표, 용어 정의를 추가했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635162)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「예기치 않은 예외가 났을 때 종료할지 계속할지 판단표」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635162 https://comcomponent.com/ko/blog/unexpected-exception-exit-or-continue-decision-table/

DOI(최신 버전)
10.5281/zenodo.21635162
DOI(이 버전)
10.5281/zenodo.22217446

일본어·영어 시트가 있는 Excel 체크리스트를 다운로드

이 파일은 Checklist-jaChecklist-en의 2시트 구성으로, 이 글의 판단 축을 계속 조건 / 종료 조건 / 구현 방침 / 판단 순서의 4개 범주 27항목으로 나눈 것입니다. StatusNotes 열은 비어 있으므로, 장애 대응 기록이나 설계 리뷰 체크표로 그대로 쓸 수 있습니다.

예상하지 못한 예외 이야기가 되면, 무심코 「떨어뜨릴 것인가, catch해서 계속할 것인가」의 양자택일로 생각하기 쉽습니다. 다만 실무에서는 이 양자택일의 두는 방식이 조금 거칠습니다.

정말로 보고 싶은 것은, 망가졌을 가능성이 있는 범위를 가둘 수 있는가입니다.

  • 그 조작만 실패로 끝낼 수 있는가
  • 그 화면 / 접속 / 워커만 재초기화하면 되는가
  • 이미 프로세스 전체의 정합성이 수상한가

이 순서로 보면 상당히 정리하기 쉬워집니다.

망가진 범위를 확인하는 순서예기치 않은 예외가 났을 때, 그 조작만 실패로 끝낼 수 있는지, 서브시스템만 재초기화하면 되는지, 프로세스 전체의 정합성이 수상한지를 순서로 망가졌을 가능성이 있는 범위를 확인하는 흐름을 나타낸 그림.그 조작만 실패로 만들 수 있는가화면이나 접속만 재초기화인가프로세스 전체가 수상한가

그림 1: 망가졌을 가능성이 있는 범위를 조작, 서브시스템, 프로세스 전체의 순서로 확인한다.

이 글에서는 C# / .NET의 Windows 앱, 상주 앱, Windows 서비스, 장치 연계 도구 등을 전제로, 예상하지 못한 예외가 났을 때 계속해도 되는 조건과, 종료하는 편이 좋은 조건을 판단표로 정리합니다.

1. 먼저 결론

  • catch (Exception)으로 삼켜 버리고 계속하는 것은 대체로 위험합니다.
  • 계속해도 되는 것은 실패한 단위를 버릴 수 있다, 공유 상태를 원래대로 되돌릴 수 있다, 외부 부작용을 설명할 수 있다의 세 가지가 갖춰질 때입니다.
  • UI의 1조작, 1건의 입력, 1건의 잡처럼 처리 경계가 명확하면 계속할 수 있는 경우가 있습니다.
  • 반대로 쓰기 가능한 공유 상태, 상주 루프, 메인 스레드, 시작 처리, 네이티브 경계, 메모리 파괴 냄새가 얽히면 종료 쪽입니다.
  • StackOverflowException, AccessViolationException, OutOfMemoryException처럼 「프로세스 전체의 건전성」을 의심하는 예외는 계속을 전제로 생각하지 않는 편이 안전합니다.
  • WPF나 Windows Forms에는 미처리 예외를 주워 겉보기로 계속하는 길도 있지만, 계속할 수 있는 것계속해도 안전한 것은 별개입니다.
  • 장시간 동작하는 서비스나 감시 앱은 반쯤 망가진 채 살아남기보다, 떨어져 재기동되는 편이 진단도 쉽고 안전한 경우가 많습니다.

요컨대 판단의 축은 불변 조건을 회복할 수 있는가입니다.

계속해도 되는 3조건실패한 단위를 버릴 수 있고, 공유 상태를 원래대로 되돌릴 수 있으며, 외부 부작용을 설명할 수 있다는 세 가지가 갖춰질 때만 계속해도 되고, 판단의 축은 불변 조건을 회복할 수 있는가임을 나타낸 그림.실패 단위를 버릴 수 있다세 가지가 갖춰지면 계속해도 된다공유 상태를 원래대로 되돌릴 수 있다외부 부작용을 설명할 수 있다축은 불변 조건을 회복할 수 있는가

그림 2: 계속해도 되는 것은 실패 단위, 공유 상태, 외부 부작용의 3조건이 갖춰질 때뿐이다.

1.1 이 글에서 쓰는 말

판단표를 읽기 전에, 반복해서 나오는 5개 용어만 먼저 정해 둡니다.

용어 이 글에서의 의미
불변 조건 처리 전후에 반드시 성립해야 하는 상태에 대한 약속입니다. 「명세 합계와 집계 값이 일치한다」, 「캐시와 DB 내용이 맞는다」, 「연 접속은 반드시 관리 목록에 올라 있다」 등이 해당합니다. 여기가 무너진 채로 계속 움직이면 이후 처리는 모두 수상해집니다
실패 단위 실패했을 때 통째로 버릴 수 있는 범위입니다. 1조작, 1화면, 1잡, 1접속 등
외부 부작용 프로세스 밖으로 나가 버린 변경입니다. DB 갱신, 파일 쓰기, 메일 송신, 장치로의 명령 송신처럼 catch로는 되돌릴 수 없는 것
서브시스템 정지와 재초기화를 한데 묶어서 할 수 있는 단위입니다. 접속, 화면, 워커, 자식 프로세스 등
FailFast Environment.FailFast를 가리킵니다. try / finally도 finalizer도 돌리지 않고 프로세스를 즉시 종료하는 API이며, 자세한 내용은 9.7에서 다룹니다

이 글의 지식 맵

예상하지 못한 예외에 대한 대응은 catch (Exception)으로 움켜쥐고 처리를 계속하는 것이 아니라, 공유 상태의 불변 조건과 실패 단위, 외부 부작용을 설명할 수 있는지에 따라 계속할지 종료할지를 판단해야 한다고 정리하는 글입니다. StackOverflowException이나 AccessViolationException, 심각한 OutOfMemoryException처럼 메모리 파괴의 조짐을 동반하는 예외, 또는 COM·P/Invoke 같은 네이티브 경계의 이상은 종료 쪽이며, Environment.FailFast나 Environment.Exit가 종료의 구현 수단이 됩니다. BackgroundService의 부모 루프가 예상하지 못한 예외로 떨어진 경우, Windows 서비스로 돌리는 한 Environment.Exit로 종료 코드를 반환하지 않으면 복구 작업이 작동하지 않으며, AppDomain.UnhandledException 같은 미처리 예외 핸들러는 기록에 써야 하고 상태의 회복 수단이 아니라는 점도 다룹니다.

예상하지 못한 예외의 계속·종료 판단예상하지 못한 예외에 대한 계속과 종료 판단이 불변 조건·실패 단위·외부 부작용의 확인을 전제로 한다는 것, 메모리 파괴가 의심되는 예외나 네이티브 경계의 이상은 종료 쪽에 가깝다는 것, BackgroundService의 예상하지 못한 예외 시에는 Environment.Exit에 의한 종료 코드가 Windows 서비스의 복구 작업에 필요하다는 것, 미처리 예외 핸들러는 복구가 아니라 기록에 써야 한다는 것을 보여주는 그림전제로 한다전제로 한다전제로 한다원인이 될 수 있다사용은 비권장권장되는 대응권장되는 대응권장되는 대응원인이 될 수 있다권장되는 대응구현을 담당한다구현을 담당한다원인이 될 수 있다권장되는 대응전제로 한다사용은 비권장구현을 담당한다사용은 비권장양립하지 않는다방지한다에서 확인할 수 있다예상치 못한 예외불변 조건처리 계속실패 단위외부 부작용catch (Exception)로 예외 삼키기좀비화프로세스 종료StackOverflowExceptionAccessViolationExceptionEnvironment.FailFastOutOfMemoryException메모리 손상 징후Environment.Exit네이티브 경계BackgroundServiceIHostApplicationLifetime.StopApplicationWindows 서비스처리되지 않은 예외 처리기예외 이후 상태 복구 시도

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 이 글에서 말하는 「예상하지 못한 예외」

2.1 예상된 예외와 예기치 않은 예외를 나눈다

우선, 드문 예외와 예기치 않은 예외는 같지 않습니다.

예를 들어 다음은 빈도가 낮아도 예상된 예외로 둘 수 있습니다.

  • 사용자가 존재하지 않는 파일을 골랐다
  • 통신 상대가 일시적으로 타임아웃됐다
  • 가져오기 CSV의 1행이 망가져 있었다
  • 취소 조작으로 OperationCanceledException이 났다
  • 업무 규칙 위반으로 그 처리만 실패로 하고 싶다

이것들은 그 실패를 어떻게 다룰지를 설계에서 먼저 정할 수 있는 종류입니다.

한편 이 글에서 주로 다루는 예상하지 못한 예외는 예를 들어 다음입니다.

  • 자기 코드의 전제가 무너져 NullReferenceException이나 InvalidOperationException이 났다
  • 공유 상태의 갱신 도중에 예외가 날아가, 어디까지 반영됐는지 수상하다
  • 감시 루프나 메시지 처리의 부모 루프가 떨어졌다
  • COM / P/Invoke / vendor SDK 경계에서 이상이 났다
  • AccessViolationException이나 StackOverflowException처럼, 애초에 프로세스의 건강검진이 빨간불이다

즉, 「이 예외가 난 뒤에, 앱의 상태를 아직 믿어도 되는지 모르겠다」는 것입니다.

예상된 예외와 예기치 않은 예외의 갈림실패의 다루기를 설계에서 먼저 정할 수 있는 예외는 빈도가 낮아도 예상된 예외로 둘 수 있고, 예외 뒤에 앱의 상태를 믿어도 되는지 모르는 것이 이 글에서 말하는 예기치 않은 예외임을 나타낸 그림.정할 수 있다정할 수 없다예외가 났다다루기를 설계에서 먼저 정할 수 있는가예상된〔빈도가 낮아도 된다〕예기치 않은〔상태를 믿을 수 있는지 불명〕

그림 3: 드문 예외와 예기치 않은 예외는 별개이며, 갈림은 다루기를 설계에서 먼저 정할 수 있는지의 여부이다.

2.2 양자택일로 보이지만 실제로는 셋이 있다

이 이야기를 복잡하게 만드는 범인은, 계속을 한 종류로 생각해 버리는 것입니다.

실무에서는 대체로 3단계로 나뉩니다.

선택 의미
그 조작만 실패시키고 계속 화면은 남기되, 이번 저장이나 가져오기만 실패로 다룬다
서브시스템만 멈추고 계속 접속, 화면, 워커, 자식 프로세스만 재초기화한다
프로세스를 종료한다 상태 파괴의 범위가 읽히지 않으므로, 재기동을 전제로 한다

「앱을 계속한다」고 해도, 아무 일도 없었다는 얼굴로 그대로 이어가는 것과, 망가진 부분을 잘라 내고 계속하는 것은 무게가 다릅니다.

같은 계속이라도 무게가 다르다앱을 계속한다고 해도, 아무 일도 없었다는 얼굴로 그대로 이어가는 것과, 망가진 부분을 잘라 내고 계속하는 것은 무게가 다름을 나타낸 그림.앱을 계속한다아무 일도 없었다는 얼굴로 이어간다망가진 부분을 잘라 내고 이어간다같은 계속이라도 무게가 다르다

그림 4: 계속을 한 종류로 생각하지 말고, 망가진 부분을 잘라 내는 계속과 구별한다.

3. 먼저 보는 판단표

3.1 전체 모습

우선 이 표부터 보면, 대체적인 방침이 정해집니다.

상황 우선 선택 이유
1개의 입력, 1개의 화면 조작, 1개의 잡만 실패하고, 상태를 버릴 수 있다 계속 쪽 실패 단위를 가둘 수 있기 때문
예외 후에 대상 객체나 접속을 폐기하고, 다시 만들 수 있다 서브시스템 재초기화 쪽 망가진 범위를 국소로 둘 수 있기 때문
공유 상태를 중간까지 갱신했지만, 어디까지 반영됐는지 모른다 종료 쪽 불변 조건이 무너져 있을 가능성이 있기 때문
DB / 파일 / 장치 명령 등 외부 부작용이 어중간하고, 중복이나 미반영을 설명할 수 없다 종료 쪽 바깥세상과의 정합을 읽을 수 없기 때문
감시 루프, 재접속 루프, 메시지 처리의 부모 루프가 예기치 않은 예외로 떨어졌다 종료 쪽 조용히 일부 기능만 죽으면 좀비화하기 쉽기 때문
시작 처리, 설정 읽기, DI 구성, 필수 의존의 초기화에서 실패했다 시작 실패로 종료 쪽 어중간하게 시작하는 편이 더 위험하기 때문
AccessViolationException, StackOverflowException, 심각한 OutOfMemoryException, native 쪽의 파괴 냄새가 있다 즉시 종료 쪽 프로세스 전체의 건전성이 수상하기 때문
위험한 처리가 별도 프로세스에 격리되어 있고, 부모 프로세스는 무사하다 부모는 계속, 자식은 재기동 장애 영역을 분리하고 있기 때문
아니요아니요아니요아니요예상하지 못한 예외메모리 파괴 / 스택 고갈 / 치명적인 자원 고갈의 냄새?종료 / FailFast / 재기동실패한 단위를 버릴 수 있는가?종료 쪽공유 상태를 롤백 / 재초기화할 수 있는가?서브시스템 정지 또는 종료외부 부작용을 설명할 수 있는가?그 조작만 실패로 두고 계속

그림 5: 파괴의 냄새, 실패 단위, 공유 상태, 외부 부작용의 순서로 확인해 방침을 정하는 흐름.

그림의 첫 분기에 있는 「메모리 파괴의 냄새」는 3.4에서, 오른쪽 위의 FailFast는 9.7에서 구체적으로 다룹니다.

3.2 예외 유형보다 먼저 볼 것

예외 유형만으로 바로 정하지 않는 편이 좋습니다. 먼저 확인하고 싶은 것은 다음입니다.

볼 점 무엇을 확인하는가
어디에서 났는가 UI 이벤트, 1건 잡, 부모 루프, 시작 처리, 네이티브 경계 중 어디인가
어디까지 진행됐는가 도중에 메모리 상태, DB, 파일, 장치 상태가 바뀌지 않았는가
망가질 수 있는 범위 그 객체만인가, 화면 전체인가, 프로세스 전체인가
롤백 가능한가 폐기하고 다시 만들 수 있는가, 트랜잭션으로 되돌릴 수 있는가
외부 부작용 송신 완료인가 미송신인가, 이중 실행이 안전한가, 보상 처리를 할 수 있는가
감시·재기동 종료시킨 뒤에 자동 재기동이나 복구 경로가 있는가

3.3 위험도가 높은 예외

세세한 예외 유형을 전부 이야기할 필요는 없지만, 계속을 전제로 보지 않는 편이 좋은 것은 있습니다.

예외 / 징후 우선 선택 보는 이유
StackOverflowException 즉시 종료 쪽 호출 스택이 무너져 있어, 통상의 회복을 전제로 하기 어렵다
AccessViolationException 즉시 종료 쪽 보호된 메모리에 대한 잘못된 액세스로, 네이티브 경계나 메모리 파괴가 의심된다
OutOfMemoryException 종료 쪽 추가 할당을 전제로 한 회복 처리 자체가 불안정해지기 쉽다
unexpected한 NullReferenceException / InvalidOperationException 문맥에 따르지만 종료 쪽 자기 전제의 붕괴이며, 중간 변경이 남아 있을 가능성이 있다
부모 루프에서 샌 예기치 않은 예외 종료 쪽 기능의 중핵이 죽었는데 프로세스만 남는 위험이 있다
COM / P/Invoke / vendor SDK callback에서 시작된 이상 즉시 종료~강한 종료 쪽 managed만 봐서는 안전성을 판단하기 어렵다

3.4 「메모리 파괴의 냄새」「좀비화」는 무엇을 보고 말하는가

이 둘은 감각적인 말로 보이지만, 실제로 보고 있는 것은 정해져 있습니다.

먼저, 메모리 파괴를 의심하는 증상입니다.

보이는 것 어디에서 보는가
예외 코드 0xc0000005(액세스 위반)나 0xc0000374(힙 손상)으로 프로세스가 떨어진다 이벤트 뷰어 > Windows 로그 > Application의 「애플리케이션 오류」(이벤트 ID 1000)
떨어지는 곳이 매번 다르다. 직전에 건드리지 않은 코드에서 떨어진다 로그, dump의 호출 스택
해제했을 객체나 핸들을 건드렸을 때만 떨어진다 재현 절차, dump
네이티브 쪽의 해제 처리(free / delete / COM의 해제)에서 떨어진다 dump의 호출 스택(ntdll.dll 안에서 떨어지는 등)
건드리지 않은 값이 바뀌어 있다. 같은 입력인데 결과가 달라진다 입출력 로그, 재실행 결과의 비교

0xc0000005는 STATUS_ACCESS_VIOLATION, 0xc0000374는 STATUS_HEAP_CORRUPTION이며, 모두 Microsoft의 NTSTATUS 목록에 정의된 값입니다. 특히 힙 손상은 망가뜨린 순간이 아니라 망가진 힙을 다음에 건드린 쪽에서 떨어지므로, 떨어진 곳이 범인이라고는 할 수 없습니다. 이런 형태의 증상이 나와 있다면, managed 쪽에서 catch해서 계속해도 의미가 없다고 보는 편이 안전합니다.

힙 손상으로 떨어지는 위치의 어긋남힙 손상에서는 망가뜨린 순간에는 떨어지지 않고, 망가진 힙을 다음에 건드린 쪽에서 떨어지므로, 떨어진 곳이 범인이라고는 할 수 없음을 나타낸 그림.어딘가에서 힙이 망가진다그 순간에는 떨어지지 않는다다음에 건드린 쪽에서 떨어진다떨어진 곳이 범인이라고는 할 수 없다

그림 6: 힙 손상은 다음에 건드린 쪽에서 떨어지므로, 떨어진 곳과 망가뜨린 곳이 어긋난다.

다음으로, 좀비화를 의심하는 증상입니다.

보이는 것 어디에서 보는가
프로세스는 살아 있는데, 직전 처리 시각이 갱신되지 않는다 최종 처리 시각의 로그, heartbeat
큐나 수신 폴더의 체류 건수만 계속 늘어난다 큐 길이, 미처리 파일 수
어떤 시각부터 로그가 뚝 끊겨 있다 앱 로그
화면은 조작할 수 있는데, 뒤의 갱신만 멈춰 있다 화면 표시와 실제 데이터의 대조
워커 스레드 수가 예상보다 줄어 있다 진단 로그, Process Explorer의 스레드 목록

좀비화란, 떨어지지 않았다는 뜻이 아니라 일을 하지 않는데 살아 있는 상태입니다. 최종 처리 시각, 체류 건수, heartbeat처럼 「움직이고 있음을 나타내는 지표」를 먼저 마련해 두면, 계속할지 종료할지 판단도, 나중에 조사하는 일도 상당히 편해집니다.

좀비화를 잡는 지표좀비화는 일을 하지 않는데 살아 있는 상태이며, 최종 처리 시각이나 체류 건수, heartbeat처럼 움직이고 있음을 나타내는 지표를 먼저 마련하면, 판단도 나중 조사도 편해짐을 나타낸 그림.최종 처리 시각움직이고 있는 지표를 먼저 마련체류 건수heartbeat계속할지 종료할지 판단이 편해진다나중 조사가 편해진다

그림 7: 떨어지지 않았다는 것이 아니라, 일을 하고 있음을 나타내는 지표로 좀비화를 잡는다.

4. 어디에서 났는지로 판단한다

4.1 UI 이벤트

버튼 클릭, 화면 전환, 검색, 파일 선택 같은 UI 이벤트는 계속할 여지가 비교적 큽니다. 다만 조건이 있습니다.

계속하기 쉬운 것은 이런 경우입니다.

  • 읽기 전에 실패했고, 업무 상태를 아직 건드리지 않았다
  • 대화상자 안의 일시 상태만 망가져 있어, 화면을 닫으면 버릴 수 있다
  • 예외 후에 ViewModel이나 접속을 다시 만들 수 있다
  • 이용자에게 「이번 조작은 실패했다」고 정직하게 전할 수 있다

반대로 이렇게 되면 종료 쪽입니다.

  • 화면과 도메인 상태를 둘 다 중간까지 갱신했다
  • static / singleton / 캐시처럼 다른 화면도 보는 공유 상태를 건드렸다
  • 예외가 난 뒤, 버튼 활성이나 선택 상태만 남아 정합을 알 수 없다
  • UI 스레드에서 unexpected한 예외가 나, 어디까지 그리기나 알림이 진행됐는지 수상하다
UI 조작 예외의 갈림UI 이벤트의 예외는, 닫으면 버릴 수 있는 일시 상태만이면 계속하기 쉽고, 다른 화면도 보는 공유 상태나 중간까지의 갱신에 손을 댔다면 종료 쪽이 됨을 나타낸 그림.버릴 수 있는 일시 상태만공유 상태나 중간 갱신UI 조작에서 예기치 않은 예외어떤 상태에 손을 댔는가그 조작만 실패로 두고 계속종료 쪽

그림 8: UI 이벤트는 계속의 여지가 크지만, 공유 상태에 손을 댔다면 이야기가 달라진다.

4.2 1건씩 처리하는 잡 / 요청

여기는 계속하기 쉬운 경계입니다.

  • 1 메시지
  • 1 파일
  • 1 HTTP 요청
  • 1 가져오기 잡
  • 1 배치 대상

이런 단위가 명확하면, 그 1건만 실패로 두고 다음으로 나아갑니다.

다만 전제가 있습니다.

  • 실패 단위가 밖에서 봐서 명확하다
  • 중간 변경이 트랜잭션이나 보상으로 맞춰진다
  • 같은 처리를 한 번 더 흘려도 결과가 망가지지 않는 성질이 있다
  • 실패를 격리 큐나 오류 기록으로 빼낼 수 있다

4.3 상주 루프 / 감시 / 큐 처리

여기는 대충 계속하면 가장 곤란한 곳입니다.

예를 들어:

  • 재접속 루프
  • 감시 루프
  • 큐 소비 루프
  • 정기 폴링
  • 장치 상태 감시
  • 트레이 앱의 상주 처리

이런 처리에서 무서운 것은, 부모 루프가 1회의 예기치 않은 예외로 죽고, 프로세스만 살아남는 것입니다.

여기서는 방침을 나눈 편이 좋습니다.

  • 각 아이템 처리의 경계에서 예상된 예외를 잡는다
  • 부모 루프에서 예기치 않은 예외가 새어 나오면, 프로세스 종료 쪽으로 둔다
상주 루프에서의 방침 나누기각 아이템 처리의 경계에서는 예상된 예외를 잡고, 부모 루프에서 예기치 않은 예외가 새어 나오면 프로세스 종료 쪽으로 둔다는, 상주 처리에서의 방침 나누기를 나타낸 그림.상주 루프각 아이템 처리의 경계부모 루프예상된 예외를 잡는다예기치 않은 예외가 새어 나오면 종료 쪽프로세스만 살아남는 것을 막는다

그림 9: 아이템 경계와 부모 루프에서 방침을 나누고, 부모 루프의 예기치 않은 예외는 종료로 잇는다.

4.4 시작 처리

시작 시의 실패를 「일단 시작한 뒤에 생각하자」로 두면, 기능의 일부만 빠진 채로 계속 움직이게 되어, 나중에 원인을 분리하기 어려워집니다.

  • 필수 설정을 읽을 수 없다
  • 버전 이행 / 마이그레이션에 실패했다
  • 필수 폴더나 인증서가 없다
  • 중핵 서비스의 초기화에 실패했다
  • 의존 관계의 구성이 망가져 있다

이런 경우는 시작 실패로 종료하는 편이 알기 쉽습니다.

4.5 네이티브 경계 / COM / P/Invoke / unsafe

여기는 별도 칸으로 조금 더 엄하게 보는 편이 좋습니다.

  • COM
  • P/Invoke
  • C++/CLI의 앞
  • vendor SDK
  • callback으로 돌아오는 native 쪽 코드
  • unsafe를 포함한 처리

특히 다음이 보이면 종료 쪽입니다.

  • AccessViolationException
  • 힙 파괴나 더블 free를 의심하는 증상
  • 핸들 이상, 해제 후 접근의 냄새
  • callback 경계에서 갑자기 죽는다
네이티브 경계 이상의 다루기COM이나 P/Invoke 등 네이티브 경계에서는, AccessViolationException이나 힙 파괴를 의심하는 증상, callback 경계에서의 급사가 보이면 종료 쪽으로 두고, 이 경계 자체를 별도 칸으로 더 엄하게 봄을 나타낸 그림.AccessViolationException종료 쪽힙 파괴나 더블 free의 증상callback 경계에서 갑자기 죽는다네이티브 경계는 별도 칸으로 더 엄하게 본다

그림 10: 네이티브 경계에서 이런 증상이 보이면, 계속 전제를 버리고 종료 쪽으로 둔다.

5. 계속해도 되는 조건

계속해도 되는 조건을 모으면 다음과 같습니다. 이것들이 대체로 갖춰져 있는 것이 전제입니다.

조건 의미
실패 단위가 명확하다 1조작, 1화면, 1잡, 1접속처럼, 버릴 단위가 보인다
상태를 버릴 수 있다 폐기하고 다시 만들 수 있거나, 미반영으로 다룰 수 있다
공유 상태가 지켜진다 다른 기능으로 오염이 퍼지지 않는다
외부 부작용을 설명할 수 있다 보냈다 / 보내지 않았다 / 재송해도 된다, 가 보인다
이용자에게 정직하게 전할 수 있다 「이번 처리는 실패했다」고 표시할 수 있다
감시할 수 있다 로그, 메트릭, dump로 나중에 조사할 수 있다

6. 종료하는 편이 좋은 조건

반대로 여기에 해당하면 종료 쪽입니다.

  • 중간까지 무엇을 바꿨는지 모른다
  • 쓰기 가능한 공유 상태를 건드렸고, 정합을 읽을 수 없다
  • 잠금, 큐, 스레드, 감시 루프의 수명 관리가 망가졌다
  • 외부 부작용의 중복 / 결락 / 어중간함을 설명할 수 없다
  • 시작 처리나 중핵 인프라의 초기화에서 실패했다
  • 네이티브 경계나 메모리 파괴를 의심한다

이 수준이라면, 깨끗이 계속하는 궁리보다 종료시켜 복구하기 쉽게 하는 궁리가 더 효과가 있습니다.

종료 쪽일 때 효과가 있는 궁리종료 쪽 조건에 해당하는 수준에서는, 깨끗이 계속하는 궁리보다 종료시켜 복구하기 쉽게 하는 궁리가 더 효과가 있음을 나타낸 그림.종료 쪽 조건에 해당한다깨끗이 계속하는 궁리종료시켜 복구하기 쉽게 하는 궁리이 수준에서는 효과가 작다이쪽이 더 효과가 있다

그림 11: 종료 쪽 상황에서는, 계속의 궁리보다 복구 용이성에 투자한다.

7. 전형 패턴별 추천

3.1의 판단표는 조건의 형태로 쓰여 있어, 실제 장면에 맞출 때 헤맬 수 있습니다. 이 표는 그 조건을 자주 보는 장면으로 다시 맞춘 것입니다. 헤매면 먼저 3.1에서 조건을 확인하고, 그다음 이 표에서 가까운 행을 찾는 순서가 빠릅니다.

헤맸을 때 표 쓰는 법장면에 맞추기가 막히면, 먼저 3.1의 판단표에서 조건을 확인하고, 그다음 이 장의 표에서 가까운 장면의 행을 찾는 순서를 나타낸 그림.장면에 맞추기가 막힌다3.1의 판단표에서 조건을 확인이 장의 표에서 가까운 행을 찾는다

그림 12: 조건의 표에서 장면의 표로, 라는 순서로 찾으면 헤매기 어렵다.

패턴 추천 이유
파일을 여는 버튼에서 존재하지 않는 경로를 지정했다 그 조작만 실패로 두고 계속 상태 파괴가 국소적이기 때문
CSV 가져오기의 1행만 망가져 있었다 1행 실패 또는 1파일 실패로 두고 계속 실패 단위를 가두기 쉽기 때문
화면 저장 도중에 unexpected한 NullReferenceException이 났다 화면 재작성~종료 쪽 어디까지 ViewModel / 업무 상태가 바뀌었는지 수상하기 때문
큐의 1메시지가 업무 규칙 위반이었다 그 메시지만 실패로 두고 계속 격리 큐로 빼낼 수 있기 때문
큐 소비의 부모 루프가 예기치 않은 예외로 떨어졌다 프로세스 종료 쪽 워커 전체의 수명이 망가져 있기 때문
시작 시에 필수 설정을 읽을 수 없다 시작 실패로 종료 어중간한 시작이 더 위험하기 때문
vendor SDK callback 주변에서 AccessViolationException 즉시 종료 쪽 메모리 파괴 가능성을 무시할 수 없기 때문
본질이 아닌 텔레메트리 송신만 실패했다 그 기능만 무효화하고 계속 주기능과 장애 영역을 나눌 수 있기 때문

8. 자주 있는 NG

8.1 catch (Exception)으로 로그만 남기고 계속한다

이것은 상당히 위험합니다. 원인을 숨기는 데다, 망가진 상태를 연명하기 쉽기 때문입니다.

8.2 마지막 미처리 예외 핸들러에서 회복하려 한다

AppDomain.UnhandledException, Application.ThreadException, DispatcherUnhandledException 등은 마지막에 기록하는 장소로는 유용하지만, 마법의 회복 포인트는 아닙니다.

8.3 외부 부작용이 있는데 쉽게 retry한다

장치 명령, 메일 송신, 과금, 파일 이동, DB 갱신 등에서 같은 처리의 재실행 안전성이 없는 채로 retry하면, 이번에는 이중 실행 사고가 주역이 됩니다.

쉬운 retry가 부르는 이중 실행외부 부작용이 있는 처리에서, 같은 처리의 재실행 안전성이 없는 채로 retry하면, 이중 실행 사고가 주역이 됨을 나타낸 그림.외부 부작용이 있는 처리가 실패재실행 안전성이 없는 채로 retry이중 실행 사고가 주역이 된다장치 명령이나 과금이나 송신 등

그림 13: 재실행 안전성 없는 retry는 이중 실행 사고를 부른다.

8.4 감시 루프가 죽었는데 UI만 남긴다

겉모습만 살아 있고 일을 하지 않는 앱은, 이용자에게는 정상으로 보이는 만큼 발견이 늦어집니다. 3.4의 좀비화 증상이 나오지 않았는지를 감시 쪽에서 잡을 수 있게 해 두고 싶은 부분입니다.

8.5 떨어뜨리는 설계를 하지 않았는데 「떨어뜨리고 싶지 않다」고 말한다

떨어뜨리고 싶지 않다면, 그 앞에 넣어 둘 것이 있습니다.

  • 자동 재기동
  • 세션 복원
  • 중간 성과의 저장
  • 재실행 안전성
  • 장애 영역의 분리

9. 구현 시의 정리 포인트

9.1 catch하는 장소를 경계로 모은다

깊은 층에서 무엇이든 catch하기보다,

  • UI 조작 경계
  • 1 요청 경계
  • 1 잡 경계
  • 1 접속 경계
  • 프로세스 경계

처럼 실패 단위를 정의할 수 있는 장소에서 받는 편이 정리하기 쉽습니다.

예를 들어 1건씩 처리하는 가져오기라면, catch는 루프 안에 둡니다. 여기가 실패 단위입니다.

// C# / .NET 8. 1건씩 처리하는 루프에서, 실패 단위를 루프 안쪽에 가두는 예.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// 1건분의 실패(검증 오류, 중복, 서식 부정 등)는 ImportItemException으로 던진다.
    /// 그 외는 「이 1건의 문제」가 아니므로, 그대로 밖으로 빠져나가게 한다.
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>버리고 다음으로 나아가도 되는, 1건분의 실패.</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // 1건분의 상태는 버릴 수 있으므로, 기록하고 다음 1건으로 나아간다.
                //
                // 여기를 catch (Exception)으로 두지 말 것. NullReferenceException이나
                // OutOfMemoryException까지 「그냥 불량 데이터」로 삼켜 버리면,
                // 4.3에서 「호스트째 멈춘다」고 정한 예기치 않은 예외가 부모 루프에 닿지 않습니다.
                // 상태를 믿을 수 없게 된 뒤에도, 남은 건을 계속 쓰게 됩니다
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

반대로, 이 처리를 돌리는 부모 루프 쪽에서는 삼켜 버리지 않는 것이 짝이 되는 판단입니다. 4.3에서 쓴 대로, 부모 루프가 죽은 채로 프로세스만 남는 것이 가장 곤란한 형태이므로, 예기치 않은 예외는 호스트 정지로 잇습니다.

// 상주 루프 쪽. 정지 요구는 정상계로 빠져나가고, 그 외의 예기치 않은 예외는 호스트째 멈춘다.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // 정지 요구는 정상계. 여기서 조용히 끝난다.
        }
        catch (Exception ex)
        {
            // 부모 루프가 망가졌다 = 워커 전체의 수명이 망가져 있다.
            // 삼켜 버리고 「프로세스만 살아 있는」 상태를 만들지 말고, 멈추고 재기동으로 넘긴다.
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication은 「정상 종료한다」는 요구입니다. 이것만으로는, 실패할 때만
            // 재기동하는 감시 쪽(서비스의 회복 조작, systemd의 Restart=on-failure,
            // 컨테이너의 restart 정책)에서는 「일을 마치고 정상 종료했다」와
            // 구분이 되지 않아, 두 번 다시 일어나지 않습니다.
            //
            // Environment.ExitCode를 설정하는 것만으로도 부족합니다. Windows 서비스로
            // 돌리는 경우, 호스트가 정상 정지하면 SCM에는 SERVICE_STOPPED가
            // 보고되고, ExitCode는 서비스의 종료 상태에 반영되지 않으므로,
            // 복구 작업은 실행되지 않습니다. 0이 아닌 종료 코드로 프로세스를 종료시킵니다
            Environment.Exit(1);
        }
    }
}

Environment.Exit(1)을 쓰는 곳이 요점입니다. IHostApplicationLifetime.StopApplication()은 정상 정지 요구이므로, Windows 서비스로 돌리고 있는 경우 SCM에는 SERVICE_STOPPED가 보고됩니다. 프로세스의 Environment.ExitCode는 서비스의 종료 상태에 반영되지 않으므로, 서비스 속성에서 설정한 복구 작업(「서비스를 재기동한다」)은 실행되지 않습니다. 공식 워커 서비스 튜토리얼도, 기본 BackgroundServiceExceptionBehavior.StopHost는 「깨끗이 멈춘다」라서 Windows의 서비스 관리 쪽은 재기동하지 않고, 복구 작업을 적용하려면 0이 아닌 종료 코드로 Environment.Exit를 호출해야 한다고 명시합니다(11장의 참고 자료).

Environment.Exit는 지금 프로세스를 끝내므로, 종료하기 전에 남겨 두고 싶은 로그는 이 줄보다 앞에서 다 쓰십시오. 버퍼링하는 로거를 쓰고 있다면 플러시를 끼웁니다. 반대로 Windows 서비스가 아니라 systemd나 컨테이너만 상대라면, Environment.ExitCode를 설정한 뒤 StopApplication()으로 종료하는 형태라도 감시 쪽은 실패로 다룰 수 있습니다. 어느 환경에서 돌릴지로 고르십시오.

아울러, 루프 안쪽과 바깥쪽에서 catch의 역할이 바뀌는 점도 잡아 둡니다. 안쪽은 실패 단위의 기록, 바깥쪽은 수명의 종료입니다. 여기를 뒤바꾸면, 1건의 실패로 앱이 떨어지거나, 반대로 워커가 죽어도 프로세스가 살아남거나 합니다.

안쪽과 바깥쪽 catch의 역할루프 안쪽의 catch는 실패 단위의 기록, 바깥쪽의 catch는 수명의 종료라는 역할 분담이며, 뒤바꾸면 1건의 실패로 떨어지거나, 워커가 죽어도 프로세스만 살아남거나 함을 나타낸 그림.루프 안쪽의 catch실패 단위의 기록루프 바깥쪽의 catch수명의 종료뒤바꾸면 1건의 실패로 떨어진다뒤바꾸면 프로세스만 남는다

그림 14: catch는 루프 안쪽과 바깥쪽에서 역할이 바뀌고, 뒤바꾸면 양방향의 사고가 된다.

9.2 예상된 예외와 예기치 않은 예외를 나눈다

  • 예상된 예외: validation, not found, timeout, cancel, 업무 규칙 위반
  • 예기치 않은 예외: 전제 붕괴, 부모 루프 누출, 네이티브 경계 이상, 메모리 파괴 냄새

9.3 공유 상태를 작게 한다

쓰기 가능한 공유 상태가 클수록, 계속 판단은 어려워집니다. 반대로 1화면 1세션 1워커 안으로 가둘수록, 실패도 가두기 쉬워집니다.

9.4 위험한 처리는 별도 프로세스로 빼낸다

COM / ActiveX / vendor SDK / unsafe / 무거운 이미지 처리 / 외부 기기 제어처럼, 떨어졌을 때 피해를 넓히고 싶지 않은 것은 별도 프로세스화가 상당히 효과가 있습니다.

9.5 미처리 예외 핸들러는 「회복」보다 「기록」

  • 예외 정보
  • 조작 문맥
  • 직전의 중요 로그
  • 설정 / 버전 / 접속처
  • dump 수집 경로

이 정도를 갖춰, 떨어진 뒤에 파고들 수 있는 형태를 우선하는 편이 결과적으로 안정됩니다.

WPF라면, 기록용 핸들러는 이 정도 형태로 충분합니다.

// WPF (.NET 8)의 App.xaml.cs. 핸들러는 「회복」이 아니라 「기록」에 쓴다.
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // 핸들러 등록은 base.OnStartup(e)보다 앞에 둔다.
        // base.OnStartup은 Startup 이벤트를 발생시키므로, 그 구독 쪽에서 예외가
        // 나면, 뒤에 쓴 핸들러는 아직 등록되지 않아,
        // 「시작 시에 떨어졌는데 로그가 한 줄도 남지 않는다」는 가장 곤란한 형태가 된다.
        // 시작 처리 자체의 실패야말로 기록하고 싶으므로, 먼저 걸어 둔다.

        // UI 스레드에서 미처리로 올라온 예외
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // args.Handled = true로 하면 계속할 수 있지만, 계속해도 되는지는 5장의 조건으로 판단한다.
            // 판단이 서지 않으면, 기록하고 기본 동작(종료)에 맡긴다.
            args.Handled = false;
        };

        // UI 스레드 이외를 포함한 마지막 알림. 여기서는 멈출 수 없으므로 기록 전용.
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // await되지 않은 채로 회수된 Task의 예외
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // 여기까지 다 건 뒤에, 기본 시작 처리(Startup 이벤트 발생)로 나아간다
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // 기록에 실패해도, 종료 처리를 멈추지 않는다.
        }
    }
}

포인트는 핸들러 안에서 상태를 고치려 하지 않는 것입니다. 여기서 할 일은 다음에 같은 현상을 쫓을 재료를 남기는 것뿐입니다.

기록 전용 핸들러의 흐름WPF에서는 핸들러 등록을 base.OnStartup보다 앞에 마친 뒤 시작 처리로 나아가고, 핸들러 안에서는 상태를 고치려 하지 않고, 다음에 같은 현상을 쫓을 재료를 남기는 일만 하는 흐름을 나타낸 그림.핸들러를 먼저 등록base.OnStartup으로 나아간다미처리 예외를 기록한다상태를 고치려 하지는 않는다

그림 15: 등록을 시작 처리보다 먼저 마치고, 핸들러에서는 기록만 한다.

9.6 WPF / WinForms의 미처리 예외 이벤트를 과신하지 않는다

WPF에서는 DispatcherUnhandledException에서 Handled = true로 하면, 미처리 예외 후에도 계속하는 것 자체는 가능합니다. Windows Forms에서도 메인 UI 스레드에서는 Application.ThreadException이나 SetUnhandledExceptionMode 설정에 따라 멈추는 방식을 고를 수 있습니다.

다만, 그것으로 계속할 수 있는지와 회복 조건이 갖춰져 있는지는 별개 문제입니다.

9.7 Environment.FailFast는 「치우는 편이 위험한」 때만

3.1의 플로차트에 나오는 FailFastEnvironment.FailFast입니다. 공식 문서에 적힌 동작은 다음과 같습니다.

  • 실행 중인 try / finally도 finalizer도 돌리지 않고 프로세스를 종료한다
  • Windows에서는, 넘긴 메시지를 Windows의 애플리케이션 이벤트 로그에 쓰고, 애플리케이션 dump를 만든 뒤 종료한다
  • 메시지와 예외 정보는 Windows 오류 보고를 통해 Microsoft로의 오류 보고에도 포함된다
  • Visual Studio 디버거 아래에서 호출하면 ExecutionEngineException이 되고, fatalExecutionEngineError의 managed debugging assistant가 발생한다

finally를 돌리지 않는 것은 단점이 아니라 이 API의 목적입니다. 상태가 망가진 때 뒷정리 코드를 돌리면, 망가진 내용을 그대로 파일이나 DB에 써 버릴 수 있기 때문입니다. 문서에서도, 앱 상태가 복구 불능으로 망가져 있어 try / finally나 finalizer를 실행하면 리소스를 망가뜨리는 경우Environment.Exit가 아니라 FailFast를 쓰라고 설명합니다.

구분은 이렇게 됩니다.

상황 고르는 것
불변 조건이 망가져 있어, 뒷정리를 돌리는 편이 위험하다 Environment.FailFast
상태는 건전하고, 치운 뒤에 끝내고 싶다 정상적인 종료 처리(IHostApplicationLifetime.StopApplication 등)
그저 종료 코드를 반환하고 끝내고 싶다 Environment.Exit 또는 Main에서의 return

코드로 쓰면, 다음 같은 자리에서 씁니다.

// C# / .NET 8. 공유 상태의 불변 조건이 망가졌음을 검출한 지점.
// 여기서부터 앞은, 어떤 뒷정리 처리도 믿을 수 없다.
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

dump가 자동으로 수집되므로, FailFast에 넘기는 메시지에는 어느 불변 조건이, 어떤 값으로 망가져 있었는지를 넣어 두면 나중 조사가 상당히 편해집니다. 반대로 입력 실수나 통신 오류 같은 예상된 실패에서 FailFast를 호출하는 것은 과합니다. 그곳은 9.1의 실패 단위 이야기로 돌아갑니다.

FailFast가 뒷정리를 돌리지 않는 이유상태가 복구 불능으로 망가져 있을 때 뒷정리 코드를 돌리면 망가진 내용을 써 낼 우려가 있으므로, Environment.FailFast는 try나 finally도 finalizer도 돌리지 않고 종료하며, Windows에서는 이벤트 로그와 dump를 남김을 나타낸 그림.불변 조건이 망가졌다FailFast로 즉시 종료finally도 finalizer도 돌리지 않는다dump와 이벤트 로그를 남긴다〔Windows〕망가진 내용의 쓰기를 막는다

그림 16: FailFast는 뒷정리를 돌리지 않음으로써, 망가진 상태의 쓰기를 막는다.

10. 정리

예상하지 못한 예외가 났을 때 봐야 할 것은 「이 예외는 catch할 수 있는가」가 아니라, 이 후에도 앱의 상태를 믿을 수 있는가입니다.

판단의 순서로는, 대체로 이것으로 충분합니다.

  1. 실패한 단위를 버릴 수 있는가
  2. 공유 상태를 되돌릴 수 있는가, 다시 만들 수 있는가
  3. 외부 부작용을 설명할 수 있는가
  4. 메모리 / 스레드 / 네이티브 경계의 건전성은 믿을 수 있는가

이 넷에 자신이 있으면 계속할 수 있습니다. 자신이 없으면 종료 쪽입니다.

정리의 판단 순서실패 단위를 버릴 수 있는지, 공유 상태를 되돌릴 수 있는지, 외부 부작용을 설명할 수 있는지, 네이티브 경계까지 포함한 건전성을 믿을 수 있는지를 순서로 확인하고, 넷에 자신이 있으면 계속, 없으면 종료 쪽으로 두는 판단의 흐름을 나타낸 그림.넷 모두 자신이 있다자신이 없다실패 단위를 버릴 수 있는가공유 상태를 되돌릴 수 있는가외부 부작용을 설명할 수 있는가건전성을 믿을 수 있는가계속할 수 있다종료 쪽

그림 17: 네 질문에 순서로 답하고, 전부에 자신이 있을 때만 계속을 고른다.

특히 장시간 동작하는 앱, 감시 앱, 서비스, 장치 연계에서는, 망가진 채로 사는 것솔직히 떨어지는 것보다 위험한 장면이 상당히 있습니다.

예외 처리는 「떨어뜨리지 않는 기술」이 아닙니다. 망가지는 방식을 작게 하고, 망가지면 정직하게 멈추며, 복구하기 쉽게 하는 설계입니다.

11. 참고 자료

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

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

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

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

예기치 않은 예외를 catch (Exception)으로 삼켜 버리고 계속하면 안 되나요?
로그만 남기고 계속하는 것은 대체로 위험합니다. 원인을 숨기는 데다 망가진 상태를 연명하기 쉽기 때문입니다. 계속해도 되는 것은 실패한 단위를 버릴 수 있고, 공유 상태를 원래대로 되돌릴 수 있으며, 외부 부작용을 설명할 수 있다는 세 가지가 갖춰질 때입니다. 판단의 축은 「이 예외는 catch할 수 있는가」가 아니라 「이 후에도 앱의 상태를 믿을 수 있는가」입니다.
어떤 예외가 나면 즉시 종료해야 하나요?
StackOverflowException, AccessViolationException, 심각한 OutOfMemoryException처럼 프로세스 전체의 건전성을 의심하는 예외는 계속을 전제로 생각하지 않는 편이 안전합니다. StackOverflowException은 호출 스택이 무너져 있고, AccessViolationException은 보호된 메모리에 대한 잘못된 액세스로 메모리 파괴가 의심됩니다. COM·P/Invoke·vendor SDK의 callback에서 시작된 이상도 managed 쪽만 봐서는 안전성을 판단하기 어려워 종료 쪽으로 더 기울입니다.
예외가 나도 앱을 계속해도 되는 조건은 무엇인가요?
실패 단위가 명확하다(1조작·1화면·1잡·1연결처럼 버릴 단위가 보인다), 상태를 폐기하고 다시 만들 수 있다, 공유 상태로 오염이 퍼지지 않는다, 외부 부작용을 설명할 수 있다, 이용자에게 「이번 처리는 실패했다」고 정직하게 전할 수 있다, 로그나 메트릭으로 나중에 조사할 수 있다, 라는 조건이 대체로 갖춰져 있는 것이 전제입니다. UI의 1조작이나 1건의 가져오기 잡처럼 처리 경계가 명확하면 계속할 수 있는 경우가 있습니다. 반대로 공유 상태의 갱신 도중, 부모 루프, 시작 처리, 네이티브 경계의 이상은 종료 쪽입니다.
WPF의 DispatcherUnhandledException에서 Handled=true로 하면 계속할 수 있나요?
미처리 예외 후에도 계속하는 것 자체는 가능하지만, 계속할 수 있는 것과 계속해도 안전한 것은 별개입니다. AppDomain.UnhandledException이나 DispatcherUnhandledException 같은 핸들러는 마지막에 기록하는 장소로는 유용하지만, 마법의 회복 포인트는 아닙니다. 예외 정보·조작 문맥·dump 수집 경로를 갖춰 죽은 뒤에 조사할 수 있는 형태를 우선하는 편이 결과적으로 안정됩니다. 특히 장시간 동작하는 서비스나 감시 앱은 반쯤 망가진 채 살아남기보다 죽고 다시 시작되는 편이 진단도 쉽고 안전한 경우가 많습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기