예외 처리 문 - `throw`, `try-catch`, `try-finally`, `try-catch-finally`
예외 처리 문 - throw, try-catch, try-finally, try-catch-finally
프로그램을 짜다 보면 예상치 못한 상황(예외)이 반드시 생기기 마련인데요, C#에서는 이 예외를 다루는 문법을 따로 마련해 두었어요. 바로 throw 문으로 예외를 던지고, try 문으로 예외를 잡아 처리하는 거예요. 어떤 상황에서 예외가 발생할 수 있는지, 그리고 그걸 어떻게 안전하게 처리하는지 하나씩 같이 살펴볼게요.
출처: Microsoft Learn
본문
예외를 직접 다루려면 크게 두 가지를 알면 돼요. 하나는 throw 문으로 예외를 발생시키는 방법이고, 다른 하나는 try 문으로 예외를 잡아서 처리하는 방법이에요. 이 두 가지를 각각 살펴본 뒤에, 예외가 발생했을 때 런타임이 내부적으로 어떤 과정을 거치는지도 이해해 볼게요.
throw 문
throw 문은 예외를 던질 때 쓰는 문법이에요. 다음처럼 쓰면 돼요.
if (shapeAmount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(shapeAmount), "Amount of shapes must be positive.");
}
throw e; 문에서 표현식 e의 결과는 xref:System.Exception?displayProperty=nameWithType 형식으로 묵시적으로 변환 가능해야 해요. 즉, throw 뒤에는 예외 형식의 값(혹은 이를 상속받은 형식의 값)이 와야 한다는 뜻이에요.
직접 예외 클래스를 만들지 않아도, .NET이 미리 준비해 둔 내장 예외 클래스들을 편하게 쓸 수 있어요. 예를 들어 xref:System.ArgumentOutOfRangeException이나 xref:System.InvalidOperationException 같은 것들이죠. .NET에는 특정 조건에서 예외를 던지도록 도와주는 헬퍼 메서드도 있는데요, xref:System.ArgumentNullException.ThrowIfNull*?displayProperty=nameWithType나 xref:System.ArgumentException.ThrowIfNullOrEmpty*?displayProperty=nameWithType가 대표적이에요. 물론 필요하면 xref:System.Exception?displayProperty=nameWithType을 상속받아 나만의 예외 클래스를 직접 정의할 수도 있어요. 자세한 내용은 예외 만들기 및 throw 문서를 참고하면 돼요.
catch 블록 안에서는 잡은 예외를 다시 던져 올릴 수 있는데(이걸 re-throw라고 해요), 이때 throw; 문을 사용해요.
try
{
ProcessShapes(shapeAmount);
}
catch (Exception e)
{
LogError(e, "Shape processing failed.");
throw;
}
[!NOTE]
throw;는 예외의 원래 스택 추적(stack trace)을 보존해요. 이 스택 추적은 xref:System.Exception.StackTrace?displayProperty=nameWithType 속성에 저장되어 있는데요, 반대로throw e;처럼 변수를 함께 던지면e의 xref:System.Exception.StackTrace 속성이 그 내용이 바뀌게 돼요. 그래서 어디서 시작된 예외인지 그 흔적을 그대로 남기고 싶다면 그냥throw;를 쓰는 게 안전해요.
예외가 던져지면 공용 언어 런타임(CLR)은 그 예외를 처리할 수 있는 catch 블록을 찾아요. 만약 지금 실행 중인 메서드 안에 그런 catch 블록이 없다면, CLR은 지금 메서드를 호출한 메서드를 뒤지고, 또 없으면 그 위의 메서드를 뒤지면서 호출 스택(call stack)을 거슬러 올라가요. 이렇게 올라가다가 맞는 catch 블록을 찾지 못하면 CLR은 실행 중인 스레드를 종료해 버려요. 더 자세한 내용은 C# 언어 사양의 예외를 처리하는 방법 절을 보면 돼요.
throw 식
throw는 문(statement)으로만 쓰는 게 아니라 식(expression) 으로도 쓸 수 있어요. 값이 필요한 위치에서 조건에 따라 예외를 던져야 할 때 유용한데요, 대표적인 경우를 몇 가지 볼게요.
- 조건 연산자(
?:) — 넘겨받은 배열args가 비어 있으면 xref:System.ArgumentException을 던지는 예시예요.
string first = args.Length >= 1
? args[0]
: throw new ArgumentException("Please supply at least one argument.");
- null 병합 연산자(
??) — 속성에 할당할 문자열이null이면 xref:System.ArgumentNullException을 던지는 예시예요.
public string Name
{
get => name;
set => name = value ??
throw new ArgumentNullException(paramName: nameof(value), message: "Name cannot be null");
}
- 식 본문(expression-bodied) 람다 또는 메서드 — xref:System.DateTime 값으로의 변환이 지원되지 않을 때 xref:System.InvalidCastException을 던지는 예시예요.
DateTime ToDateTime(IFormatProvider provider) =>
throw new InvalidCastException("Conversion to a DateTime is not supported.");
이렇게 throw를 식으로 쓰면 값을 만들어야 하는 자리에서 바로 예외를 던질 수 있어서 코드가 훨씬 간결해져요.
try 문
try 문은 세 가지 형태로 쓸 수 있어요.
try-catch—try블록 안의 코드를 실행하다 예외가 발생하면 처리해요.try-finally— 제어 흐름이try블록을 떠날 때 실행할 코드를 지정해요.try-catch-finally— 위 두 형태를 합쳐 놓은 거예요.
상황에 맞는 형태를 골라 쓰면 되는데요, 각각 어떻게 동작하는지 자세히 볼게요.
try-catch 문
try-catch 문은 코드 블록 실행 중 발생할 수 있는 예외를 처리할 때 써요. 예외가 발생할 가능성이 있는 코드는 try 블록 안에 두고, 처리하고 싶은 예외의 기본 형식을 catch 절(catch clause)로 지정하는 방식이에요.
try
{
var result = Process(-3, 4);
Console.WriteLine($"Processing succeeded: {result}");
}
catch (ArgumentException e)
{
Console.WriteLine($"Processing failed: {e.Message}");
}
catch 절은 여러 개를 만들어 둘 수도 있어요.
try
{
var result = await ProcessAsync(-3, 4, cancellationToken);
Console.WriteLine($"Processing succeeded: {result}");
}
catch (ArgumentException e)
{
Console.WriteLine($"Processing failed: {e.Message}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Processing is cancelled.");
}
예외가 발생하면 런타임은 catch 절을 위에서부터 아래로 순서대로 확인해요. 그리고 던져진 예외 하나당 실행되는 catch 블록은 많아야 하나예요. 위 예시에서도 볼 수 있듯이, catch 절에서 예외 변수를 선언하지 않고 예외 형식만 적어 둘 수도 있어요(예를 들어 catch (OperationCanceledException)처럼). 그리고 예외 형식을 아예 지정하지 않은 catch 절은 모든 예외를 잡게 되는데, 이런 절이 있다면 반드시 마지막에 와야 해요.
잡은 예외를 다시 던지려면 throw 문을 쓰면 돼요.
try
{
var result = Process(-3, 4);
Console.WriteLine($"Processing succeeded: {result}");
}
catch (Exception e)
{
LogError(e, "Processing failed.");
throw;
}
[!NOTE]
throw;는 예외의 원래 스택 추적을 보존해요. 반대로throw e;는e의 xref:System.Exception.StackTrace 속성을 갱신해 버려요. 오류의 발생 지점을 그대로 추적하고 싶다면throw;를 사용하는 걸 권장해요.
when 예외 필터
catch 절에는 예외 형식과 함께 예외 필터를 지정할 수도 있어요. 예외 필터는 예외를 좀 더 자세히 검사해서, 해당 예외를 이 catch 블록이 처리할지를 결정해 주는 역할을 해요. 필터는 when 키워드 뒤에 오는 부울(boolean) 식으로 표기해요.
try
{
var result = Process(-3, 4);
Console.WriteLine($"Processing succeeded: {result}");
}
catch (Exception e) when (e is ArgumentException || e is DivideByZeroException)
{
Console.WriteLine($"Processing failed: {e.Message}");
}
위 예시는 예외 필터를 이용해 두 종류의 예외를 하나의 catch 블록으로 처리하는 모습이에요. when 조건이 true인 예외만 이 블록이 잡겠다는 뜻이죠.
같은 예외 형식에 대해 catch 절을 여러 개 만들 수도 있어요. 이 경우 예외 필터로 서로 구분하게 되는데요, 그중 하나는 필터를 아예 붙이지 않을 수도 있어요. 이러한 절이 존재한다면, 해당 예외 형식을 지정한 catch 절들 중 마지막에 와야 해요.
그리고 예외 필터가 붙은 catch 절은, 그 뒤에 오는 catch 절의 예외 형식과 같거나 더 구체적(더 파생된)인 예외 형식을 지정할 수 있어요. 예를 들어 필터가 있다면 catch (Exception e) 절이 굳이 마지막에 올 필요가 없다는 뜻이에요.
예외 필터 vs. 전통적인 예외 처리
예외 필터는 전통적인 예외 처리 방식보다 상당한 이점이 있는데요, 핵심 차이는 예외 처리 로직이 언제 평가되느냐에 있어요.
- 예외 필터(
when): 필터 식은 스택이 풀리기(unwind)** 전에** 평가돼요. 덕분에 필터를 평가하는 동안 원래의 호출 스택과 모든 지역 변수가 그대로 남아 있어요. - 전통적인
catch블록: catch 블록은 스택이 풀린 후에 실행돼요. 그래서 디버깅에 유용한 정보가 일부 사라질 수 있어요.
차이를 직접 비교하는 코드를 볼게요.
public static void DemonstrateStackUnwindingDifference()
{
var localVariable = "Important debugging info";
try
{
ProcessWithExceptionFilter(localVariable);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("filter"))
{
// Exception filter: Stack not unwound yet.
// localVariable is still accessible in debugger.
// Call stack shows original throwing location.
Console.WriteLine($"Caught with filter: {ex.Message}");
Console.WriteLine($"Local variable accessible: {localVariable}");
}
try
{
ProcessWithTraditionalCatch(localVariable);
}
catch (InvalidOperationException ex)
{
// Traditional catch: Stack already unwound.
// Some debugging information may be lost.
if (ex.Message.Contains("traditional"))
{
Console.WriteLine($"Caught with if: {ex.Message}");
Console.WriteLine($"Local variable accessible: {localVariable}");
}
else
{
throw; // Re-throws and further modifies stack trace.
}
}
}
첫 번째 try 블록의 when 필터는 스택이 풀리기 전에 평가되므로, 필터 조건이 맞는 순간까지는 localVariable 같은 지역 변수와 원래의 호출 위치가 그대로 남아 있어요. 반면 두 번째 전통적인 catch 방식은 이미 스택이 풀린 뒤라 내부에서 if-else로 판단하거나 다시 throw; 해야 해요.
예외 필터의 장점
- 더 나은 디버깅 경험: 필터가 일치하기 전까지는 스택이 풀리지 않으므로, 디버거에서 모든 지역 변수를 그대로 둔 채 원래 실패 지점을 보여줄 수 있어요.
- 성능 이점: 어떤 필터도 일치하지 않으면 예외는 스택 풀기와 복구의 오버헤드 없이 그대로 계속 전파돼요.
- 더 깔끔한 코드: 같은 예외 형식을 여러 개의 필터로 조건별로 처리하면, 중첩된 if-else를 피할 수 있어요.
- 로깅 및 진단: 예외를 처리할지 결정하기 전에 예외의 세부 내용을 검사하고 기록할 수 있어요.
public static void DemonstrateDebuggingAdvantage()
{
var contextData = new Dictionary<string, object>
{
["RequestId"] = Guid.NewGuid(),
["UserId"] = "user123",
["Timestamp"] = DateTime.Now
};
try
{
// Simulate a deep call stack.
Level1Method(contextData);
}
catch (Exception ex) when (LogAndFilter(ex, contextData))
{
// This catch block may never execute if LogAndFilter returns false.
// But LogAndFilter can examine the exception while the stack is intact.
Console.WriteLine("Exception handled after logging");
}
}
여기서 LogAndFilter 메서드는 스택이 풀리기 전에 실행되기 때문에, 예외 메시지와 스택 추적 전체를 그대로 읽어 로그로 남긴 뒤, 처리할지 여부를 결정할 수 있어요.
예외 필터는 언제 쓰면 좋을까
다음 같은 상황에서 예외 필터가 유용해요.
- 예외를 특정 조건이나 속성에 따라 처리해야 할 때
- 디버깅을 위해 원래의 호출 스택을 보존하고 싶을 때
- 예외를 처리할지 결정하기 전에 로그를 남기거나 검사하고 싶을 때
- 같은 예외 형식을 상황(컨텍스트)에 따라 다르게 처리하고 싶을 때
같은 예외 형식을 조건별로 나눠 처리하는 예시를 볼게요.
public static void HandleFileOperations(string filePath)
{
try
{
// Simulate file operation that might fail.
ProcessFile(filePath);
}
catch (IOException ex) when (ex.Message.Contains("access denied"))
{
Console.WriteLine("File access denied. Check permissions.");
}
catch (IOException ex) when (ex.Message.Contains("not found"))
{
Console.WriteLine("File not found. Verify the path.");
}
catch (IOException ex) when (IsNetworkPath(filePath))
{
Console.WriteLine($"Network file operation failed: {ex.Message}");
}
catch (IOException)
{
Console.WriteLine("Other I/O error occurred.");
}
}
모두 IOException이지만 when 필터로 원인을 구분해 각각 다른 메시지를 보여주는 모습이에요. 마지막 catch (IOException)처럼 필터 없는 절이 있으므로 맨 끝에 배치되어 있어요.
스택 추적 보존
예외 필터는 원래의 ex.StackTrace 속성을 그대로 보존해요. 만약 어떤 catch 절이 예외를 처리하지 못하고 다시 던진다면, 그 과정에서 원래 스택 정보가 손실될 수 있어요. 그런데 when 필터는 스택을 풀지 않으므로, when 필터가 false로 판정되더라도 원래 스택 추적은 바뀌지 않아요.
이런 예외 필터 방식은 디버깅 정보의 보존이 중요한 애플리케이션에서 특히 값지게 쓰여요.
async 및 iterator 메서드에서의 예외
async 함수에서 예외가 발생하면, 그 예외는 함수의 결과를 await할 때 호출자에게 전파돼요.
public static async Task Run()
{
try
{
Task<int> processing = ProcessAsync(-1);
Console.WriteLine("Launched processing.");
int result = await processing;
Console.WriteLine($"Result: {result}.");
}
catch (ArgumentException e)
{
Console.WriteLine($"Processing failed: {e.Message}");
}
// Output:
// Launched processing.
// Processing failed: Input must be non-negative. (Parameter 'input')
}
ProcessAsync(-1)에서 예외가 발생하지만, 미리 Task만 만들어 두고 await가 나중에 이뤄지기 때문에 "Launched processing."이 먼저 출력되고, await 지점에서 비로소 예외가 잡히는 흐름이에요.
반면 iterator 메서드에서 예외가 발생하면, iterator가 다음 요소로 전진할 때만 그 예외가 호출자에게 전파돼요.
try-finally 문
try-finally 문에서 finally 블록은 제어 흐름이 try 블록을 떠날 때 실행돼요. 제어 흐름이 try 블록을 떠나는 경우는 크게 세 가지예요.
- 정상적으로 실행이 끝난 경우
- 점프 문(즉
return,break,continue,goto)이 실행된 경우 - 예외가
try블록 밖으로 전파된 경우
아래 예시는 메서드에서 제어가 떠나기 전에 객체의 상태를 원래대로 되돌리는 데 finally 블록을 사용한 모습이에요.
public async Task HandleRequest(int itemId, CancellationToken ct)
{
Busy = true;
try
{
await ProcessAsync(itemId, ct);
}
finally
{
Busy = false;
}
}
try 블록 안에서 확보한 리소스를 정리하는 데에도 finally 블록을 쓸 수 있어요.
[!NOTE] 만약 리소스의 형식이 xref:System.IDisposable 또는 xref:System.IAsyncDisposable 인터페이스를 구현한다면,
using문을 고려해 보세요.using문은 제어 흐름이using문을 떠날 때 확보한 리소스가 확실히 해제(dispose)되도록 보장해 줘요. 컴파일러가using문을try-finally문으로 변환해 주기 때문이에요.
그런데 finally 블록이 항상 실행된다고 단정할 순 없어요. finally 블록이 실행되는지 여부는 운영 체제가 예외 풀기(unwind) 연산을 실행하도록 선택하는지에 따라 달라져요. finally 블록이 실행되지 않는 경우는 프로그램이 즉시 종료되는 상황뿐인데요, 예를 들어 xref:System.Environment.FailFast*?displayProperty=nameWithType 호출이나 xref:System.OverflowException, xref:System.InvalidProgramException 예외 때문에 종료되는 경우가 그래요. 대부분의 운영 체제는 프로세스를 중지·언로드하는 과정에서 합리적인 수준의 리소스 정리를 수행해 줘요.
try-catch-finally 문
try-catch-finally 문은 try 블록 실행 중 발생할 수 있는 예외도 처리하면서, 동시에 제어 흐름이 try 문을 떠날 때 반드시 실행돼야 하는 코드도 지정하고 싶을 때 써요.
public async Task ProcessRequest(int itemId, CancellationToken ct)
{
Busy = true;
try
{
await ProcessAsync(itemId, ct);
}
catch (Exception e) when (e is not OperationCanceledException)
{
LogError(e, $"Failed to process request for item ID {itemId}.");
throw;
}
finally
{
Busy = false;
}
}
catch 블록이 예외를 처리하면, finally 블록은 그 catch 블록의 실행이 끝난 뒤에 실행돼요. (설령 catch 블록 실행 중에 또 다른 예외가 발생하더라도 finally는 실행된다는 뜻이에요.) catch 블록과 finally 블록에 대한 자세한 설명은 각각 try-catch 문과 try-finally 문 절을 참고하면 돼요.
더 알아보기
- 예외 및 예외 처리
- .NET에서 예외 처리 및 throw
- throw 우선 설정(스타일 규칙 IDE0016)
- C# 언어 사양:
throw문,try문, 예외 - xref:System.AppDomain.FirstChanceException?displayProperty=nameWithType
- xref:System.AppDomain.UnhandledException?displayProperty=nameWithType
- xref:System.Threading.Tasks.TaskScheduler.UnobservedTaskException?displayProperty=nameWithType