상정하지 않은 예외가 발생했을 때의 체크리스트 - 앱을 종료시킬지 계속할지, 먼저 보는 판단표

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

수정 이력(1건, 최종 수정 2026년 09월 01일)

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

일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635163)
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635162)

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

小村 豪 (2026). 「상정하지 않은 예외가 발생했을 때의 체크리스트 - 앱을 종료시킬지 계속할지, 먼저 보는 판단표」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635162 https://comcomponent.com/ko/blog/2026/03/16/005-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)으로 움켜쥐고 속행, 은 대체로 위험합니다.
  • 계속해도 되는 것은 실패한 단위를 버릴 수 있다, 공유 상태를 원래대로 되돌릴 수 있다, 외부 부작용을 설명할 수 있다의 3가지가 갖춰질 때입니다.
  • UI의 1조작, 1건의 입력, 1건의 잡처럼 처리 경계가 명확하다면 계속할 수 있는 경우가 있습니다.
  • 반대로 공유의 쓸 수 있는 상태, 상주 루프, 메인 스레드, 기동 처리, 네이티브 경계, 메모리 파괴 냄새가 얽히면 종료 쪽입니다.
  • StackOverflowException, AccessViolationException, OutOfMemoryException 같은 「프로세스 전체의 건전성」을 의심하는 예외는 계속 전제로 생각하지 않는 편이 안전합니다.
  • WPF나 Windows Forms에는 미처리 예외를 주워서 겉보기에 계속하는 길도 있지만, 계속할 수 있는 것계속해도 안전한 것은 별개입니다.
  • 장시간 도는 서비스나 감시 앱은 반쯤 망가진 채 살아남기보다, 떨어져 재기동되는 편이 진단도 쉽고 안전한 경우가 많습니다.

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

계속해도 되는 3가지 조건실패한 단위를 버릴 수 있다, 공유 상태를 원래대로 되돌릴 수 있다, 외부 부작용을 설명할 수 있다는 3가지가 갖춰질 때만 계속해도 되며, 판단의 축은 불변 조건을 회복할 수 있는가임을 나타낸 그림.실패 단위를 버릴 수 있다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 이지선다로 보이지만 실은 삼지선다다

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

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

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

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

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

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

3. 먼저 보는 판단표

3.1 전체상

먼저 이 표부터 보면 대체로의 방침이 정해집니다.

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

그림 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 「메모리 파괴의 냄새」와 「좀비화」는 무엇을 보고 말하는가

이 2가지는 감각적인 표현으로 보이지만, 실제로 보고 있는 것은 정해져 있습니다.

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

보이는 것 어디서 보는가
예외 코드 0xc0000005(액세스 위반)나 0xc0000374(힙 손상)로 프로세스가 죽는다 이벤트 뷰어 > Windows 로그 > Application의 「애플리케이션 오류」(이벤트 ID 1000)
죽는 위치가 매번 다르다. 직전에 건드리지 않은 코드에서 죽는다 로그, 덤프의 호출 스택
이미 해제됐어야 할 객체나 핸들을 건드렸을 때만 죽는다 재현 절차, 덤프
네이티브 쪽 해제 처리(free / delete / COM의 해제)에서 죽는다 덤프의 호출 스택(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 상주 루프 / 감시 / 큐 처리

여기는 거칠게 계속하면 가장 위험한 곳입니다.

예를 들면:

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

이런 처리에서 무서운 것은 부모 루프가 한 번의 상정 외 예외로 죽고 프로세스만 살아남는 것입니다.

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

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

그림 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 접속 등 버릴 단위를 알 수 있다
상태를 버릴 수 있다 파기해 다시 만들 수 있거나 미반영으로 다룰 수 있다
공유 상태가 지켜진다 다른 기능으로 오염이 번지지 않는다
외부 부작용을 설명할 수 있다 보냈다 / 안 보냈다 / 재송해도 된다를 알 수 있다
이용자에게 정직하게 전할 수 있다 「이번 처리는 실패했다」고 표시할 수 있다
감시할 수 있다 로그, 메트릭, 덤프로 추적 조사할 수 있다

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

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

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

이 레벨이라면 깔끔하게 계속하는 궁리보다 떨어뜨려서 복구하기 쉽게 만드는 궁리가 더 효과적입니다.

종료 쪽일 때 효과적인 궁리종료 쪽 조건에 해당하는 수준에서는 깔끔하게 계속하는 궁리보다 떨어뜨려서 복구하기 쉽게 만드는 궁리가 더 효과적임을 나타낸 그림.종료 쪽 조건에 해당한다깔끔하게 계속하는 궁리떨어뜨려서 복구하기 쉽게 만드는 궁리이 수준에서는 효과가 적다이쪽이 더 효과적이다

그림 11: 종료 쪽 상황에서는 계속하려는 궁리보다 복구하기 쉬움에 투자한다.

7. 전형 패턴별의 추천

3.1의 판단표는 조건의 형태로 쓰여 있어서, 실제 상황에 적용할 때 헷갈릴 수 있습니다. 이 표는 그 조건을 자주 보는 상황에 다시 적용한 것입니다. 헷갈릴 때는 먼저 3.1에서 조건을 확인하고, 그다음 이 표에서 가까운 행을 찾는 순서가 빠릅니다.

헷갈릴 때 표를 쓰는 방법상황에 적용하기가 헷갈릴 때는 먼저 3.1의 판단표로 조건을 확인하고, 그다음 이 장의 표에서 가까운 상황의 행을 찾는 순서를 나타낸 그림.상황 적용이 헷갈린다3.1의 판단표로 조건을 확인이 장의 표에서 가까운 행을 찾는다

그림 12: 조건의 표에서 상황의 표로, 라는 순서로 찾으면 헷갈리지 않는다.

패턴 추천 이유
파일을 여는 버튼에서 존재하지 않는 경로를 지정했다 그 조작만 실패로 계속 상태 파괴가 국소적이므로
CSV 취합의 1행만 망가져 있었다 1행 실패 or 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는 지금의 프로세스를 끝내므로, 죽기 전에 남겨 두고 싶은 로그는 이 행보다 앞에서 다 써 두어야 합니다. 버퍼링하는 로거를 쓰고 있다면 flush를 끼워 넣습니다. 반대로 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 애플리케이션 이벤트 로그에 기록하고, 애플리케이션 덤프를 생성한 뒤 종료한다
  • 메시지와 예외 정보는 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."));
}

덤프가 자동으로 수집되므로, FailFast에 전달하는 메시지에는 어떤 불변 조건이 어떤 값에서 무너졌는지를 넣어 두면 이후 조사가 훨씬 쉬워집니다. 반대로 입력 실수나 통신 오류 같은 상정 내 실패로 FailFast를 부르는 것은 과합니다. 그 부분은 9.1의 실패 단위 이야기로 돌아갑니다.

FailFast가 뒷정리를 실행하지 않는 이유상태가 복구 불가능하게 망가져 있을 때 뒷정리 코드를 실행하면 망가진 내용을 써 버릴 우려가 있으므로, Environment.FailFast는 try나 finally도 finalizer도 실행하지 않고 종료하며 Windows에서는 이벤트 로그와 덤프를 남김을 나타낸 그림.불변 조건이 무너졌다FailFast로 즉시 종료finally도 finalizer도 실행하지 않는다덤프와 이벤트 로그를 남긴다(Windows)망가진 내용의 기록을 막는다

그림 16: FailFast는 뒷정리를 실행하지 않음으로써 망가진 상태의 기록을 막는다.

10. 정리

상정하지 않은 예외가 발생했을 때 봐야 할 것은 「이 예외는 catch할 수 있는가」가 아니라 이 뒤에도 앱의 상태를 신뢰할 수 있는가입니다.

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

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

이 4가지에 자신이 있다면 계속할 수 있습니다. 자신이 없다면 종료 쪽입니다.

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

그림 17: 4가지 질문에 순서대로 답하고, 전부에 자신이 있을 때만 계속을 선택한다.

특히 장시간 동작하는 앱, 감시 앱, 서비스, 장치 연계에서는 망가진 채로 사는 것이 깔끔하게 죽는 것보다 위험한 장면이 꽤 있습니다.

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

11. 참고 자료

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

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

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

자주 묻는 질문

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

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

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기