예외 처리에서 catch와 로그는 어디에 두어야 하는가

· 업데이트: · · 예외 처리, 로그, 에러 처리, 설계, C# / .NET

수정 이력(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 같은 반환값으로 바꾸는 것입니다. 예상된 실패를 호출 측이 분기로 다룰 수 있는 형태로 만드는 조작을 가리킵니다

목차

  1. 먼저 결론
  2. catch와 로그와 에러 처리는 별개
    • 2.1. catch하는 것
    • 2.2. 로그를 남기는 것
    • 2.3. 에러 처리하는 것
    • 2.4. 예외를 번역하는 것
  3. 먼저 보는 판단표
  4. 호출 계층에서 어디서 무엇을 하는가
    • 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. 하나의 호출 계층으로 보기
  5. 예상된 실패와 예상하지 못한 예외를 나누기
  6. 로그는 어디서 몇 번 남겨야 하는가
  7. 흔한 NG
  8. 리뷰 시 체크리스트
  9. 대략적인 구분
  10. 정리
  11. 참고 자료
  12. 관련 기사

이 글의 지식 맵

이 글은 C#/.NET 예외 처리에서, 예외를 잡는 경계와 주 로그를 남기는 장소를 나눌 것을 제안합니다. 가장 깊은 helper/utility 층에서는 넓게 catch하지 않고, Repository/Gateway 층은 하위 구현 고유의 예외를 의미 있는 실패로 번역하며, Application Service/UseCase 층은 예상된 실패를 결과로 변환하고, UI/HTTP/Job 경계가 실패 단위와 운영 맥락을 맞춰 주 로그를 한 번만 남기는 지점이 되기 쉽다고 합니다. 처리되지 않은 예외 핸들러는 복구 지점이 아니라 마지막 기록 지점이며, 종료·재시작 경로를 갖춥니다. 각 층에서 중복된 Error 로그, throw ex로 인한 스택 트레이스 손실, OperationCanceledException을 장애 로그로 다루는 것은, 모두 원인 추적을 어렵게 하므로 피해야 할 대응으로 둡니다.

예외 처리에서 catch와 로그를 어디에 두어야 하는지의 지식 맵예외를 잡는 경계·주 로그·결과 변환·예외 번역이라는 역할을, helper 층에서 Repository/Gateway 층·UseCase 층·UI/HTTP/Job 경계·처리되지 않은 예외 핸들러로 어떻게 배분하는지를 보여주는 그림사용은 비권장권장되는 대응원인이 될 수 있다원인이 될 수 있다방지한다구현을 담당한다이용한다구현을 담당한다권장되는 대응구현을 담당한다사용은 비권장원인이 될 수 있다원인이 될 수 있다구현을 담당한다권장되는 대응보다 먼저 해야 한다전제로 한다이용한다이용한다사용은 비권장사용은 비권장사용은 비권장권장되는 대응권장되는 대응사용은 비권장예외를 잡는 경계주 로그깊은 계층의 넓은 catchhelper/utility/private method 계층실패 단위장애 원인 추적의 어려움각 계층의 중복 로그Repository/Gateway/SDK 래퍼 계층.NET 예외 변환국소적 retryApplication Service/UseCase 계층Result형화예상된 실패UI/HTTP/Job/Message 경계OperationCanceledExceptionthrow ex;에 의한 스택 트레이스 재작성스택 추적 상실처리되지 않은 예외 처리기종료·재시작 경로BackgroundService의 처리되지 않은 예외예상치 못한 예외

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

1. 먼저 결론

  • 원칙은 깊은 계층에서 넓게 catch하지 않는 것입니다. catch하는 위치는 실패 단위를 정의할 수 있는 경계로 모읍니다.
  • 로그는 하나의 실패에 대해 하나의 주 로그를 기본으로 합니다. 각 계층에서 같은 예외를 계속 Error하면 읽는 쪽이 힘들어집니다.
  • 가장 깊은 계층의 책임은 뒷정리, 국소적 롤백, 예외 번역, 필요하면 한정적인 retry입니다. 다시 throw한다면 보통은 거기서 주 로그를 남기지 않습니다.
  • 화면 조작, HTTP 요청, 작업 1건, 메시지 처리 1건 같은 처리 경계가 가장 자연스러운 주 로그 지점이 되기 쉽습니다.
  • 예상된 실패는 그 유스케이스 단위로 결과화합니다. 반드시 전부를 예외로 위까지 계속 던질 필요는 없습니다.
  • AppDomain.UnhandledException, WPF의 DispatcherUnhandledException, WinForms의 ThreadException, ASP.NET Core의 예외 핸들러, 호스트의 최종 예외 처리는 회복 지점이라기보다 마지막 기록 지점입니다.
  • 사용자 취소나 셧다운에 의한 OperationCanceledException은 보통 Error로 다루지 않습니다.
  • 망설이면 다음 순서로 봅니다.
    1. 이 위치에서 정말 판단할 수 있는가
    2. 실패한 단위가 여기서 보이는가
    3. 여기서 상태를 되돌릴 수 있는가, 다시 만들 수 있는가
    4. 여기서 로그하면 같은 예외를 위에서도 로그하지 않는가

요컨대 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와 「처리한다」 사이에 또 하나 중요한 일이 있습니다. 그것이 번역입니다.

예를 들어,

  • HttpRequestException
  • IOException
  • JsonException
  • 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, 종료·재시작 경로

그림으로 보면 대략 이렇습니다.

아니요아니요아니요예외가 일어났다이 위치에서 retry / 결과화 / 계속 여부를 정할 수 있는가?원칙적으로 catch하지 않고 위로 보낸다여기는 계층 경계인가?국소적 cleanup만필요하면 의미 있는 예외로 번역여기서 실패 단위와 운영 문맥이 보이는가?주 로그는 남기지 않고 상위로 보낸다주 로그를 1회 남기고 응답을 정한다필요하면 종료 / 재초기화 / 다음 건 계속

이 그림의 요점은 두 가지입니다.

  1. catch의 첫 이유는 회복이나 cleanup이지, 로그가 아니다
  2. 로그의 첫 이유는 운영 문맥이 갖춰진 것이지, 예외를 발견한 것이 아니다

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의 고유 예외
  • 파싱 라이브러리나 시리얼라이저의 예외

이 계층에서 하는 일은 대략 네 가지입니다.

  1. 구체 예외를 받는다 넓은 Exception이 아니라, 의미가 있는 구체 예외를 받습니다.

  2. 의미 있는 실패로 번역한다 상위 계층이 하위의 사정을 직접 알지 않아도 되게 합니다.

  3. 국소적으로 retry한다면 여기서 한다 다만 조건은 엄격합니다.
    • 일시적 실패로 알려져 있다
    • 멱등성이 있다
    • 상한 횟수와 대기 방법이 정해져 있다
    • 실패 시 최종 동작이 명확하다 이 네 가지가 갖춰질 때만입니다.
  4. 깨진 연결이나 핸들을 버린다 「다음에도 같은 객체로 이어간다」보다 「연결을 다시 만든다」가 안전한 경우가 많습니다.

여기의 로그 방침은 이렇게 생각하면 흔들리지 않습니다.

  • 위로 다시 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. 하나의 호출 계층으로 보기

예를 들어 이런 흐름을 생각합니다.

UI / Controller / Job 경계Application Service / UseCaseDomain / 업무 로직Repository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

이때 역할은 대략 이렇게 나뉩니다.

저장 버튼 → SaveOrderUseCasePaymentGateway → 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 위치보다 누가 주 로그를 남기는지를 먼저 정하는 편이 중요합니다.

기본 규칙은 여섯 가지입니다.

  1. 하나의 실패에 대해, 주된 Error / Critical 로그는 1회
  2. 하위 계층은 필요하면 번역과 문맥 추가를 한다
  3. 상위 경계는 실패 단위와 운영 문맥을 붙여 주 로그를 남긴다
  4. 그 자리에서 삼키는 계층만이, 그 삼킨 실패의 기록 책임을 진다
  5. 예상된 실패는 매번 Error로 두지 않는다
  6. 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.JsonMicrosoft.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. 깊은 계층에서는 넓게 잡지 않는다
  2. 경계에서 받는다
  3. 주 로그는 1회
  4. 삼키는 계층이 책임을 진다
  5. 마지막 미처리 예외는 기록과 종료 경로

10. 정리

예외 처리는 「어디서든 catch할 수 있으니 어디서든 catch한다」는 이야기가 아닙니다.

보는 순서는 대략 이것으로 충분합니다.

  1. 이 위치에서 정말 판단할 수 있는가
  2. 여기서 실패 단위가 보이는가
  3. 여기서 상태를 되돌릴 수 있는가, 다시 만들 수 있는가
  4. 여기서 로그하면 중복되지 않는가
  5. 여기는 회복 지점인가, 아니면 마지막 기록 지점인가

이 순서로 보면 호출 계층 정리는 꽤 쉬워집니다.

특히 중요한 것은 다음 세 가지입니다.

  • 깊은 계층은 주로 번역과 cleanup
  • 경계는 주로 판단과 주 로그
  • 마지막 미처리 예외 핸들러는 주로 기록과 종료 경로

바꿔 말하면, 예외는 경계에서 받고, 문맥을 붙이고, 회복할 수 있는 곳에서만 처리하는 것이 기본입니다.

이것이 정해지면 코드 리뷰에서도 장애 조사에서도 꽤 흔들리기 어렵습니다.

11. 참고 자료

12. 관련 기사

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

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

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

자주 묻는 질문

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

예외는 어느 계층에서 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 경계에 두는 편이 안전합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기