수정 이력(5건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 맨 앞에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 도입부를 추가하고, 이 기사에서 쓰는 용어(실패 단위·주 로그·결과화)를 정의했습니다. 나쁜 예와 좋은 예의 코드, 한 번의 실패에서 각 계층이 실제로 남기는 로그 예, 판단표 3개의 쓰임도 함께 추가했습니다.
- 본문의 관련 기사 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635256)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「예외 처리에서 catch와 로그는 어디에 두어야 하는가」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635256 https://comcomponent.com/ko/blog/exception-catching-logging-error-handling/
- DOI(최신 버전)
- 10.5281/zenodo.21635256
- DOI(이 버전)
- 10.5281/zenodo.21635257
예외 처리 코드 리뷰에서 반복해서 나오는 지적은 대략 다음 세 가지로 모입니다.
- 가장 깊은 공통 함수가
catch (Exception)을 해서, 호출 측에서는 「데이터가 없었다」인지 「도중에 깨졌다」인지 구분할 수 없다 - 한 번의 장애인데 Repository, Service, Controller, 미처리 예외 핸들러에 같은 스택 트레이스가 4줄 나란히 쌓인다
- 사용자가 취소했을 뿐인데
Error로그가 나와, 정말로 위험한 장애가 그 안에 묻힌다
어느 쪽도 try / catch 작성 실력이 부족한 것이 아닙니다. 어디서 받을지, 누가 로그를 남길지, 어디서 실패의 형태를 정할지라는 역할 분담이 정해져 있지 않을 뿐입니다. 역할이 없으면 각 계층 개발자가 저마다 「만약을 위해」 catch와 로그를 보태고, 결국 원인을 볼 수 없는 코드가 됩니다.
이 기사는 C# / .NET으로 업무 앱이나 Web API를 작성하는 개발자와, 그 설계를 리뷰하는 분을 대상으로, 예외를 잡는 경계, 주 로그를 남기는 위치, 회복 판단의 책임을 어떻게 나눌지를 정리합니다. 호출 계층에서 어디서 무엇을 할지 먼저 정해 두면, 리뷰에서도 장애 조사에서도 판단이 흔들리기 어렵습니다.
이 기사에서 쓰는 용어
본문에 반복해서 나오는 세 단어를 먼저 정의합니다. 일반적인 용어가 아니라, 이 기사 안에서의 의미입니다.
| 용어 | 이 기사에서의 의미 |
|---|---|
| 실패 단위 | 「무엇이 한 번 실패했는지」를 나타내는, 업무상 묶음이 있는 처리 단위입니다. 화면 조작 1회, HTTP 요청 1건, 작업 1건, 메시지 1건, CSV 1행 등이 해당합니다. 로그에도 응답에도 이 단위가 나타납니다 |
| 주 로그 | 하나의 실패에 대해 한 번만 남기는, Error 또는 Critical 기록입니다. 실패 단위와 운영 문맥(requestId, userId, 대상 ID 등)을 함께 붙입니다. 그 밖의 기록은 보조 로그로 Debug / Information / Warning으로 다룹니다 |
| 결과화 | 예외로 계속 던지기를 그만두고, Result 형이나 실패를 나타내는 DTO 같은 반환값으로 바꾸는 것입니다. 예상된 실패를 호출 측이 분기로 다룰 수 있는 형태로 만드는 조작을 가리킵니다 |
목차
- 먼저 결론
catch와 로그와 에러 처리는 별개- 2.1.
catch하는 것 - 2.2. 로그를 남기는 것
- 2.3. 에러 처리하는 것
- 2.4. 예외를 번역하는 것
- 2.1.
- 먼저 보는 판단표
- 호출 계층에서 어디서 무엇을 하는가
- 4.1. 가장 깊은 helper / utility / private method
- 4.2. 외부 I/O 경계: Repository / Gateway / SDK 래퍼
- 4.3. Application Service / UseCase
- 4.4. UI / HTTP / Job / Message 경계
- 4.5. 마지막 미처리 예외 핸들러
- 4.6. 하나의 호출 계층으로 보기
- 예상된 실패와 예상하지 못한 예외를 나누기
- 로그는 어디서 몇 번 남겨야 하는가
- 흔한 NG
- 리뷰 시 체크리스트
- 대략적인 구분
- 정리
- 참고 자료
- 관련 기사
이 글의 지식 맵
이 글은 C#/.NET 예외 처리에서, 예외를 잡는 경계와 주 로그를 남기는 장소를 나눌 것을 제안합니다. 가장 깊은 helper/utility 층에서는 넓게 catch하지 않고, Repository/Gateway 층은 하위 구현 고유의 예외를 의미 있는 실패로 번역하며, Application Service/UseCase 층은 예상된 실패를 결과로 변환하고, UI/HTTP/Job 경계가 실패 단위와 운영 맥락을 맞춰 주 로그를 한 번만 남기는 지점이 되기 쉽다고 합니다. 처리되지 않은 예외 핸들러는 복구 지점이 아니라 마지막 기록 지점이며, 종료·재시작 경로를 갖춥니다. 각 층에서 중복된 Error 로그, throw ex로 인한 스택 트레이스 손실, OperationCanceledException을 장애 로그로 다루는 것은, 모두 원인 추적을 어렵게 하므로 피해야 할 대응으로 둡니다.
flowchart LR
accTitle: 예외 처리에서 catch와 로그를 어디에 두어야 하는지의 지식 맵
accDescr: 예외를 잡는 경계·주 로그·결과 변환·예외 번역이라는 역할을, helper 층에서 Repository/Gateway 층·UseCase 층·UI/HTTP/Job 경계·처리되지 않은 예외 핸들러로 어떻게 배분하는지를 보여주는 그림
exception_catch_boundary["예외를 잡는 경계"]
primary_error_log["주 로그"]
deep_broad_catch["깊은 계층의 넓은 catch"]
helper_utility_layer["helper/utility/private method 계층"]
failure_unit["실패 단위"]
diagnosis_difficulty["장애 원인 추적의 어려움"]
duplicate_error_logging["각 계층의 중복 로그"]
repository_gateway_layer["Repository/Gateway/SDK 래퍼 계층"]
exception_translation[".NET 예외 변환"]
limited_retry["국소적 retry"]
usecase_layer["Application Service/UseCase 계층"]
result_conversion["Result형화"]
expected_failure["예상된 실패"]
ui_http_job_boundary["UI/HTTP/Job/Message 경계"]
operation_canceled_exception["OperationCanceledException"]
throw_ex_antipattern["throw ex;에 의한 스택 트레이스 재작성"]
stack_trace_loss["스택 추적 상실"]
unhandled_exception_handler["처리되지 않은 예외 처리기"]
process_restart_strategy["종료·재시작 경로"]
backgroundservice_unhandled_exception["BackgroundService의 처리되지 않은 예외"]
unexpected_exception["예상치 못한 예외"]
deep_broad_catch -->|"사용은 비권장"| helper_utility_layer
failure_unit -->|"권장되는 대응"| exception_catch_boundary
deep_broad_catch -->|"원인이 될 수 있다"| diagnosis_difficulty
duplicate_error_logging -->|"원인이 될 수 있다"| diagnosis_difficulty
primary_error_log -->|"방지한다"| duplicate_error_logging
repository_gateway_layer -->|"구현을 담당한다"| exception_translation
repository_gateway_layer -.->|"이용한다"| limited_retry
usecase_layer -->|"구현을 담당한다"| result_conversion
result_conversion -->|"권장되는 대응"| expected_failure
ui_http_job_boundary -->|"구현을 담당한다"| primary_error_log
operation_canceled_exception -->|"사용은 비권장"| primary_error_log
throw_ex_antipattern -->|"원인이 될 수 있다"| stack_trace_loss
stack_trace_loss -->|"원인이 될 수 있다"| diagnosis_difficulty
unhandled_exception_handler -->|"구현을 담당한다"| process_restart_strategy
process_restart_strategy -.->|"권장되는 대응"| backgroundservice_unhandled_exception
operation_canceled_exception -->|"보다 먼저 해야 한다"| unexpected_exception
primary_error_log -->|"전제로 한다"| failure_unit
usecase_layer -->|"이용한다"| repository_gateway_layer
ui_http_job_boundary -->|"이용한다"| usecase_layer
primary_error_log -->|"사용은 비권장"| helper_utility_layer
primary_error_log -.->|"사용은 비권장"| repository_gateway_layer
helper_utility_layer -->|"사용은 비권장"| exception_catch_boundary
usecase_layer -->|"권장되는 대응"| exception_catch_boundary
ui_http_job_boundary -->|"권장되는 대응"| exception_catch_boundary
unhandled_exception_handler -->|"사용은 비권장"| exception_catch_boundary
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 25건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론
- 원칙은 깊은 계층에서 넓게
catch하지 않는 것입니다.catch하는 위치는 실패 단위를 정의할 수 있는 경계로 모읍니다. - 로그는 하나의 실패에 대해 하나의 주 로그를 기본으로 합니다. 각 계층에서 같은 예외를 계속
Error하면 읽는 쪽이 힘들어집니다. - 가장 깊은 계층의 책임은 뒷정리, 국소적 롤백, 예외 번역, 필요하면 한정적인 retry입니다. 다시 throw한다면 보통은 거기서 주 로그를 남기지 않습니다.
- 화면 조작, HTTP 요청, 작업 1건, 메시지 처리 1건 같은 처리 경계가 가장 자연스러운 주 로그 지점이 되기 쉽습니다.
- 예상된 실패는 그 유스케이스 단위로 결과화합니다. 반드시 전부를 예외로 위까지 계속 던질 필요는 없습니다.
AppDomain.UnhandledException, WPF의DispatcherUnhandledException, WinForms의ThreadException, ASP.NET Core의 예외 핸들러, 호스트의 최종 예외 처리는 회복 지점이라기보다 마지막 기록 지점입니다.- 사용자 취소나 셧다운에 의한
OperationCanceledException은 보통 Error로 다루지 않습니다. - 망설이면 다음 순서로 봅니다.
- 이 위치에서 정말 판단할 수 있는가
- 실패한 단위가 여기서 보이는가
- 여기서 상태를 되돌릴 수 있는가, 다시 만들 수 있는가
- 여기서 로그하면 같은 예외를 위에서도 로그하지 않는가
요컨대 catch할 수 있는 곳이 아니라, 책임지고 판단할 수 있는 곳에서 받는 것이 기본입니다.
2. catch와 로그와 에러 처리는 별개
2.1. catch하는 것
catch는 예외를 한 번 받아 처리 흐름을 바꾸는 것입니다.
다만 그 자체는 회복이 아닙니다.
예를 들어 하위 메서드에서 예외를 받아도,
- 사용자에게 무엇을 보여야 하는지 모른다
- 그 실패로 화면 전체를 멈춰야 하는지, 이번 조작만 실패여도 되는지 모른다
- 그 request나 job을 계속해도 되는지 모른다
라면, 그 위치는 catch에 적합한 곳이 아닌 경우가 많습니다.
2.2. 로그를 남기는 것
로그는 「예외가 일어났다」는 사실만이 아니라, 어떤 일이 실패했는지를 나중에 쫓기 위한 기록입니다.
그래서 좋은 로그 지점에는 대개 다음 중 하나가 갖춰져 있습니다.
- requestId / traceId
- userId
- orderId / fileId / batchId
- 몇 번째 입력인지
- 어느 화면 조작인지
- 어느 큐·어느 메시지인지
깊은 helper나 공통 함수는 기술 세부는 알아도 이 문맥을 갖고 있지 않은 경우가 많습니다. 그래서 기술 세부를 아는 위치와 운영 문맥을 아는 위치는 종종 다릅니다.
2.3. 에러 처리하는 것
여기서 말하는 에러 처리는 다음과 같은 처리입니다.
- 화면에 오류 메시지를 낸다
- HTTP에서는 4xx / 5xx를 반환한다
- 1건만 실패로 두고 다음 건으로 진행한다
- 그 subsystem을 다시 초기화한다
- 프로세스를 종료하고 재시작에 맡긴다
- 리소스를 해제하고 안전하게 빠져나간다
즉, 호출 측이나 사용자 관점에서 본 실패의 형태를 정하는 것입니다.
2.4. 예외를 번역하는 것
실무에서는 catch와 「처리한다」 사이에 또 하나 중요한 일이 있습니다.
그것이 번역입니다.
예를 들어,
HttpRequestExceptionIOExceptionJsonException- DB 드라이버 고유 예외
- vendor SDK 고유 예외
를 그대로 UI나 Controller로 흘리면, 상위 계층이 하위 구현 사정을 알기 시작합니다.
그래서 경계면에서는,
- 「결제 서비스에 연결하지 못했다」
- 「CSV 형식이 깨져 있었다」
- 「저장 위치에 쓰지 못했다」
- 「장치 응답이 올바르지 않았다」
처럼, 그 계층에서 의미 있는 실패로 바꿉니다.
여기서 중요한 것은 번역과 로그는 같지 않다는 점입니다. 번역해서 위로 throw하기만 한다면, 보통은 주 로그까지 남기지 않습니다.
3. 먼저 보는 판단표
이 기사에는 비슷한 형태의 표가 3개 나옵니다. 역할이 다르므로, 쓰임을 먼저 적어둡니다.
| 표 | 언제 보는가 | 무엇이 적혀 있는가 |
|---|---|---|
| 3장 먼저 보는 판단표 | 설계 시. 계층마다 책임을 정할 때 | 위치별 기본 방침, 주 로그를 남길지, 주된 책임 |
| 6장 로그 지점 표 | 구현 시. 로그 줄을 쓰다 손이 멈출 때 | 실패 종류마다 어디서 어느 레벨로 기록할지 |
| 9장 대략적인 구분 | 리뷰 시. 마지막 확인할 때 | 3장과 6장을 catch / 로그 / 에러 처리 3열로 접은 것 |
우선 이 표로 큰 방침을 정해 두는 편이 편합니다.
| 위치 | 기본 방침 | 주 로그 | 주된 책임 |
|---|---|---|---|
| helper / utility / private method | 원칙적으로 넓게 catch하지 않는다 |
남기지 않는다 | finally에 의한 뒷정리, 국소적 롤백, 필요 최소한의 문맥 추가 |
| Repository / Gateway / SDK 래퍼 | 구체 예외만 받는다 | 보통은 남기지 않는다 | 예외 번역, 한정적인 retry, 연결이나 핸들 폐기 |
| Application Service / UseCase | 예상된 실패를 결과화한다 | 삼킨다면 여기서 필요에 따라 | 실패 단위 정의, 부분 실패화, 유스케이스 단위 판단 |
| UI / Controller / API / Job / Message 경계 | 예상하지 못한 예외의 주된 수신 지점 | 여기가 주 로그가 되기 쉽다 | 사용자에게 보이는 응답, HTTP 응답, 다음 건 계속, abort 판단 |
| 미처리 예외 핸들러 / 호스트 최종 경계 | 놓친 것을 막는 마지막 보루 | Critical |
최종 기록, flush, dump, 종료·재시작 경로 |
그림으로 보면 대략 이렇습니다.
flowchart TD
A["예외가 일어났다"] --> B{"이 위치에서 retry / 결과화 / 계속 여부를 정할 수 있는가?"}
B -- "아니요" --> C["원칙적으로 catch하지 않고 위로 보낸다"]
B -- "예" --> D{"여기는 계층 경계인가?"}
D -- "아니요" --> E["국소적 cleanup만"]
D -- "예" --> F["필요하면 의미 있는 예외로 번역"]
E --> G{"여기서 실패 단위와 운영 문맥이 보이는가?"}
F --> G
G -- "아니요" --> H["주 로그는 남기지 않고 상위로 보낸다"]
G -- "예" --> I["주 로그를 1회 남기고 응답을 정한다"]
I --> J["필요하면 종료 / 재초기화 / 다음 건 계속"]
이 그림의 요점은 두 가지입니다.
catch의 첫 이유는 회복이나 cleanup이지, 로그가 아니다- 로그의 첫 이유는 운영 문맥이 갖춰진 것이지, 예외를 발견한 것이 아니다
4. 호출 계층에서 어디서 무엇을 하는가
4.1. 가장 깊은 helper / utility / private method
여기서는 원칙적으로 넓게 받지 않는 것이 기본입니다.
예를 들어 문자열 변환, 파싱, 계산, 내부 정형, 공통 helper 같은 위치는,
- 어느 화면 조작이었는지
- 어느 request였는지
- 이번만 실패여도 되는지
- 화면 전체를 닫아야 하는지
를 판단할 수 없습니다.
이 계층에서 해도 되는 일은 주로 다음입니다.
finally에서의 resource 해제- 도중까지 깨뜨린 국지 상태의 롤백
- 예외 메시지에 최소한의 문맥 추가
- 더 적절한 예외 형으로의 교체
- 재사용할 수 없게 된 객체의 폐기
이들에 공통된 점은, 호출 측이 누구인지 몰라도 올바르게 실행할 수 있는 뒷정리라는 점입니다. 판단이 필요 없는 처리만 여기에 두고, 판단이 필요한 것은 위로 보낸다고 생각하면 선을 긋기 쉽습니다.
반대로 피하고 싶은 것은 이런 코드입니다.
catch (Exception)해서null/false/ 빈 배열을 반환한다- 여기서
MessageBox를 띄운다 - 여기서
Error로그를 남긴 뒤 다시 throw한다 - 되돌릴 수 없는데 「일단 계속한다」
특히 위험한 것은 도중까지 자기 상태를 바꾼 뒤에 실패했는데, 그대로 계속 쓰는 패턴입니다. 이 경우는 그 자리에서 되돌릴 수 있으면 되돌리고, 못 하면 폐기 전제로 두는 둘 중 하나입니다.
4.2. 외부 I/O 경계: Repository / Gateway / SDK 래퍼
여기는 catch하는 이유가 분명한 계층입니다.
왜냐하면 여기서는 아래 계층의 구현 사정이 표면에 나오기 때문입니다.
- DB 드라이버 예외
- HTTP 통신 예외
- 파일 I/O 예외
- COM / P/Invoke / vendor SDK의 고유 예외
- 파싱 라이브러리나 시리얼라이저의 예외
이 계층에서 하는 일은 대략 네 가지입니다.
-
구체 예외를 받는다 넓은
Exception이 아니라, 의미가 있는 구체 예외를 받습니다. -
의미 있는 실패로 번역한다 상위 계층이 하위의 사정을 직접 알지 않아도 되게 합니다.
- 국소적으로 retry한다면 여기서 한다
다만 조건은 엄격합니다.
- 일시적 실패로 알려져 있다
- 멱등성이 있다
- 상한 횟수와 대기 방법이 정해져 있다
- 실패 시 최종 동작이 명확하다 이 네 가지가 갖춰질 때만입니다.
- 깨진 연결이나 핸들을 버린다 「다음에도 같은 객체로 이어간다」보다 「연결을 다시 만든다」가 안전한 경우가 많습니다.
여기의 로그 방침은 이렇게 생각하면 흔들리지 않습니다.
- 위로 다시 throw한다면 보통은 주 로그를 남기지 않는다
- 여기서 예외를 삼켜 결과로 바꾼다면, 그 시점에 필요한 로그나 메트릭을 남긴다
- retry 중의 각 시도는
Debug/Information/Warning범위로 다루고, 최종 실패만 강하게 기록한다
이 계층은 번역하는 위치이지, 보통은 최종 판단하는 위치가 아닙니다.
4.3. Application Service / UseCase
여기는 「이번 일을 어떻게 실패시킬지」를 정하는 계층입니다.
예를 들어,
- 저장 처리
- 주문 확정
- CSV 가져오기
- 배치 1건 처리
- 메시지 1건 반영
처럼, 유스케이스로 묶음이 있는 단위가 여기에 있습니다.
이 계층에서는 다음과 같은 판단을 할 수 있습니다.
- validation 오류는 이번만 실패
NotFound는 404 상당- 업무 규칙 위반은 사용자 수정 대기
- CSV 1행 오류는
Warning으로 계속 - 외부 서비스 일시 장애는 처리 전체를 실패
- 중간 성과를 버리고 처음부터 다시 한다
즉, 실패 단위를 정할 수 있는 위치입니다.
이 계층이 맞는 일은 다음과 같습니다.
- 예상된 실패를
Result나 실패 DTO 형태로 만든다 - 부분 실패를 집계한다
- 몇 건까지 실패를 허용하고 계속할지 정한다
- 오류 코드나 사용자용 메시지 키로 변환한다
반대로 이 계층에서 하지 말아야 할 것은 UI 표시나 HTTP 응답 본문 조립을 너무 들여오는 것입니다. 여기서는 유스케이스로서의 의미까지 정하고, 최종적인 보여 주는 방식은 경계 측에 맡기는 편이 분리하기 쉽습니다.
4.4. UI / HTTP / Job / Message 경계
여기가 많은 앱에서 주 로그 지점이 되기 쉽습니다.
예를 들어 이런 단위입니다.
- WinForms / WPF의 「저장」 버튼 누름 1회
- ASP.NET Core의 HTTP request 1건
- worker의 메시지 1건
- 배치 입력 1건
- 스케줄 실행 작업 1회
이 위치는 다음을 알고 있습니다.
- 무슨 조작이었는지
- 누구의 조작이었는지
- 몇 번째였는지
- 어느 request / batch / message였는지
- 실패하면 사용자나 호출 측에 무엇을 반환하는지
이 다섯이 갖춰진 것은 많은 앱에서 이 계층뿐입니다. 아래 계층은 기술 세부는 알아도 운영 문맥이 없고, 위의 미처리 예외 핸들러까지 오면 이번에는 실패 단위가 안 보입니다. 그래서,
- 예상하지 못한 예외를 여기서 모아 받는다
- 문맥을 붙여 주 로그를 1회 남긴다
- 오류 대화상자, HTTP 500, Problem Details, 작업 실패, 다음 건 계속 등으로 변환한다
는 역할을 갖기 쉽습니다.
이 계층에서 중요한 것은 넓게 받는 것 자체가 아니라, 넓게 받은 뒤에 무엇을 반환할지가 정의되어 있는 것입니다.
예를 들어 batch나 queue에서는 2단계로 나누면 흐름이 분명해집니다.
- 1건 경계에서 받는다 1건만 실패로 두고 다음으로 진행할지 정한다
- 부모 루프에서는 넓게 삼키지 않는다 부모 루프가 죽으면 프로세스 전체 재시작으로 모은다
「1건씩 실패시키고 계속」과 「부모 루프가 예상하지 못한 예외로 죽어도 조용히 살아 있다」는 전혀 다릅니다.
4.5. 마지막 미처리 예외 핸들러
여기는 마지막 보루입니다. 마법의 회복 지점이 아닙니다.
대표적인 것은 다음입니다.
AppDomain.UnhandledException- WPF의
Application.DispatcherUnhandledException - WinForms의
Application.ThreadException - ASP.NET Core의 예외 처리 미들웨어나 핸들러
- Generic Host / worker /
BackgroundService의 최종 예외 처리
이 계층의 주된 책임은 기껏해야 이 정도입니다.
- 최종 로그
- flush
- dump 채취 경로
- 세션 정보나 직전 문맥의 보존
- 종료 코드나 재시작 경로 정비
반대로 여기에 너무 기대하지 않는 편이 좋은 것도 있습니다.
- 여기까지 온 시점에서 위의 설계 누락인 경우가 많다
- 이미 상태가 깨져 있을 수 있다
- 잠금을 쥔 채일 수도 있어, 무거운 처리는 위험하다
- 겉보기에 이어갈 수 있어도, 이어서 안전하다고 할 수 없다
.NET 주변에서 짚어 둘 실무상의 주의도 있습니다.
AppDomain.UnhandledException은 미처리 예외의 알림과 기록을 위한 이벤트입니다. 이후에 회복 처리를 너무 넣는 것은 위험합니다.- WPF의
DispatcherUnhandledException에서는Handled = true로 겉보기에 이어가는 길이 있지만, 회복 가능한지의 판단이 먼저입니다. - WinForms의
ThreadException도, 거기서 처리한 뒤에 애플리케이션이 알 수 없는 상태가 될 수 있습니다. - ASP.NET Core의 예외 처리 미들웨어는 이후 예외를 받을 수 있도록 파이프라인 앞쪽에 둘 필요가 있습니다.
BackgroundService의 미처리 예외는 .NET 6 이후에는 로그되고, 기본값으로는 호스트를 중지하는 쪽에 가깝습니다. 부모 루프에서 전부 삼키기보다, 중지하고 재시작 전략에 태우는 편이 안전한 경우가 있습니다.
특히 데스크톱 앱에서는 「미처리 예외를 잡아 계속한다」는 길이 존재합니다. 다만 계속할 수 있는 것과 계속해도 되는 것은 별개입니다.
4.6. 하나의 호출 계층으로 보기
예를 들어 이런 흐름을 생각합니다.
flowchart LR
A["UI / Controller / Job 경계"] --> B["Application Service / UseCase"]
B --> C["Domain / 업무 로직"]
C --> D["Repository / Gateway / SDK wrapper"]
D --> E["DB / HTTP / File / Vendor SDK"]
이때 역할은 대략 이렇게 나뉩니다.
저장 버튼 → SaveOrderUseCase → PaymentGateway → HTTP
PaymentGateway- 통신 실패나 응답 형식 이상을 받는다
- 「결제 서비스 연결 실패」「결제 서비스 응답이 올바르지 않음」으로 번역한다
- retry한다면 여기서 조건부로 한다
- 다시 throw한다면 보통은 주 로그하지 않는다
SaveOrderUseCase- 결제 거절 같은 예상된 실패를 결과로 바꾼다
- 「이번 주문 확정만 실패」로 다룬다
- 실패 결과를 UI나 API에 반환하기 쉬운 형태로 만든다
- UI 버튼 핸들러 / Controller
- 예상하지 못한 예외를 모아 받는다
orderId,userId,requestId를 붙여 주 로그한다- 대화상자 표시나 500 / 503 응답으로 변환한다
- 미처리 예외 핸들러
- 거기까지 샌 것만 기록한다
- dump나 최종 flush를 한다
- 회복이 아니라 종료 경로를 우선한다
이렇게 나누면 기술 세부는 아래에서 닫고, 운영 문맥은 위에서 붙이고, 판단은 경계에서 합니다.
각 계층이 실제로 남기는 로그
같은 한 번의 실패에 대해 각 계층이 실제로 무엇을 쓰는지까지 구체화하면 분담이 분명해집니다. 이번 주문에서 「결제 서비스가 2회 타임아웃하고, 3회째에 성공했으나, 그 뒤 재고 차감에서 전제 붕괴가 일어났다」는 가정입니다.
| 계층 | 남기는 로그 | 레벨 | 메시지 예 |
|---|---|---|---|
PaymentGateway |
retry의 각 시도 | Warning |
결제 서비스 연결을 재시도합니다. attempt={Attempt}/{MaxAttempts}, orderId={OrderId} |
PaymentGateway |
번역해 다시 throw할 때 | 남기지 않는다 | ─ (주 로그는 경계의 역할) |
SaveOrderUseCase |
예상된 실패를 결과화했을 때 | Information |
주문의 결제가 거절되었습니다. orderId={OrderId}, reason={DeclineReason} |
SaveOrderUseCase |
예상하지 못한 예외 | 남기지 않는다 | ─ (그대로 경계로 보낸다) |
| UI 버튼 핸들러 / Controller | 예상하지 못한 예외의 주 로그 | Error |
주문 확정에 실패했습니다. orderId={OrderId}, userId={UserId} + 예외 객체 |
| 미처리 예외 핸들러 | 최종 기록 | Critical |
미처리 예외로 프로세스를 종료합니다 + 예외 객체 |
요점은 Error가 나오는 것은 한 줄뿐이라는 점입니다. retry의 각 시도는 Warning, 예상된 실패는 Information으로 내려 두었으므로, Error로 검색하면 이 장애가 1건으로 잡힙니다.
// 주 로그. 예외 객체를 제1 인수로 넘기고, 실패 단위의 문맥을 이름 있는 자리 표시로 싣는다
_logger.LogError(ex, "주문 확정에 실패했습니다. orderId={OrderId}, userId={UserId}",
orderId, userId);
예외 객체를 제1 인수로 넘기는 것을 잊으면 스택 트레이스가 기록되지 않습니다. _logger.LogError(ex.Message)처럼 문자열만 넘기면 나중에 원인을 쫓을 수 없게 됩니다.
5. 예상된 실패와 예상하지 못한 예외를 나누기
이 주제에서 가장 중요한 것은 전부를 같은 「예외」로 다루지 않는 것입니다.
우선 이렇게 나눠 봅니다.
| 실패의 종류 | 먼저 다루는 위치 | 전형적인 취급 |
|---|---|---|
| validation 미비 | UseCase / request 경계 | 입력 오류로 반환한다 |
NotFound / Conflict |
UseCase / Controller | 404 / 409나 화면 메시지 |
| 사용자 취소 / 셧다운 | 조작 경계 | 취소로 다룬다. 보통은 Error로 두지 않는다 |
| CSV 1행 오류 | 1행 경계 | Warning으로 기록하고 다음으로 진행한다 |
| 일시적 timeout으로 최종 실패 | I/O 경계〜request 경계 | retry 후에 실패로 반환한다 |
NullReferenceException, 전제 붕괴 |
request / job 경계 | 주 로그하고 실패 응답 |
AccessViolationException, 심각한 OutOfMemoryException, native 경계 파괴 냄새 |
최종 경계 | Critical로 종료에 가깝게 |
예상된 실패는 설계에서 미리 정할 수 있는 실패입니다. 예상하지 못한 예외는 이후에도 상태를 믿어도 될지 수상한 실패입니다.
이 둘만 나눠도 이런 사고가 줄어듭니다.
NotFound를 매번Error로 둔다- 사용자 취소를 장애로 다룬다
- 정말로 위험한 전제 붕괴를 「이번만 실패」로 흘려보낸다
6. 로그는 어디서 몇 번 남겨야 하는가
로그 설계에서는 catch 위치보다 누가 주 로그를 남기는지를 먼저 정하는 편이 중요합니다.
기본 규칙은 여섯 가지입니다.
- 하나의 실패에 대해, 주된
Error/Critical로그는 1회 - 하위 계층은 필요하면 번역과 문맥 추가를 한다
- 상위 경계는 실패 단위와 운영 문맥을 붙여 주 로그를 남긴다
- 그 자리에서 삼키는 계층만이, 그 삼킨 실패의 기록 책임을 진다
- 예상된 실패는 매번
Error로 두지 않는다 OperationCanceledException은 보통의 장애 로그에서 나눈다
로그 지점을 대략 표로 둡니다. 3장의 표가 「어느 계층에 어떤 책임을 둘지」였다면, 이쪽은 실패 종류마다 어디서 어느 레벨의 기록을 남길지의 표입니다. 구현 중에 「이 catch에서 로그를 남겨야 하는가」로 망설이면 여기를 보십시오.
| 상황 | 주로 로그하는 위치 | 레벨 기준 | 보충 |
|---|---|---|---|
| validation 오류 | request / use case 경계 | Information 또는 로그 없음 |
장애가 아니라 계약상의 실패 |
| 사용자 취소 / shutdown | 조작 경계 | Debug / Information |
보통은 Error로 두지 않는다 |
| retry 중의 일시 실패 | retry를 가진 계층 | Debug / Warning |
최종 실패 전에는 너무 떠들지 않는다 |
| retry를 다 쓰고 실패 | request / job 경계, 또는 그 자리에서 삼키는 계층 | Warning / Error |
실패 단위를 붙여 기록 |
| 1행만 오류여도 계속 | item 경계 | Warning |
fileId, rowNumber를 붙인다 |
| request 전체를 실패시키는 예상하지 못한 예외 | request / UI / job 경계 | Error |
requestId, userId, entityId를 붙인다 |
| 프로세스 종료급 | 미처리 예외 경계 | Critical |
flush, dump, 재시작 경로 |
실무에서 꽤 많은 것이 이런 중복 로그입니다.
- Repository가
Error - Service가 같은 예외를
Error - Controller가 다시
Error - 마지막 미처리 예외 핸들러에서도
Critical
이러면 한 번의 장애로 같은 스택 트레이스가 여러 줄 나란히 쌓입니다. 읽는 쪽이 원하는 것은 같은 stack trace 4줄이 아니라, 주 로그 1줄과, 필요하면 소수의 보조 로그입니다.
바꿔 말하면 로그는 1회, 문맥은 필요한 만큼이 기본입니다.
7. 흔한 NG
여기서부터는 리뷰에서 실제로 자주 나오는 패턴을 나열합니다. 대표적인 세 가지에는 NG와 OK의 최소 코드를 붙였습니다. 코드는 C# 10 / .NET 6 이후, nullable 참조 형을 켠 전제이며, System.Text.Json과 Microsoft.Extensions.Logging을 사용합니다.
7.1. 깊은 계층에서 catch (Exception)해서 null / false를 반환한다
이는 원인 정보가 사라지기 쉽습니다. 게다가 호출 측은 「정말로 데이터가 없었다」인지 「도중에 깨졌다」인지 구분할 수 없게 됩니다.
// NG: 깊은 계층에서 넓게 받아 null을 반환한다
private static Order? LoadOrder(string path)
{
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Order>(json);
}
catch (Exception)
{
// 호출 측에서는 파일이 없었는지, JSON이 깨져 있었는지,
// 디스크를 읽지 못했는지를 구분할 수 없다
return null;
}
}
이 계층은 실패를 어떻게 다뤄야 하는지 판단할 수 없습니다. 판단은 경계로 넘기고, 여기서는 의미 있는 실패로 번역하는 데까지로 그칩니다.
// 이 계층이 throw하는, 의미 있는 실패를 나타내는 예외 형
public sealed class OrderFileFormatException : Exception
{
public OrderFileFormatException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
// OK: 번역만 하고, 판단은 위 경계로 넘긴다
private static Order LoadOrder(string path)
{
string json = File.ReadAllText(path);
try
{
return JsonSerializer.Deserialize<Order>(json)
?? throw new OrderFileFormatException($"주문 파일이 비어 있습니다: {path}");
}
catch (JsonException ex)
{
// 하위 구현 사정인 JsonException을, 이 계층에서 의미 있는 실패로 바꾼다
throw new OrderFileFormatException($"주문 파일 형식이 올바르지 않습니다: {path}", ex);
}
// IOException이나 UnauthorizedAccessException은 번역하지 않고 그대로 위로 보낸다.
// 「파일을 읽을 수 없다」는 이 계층이 의미를 더할 수 있는 실패가 아니기 때문
}
7.2. 각 계층에서 Error 로그를 남긴 뒤 다시 throw한다
가장 흔한 중복 로그의 원인입니다.
- 하위 계층은 번역만
- 상위 경계가 주 로그
라는 분담으로 두면 꽤 줄일 수 있습니다.
// NG: 하위 계층에서 로그한 뒤 다시 throw한다. 위에서도 같은 예외가 로그되어 2줄이 된다
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "결제에 실패했습니다");
throw;
}
}
하위 계층은 번역해서 그대로 통과시키기만 합니다.
// PaymentGatewayException은 OrderFileFormatException과 같은 형태의,
// 「결제 서비스와의 주고받기가 실패했다」는 것을 나타내는 독자 예외 형입니다
// OK: 하위 계층(PaymentGateway)은 번역만. 로그는 남기지 않는다
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
throw new PaymentGatewayException(
$"결제 서비스에 연결하지 못했습니다. orderId={payment.OrderId}", ex);
}
}
그 위에서, 실패 단위와 운영 문맥이 모이는 경계에서 주 로그를 한 번만 남깁니다.
// OK: 경계에서 주 로그를 한 번만 남기고, 호출 측으로의 응답을 정한다
[ApiController]
public sealed class PaymentController : ControllerBase
{
private readonly ILogger<PaymentController> _logger;
private readonly SaveOrderUseCase _useCase;
public PaymentController(ILogger<PaymentController> logger, SaveOrderUseCase useCase)
{
_logger = logger;
_useCase = useCase;
}
[HttpPost("orders/{orderId}/pay")]
public async Task<IActionResult> PayAsync(string orderId, CancellationToken ct)
{
try
{
Receipt receipt = await _useCase.ExecuteAsync(orderId, ct);
return Ok(receipt);
}
catch (PaymentGatewayException ex)
{
// 실패 단위(이 주문의 결제 1회)와 운영 문맥이 모이는 곳은 여기뿐이다
_logger.LogError(ex, "주문 {OrderId}의 결제에 실패했습니다", orderId);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
}
C#에서 다시 throw한다면 스택 트레이스를 깨지 않도록 throw;를 쓰는 것이 기본입니다. throw ex;라고 쓰면 그 줄에서 스택 트레이스가 덮어쓰여, 실제 발생 위치가 사라집니다.
7.3. 라이브러리 계층이나 공통 컴포넌트가 UI를 직접 낸다
공통 컴포넌트가 MessageBox를 띄우거나 HTTP 응답 본문을 직접 정하면, 재사용성도 책임 분리도 무너집니다.
하위 계층은 의미 있는 실패를 반환하는 데까지로 모으는 편이 안전합니다.
7.4. OperationCanceledException을 장애로 Error 로그한다
취소는 제어 흐름의 일부입니다.
매번 Error로 두면 진짜 장애가 묻힙니다.
// NG: 넓은 catch에 취소까지 끌어넣어 Error로 둔다
try
{
await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
// 사용자가 「중지」를 눌렀을 뿐인데도 여기로 와서 Error가 나온다
_logger.LogError(ex, "가져오기에 실패했습니다");
throw;
}
catch 절은 위에서부터 평가되므로, 취소만 먼저 더 구체적인 형으로 받습니다. when 절을 붙여 두면, 이쪽이 넘긴 토큰에 의한 중단과, 내부 타임아웃 등 다른 이유로 난 OperationCanceledException을 혼동하지 않습니다.
// OK: 취소를 먼저 잡아, 장애 로그에서 나눈다
try
{
await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 사용자의 중단이나 셧다운. 제어 흐름의 일부이므로 Error로 두지 않는다
_logger.LogInformation("가져오기를 중단했습니다. fileId={FileId}", file.Id);
}
catch (Exception ex)
{
// 여기로 오는 것은 예상하지 못한 실패만. 실패 단위 문맥을 붙여 주 로그를 1회 남긴다
_logger.LogError(ex, "가져오기에 실패했습니다. fileId={FileId}", file.Id);
throw;
}
7.5. 외부 부작용이 있는데 쉽게 retry한다
메일 발송, 과금, 장치 명령, 파일 이동처럼, 같은 조작을 한 번 더 하면 사고가 나는 것은 많습니다. retry는 일시적 실패와 멱등성이 둘 다 보일 때만입니다.
7.6. 마지막 미처리 예외 핸들러에서 무엇이든 회복하려 한다
여기는 마지막 보험입니다. 설계의 중심에 두는 위치가 아닙니다.
회복 전략은 그보다 앞 계층, 즉 request / job / subsystem 경계에 두는 편이 안전합니다.
8. 리뷰 시 체크리스트
예외 처리 리뷰에서는 이 순서로 보면 빠짐이 적습니다.
- 이
catch는 무엇을 판단하기 위해 있는지를 한 문장으로 말할 수 있는가 - 이 위치에서 retry / 결과화 / 계속 여부 / 사용자 응답을 정말 정할 수 있는가
- 여기서 로그하면 위 계층에서도 같은 실패를
Error하지 않는가 - 하위 구현 고유 예외를 경계에서 의미 있는 실패로 번역하고 있는가
- 도중에 깨진 상태를 여기서 되돌릴 수 있는가. 못 하면 폐기 전제로 되어 있는가
OperationCanceledException을 보통의 장애에서 나누고 있는가- item 단위 계속인지, request 단위 실패인지, process 종료인지가 명확한가
- 마지막 미처리 예외 핸들러에 회복이 아니라 기록을 기대하고 있는가
- 로그에 requestId / userId / batchId / fileId / rowNumber 등 실패 단위 문맥이 실려 있는가
- 「예상된 실패」와 「전제 붕괴」를 같은 취급으로 두지 않았는가
이 체크리스트에서 특히 효과가 있는 것은 「이 catch는 무엇을 정하고 있는가」를 매번 말로 하는 것입니다.
여기를 말할 수 없는 catch는 대개 불필요하거나, 위치가 너무 깊습니다.
9. 대략적인 구분
마지막으로 3장과 6장을 한 장으로 접은 확인용 표를 둡니다. 리뷰할 때나 다 쓴 코드를 다시 볼 때, 이 한 장만 보면 되도록 한 것입니다.
| 장면 | catch |
로그 | 에러 처리 |
|---|---|---|---|
| helper / utility | 원칙적으로 하지 않는다 | 하지 않는다 | 하지 않는다 |
| Repository / Gateway / SDK 래퍼 | 구체 예외만 받는다 | 보통은 주 로그하지 않는다 | 번역, 국소적 retry, 연결 폐기 |
| UseCase / Application Service | 예상된 실패를 받는다 | 삼킨다면 필요에 따라 | 결과화, 부분 실패화 |
| UI / Controller / request / item / job 경계 | 예상하지 못한 예외를 넓게 받는다 | 주 로그 | 응답, 메시지, 계속 / abort |
| 미처리 예외 핸들러 | 샌 것만 | Critical |
최종 기록, 종료 경로 |
망설일 때는 우선 이 다섯만으로 충분합니다.
- 깊은 계층에서는 넓게 잡지 않는다
- 경계에서 받는다
- 주 로그는 1회
- 삼키는 계층이 책임을 진다
- 마지막 미처리 예외는 기록과 종료 경로
10. 정리
예외 처리는 「어디서든 catch할 수 있으니 어디서든 catch한다」는 이야기가 아닙니다.
보는 순서는 대략 이것으로 충분합니다.
- 이 위치에서 정말 판단할 수 있는가
- 여기서 실패 단위가 보이는가
- 여기서 상태를 되돌릴 수 있는가, 다시 만들 수 있는가
- 여기서 로그하면 중복되지 않는가
- 여기는 회복 지점인가, 아니면 마지막 기록 지점인가
이 순서로 보면 호출 계층 정리는 꽤 쉬워집니다.
특히 중요한 것은 다음 세 가지입니다.
- 깊은 계층은 주로 번역과 cleanup
- 경계는 주로 판단과 주 로그
- 마지막 미처리 예외 핸들러는 주로 기록과 종료 경로
바꿔 말하면, 예외는 경계에서 받고, 문맥을 붙이고, 회복할 수 있는 곳에서만 처리하는 것이 기본입니다.
이것이 정해지면 코드 리뷰에서도 장애 조사에서도 꽤 흔들리기 어렵습니다.
11. 참고 자료
- .NET: 예외 베스트 프랙티스
- .NET: System.AppDomain.UnhandledException 이벤트
- WPF: Application.DispatcherUnhandledException 이벤트
- Windows Forms: Application.ThreadException 이벤트
- ASP.NET Core의 오류 처리
- ASP.NET Core의 미들웨어
- BackgroundService를 사용한 Windows 서비스
12. 관련 기사
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
예기치 않은 예외가 났을 때 종료할지 계속할지 판단표
예상하지 못한 예외가 났을 때 앱을 종료할지 계속할지를, 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
Write-Host를 그만두기 ── PowerShell의 출력 스트림과 로그 설계
PowerShell 6가지 출력 스트림의 구분 사용, Write-Host가 가진 문제와 올바른 쓰임새, 함수 반환값이 오염되는 원인, -Verbose와 -InformationVariable로 호출 측에서 제어하는 방법, 구조화 로그를 남기는 방법...
Windows 앱 크래시 시 로그와 덤프를 남기는 설계
Windows 앱이 예상 밖 예외나 프로그램 실수로 죽어도 나중에 원인을 추적할 수 있는 증거를 남기기 위해, 통상 로그, 최종 크래시 마커, WER LocalDumps, 감시 프로세스를 어떻게 조합해야 하는지를 정리합니다.
Windows 앱 개발 보안 최소 체크리스트
WPF / WinForms / WinUI / C++ / C# 업무 앱에서 권한, 서명, 업데이트, 비밀 정보, HTTPS, 입력 검증, DLL 로드, 로그의 기본을 체크리스트 형식으로 정리합니다.
멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석
Java 멀티스레드는 스레드를 직접 만들지 않고 ExecutorService와 가상 스레드에 맡기는 것이 정석입니다. synchronized와 ReentrantLock의 구분, interrupt를 통한 협력적 중단, ConcurrentHashMa...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
장애 조사 & 원인 분석
간헐적 장애, 장기 가동 중 크래시, 누수, 통신 중단 등 까다로운 프로덕션 이슈를 조사합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 예외는 어느 계층에서 catch해야 합니까?
- 원칙은 깊은 계층에서 넓게 catch하지 않고, 실패 단위를 정의할 수 있는 경계로 모으는 것입니다. 화면 조작 1회, HTTP 요청 1건, 작업 1건, 메시지 1건 같은 처리 경계가 자연스러운 수신 지점이 됩니다. catch할 수 있는 곳이 아니라, retry·결과화·계속 여부를 책임지고 판단할 수 있는 곳에서 받는 것이 기본입니다. 깊은 helper나 utility에서는 finally로 뒷정리하거나 국소적으로 롤백하고, 예외를 번역하는 데까지로 한정합니다.
- 예외 로그는 각 계층에서 남겨야 합니까?
- 하나의 실패에 대한 주된 Error / Critical 로그는 1회가 기본입니다. Repository에서 Error, Service에서 같은 예외를 Error, Controller에서 다시 Error처럼 각 계층에서 남기면, 한 번의 장애로 같은 스택 트레이스가 여러 줄 나란히 쌓여 읽는 쪽이 힘들어집니다. 하위 계층은 번역과 문맥 추가까지로 그치고, requestId나 userId 같은 운영 문맥이 모이는 상위 경계에서 주 로그를 남깁니다. 예외를 삼켜 결과로 바꾸는 계층만이 그 실패의 기록 책임을 집니다.
- 예상된 실패와 예상하지 못한 예외는 어떻게 나눕니까?
- 예상된 실패는 설계에서 미리 정할 수 있는 실패이며, validation 미비나 NotFound 등은 유스케이스 단위로 결과화하고, 매번 Error 로그로 두지 않습니다. 사용자 취소에 의한 OperationCanceledException도 보통은 Error로 다루지 않습니다. 반면 NullReferenceException 같은 전제 붕괴는 request / job 경계에서 주 로그를 남기고 실패 응답으로 처리하며, AccessViolationException이나 심각한 OutOfMemoryException은 Critical로 종료에 가깝게 다룹니다. 이 둘만 나눠도, 정말로 위험한 실패가 묻히는 사고가 줄어듭니다.
- 미처리 예외 핸들러에서는 무엇을 해야 합니까?
- AppDomain.UnhandledException, WPF의 DispatcherUnhandledException, WinForms의 ThreadException 등은 회복 지점이 아니라 마지막 기록 지점입니다. 주된 책임은 최종 로그, flush, 덤프 채취 경로, 종료 코드나 재시작 경로를 갖추는 것까지입니다. 여기까지 샌 시점에서 상태가 깨져 있을 수 있으므로, 겉보기에 이어갈 수 있어도 계속해도 된다고 단정할 수 없습니다. 회복 전략은 그보다 앞의 request나 job 경계에 두는 편이 안전합니다.