C# 예외 처리: .NET 10에서 다시 검증한 try·catch·finally

C# try·catch·finally 기초부터 예외 필터 when, throw; 와 throw ex; 의 스택 트레이스 차이, using 선언, ThrowIfNull까지. 2021년 예제 검증과 예외 비용 실측 포함.

2026년 11월 보강: 2021년에 쓴 예외 처리 강좌의 예제 네 개를 .NET 10.0.14(C# 14) 기본 콘솔 프로젝트에서 다시 컴파일·실행했습니다. 모두 동작했지만 바로잡을 점이 있었습니다. ① 도입부의 "음수의 제곱근을 구하면 예외가 난다"는 틀린 설명입니다. Math.Sqrt(-1)은 NaN을 돌려줄 뿐 예외가 없습니다. ② finally 예제는 nullable 경고 CS8600 두 개가 나오고, 지금은 using 선언으로 훨씬 짧게 씁니다. ③ throw 예제는 기반 클래스 Exception을 그대로 던지고, 나눗셈에 문제가 없는 a = 0까지 막고 있었습니다. ④ 원문의 콘솔 스크린샷(Windows의 한국어 메시지와 로컬 경로가 찍힌 화면)은 지우고 .NET 10의 실제 출력으로 바꿨습니다. 여기에 예외 필터 when, throw;와 throw ex;의 스택 트레이스 차이, ExceptionDispatchInfo, ThrowIfNull 계열, 예외 비용 실측을 더했습니다.

이 글은 C#에서 try/catch는 쓸 줄 알지만, 어디서 잡고 어떻게 다시 던져야 로그에 원인이 남는지, 예외를 일반 분기처럼 써도 되는지가 궁금한 분을 위한 글입니다. 결론부터 말하면 잡을 수 있는 구체적 예외만 잡고, 다시 던질 때는 throw;, 자원 정리는 using, 예상되는 실패는 TryParse 같은 반환값으로 처리하는 것이 기본입니다. 아래에서 각각을 실제 출력으로 확인합니다.

예외는 언제 발생하나: 2021년 설명 바로잡기

예외란 실행 중에 정상 흐름을 이어갈 수 없는 상황이 생겼다는 신호입니다. 아무도 잡지 않으면 프로그램이 종료됩니다. 정수를 0으로 나누는 두 줄짜리 프로그램을 .NET 10에서 실행하면 이렇게 끝납니다.

int a = 5, b = 0;
Console.WriteLine(a / b);
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
   at Program.<Main>$(String[] args) in …/Program.cs:line 2

macOS 셸에서 본 종료 코드는 134였습니다. 2021년 원문에는 Windows .NET Framework에서 찍은 "처리되지 않은 예외: … 0으로 나누려 했습니다." 화면이 있었는데, .NET(Core) 이후 런타임 메시지는 OS 언어와 관계없이 영어로 나옵니다. 로그를 검색할 때 알아 두면 좋습니다.

원문은 예외의 예로 "0으로 나누기, 음수의 제곱근, 비어 있는 인수"를 들었는데, 직접 확인해 보면 결과가 다릅니다.

Console.WriteLine($"Math.Sqrt(-1)   = {Math.Sqrt(-1)}");
Console.WriteLine($"5.0 / 0         = {5.0 / 0}");
Console.WriteLine($"int.MaxValue + 1 = {Max() + 1}");
try { Console.WriteLine(checked(Max() + 1)); }
catch (OverflowException e) { Console.WriteLine($"checked: {e.GetType().Name}: {e.Message}"); }
try { int zero = 0; Console.WriteLine(5 / zero); }
catch (DivideByZeroException e) { Console.WriteLine($"5 / 0: {e.GetType().Name}: {e.Message}"); }
static int Max() => int.MaxValue;
Math.Sqrt(-1)   = NaN
5.0 / 0         = ∞
int.MaxValue + 1 = -2147483648
checked: OverflowException: Arithmetic operation resulted in an overflow.
5 / 0: DivideByZeroException: Attempted to divide by zero.

double 연산은 IEEE 754 규칙에 따라 NaN이나 무한대를 돌려줄 뿐 예외를 던지지 않습니다. 정수 오버플로도 기본(unchecked)에서는 조용히 음수로 넘어갑니다. 예외를 기대하고 try로 감쌌는데 잡히는 게 없는 버그가 여기서 생기니, 실수 계산 결과는 double.IsNaN, double.IsFinite로 직접 검사해야 합니다.

try~catch와 catch 순서

try 블록 안에서 예외가 나면, 그 형식에 맞는 catch가 받아 처리합니다. 원문 예제(정수 나눗셈 세 번)는 .NET 10에서도 경고 없이 컴파일되었습니다.

static int Division(int a, int b)
{
    try
    {
        return a / b;
    }
    catch (DivideByZeroException e)
    {
        Console.WriteLine(e.Message);
        return -1;
    }
}
// Main: Division(5, 0), Division(10, 2), Division(49, 7) 출력
Attempted to divide by zero.
-1
5
7

catch는 여러 개 둘 수 있고 위에서부터 순서대로 비교됩니다. 그래서 넓은 형식을 먼저 두면 컴파일러가 막습니다.

try { int z = 0; _ = 1 / z; }
catch (Exception e) { Console.WriteLine(e.Message); }
catch (DivideByZeroException e) { Console.WriteLine("never"); }
Program.cs(3,8): error CS0160: 이전의 catch 절에서 이 형식이나 상위 형식('Exception')의 예외를 모두 catch합니다.

구체적인 형식을 위에, Exception은 맨 아래(또는 프로그램 최상단의 로깅 지점 한 곳)에 둡니다. 원문 설명대로, 맞는 catch가 하나도 없으면 예외는 호출한 메서드로 올라가고 끝까지 아무도 잡지 않으면 프로그램이 종료됩니다.

순서도: try 블록이 실행된다. 예외가 없으면 finally 를 실행하고 try 문 다음으로 진행한다. 예외가 발생하면 catch 절을 위에서부터 형식과 when 필터로 비교한다. 맞으면 그 catch 블록을 실행하고 finally 를 거쳐 다음으로 진행한다(catch 안에서 throw; 한 경우는 제외). 맞는 catch 가 없으면 finally 를 실행한 뒤 예외가 호출한 쪽으로 전달된다.
try 블록 이후 제어가 어디로 가는지. finally 는 모든 경로에서 실행된다. 맞는 catch 가 없어 예외가 메서드 밖으로 나가는 경우도 마찬가지다.

예외 필터 when: 조건이 맞을 때만 잡기

C# 6부터 catch (형식 e) when (조건)으로 조건을 붙일 수 있습니다. 같은 HttpRequestException이라도 상태 코드에 따라 다르게 처리하는 예입니다.

foreach (var code in new[] { 404, 503 })
{
    try
    {
        Fetch(code);
    }
    catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable)
    {
        Console.WriteLine($"{code}: retry later");
    }
    catch (HttpRequestException e) when (Log(e))   // Log 는 false 를 돌려줌: 기록만 하고 잡지 않음
    {
    }
    catch (HttpRequestException e)
    {
        Console.WriteLine($"{code}: give up ({e.Message})");
    }
}

static void Fetch(int code) =>
    throw new HttpRequestException($"HTTP {code}", null, (System.Net.HttpStatusCode)code);

static bool Log(Exception e) { Console.WriteLine($"  log: {e.Message}"); return false; }
  log: HTTP 404
404: give up (HTTP 404)
503: retry later

503은 첫 번째 필터에서 잡혔고, 404는 첫 번째 필터를 통과하지 못한 뒤 Log가 기록만 하고 false를 돌려줘 세 번째 catch로 넘어갔습니다. 필터의 장점은 조건이 거짓이면 스택을 풀지 않는다는 것입니다. catch 안에서 if로 검사하고 다시 던지는 방식과 달리, 처리하지 않을 예외의 원래 상태가 그대로 남습니다.

finally와 using 선언

finally 블록은 예외 여부와 관계없이 항상 실행되어, 파일이나 연결을 닫는 데 씁니다. 원문의 파일 복사 예제(A.txt를 B.txt에 덧붙이기)는 그대로 동작했고, 파일이 없으면 Could not find file '…/A.txt'.를 출력했습니다. 다만 지금 기본 프로젝트에서는 경고가 두 개 나옵니다.

Program.cs(14,36): warning CS8600: null 리터럴 또는 가능한 null 값을 null을 허용하지 않는 형식으로 변환하는 중입니다.
Program.cs(15,36): warning CS8600: null 리터럴 또는 가능한 null 값을 null을 허용하지 않는 형식으로 변환하는 중입니다.

StreamReader sreader = null;처럼 null을 넣어 두고 finally에서 검사하는 패턴 때문입니다. C# 8의 using 선언을 쓰면 null 검사도 finally도 필요 없습니다. 메서드(블록)가 끝날 때 선언의 역순으로 Dispose가 호출되고, 예외가 나도 마찬가지입니다.

File.WriteAllLines("A.txt", ["line 1", "line 2"]);
Console.WriteLine($"copied {Copy("A.txt", "B.txt")} lines");
try { Copy("missing.txt", "B.txt"); }
catch (FileNotFoundException e) { Console.WriteLine($"not found: {Path.GetFileName(e.FileName)}"); }
Console.WriteLine(Order());

static int Copy(string from, string to)
{
    using var reader = new StreamReader(from);              // 메서드 끝에서
    using var writer = new StreamWriter(to, append: true);  // 역순으로 Dispose
    int n = 0;
    while (reader.ReadLine() is { } line) { writer.WriteLine(line); n++; }
    return n;
}

static string Order()
{
    try { Console.WriteLine("try"); return "return value"; }
    finally { Console.WriteLine("finally (runs before the caller sees the return)"); }
}
copied 2 lines
not found: missing.txt
try
finally (runs before the caller sees the return)
return value

catch (Exception e)로 모든 예외를 받던 원문과 달리 여기서는 FileNotFoundException만 잡았습니다. 권한 오류나 디스크 오류까지 "파일 없음"처럼 메시지 한 줄로 삼키지 않기 위해서입니다. Order()의 출력에서 보듯 try 안에서 return해도 finally는 호출한 쪽이 값을 받기 전에 실행됩니다.

throw: 인수 검사는 ThrowIfNull 계열로

원문의 throw 예제는 0 이하의 값이 오면 throw new Exception("0 또는 음수가 오는 것을 허용하지 않습니다.");를 던졌고, 출력도 원문과 같았습니다(5, 7, 메시지). 하지만 기반 클래스 Exception을 던지면 받는 쪽은 catch (Exception)으로 모든 예외를 함께 잡을 수밖에 없습니다. 또 a = 0은 0을 나누는 것이라 문제가 없는데도 막고 있었습니다. 인수 검사에는 .NET 6~8에서 추가된 정적 헬퍼를 씁니다.

Console.WriteLine(Division(10, 2));
Console.WriteLine(Division(49, 7));
foreach (var (a, b) in new[] { (5, -1), (5, 0) })
{
    try { Console.WriteLine(Division(a, b)); }
    catch (ArgumentOutOfRangeException e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }
}
try { Greet(null); }
catch (ArgumentNullException e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }

static int Division(int a, int b)
{
    ArgumentOutOfRangeException.ThrowIfNegative(a);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(b);
    return a / b;
}

static string Greet(string? name)
{
    ArgumentNullException.ThrowIfNull(name);
    return "Hello " + name;
}
5
7
ArgumentOutOfRangeException: b ('-1') must be a non-negative and non-zero value. (Parameter 'b')
Actual value was -1.
ArgumentOutOfRangeException: b ('0') must be a non-negative and non-zero value. (Parameter 'b')
Actual value was 0.
ArgumentNullException: Value cannot be null. (Parameter 'name')

매개변수 이름('b', 'name')을 직접 넘기지 않았는데도 메시지에 들어갔습니다. [CallerArgumentExpression] 덕분입니다. ThrowIfNull은 .NET 6, ThrowIfNegative·ThrowIfNegativeOrZero는 .NET 8부터 있습니다.

throw; 와 throw ex; 그리고 ExceptionDispatchInfo

잡은 예외를 로그만 남기고 다시 던질 때, throw;와 throw ex;는 결과가 다릅니다. 같은 int.Parse("abc") 실패를 세 방식으로 다시 던지고 스택 트레이스를 출력했습니다(Debug 빌드).

using System.Runtime.ExceptionServices;

Show("throw;", Demo.Rethrow);
Show("throw ex;", Demo.RethrowEx);
Show("ExceptionDispatchInfo", Demo.Deferred);

static void Show(string title, Action a)
{
    try { a(); }
    catch (Exception e) { Console.WriteLine($"--- {title}\n{e.StackTrace}\n"); }
}

static class Demo
{
    static void Parse() => int.Parse("abc");

    public static void Rethrow()
    {
        try { Parse(); }
        catch (FormatException) { throw; }
    }

    public static void RethrowEx()
    {
        try { Parse(); }
        catch (FormatException ex) { throw ex; }
    }

    public static void Deferred()
    {
        ExceptionDispatchInfo? captured = null;
        try { Parse(); }
        catch (FormatException ex) { captured = ExceptionDispatchInfo.Capture(ex); }
        Console.WriteLine("(cleanup, then rethrow)");
        captured?.Throw();
    }
}
--- throw;
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Demo.Parse() in Program.cs:line 15
   at Demo.Rethrow() in Program.cs:line 19
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

--- throw ex;
   at Demo.RethrowEx() in Program.cs:line 26
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

(cleanup, then rethrow)
--- ExceptionDispatchInfo
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Demo.Parse() in Program.cs:line 15
   at Demo.Deferred() in Program.cs:line 32
--- End of stack trace from previous location ---
   at Demo.Deferred() in Program.cs:line 35
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

(경로는 Program.cs로 줄였습니다.) throw ex;의 트레이스는 26행, 즉 다시 던진 줄에서 시작하고, 실제로 실패한 int.Parse와 Demo.Parse()가 사라졌습니다. 운영 로그에서 원인을 찾을 수 없게 되는 전형적인 실수입니다. 컴파일러도 기본 분석기로 경고합니다.

Program.cs(26,38): warning CA2200: catch한 예외를 다시 throw하면 스택 정보가 변경됩니다. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2200)

ExceptionDispatchInfo.Capture(ex).Throw()는 catch 블록 밖에서, 나중에 다시 던져야 할 때 씁니다. 정리 작업을 마친 뒤 던지거나, 다른 스레드에서 잡은 예외를 원래 스레드에서 던질 때입니다. 원래 트레이스 뒤에 --- End of stack trace from previous location ---가 붙고 다시 던진 위치가 이어집니다. await가 Task의 예외를 다시 던질 때 내부적으로 쓰는 방식도 이것입니다.

예외를 흐름 제어에 쓰지 말 것 (실측)

"사용자가 숫자를 입력했는지" 같은 예상되는 실패를 예외로 처리하면 얼마나 손해일까요? 잘못된 문자열 "abc"와 올바른 문자열 "123"을 각각 10만 번 파싱했습니다. 측정 방법: .NET 10.0.14, Release 빌드, Stopwatch, 10만 회, 워밍업 1회 후 3회 측정, Apple Silicon Mac(M1 Pro).

// 잘못된 입력: 예외로 처리
for (int i = 0; i < N; i++) { try { s += int.Parse(bad); } catch (FormatException) { s--; } }
// 잘못된 입력: 반환값으로 처리
for (int i = 0; i < N; i++) { if (int.TryParse(bad, out int v)) s += v; else s--; }
// 같은 두 루프를 올바른 입력 "123" 으로도 측정
경우 1회차 2회차 3회차
잘못된 값: Parse + catch 292.67 ms 264.28 ms 262.12 ms
잘못된 값: TryParse 0.50 ms 0.45 ms 0.45 ms
올바른 값: try 안의 Parse 0.65 ms 0.62 ms 0.61 ms
올바른 값: TryParse 0.66 ms 0.61 ms 0.61 ms
10만 번 파싱에 걸린 시간 막대그래프. 잘못된 입력을 int.Parse 와 catch 로 처리: 273.0ms. 잘못된 입력을 int.TryParse 로 처리: 0.47ms. 올바른 입력을 try 안의 int.Parse 로 처리: 0.63ms. 올바른 입력을 int.TryParse 로 처리: 0.63ms.
10만 번 파싱, 3회 측정 평균. .NET 10.0.14, Release, Stopwatch, Apple Silicon Mac(M1 Pro). 비용은 try 블록이 아니라 예외를 던지는 데서 생긴다.

예외 한 번을 던지고 잡는 데 평균 약 2.7µs, TryParse의 약 580배였습니다. 호출 스택이 얕은 예제가 이 정도이니, 깊은 스택에서는 더 커집니다. 반면 예외가 나지 않을 때 try 블록 자체의 비용은 측정되지 않았습니다(0.63ms 대 0.63ms). 정리하면 try를 두는 것은 공짜에 가깝고, 던지는 것이 비싸다는 뜻입니다. 초당 몇 번 일어나는 진짜 오류라면 2.7µs는 문제가 아니지만, 입력 검증·파일 존재 확인·딕셔너리 조회처럼 실패가 흔한 경로에서는 TryParse, File.Exists, TryGetValue 같은 Try 패턴을 쓰세요.

정리: 상황별 선택

상황 권장
예상되는 실패(입력 형식, 키 없음) TryParse·TryGetValue 등 Try 패턴
잘못된 인수 ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegative 등
특정 조건의 예외만 처리 catch (T e) when (조건)
로그 남기고 다시 던지기 throw; (throw ex; 금지, CA2200)
catch 밖에서 나중에 다시 던지기 ExceptionDispatchInfo.Capture(ex).Throw()
파일·연결 정리 using var 선언 (finally + null 검사 대신)
실수 계산 오류 예외가 안 나므로 double.IsNaN/IsFinite 검사
catch (Exception) 프로그램 최상단의 로깅 지점 한 곳에만

이벤트 핸들러 안에서 난 예외가 멀티캐스트 체인에서 어떻게 되는지는 델리게이트와 이벤트와 Func·Action 델리게이트의 멀티캐스트 함정 부분을 참고하세요.

확인 환경: .NET SDK 10.0.403 / 런타임 10.0.14, C# 14, macOS(Apple Silicon, M1 Pro). 컴파일러 메시지는 SDK 한국어 표시, 런타임 예외 메시지는 영어 그대로입니다.

자주 묻는 질문

catch 안에서 throw; 와 throw ex; 는 무엇이 다른가요?

throw; 는 원래 스택 트레이스를 그대로 유지하고, throw ex; 는 그 줄에서 새로 던진 것처럼 트레이스를 덮어씁니다. .NET 10에서 확인하니 throw ex; 쪽은 실제로 예외가 난 int.Parse 와 Parse() 프레임이 사라졌고, 컴파일 시 warning CA2200 도 나왔습니다. 다시 던질 때는 throw; 를 쓰세요.

음수의 제곱근이나 실수를 0으로 나누면 예외가 발생하나요?

아닙니다. Math.Sqrt(-1) 은 NaN, 5.0 / 0 은 무한대(∞)를 돌려주고 예외는 없습니다. DivideByZeroException 은 정수 나눗셈에서만, OverflowException 은 checked 문맥에서만 발생합니다.

예외를 던지고 잡는 비용은 얼마나 되나요?

Release 빌드, Apple Silicon Mac에서 잘못된 문자열 10만 개를 int.Parse + catch 로 처리하는 데 평균 273.0ms(1회 약 2.7µs), int.TryParse 로는 0.47ms였습니다. 예외가 안 나는 경우에는 try 블록 유무에 따른 차이가 없었습니다(둘 다 0.63ms).

  • #C#
  • #Exception Handling
  • #예외 처리
  • #.NET

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑