예외 처리 문 - `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=nameWithTypexref: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;처럼 변수를 함께 던지면 exref:System.Exception.StackTrace 속성이 그 내용이 바뀌게 돼요. 그래서 어디서 시작된 예외인지 그 흔적을 그대로 남기고 싶다면 그냥 throw;를 쓰는 게 안전해요.

예외가 던져지면 공용 언어 런타임(CLR)은 그 예외를 처리할 수 있는 catch 블록을 찾아요. 만약 지금 실행 중인 메서드 안에 그런 catch 블록이 없다면, CLR은 지금 메서드를 호출한 메서드를 뒤지고, 또 없으면 그 위의 메서드를 뒤지면서 호출 스택(call stack)을 거슬러 올라가요. 이렇게 올라가다가 맞는 catch 블록을 찾지 못하면 CLR은 실행 중인 스레드를 종료해 버려요. 더 자세한 내용은 C# 언어 사양예외를 처리하는 방법 절을 보면 돼요.

throw

throw는 문(statement)으로만 쓰는 게 아니라 식(expression) 으로도 쓸 수 있어요. 값이 필요한 위치에서 조건에 따라 예외를 던져야 할 때 유용한데요, 대표적인 경우를 몇 가지 볼게요.

string first = args.Length >= 1 
    ? args[0]
    : throw new ArgumentException("Please supply at least one argument.");
public string Name
{
    get => name;
    set => name = value ??
        throw new ArgumentNullException(paramName: nameof(value), message: "Name cannot be null");
}
DateTime ToDateTime(IFormatProvider provider) =>
         throw new InvalidCastException("Conversion to a DateTime is not supported.");

이렇게 throw를 식으로 쓰면 값을 만들어야 하는 자리에서 바로 예외를 던질 수 있어서 코드가 훨씬 간결해져요.

try

try 문은 세 가지 형태로 쓸 수 있어요.

  • try-catchtry 블록 안의 코드를 실행하다 예외가 발생하면 처리해요.
  • 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;exref: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-catchtry-finally 절을 참고하면 돼요.

더 알아보기