C# · 심층 가이드
C# 에러 처리 완전 정리
try catch의 기본형에서 시작해 사용자 정의 예외, TryParse 계열 안전 파싱, 예외 필터, 도메인 오류와 Result 반환까지 C# 오류 처리 열다섯 가지를 다룹니다.
C#에는 검사 예외가 없습니다. 메서드 시그니처만 봐서는 무엇이 던져질지 알 수 없고, 컴파일러도 잡으라고 강요하지 않습니다. 그래서 오류 처리 설계가 전적으로 작성자 몫이 됩니다. 실패를 예외로 알릴지, 값이 없을 뿐이라고 볼지, 아니면 반환 타입에 결과와 실패를 함께 담을지를 API 경계마다 정해야 합니다. 이 절의 예제들은 그 세 갈래를 번갈아 보여 줍니다. 기준을 하나 잡자면, 호출하는 쪽이 충분히 예상할 수 있는 실패는 반환값으로 알리고 예상 밖의 상태만 예외로 올리는 편이 대체로 잘 맞습니다.
try catch 기본과 finally 정리로 흐름을 잡은 다음 안전한 숫자 파싱과 안전한 나눗셈을 보면, 예상되는 실패에는 예외를 쓰지 않는다는 감각이 생깁니다. 사용자 정의 오류와 도메인 오류 표현은 예외를 계층으로 정리하는 쪽이고, Result 반환과 다단계 검증은 아예 예외를 쓰지 않는 쪽입니다. 입력 가드는 이 모든 처리를 메서드 앞단으로 끌어올려 본문을 얇게 만드는 기법이고, fallback 값 사용과 recover 흐름은 실패한 뒤에도 서비스를 계속 굴리는 쪽에 초점을 둡니다.
오류 다시 던지기에서 throw ex;와 throw;는 완전히 다릅니다. 앞의 형태는 스택 추적을 그 지점에서 새로 시작해, 실제로 문제가 난 위치를 지워 버립니다. 다시 던질 때는 예외 변수 없이 throw;만 씁니다. 예외 필터도 알아 둘 만합니다. catch (Exception e) when (조건)의 조건식은 스택이 풀리기 전에 평가되므로, 조건이 거짓이면 그 프레임이 그대로 남아 디버깅에 유리합니다. 사용자 입력을 숫자로 바꿀 때는 문화권에 따라 소수점 기호가 달라 Parse가 실패할 수 있다는 점도 함께 챙깁니다.
01try catch 기본
int.Parse("x")가 숫자로 해석할 수 없는 문자열을 만나 FormatException을 던지고, catch가 그 메시지를 출력합니다. 실패 지점을 감싸 프로그램이 중단되지 않게 하는 기본 형태입니다. 다만 Exception을 통째로 잡으면 메모리 부족 같은 무관한 오류까지 삼키므로, 예상되는 예외 타입만 지정하는 편이 낫습니다.
C# code
// try catch 기본
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }예외 변수를 쓰지 않는다면 catch (FormatException)처럼 타입만 적어도 되고, 조건에 따라 잡을지 고르려면 when 필터를 덧붙일 수 있습니다.
catch 블록을 비워 두면 실패가 아무 흔적 없이 사라져, 나중에 값이 이상해진 원인을 되짚을 수 없게 됩니다. 최소한 기록은 남겨야 합니다.
02사용자 정의 오류
Require는 조건이 거짓일 때 메시지를 담아 예외를 던지는 지역 함수이고, 여기서는 true를 넘겨 통과합니다. 검증 로직을 한 줄로 재사용하는 데 쓰입니다. 던지는 타입이 Exception이라는 점이 아쉬운데, 호출 측에서 어떤 실패인지 구분할 수 없으므로 ArgumentException처럼 구체적인 타입이나 직접 정의한 파생 클래스를 쓰는 편이 좋습니다.
C# code
// 사용자 정의 오류
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");직접 예외 타입을 만들 때는 메시지만 받는 생성자와 함께 내부 예외를 받는 생성자를 두어야, 원인 예외를 잃지 않고 감싸 올릴 수 있습니다.
예외를 감싸면서 원본을 내부 예외로 넘기지 않으면 진짜 원인이 사라집니다. 새 예외를 던질 때는 잡은 예외를 두 번째 인자로 반드시 전달해야 합니다.
03입력 검증
입력을 미리 검사하는 대신, 변환을 시도하고 실패하면 잡아서 메시지를 찍는 구조입니다. 외부에서 들어온 값을 다룰 때 자주 보이는 형태입니다. 다만 예외 발생에는 스택 정보를 수집하는 비용이 붙으므로, 사용자 입력처럼 실패가 흔한 경로에서는 검증을 먼저 하고 예외는 정말 예외적인 경우에만 남기는 것이 좋습니다.
C# code
// 입력 검증
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }예외 비용은 대부분 스택 트레이스를 수집하고 푸는 과정에서 나오며 실제로 예외가 발생할 때만 듭니다. try 블록에 들어가는 것 자체는 거의 공짜입니다.
int.Parse는 앞뒤 공백은 허용하지만 쉼표나 통화 기호가 섞이면 실패합니다. 사용자가 붙여 넣은 값을 그대로 넘기면 예상보다 훨씬 자주 예외가 납니다.
04안전한 숫자 파싱
이 코드에는 파싱이 없고, 조건 검사에 실패하면 예외를 던지는 가드 함수만 정의돼 있습니다. 문자열을 숫자로 바꿀 때는 int.TryParse가 예외 대신 bool을 돌려주므로 흐름이 단순해집니다. 실패 시 out 변수에는 0이 들어가므로, 반환값을 확인하지 않고 결과만 쓰면 오류를 놓칩니다.
C# code
// 안전한 숫자 파싱
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");if (int.TryParse(s, out var n))처럼 out 변수를 그 자리에서 선언하면 검사와 사용을 한 줄에 담을 수 있고, 변수의 범위도 필요한 곳으로 좁아집니다.
false가 돌아왔는데 out 변수를 그대로 쓰면 0이 정상 입력으로 취급됩니다. 실패와 값 0을 구분하려면 반환값을 반드시 분기 조건에 써야 합니다.
05안전한 나눗셈
나눗셈은 없고 int.Parse 실패를 잡아 메시지를 출력합니다. 0으로 나누는 경우도 이런 방어가 필요한 대표적인 자리입니다. 짚어 둘 점은 타입에 따라 동작이 갈린다는 것으로, 정수 나눗셈은 DivideByZeroException을 던지지만 double 나눗셈은 예외 없이 Infinity나 NaN을 만들어 냅니다.
C# code
// 안전한 나눗셈
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }decimal 나눗셈은 double과 달리 0으로 나누면 DivideByZeroException을 던지므로, 금액 계산에서는 무한대가 조용히 퍼지는 일이 없습니다.
double 나눗셈 결과를 크기 비교로만 검사하면 NaN이 모든 비교에서 거짓이라 오류 경로를 그냥 지나칩니다. double.IsNaN으로 따로 확인해야 합니다.
06finally 정리
조건이 거짓이면 예외를 던지는 Require만 있고 finally 블록은 쓰이지 않았습니다. 파일 핸들이나 연결처럼 반드시 닫아야 하는 자원이 있으면 finally나 using으로 정리를 보장합니다. finally도 만능은 아니어서 Environment.Exit이나 프로세스 강제 종료 앞에서는 실행되지 않습니다.
C# code
// finally 정리
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");using 선언 형태로 쓰면 블록 끝이 아니라 변수의 범위가 끝날 때 정리가 실행되어, 중괄호를 한 겹 줄이면서도 해제는 그대로 보장됩니다.
finally 안에서 예외를 던지면 원래 올라가던 예외가 그것으로 덮여 사라집니다. 정리 코드가 실패할 여지가 있다면 그 안에서 따로 잡아야 합니다.
07오류 다시 던지기
예외를 잡아 메시지만 출력하고 흐름을 그대로 이어 가며, 다시 던지지는 않습니다. 로그만 남기고 상위로 넘겨야 할 때는 재던지기가 필요합니다. 이때 throw ex;로 쓰면 스택 트레이스가 그 지점에서 새로 시작되어 원래 발생 위치를 잃으므로, 반드시 인자 없는 throw;를 씁니다.
C# code
// 오류 다시 던지기
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }다시 던지기 전에 ex.Data에 값을 넣어 두면 스택 트레이스를 잃지 않고도 상위에서 어떤 입력이었는지 같은 맥락을 읽을 수 있습니다.
잡아서 아무 처리도 하지 않고 다시 던지기만 하는 블록은 없느니만 못합니다. 트레이스가 유지되더라도 코드만 늘고 실행 경로가 복잡해집니다.
08도메인 오류 표현
실패를 표현하는 수단이 문자열 메시지를 담은 Exception 하나뿐입니다. 재고 부족이나 잔액 초과 같은 업무 규칙 위반은 전용 예외 타입으로 나눠 두면 호출 측이 catch 절만으로 분기할 수 있습니다. 반대로 자주 일어나는 정상 흐름까지 예외로 표현하면 제어 흐름이 숨어 버려 추적이 어려워집니다.
C# code
// 도메인 오류 표현
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");예외 타입을 계층으로 설계해 두면 상위 타입 하나만 잡아 공통 응답을 만들고, 특별한 처리가 필요한 것만 아래에 별도 catch를 둘 수 있습니다.
catch 절은 위에서부터 검사되므로 Exception을 먼저 적으면 그 아래의 구체적인 타입에는 도달할 수 없고 컴파일 오류가 납니다.
09fallback 값 사용
catch 안에서 메시지를 찍기만 하고 대체 값을 돌려주지는 않습니다. 실패 시 기본값으로 이어 가려면 catch 블록에서 값을 대입하거나 TryParse 형태로 바꿉니다. 조건에 따라 잡을지 말지 고르고 싶다면 catch (FormatException ex) when (...)처럼 예외 필터를 쓸 수 있는데, 필터가 거짓이면 스택을 풀지 않고 그대로 통과시킵니다.
C# code
// fallback 값 사용
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }예외 필터가 거짓이면 스택을 풀지 않아 디버거가 발생 지점의 지역 변수를 그대로 보여 줍니다. 잡았다가 다시 던지는 방식보다 진단에 유리합니다.
실패할 때마다 조용히 기본값으로 넘어가면 설정이 깨졌는데도 서비스가 잘 도는 것처럼 보입니다. 대체 값을 쓸 때는 그 사실을 반드시 기록해야 합니다.
10assertion
Require(true, "ok")는 조건이 참이라 아무 일도 하지 않고 지나가며, 거짓이면 예외로 실행을 끊습니다. 코드가 스스로 전제를 검사하게 만드는 방식입니다. 비슷한 목적의 Debug.Assert는 릴리스 빌드에서 통째로 사라지므로, 운영 환경에서도 지켜야 하는 조건이라면 이렇게 직접 던지는 검사를 남겨야 합니다.
C# code
// assertion
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");운영에서도 지켜야 하는 전제라면 ArgumentOutOfRangeException처럼 의미가 드러나는 타입을 던져야 호출자가 무엇이 잘못됐는지 바로 알 수 있습니다.
메시지를 보간 문자열로 넘기면 조건이 참이어서 쓰이지 않을 때도 문자열이 매번 조립됩니다. 자주 불리는 경로에서는 이 비용이 실제로 드러납니다.
11Result 반환
성공과 실패를 값으로 돌려주는 대신, 예외를 잡아 메시지를 출력하는 방식입니다. 실패가 흔한 연산이라면 bool과 out 값을 함께 넘기는 TryParse 스타일이나 결과 타입을 정의해 반환하는 편이 호출 측 흐름을 명확하게 만듭니다. 이러면 호출자가 실패 처리를 잊는 일도 컴파일 단계에서 드러납니다.
C# code
// Result 반환
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }성공과 실패를 담는 결과 타입을 구조체로 만들면 실패 경로에서도 힙 할당이 생기지 않아, 실패가 흔한 연산에서는 예외보다 훨씬 가볍습니다.
결과 타입을 돌려줘도 호출부가 반환값을 무시하는 것을 막지는 못합니다. 컴파일러가 짚어 주게 하려면 반환값 사용을 요구하는 분석기 설정이 따로 필요합니다.
12다단계 검증
검증이 Require 한 번뿐이라 여러 규칙을 차례로 확인하지는 않습니다. 이 함수를 여러 번 이어 부르면 첫 실패에서 바로 예외가 나가고 뒤쪽 검사는 실행되지 않습니다. 사용자에게 잘못된 항목을 한꺼번에 보여 줘야 하는 화면이라면, 던지는 대신 오류 목록을 모아 마지막에 판단하도록 바꿔야 합니다.
C# code
// 다단계 검증
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");오류를 모아 보고하려면 검사마다 던지는 대신 메시지를 리스트에 담고, 목록이 비어 있지 않을 때 한 번만 예외로 감싸 올리는 구조가 다루기 편합니다.
검사 순서를 뒤섞으면 null 검사보다 앞에서 속성에 접근하게 되어, 보여 주려던 안내 대신 NullReferenceException이 먼저 터집니다.
13로깅 래퍼
ex.Message만 콘솔로 내보내므로 어디서 터졌는지는 남지 않습니다. 원인을 추적하려면 스택 트레이스와 내부 예외까지 포함하는 ex.ToString()이나 예외 객체를 그대로 받는 로거 오버로드를 씁니다. 로그에 예외 메시지를 그대로 실을 때는 경로나 연결 문자열 같은 민감한 정보가 섞이지 않는지 확인해야 합니다.
C# code
// 로깅 래퍼
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }구조화 로깅에서는 문자열을 미리 조립하지 말고 logger.LogError(ex, "{Id} 처리 실패", id)처럼 자리표시자와 값을 따로 넘겨야 나중에 필드로 검색할 수 있습니다.
같은 예외를 각 계층에서 잡아 다시 기록하면 하나의 사건이 여러 줄로 부풀어 원인 파악이 오히려 어려워집니다. 기록은 실제로 처리하는 곳에서만 남겨야 합니다.
14recover 흐름
Require는 조건이 깨지면 예외를 던질 뿐 복구를 시도하지 않습니다. 복구는 보통 요청 처리 경계처럼 한 단계 위에서 잡아 대체 응답을 만드는 방식으로 구성합니다. 주의할 부분은 catch를 너무 아래쪽에 두면 호출자가 실패를 성공으로 오해한다는 점이라, 잡을 위치는 대안을 실제로 결정할 수 있는 곳이어야 합니다.
C# code
// recover 흐름
using System;
void Require(bool condition, string message) { if (!condition) throw new Exception(message); }
Require(true, "ok");복구 지점은 재시도나 대체 응답, 사용자에게 되묻기 중 하나를 실제로 고를 수 있는 계층이어야 하며, 그럴 수 없는 곳에서는 그대로 위로 흘려보내는 편이 낫습니다.
복구 코드에서 다시 같은 자원을 건드리면 원래 실패 원인이 그대로 남아 있어 두 번째 실패로 이어집니다. 대체 경로는 실패한 것과 다른 수단을 써야 합니다.
15입력 가드
값을 미리 막는 가드 없이, 변환을 실행한 뒤 catch로 수습합니다. 메서드 진입부에서 인자를 먼저 검사하면 잘못된 값이 안쪽으로 퍼지는 것을 막을 수 있습니다. .NET 6부터는 ArgumentNullException.ThrowIfNull(arg)처럼 한 줄짜리 헬퍼가 제공되어 if와 throw를 직접 쓸 필요가 줄었습니다.
C# code
// 입력 가드
using System;
try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }ArgumentException.ThrowIfNullOrEmpty 같은 헬퍼는 매개변수 이름을 호출자 정보로 자동으로 채워 주므로, 이름을 문자열로 적다가 오타를 내는 일이 없습니다.
가드가 던지는 예외 메시지에 사용자가 넣은 값을 그대로 담으면 로그나 응답으로 새어 나갈 수 있습니다. 이름과 허용 범위만 알리는 편이 안전합니다.
정리하며
- 예상 가능한 실패는 예외 대신
TryParse계열이나 결과 타입으로 표현합니다 - 예외를 다시 던질 때는
throw ex;가 아니라throw;를 써 스택을 보존합니다 when필터는 스택이 풀리기 전에 평가되어 조건이 거짓이면 프레임이 남습니다- 검증은 메서드 앞단 가드로 모아 본문에서 유효성 분기를 없앱니다
더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.