Exception

Exception

try-catch
throw
finally
Published

April 6, 2025

Abstract

프로그램을 작성하고 실행하다 보면 예상치 못한 문제들이 발생할 수 있습니다. 사용자가 잘못된 값을 입력하거나, 필요한 파일이 없거나, 네트워크 연결이 끊기거나, 계산 중 0으로 나누는 등 다양한 오류 상황이 발생할 수 있습니다. 이런 얘기치 않은 상황들을 예외(Exception)라고 부릅니다. 예외가 발생했을 때 프로그램이 그냥 비정상적으로 종료되어 버린다면 사용자 경험도 좋지 않고, 데이터가 손실되거나 시스템이 불안정한 상태에 빠질 수도 있습니다. 따라서 프로그램의 안정성과 신뢰성을 높이기 위해서는 이러한 예외 상황을 미리 예측하고 적절하게 처리하는 예외 처리(Exception Handling) 메커니즘이 필수적입니다. C#에서 예외를 다루는 방법, 즉 예외를 감지하고(try-catch), 예외 정보를 파악하며(System.Exception), 필요에 따라 예외를 직접 발생시키고(throw), 어떤 상황에서도 마무리 작업을 보장하며(finally), 사용자 정의 예외를 만들고 사용하는 방법에 대해 알아보겠습니다.

예외에 대하여

예외(Exception)란 프로그램 실행 중에 발생하는 비정상적이거나 예상치 못한 이벤트를 의미합니다. 이러한 이벤트는 프로그램의 정상적인 흐름을 방해합니다.

예외는 왜 발생할까요?

  • 잘못된 사용자 입력: 숫자가 와야 할 자리에 문자열을 입력하는 경우 (FormatException).
  • 존재하지 않는 리소스 접근: 없는 파일을 열려고 시도하는 경우 (FileNotFoundException).
  • 논리적 오류: 0으로 나누려고 시도하는 경우 (DivideByZeroException), null인 객체의 멤버에 접근하려는 경우 (NullReferenceException), 배열의 범위를 벗어난 인덱스에 접근하는 경우 (IndexOutOfRangeException).
  • 하드웨어 또는 시스템 문제: 네트워크 연결 실패 (IOException), 메모리 부족 (OutOfMemoryException).

예외 처리의 필요성

과거에는 오류가 발생하면 특정 에러 코드(숫자나 문자열)를 반환하는 방식으로 처리하기도 했습니다. 하지만 이 방식은 다음과 같은 문제들이 있습니다.

  • 오류 확인 로직과 정상 로직이 뒤섞여 코드가 복잡해집니다.
  • 개발자가 에러 코드 확인을 잊거나 누락하기 쉽습니다.
  • 오류가 발생한 정확한 위치나 원인을 파악하기 어렵습니다.

C#의 예외 처리 메커니즘은 이러한 문제들을 해결하기 위해 설계되었습니다.

  • 분리된 처리: try-catch 구문을 통해 정상적인 코드 실행 로직과 예외 처리 로직을 분리할 수 있습니다.
  • 구조화된 방식: 예외가 발생하면 프로그램의 정상 흐름이 중단되고, 해당 예외를 처리할 수 있는 catch 블록으로 제어가 이동합니다.
  • 호출 스택 전파: 만약 현재 메서드에서 예외를 처리하지 않으면, 예외는 메서드를 호출한 상위 메서드로 전파(propagate)됩니다. 이 과정은 예외가 처리되거나 프로그램이 종료될 때까지 호출 스택(call stack)을 따라 계속됩니다.

try~catch로 예외 받기

C#에서 예외를 처리하는 가장 기본적인 방법은 try-catch 블록을 사용하는 것입니다.

구문

try
{
    // 예외가 발생할 가능성이 있는 코드
}
catch (ExceptionType1 ex1) // 특정 타입(ExceptionType1)의 예외를 잡음
{
    // ExceptionType1 또는 그 자식 타입의 예외가 발생했을 때 실행될 코드
    // ex1 변수를 통해 발생한 예외 객체에 접근 가능
}
catch (ExceptionType2 ex2) // 다른 타입(ExceptionType2)의 예외를 잡음
{
    // ExceptionType2 또는 그 자식 타입의 예외가 발생했을 때 실행될 코드
}
catch // 모든 종류의 예외를 잡음 (권장하지는 않음)
{
    // 위 catch 블록들에서 잡지 못한 모든 예외가 발생했을 때 실행될 코드
}
  • try 블록: 예외가 발생할 수 있는 코드를 이 블록 안에 작성합니다.
  • catch 블록: try 블록에서 특정 타입의 예외가 발생했을 때 실행될 코드를 작성합니다.
    • catch 블록은 하나 이상 존재할 수 있습니다.
    • 예외가 발생하면, 발생한 예외 타입과 일치하거나 그 예외 타입의 부모 타입인 catch 블록 중 가장 먼저 나오는 블록 하나만 실행됩니다.
    • 따라서 catch 블록은 구체적인 예외 타입부터 일반적인 예외 타입 순서로 작성해야 합니다. (예: catch(FileNotFoundException) 다음에 catch(IOException) 다음에 catch(Exception))
    • 괄호 안에 예외 타입을 명시하고 변수 이름(예: ex1)을 지정하면, 해당 변수를 통해 발생한 예외 객체의 정보(메시지, 스택 추적 등)에 접근할 수 있습니다.
    • catch만 쓰고 괄호를 생략하면 모든 종류의 CLS(Common Language Specification) 규격 예외를 잡지만, 예외 객체에 접근할 수 없고 어떤 예외가 발생했는지 알기 어렵기 때문에 권장되지 않습니다. 일반적으로 최소한 catch (Exception ex)를 사용하여 예외 정보를 로깅하는 것이 좋습니다.

사용자로부터 숫자를 입력받아 처리하는 코드에서 발생할 수 있는 예외를 처리해 봅시다.

using System;

public class TryCatchExample
{
    public static void Main(string[] args)
    {
        Console.Write("숫자를 입력하세요: ");
        string input = Console.ReadLine();

        try
        {
            int number = int.Parse(input); // FormatException 발생 가능
            Console.WriteLine($"입력한 숫자의 두 배는 {number * 2} 입니다.");

            int[] arr = new int[1];
            arr[1] = number; // IndexOutOfRangeException 발생 가능 (인덱스는 0만 유효)
        }
        catch (FormatException fe) // 사용자가 숫자가 아닌 값을 입력한 경우
        {
            Console.WriteLine($"오류: 잘못된 형식의 입력입니다. 숫자만 입력해주세요.");
            Console.WriteLine($"   (예외 메시지: {fe.Message})");
        }
        catch (IndexOutOfRangeException ioe) // 배열 인덱스 범위를 벗어난 경우
        {
            Console.WriteLine($"오류: 배열 접근 범위를 벗어났습니다.");
            Console.WriteLine($"   (예외 메시지: {ioe.Message})");
        }
        catch (Exception ex) // 위에서 잡지 못한 다른 모든 예외 처리 (최후의 보루)
        {
            Console.WriteLine($"알 수 없는 오류가 발생했습니다.");
            Console.WriteLine($"   (예외 타입: {ex.GetType().Name})");
            Console.WriteLine($"   (예외 메시지: {ex.Message})");
        }

        Console.WriteLine("프로그램이 정상적으로 계속 실행됩니다.");
    }
}

이 예제에서 try 블록 안의 코드가 실행되다가 만약 FormatException이 발생하면 첫 번째 catch 블록이 실행되고, IndexOutOfRangeException이 발생하면 두 번째 catch 블록이 실행됩니다. 둘 다 아닌 다른 예외가 발생하면 마지막 catch (Exception ex) 블록이 실행됩니다. 예외가 발생하더라도 프로그램이 비정상 종료되지 않고 catch 블록 실행 후 다음 코드(“프로그램이 정상적으로 계속 실행됩니다.”)로 넘어갑니다.

System.Exception 클래스

.NET에서 발생하는 모든 예외 클래스는 System.Exception 클래스를 직간접적으로 상속받습니다. 즉, System.Exception은 모든 예외의 기반(Base) 클래스입니다. 따라서 catch (Exception ex) 구문은 모든 종류의 .NET 예외를 잡을 수 있습니다.

System.Exception 클래스는 발생한 예외에 대한 유용한 정보를 담고 있는 여러 속성(Property)들을 제공합니다. 주요 속성은 다음과 같습니다.

  • Message: 예외의 원인을 설명하는 사람이 읽을 수 있는 형태의 문자열 메시지입니다. 개발자가 예외를 생성할 때 주로 지정합니다.
  • StackTrace: 예외가 발생한 시점의 호출 스택(Call Stack) 정보를 담고 있는 문자열입니다. 어떤 메서드 호출 경로를 거쳐 예외가 발생했는지 알려주므로 디버깅에 매우 중요합니다. (디버그 빌드에서 더 자세한 정보 제공)
  • InnerException: 현재 예외를 발생시킨 원인이 된 다른 예외 객체를 참조합니다. 예를 들어, 내부적으로 IOException이 발생했을 때 이를 감싸서 더 구체적인 사용자 정의 예외(MyDataAccessException)를 발생시킬 경우, MyDataAccessException 객체의 InnerException 속성에 원래의 IOException 객체를 담아 전달할 수 있습니다. 예외를 래핑(wrapping)할 때 유용합니다.
  • Source: 예외를 발생시킨 애플리케이션 또는 객체의 이름입니다.
  • HResult: 특정 예외에 할당된 코드화된 숫자 값입니다. (주로 COM 상호 운용성 등에서 사용)
  • GetType(): (상속된 메서드) 발생한 예외의 실제 타입을 반환합니다. catch(Exception ex) 블록에서 구체적인 예외 타입을 확인할 때 사용할 수 있습니다.

자주 사용되는 표준 예외 타입들

System 네임스페이스 및 하위 네임스페이스에는 특정 오류 상황을 나타내는 다양한 예외 클래스들이 미리 정의되어 있습니다. 몇 가지 예시는 다음과 같습니다.

  • System.NullReferenceException: null인 객체를 참조하려고 할 때 발생합니다.
  • System.IndexOutOfRangeException: 배열 등의 인덱스가 범위를 벗어났을 때 발생합니다.
  • System.FormatException: 인수의 형식이 메서드 매개변수 형식과 다를 때 발생합니다. (예: int.Parse("abc"))
  • System.IO.IOException: 입출력 작업 중 오류가 발생했을 때 발생합니다. (FileNotFoundException, DirectoryNotFoundException 등이 여기서 파생됨)
  • System.DivideByZeroException: 정수 또는 Decimal 값을 0으로 나누려고 할 때 발생합니다.
  • System.ArgumentException: 메서드에 전달된 인수가 유효하지 않을 때 발생합니다. (ArgumentNullException, ArgumentOutOfRangeException 등이 여기서 파생됨)
  • System.OutOfMemoryException: 메모리가 부족할 때 발생합니다.
using System;
using System.IO;

public class ExceptionInfoExample
{
    public static void Main(string[] args)
    {
        try
        {
            // 존재하지 않는 파일을 읽으려고 시도
            ReadFile("nonexistent_file.txt");
        }
        catch (Exception ex) // 모든 예외를 받음
        {
            Console.WriteLine("=== 예외 정보 출력 ===");
            Console.WriteLine($"예외 타입: {ex.GetType().FullName}"); // 실제 발생한 예외 타입
            Console.WriteLine($"메시지: {ex.Message}");
            Console.WriteLine($"소스: {ex.Source}"); // 예외 발생 어셈블리 등
            Console.WriteLine($"HResult: {ex.HResult}");
            Console.WriteLine("\n--- 스택 추적 (StackTrace) ---");
            Console.WriteLine(ex.StackTrace); // 예외 발생 경로 추적
            Console.WriteLine("--------------------------");

            // InnerException이 있는지 확인
            if (ex.InnerException != null)
            {
                Console.WriteLine("\n--- 내부 예외 (InnerException) ---");
                Console.WriteLine($"내부 예외 타입: {ex.InnerException.GetType().FullName}");
                Console.WriteLine($"내부 예외 메시지: {ex.InnerException.Message}");
                Console.WriteLine(ex.InnerException.StackTrace);
                Console.WriteLine("-------------------------------");
            }
        }
    }

    public static void ReadFile(string filePath)
    {
        try
        {
            // StreamReader는 파일을 찾지 못하면 FileNotFoundException 발생시킴
            using (StreamReader reader = new StreamReader(filePath))
            {
                Console.WriteLine(reader.ReadToEnd());
            }
        }
        catch (FileNotFoundException fnfEx) // 구체적인 파일 관련 예외를 잡아서
        {
            // 더 포괄적인 IOException으로 감싸서 다시 던짐 (InnerException 활용)
            throw new IOException($"파일 처리 중 오류 발생: {filePath}", fnfEx);
        }
    }
}

이 예제는 ReadFile 메서드 내부에서 FileNotFoundException이 발생하면 이를 잡아서 원래 예외(fnfEx)를 InnerException으로 포함하는 새로운 IOException을 던집니다. Main 메서드의 catch 블록에서는 최종적으로 잡힌 IOException 객체의 Message, StackTrace, InnerException 등의 정보를 출력하여 문제 해결에 도움을 줍니다.

예외 던지기

try-catch가 예외를 받아서 처리하는 방법이라면, throw 키워드는 예외를 발생시키는(던지는) 방법입니다. 메서드가 자신의 작업을 수행하는 도중 오류 상황을 감지했지만 스스로 처리할 수 없을 때, 또는 호출자에게 특정 조건이 충족되지 않았음을 알릴 필요가 있을 때 예외를 던질 수 있습니다.

사용 시점

  • 메서드의 사전 조건(precondition)이 만족되지 않았을 때 (예: 인수가 null이거나 범위를 벗어남).
  • 작업 수행에 필요한 객체의 상태가 유효하지 않을 때.
  • 외부 시스템(파일, 네트워크, 데이터베이스) 접근 중 복구할 수 없는 오류가 발생했을 때.

새로운 예외 객체 던지기: throw new 예외타입(인수);

  • 가장 일반적인 방법입니다. new 키워드로 예외 클래스의 인스턴스를 생성하여 던집니다.
  • 예외 타입은 상황에 맞는 표준 예외(ArgumentException, InvalidOperationException 등)를 사용하거나 사용자 정의 예외를 사용합니다.
  • 생성자에 메시지 문자열이나 내부 예외(InnerException)를 전달할 수 있습니다.

현재 예외 다시 던지기 (re-throwing): throw;

  • 이 구문은 catch 블록 내에서만 사용할 수 있습니다.
  • catch 블록에서 잡은 예외를 처리하지 않고 그대로 상위 호출자에게 다시 전달할 때 사용합니다.
  • 중요: throw;는 원래 예외가 발생했던 지점의 호출 스택 정보를 그대로 보존합니다. 디버깅에 매우 중요합니다.
  • 반면, catch (Exception ex) 블록 안에서 throw ex; 와 같이 잡은 예외 변수를 다시 던지면, 호출 스택 정보가 현재 catch 블록 위치에서 재설정(reset)되어 버립니다. 따라서 원래 예외 발생 위치를 추적하기 어려워지므로, 예외를 다시 던질 때는 반드시 throw; 를 사용해야 합니다.
using System;

public class ThrowExample
{
    // 나이가 유효한 범위 내에 있는지 확인하고, 아니면 예외를 던지는 메서드
    public static void ValidateAge(int age)
    {
        if (age < 0 || age > 120)
        {
            // ArgumentOutOfRangeException 예외 객체를 생성하여 던짐
            throw new ArgumentOutOfRangeException(nameof(age), $"나이는 0세 이상 120세 이하여야 합니다. 입력된 값: {age}");
        }
        Console.WriteLine($"입력된 나이 {age}는 유효합니다.");
    }

    public static void ProcessUser(int age)
    {
        try
        {
            ValidateAge(age);
            // ... 나이가 유효할 경우 계속 진행하는 로직 ...
            Console.WriteLine("사용자 처리 로직 실행 완료.");
        }
        catch (ArgumentOutOfRangeException ex) // ValidateAge에서 던진 예외를 받음
        {
            Console.WriteLine($"[ProcessUser] 사용자 처리 중 오류 발생: {ex.Message}");

            // 여기서 예외를 처리할 수 없으므로, 상위 호출자에게 다시 던짐
            // ★★★ 중요: throw; 를 사용하여 스택 추적 정보 보존 ★★★
            throw;
            // throw ex; // 이렇게 하면 스택 추적이 여기서 새로 시작됨 (나쁜 습관!)
        }
    }

    public static void Main(string[] args)
    {
        try
        {
            ProcessUser(25);  // 유효한 나이
            ProcessUser(150); // 유효하지 않은 나이 -> 예외 발생 및 전달됨
        }
        catch (ArgumentOutOfRangeException finalEx) // ProcessUser에서 다시 던진 예외를 최종적으로 받음
        {
            Console.WriteLine($"[Main] 최종 예외 처리: {finalEx.Message}");
            Console.WriteLine("--- 스택 추적 ---");
            Console.WriteLine(finalEx.StackTrace); // ValidateAge에서 발생한 스택 정보가 포함됨
        }
        Console.WriteLine("\n프로그램 종료.");
    }
}

이 예제에서 ValidateAge는 유효하지 않은 age 값에 대해 ArgumentOutOfRangeExceptionthrow합니다. ProcessUser는 이 예외를 catch하지만 완전히 처리하지 않고 throw;를 사용하여 Main 메서드로 다시 던집니다. Main 메서드는 최종적으로 예외를 받아 처리하고 스택 추적 정보를 출력하는데, throw;를 사용했기 때문에 원래 예외가 발생한 ValidateAge 메서드 내부의 정보까지 포함된 완전한 스택 추적을 볼 수 있습니다.

try~catch와 finally

때로는 예외 발생 여부나 예외 처리 여부와 상관없이 반드시 실행되어야 하는 코드가 있습니다. 예를 들어, 파일을 열거나 네트워크 연결을 맺는 등의 리소스를 사용한 후에는 작업이 성공하든 실패하든 관계없이 사용한 리소스를 해제(release) 또는 정리(cleanup)해야 합니다. 이러한 필수 마무리 작업을 위해 finally 블록을 사용합니다.

finally 블록

  • try 블록 뒤에, 그리고 모든 catch 블록 뒤에 위치합니다.
  • finally 블록 안의 코드는 다음과 같은 경우에도 항상 실행됨을 보장합니다.
    • try 블록이 예외 없이 성공적으로 완료되었을 때.
    • try 블록에서 예외가 발생하여 catch 블록이 실행되었을 때 (catch 블록 실행 후 finally 실행).
    • try 블록에서 예외가 발생했지만 해당 예외를 잡는 catch 블록이 없을 때 (예외가 상위로 전파되기 직전에 finally 실행).
    • try 또는 catch 블록 내에서 return, break, continue, goto 등으로 제어가 블록 밖으로 나가려고 할 때 (나가기 직전에 finally 실행).
// try-catch-finally
try
{
    // 리소스 사용 및 예외 발생 가능 코드
}
catch (ExceptionType ex)
{
    // 예외 처리 코드
}
finally
{
    // 항상 실행되어야 하는 리소스 정리 코드
}

// try-finally (catch 없이 사용 가능)
try
{
    // 리소스 사용 및 예외 발생 가능 코드
}
finally
{
    // 항상 실행되어야 하는 리소스 정리 코드
}

파일 리소스 정리

using System;
using System.IO;

public class FinallyExample
{
    public static void ProcessFile(string filePath)
    {
        StreamReader reader = null; // try 블록 밖에서 선언해야 finally에서 접근 가능
        try
        {
            reader = new StreamReader(filePath); // 파일 열기 (IOException 가능)
            Console.WriteLine("파일 열기 성공.");

            string line = reader.ReadLine();
            Console.WriteLine($"첫 줄: {line}");

            // 일부러 예외 발생시켜 보기
            // throw new InvalidOperationException("파일 처리 중 임의 예외 발생");

            Console.WriteLine("파일 처리 성공.");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"오류: 파일을 찾을 수 없습니다. ({ex.Message})");
        }
        catch (IOException ex)
        {
            Console.WriteLine($"오류: 파일을 읽는 중 입출력 오류 발생. ({ex.Message})");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"오류: 예상치 못한 오류 발생. ({ex.Message})");
            // throw; // 필요하다면 다시 던질 수 있음
        }
        finally
        {
            // reader가 null이 아니고 (파일이 성공적으로 열렸다면) 파일을 닫음
            if (reader != null)
            {
                Console.WriteLine("finally: 파일을 닫습니다.");
                reader.Close(); // 리소스 해제!
            }
            else
            {
                Console.WriteLine("finally: 파일이 열리지 않았으므로 닫을 필요 없음.");
            }
        }
    }

    public static void Main(string[] args)
    {
        // 임시 파일 생성 (예제 실행을 위해)
        string tempFile = "temp_test_file.txt";
        File.WriteAllText(tempFile, "Hello, finally!");

        Console.WriteLine("--- 정상 처리 시나리오 ---");
        ProcessFile(tempFile);

        Console.WriteLine("\n--- 예외 처리 시나리오 (파일 없음) ---");
        ProcessFile("nonexistent_file.txt");

        // 임시 파일 삭제
        File.Delete(tempFile);
    }
}

이 예제에서 ProcessFile 메서드는 파일을 열어서 처리합니다. finally 블록은 try 블록에서 예외가 발생하든(FileNotFoundException 등) 발생하지 않든 항상 실행되어, StreamReader 객체(reader)가 생성되었다면 반드시 reader.Close()를 호출하여 파일 리소스를 해제합니다.

using

파일 스트림, 데이터베이스 연결, 그래픽 핸들 등과 같이 사용 후 명시적으로 해제(Dispose)해야 하는 리소스를 다룰 때는 finally 블록보다 더 간결하고 안전한 using 문을 사용하는 것이 좋습니다. using 문은 IDisposable 인터페이스를 구현하는 객체에 대해 컴파일러가 자동으로 try-finally 블록을 생성하여 finally 블록에서 Dispose() 메서드를 호출하도록 보장해 줍니다.

using System;
using System.IO;

public class UsingStatementExample
{
    public static void ProcessFileWithUsing(string filePath)
    {
        try
        {
            // using 문 블록을 벗어나면 reader.Dispose()가 자동으로 호출됨
            using (StreamReader reader = new StreamReader(filePath))
            {
                Console.WriteLine("파일 열기 성공 (using).");
                Console.WriteLine($"첫 줄: {reader.ReadLine()}");
                Console.WriteLine("파일 처리 성공 (using).");
            } // <-- 이 지점에서 reader.Dispose() 자동 호출 (finally 역할)

            Console.WriteLine("using 블록 실행 완료.");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"오류: 파일을 찾을 수 없습니다. ({ex.Message})");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"오류: 예상치 못한 오류 발생. ({ex.Message})");
        }
    }
     // ... Main 메서드는 FinallyExample과 유사하게 작성 ...
}

using 문을 사용하면 finally 블록에서 직접 Close()Dispose()를 호출하는 코드를 작성할 필요가 없어 코드가 더 깔끔해지고 리소스 누수를 방지하는 데 효과적입니다. 따라서 IDisposable 객체를 다룰 때는 finally보다 using 문 사용을 적극 권장합니다.

사용자 정의 예외 클래스 만들기

.NET에서 제공하는 표준 예외 클래스들(ArgumentException, IOException 등)은 일반적인 오류 상황을 나타내는 데 유용합니다. 하지만 애플리케이션 고유의 특정 오류 조건을 더 명확하게 표현하고 싶을 때는 사용자 정의 예외 클래스(Custom Exception Class)를 직접 만들어 사용할 수 있습니다.

왜 만들까?

  • 오류의 명확성: 애플리케이션 도메인에 맞는 구체적인 예외 타입을 정의하여 어떤 종류의 오류가 발생했는지 쉽게 식별할 수 있습니다. (예: InsufficientStockException, InvalidUserCredentialsException)
  • 추가 정보 전달: 표준 예외 속성 외에 오류 상황에 대한 추가적인 정보(예: 실패한 사용자 ID, 부족한 재고량)를 예외 객체에 담아 전달할 수 있습니다.
  • 체계적인 예외 처리: 특정 비즈니스 로직과 관련된 예외들을 그룹화하여 처리하기 용이합니다.

만드는 방법

  1. System.Exception 클래스 또는 다른 적절한 표준 예외 클래스(예: ApplicationException, ArgumentException 등)를 상속받습니다. (일반적으로 System.Exception 상속 권장)
  2. 클래스 이름은 “Exception”으로 끝나도록 명명 규칙을 따르는 것이 좋습니다.
  3. 최소한 다음과 같은 표준 생성자들을 구현하는 것이 좋습니다.
    • 기본 생성자: public MyCustomException()
    • 메시지를 받는 생성자: public MyCustomException(string message)
    • 메시지와 내부 예외(Inner Exception)를 받는 생성자: public MyCustomException(string message, Exception innerException) (예외 래핑 시 중요) 이 생성자들은 내부적으로 base() 키워드를 사용하여 부모 클래스(Exception)의 해당 생성자를 호출해야 합니다.
  4. (선택 사항) 예외 상황에 대한 추가 정보를 담을 사용자 정의 속성(Property)을 추가할 수 있습니다.
  5. (선택 사항) 애플리케이션 도메인 간에 예외를 전달해야 하는 경우 [Serializable] 특성을 클래스에 적용합니다. (최근에는 중요도가 낮아짐)

사용자 정의 예외 클래스 예시

온도가 유효 범위를 벗어났을 때 발생하는 예외를 정의해 봅시다.

using System;

// 1. System.Exception을 상속받고 이름은 "Exception"으로 끝냄
[Serializable] // 선택 사항
public class InvalidTemperatureException : Exception
{
    // 4. 추가 정보(측정된 온도)를 위한 속성
    public double Temperature { get; }

    // 3. 표준 생성자 구현
    public InvalidTemperatureException() : base("온도가 유효 범위를 벗어났습니다.") { }

    public InvalidTemperatureException(double temperature)
        : base($"온도 값 {temperature}°C는 유효 범위를 벗어났습니다.")
    {
        Temperature = temperature; // 추가 정보 저장
    }

    public InvalidTemperatureException(string message) : base(message) { }

    public InvalidTemperatureException(string message, Exception innerException)
        : base(message, innerException) { }

    // 메시지와 온도를 받는 생성자 (추가 속성 활용)
    public InvalidTemperatureException(double temperature, string message)
        : base(message)
    {
        Temperature = temperature;
    }

    // 메시지, 온도, 내부 예외를 받는 생성자
    public InvalidTemperatureException(double temperature, string message, Exception innerException)
        : base(message, innerException)
    {
        Temperature = temperature;
    }

    // (선택 사항) 직렬화를 위한 생성자 (Serializable 특성 사용 시 필요)
    protected InvalidTemperatureException(
      System.Runtime.Serialization.SerializationInfo info,
      System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
}

// 사용자 정의 예외 사용
public class CustomExceptionExample
{
    public static void SetTemperature(double temp)
    {
        if (temp < -273.15 || temp > 1000.0) // 섭씨 온도 유효 범위 체크
        {
            // 사용자 정의 예외 던지기 (추가 정보 포함)
            throw new InvalidTemperatureException(temp, $"입력된 온도 {temp}°C는 시스템에서 처리 가능한 범위를 벗어났습니다.");
        }
        Console.WriteLine($"온도 {temp}°C가 성공적으로 설정되었습니다.");
    }

    public static void Main(string[] args)
    {
        try
        {
            SetTemperature(25.0);
            SetTemperature(-300.0); // 예외 발생 지점
        }
        catch (InvalidTemperatureException ex) // 사용자 정의 예외 잡기
        {
            Console.WriteLine($"오류 발생: {ex.Message}");
            Console.WriteLine($"   -> 잘못된 온도 값: {ex.Temperature}°C"); // 추가 정보 사용
            Console.WriteLine("\n--- 스택 추적 ---");
            Console.WriteLine(ex.StackTrace);
        }
        catch (Exception ex) // 다른 예외 처리
        {
            Console.WriteLine($"알 수 없는 오류: {ex.Message}");
        }
    }
}

이 예제에서는 InvalidTemperatureException 이라는 사용자 정의 예외를 만들고, 온도가 유효 범위를 벗어났을 때 이 예외를 던집니다. catch 블록에서는 이 특정 예외 타입을 잡아서 처리하며, 예외 객체에 추가된 Temperature 속성을 통해 잘못된 온도 값을 확인합니다.

예외 필터하기

C# 6.0부터 도입된 예외 필터(Exception Filter)는 catch 블록에 when 키워드를 사용하여 추가적인 조건을 지정할 수 있게 해주는 기능입니다. 이를 통해 특정 타입의 예외가 발생했을 때, 추가적인 조건까지 만족하는 경우에만 해당 catch 블록을 실행하도록 만들 수 있습니다.

구문

try
{
    // ...
}
catch (ExceptionType ex) when (boolean_condition)
{
    // ExceptionType 예외가 발생했고,
    // 동시에 when 뒤의 boolean_condition이 true일 때만 실행됨
}

when (boolean_condition): catch 할 예외 객체(ex)의 속성 등을 사용하여 조건을 검사하는 bool 표현식을 작성합니다.

동작 방식

  1. try 블록에서 예외가 발생합니다.
  2. 실행 환경은 호출 스택을 거슬러 올라가면서 해당 예외 타입과 일치하는 catch 블록을 찾습니다.
  3. 만약 when 절이 있는 catch 블록을 만나면, when 절의 조건을 평가합니다.
    • 조건이 true이면: 해당 catch 블록이 예외를 처리하기 위해 선택되고, 스택 풀기(stack unwinding)가 시작된 후 catch 블록 코드가 실행됩니다.
    • 조건이 false이면: 해당 catch 블록은 무시되고, 실행 환경은 계속해서 다음 catch 블록(같은 레벨 또는 상위 호출자)을 찾습니다.
  4. when 절이 없는 catch 블록은 타입만 일치하면 즉시 선택됩니다.

장점

  • 조건부 예외 처리: 예외 객체의 상태(예: 특정 에러 코드, 플래그 값)를 확인하여 처리 여부를 결정할 수 있습니다. catch 블록 내부에서 if문으로 조건을 확인하고 처리하지 않을 경우 throw;로 다시 던지는 것보다 코드가 간결해질 수 있습니다.
  • 스택 정보 보존: when 조건이 false일 경우 스택 풀기가 일어나지 않기 때문에, 나중에 다른 catch 블록이나 디버거에서 예외를 잡았을 때 정확한 원본 스택 상태를 확인할 수 있습니다. (이는 catchif 조건 불만족 시 throw; 하는 것과 유사하지만, when 절 평가 시점에는 아직 스택이 풀리지 않았다는 차이가 있음)
  • 로깅 등 부가 작업: when 절 안에서 로깅 함수를 호출하는 등의 부가 작업을 수행할 수도 있습니다 (단, when 절은 예외 처리 여부 결정이 주 목적이므로 너무 복잡한 작업은 지양).

사용 예시

웹 요청 중 발생하는 HttpRequestException을 처리하되, HTTP 상태 코드가 404(Not Found)일 때만 특정 로직을 수행하고 싶다고 가정해 봅시다.

using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

public class ExceptionFilterExample
{
    public static async Task MakeRequestAsync(string url)
    {
        using (HttpClient client = new HttpClient())
        {
            try
            {
                HttpResponseMessage response = await client.GetAsync(url);
                Console.WriteLine($"요청 URL: {url}");
                Console.WriteLine($"상태 코드: {response.StatusCode}");

                // 응답 상태가 성공이 아니면 예외 발생시키기
                response.EnsureSuccessStatusCode();

                string content = await response.Content.ReadAsStringAsync();
                Console.WriteLine("응답 내용 수신 완료.");
                // ... 응답 처리 로직 ...
            }
            // HttpRequestException 중에서도 StatusCode가 NotFound일 때만 이 catch 블록 실행
            catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
            {
                Console.WriteLine($"[필터 적용됨] 리소스를 찾을 수 없습니다 (404 Not Found): {url}");
                // 404 에러에 대한 특정 처리 로직 (예: 사용자에게 알림)
            }
            // 다른 모든 HttpRequestException 처리 (위 when 조건이 false인 경우 포함)
            catch (HttpRequestException ex)
            {
                Console.WriteLine($"[일반 처리] HTTP 요청 오류 발생: {ex.Message}");
                Console.WriteLine($"   -> StatusCode: {ex.StatusCode}");
                // 일반적인 HTTP 오류 처리 로직
            }
            catch (Exception ex) // 기타 예외
            {
                Console.WriteLine($"알 수 없는 오류: {ex.Message}");
            }
        }
    }

    public static async Task Main(string[] args)
    {
        // 정상적인 URL (예: 구글)
        await MakeRequestAsync("https://www.google.com");

        Console.WriteLine("\n------------------\n");

        // 존재하지 않을 가능성이 높은 URL (404 예상)
        await MakeRequestAsync("https://httpbin.org/status/404"); // httpbin은 지정된 상태 코드를 반환

         Console.WriteLine("\n------------------\n");

        // 다른 HTTP 오류 발생 URL (예: 500 Internal Server Error)
        await MakeRequestAsync("https://httpbin.org/status/500");
    }
}

이 예제에서 MakeRequestAsync("https://httpbin.org/status/404")를 호출하면 HttpRequestException이 발생하고, when (ex.StatusCode == HttpStatusCode.NotFound) 조건이 true가 되어 첫 번째 catch 블록(“[필터 적용됨]…”)이 실행됩니다. 반면, MakeRequestAsync("https://httpbin.org/status/500")를 호출하면 역시 HttpRequestException이 발생하지만 when 조건이 false이므로 첫 번째 catch 블록은 건너뛰고 두 번째 catch 블록(“[일반 처리]…”)이 실행됩니다.

예외 필터는 특정 조건에 따라 예외 처리 로직을 분기해야 할 때 코드를 더 명확하고 효율적으로 작성하는 데 도움을 줄 수 있습니다.

예외 처리 다시 생각해보기

예외 처리는 프로그램의 안정성을 위해 필수적이지만, 잘못 사용하면 오히려 코드의 가독성을 떨어뜨리고 성능에 악영향을 줄 수 있습니다. 효과적인 예외 처리를 위한 몇 가지 지침과 고려사항을 정리해 보겠습니다.

예외는 예외적인 상황에만 사용하세요.

  • 예외 처리는 일반적인 프로그램 제어 흐름(control flow)을 위해 사용되어서는 안 됩니다. 예를 들어, 사용자 입력 유효성 검사 실패를 알리기 위해 예외를 던지는 것보다 if 문으로 조건을 확인하거나, bool TryParse(string s, out int result) 와 같은 TryXxx 패턴을 사용하는 것이 더 효율적이고 코드 가독성도 좋습니다.
  • 예외 처리에는 상당한 성능 비용이 따르므로, 예측 가능하고 자주 발생하는 조건 확인에는 사용하지 않는 것이 좋습니다. 예외는 이름 그대로 정말로 예외적인, 예측하기 어렵거나 발생해서는 안 되는 오류 상황에 사용해야 합니다.

가능하면 구체적인 예외를 잡으세요.

  • catch (Exception ex) 처럼 모든 예외를 잡는 것은 편리해 보일 수 있지만, 어떤 종류의 오류가 발생했는지 구분하기 어렵게 만들고 예상치 못한 오류까지 숨겨버릴 수 있습니다.
  • 가능한 한 발생할 것으로 예상되는 구체적인 예외 타입(FileNotFoundException, ArgumentNullException 등)을 먼저 catch하고, 각각의 상황에 맞는 복구 로직을 구현하는 것이 좋습니다. catch (Exception ex)는 주로 애플리케이션의 최상위 레벨에서 예기치 못한 모든 오류를 로깅하고 사용자에게 일반적인 오류 메시지를 보여주는 등의 최후의 수단으로 사용됩니다.

예외를 잡았다면, 제대로 처리하거나 다시 던지세요.

  • catch 블록에서 예외를 잡았다는 것은 해당 문제를 인지했다는 의미입니다. 그렇다면 그 문제를 해결하기 위한 적절한 조치(로그 기록, 사용자 알림, 기본값 사용, 작업 재시도 등)를 취해야 합니다.
  • 만약 현재 catch 블록 수준에서 예외를 완전히 처리할 수 없다면, throw; 를 사용하여 예외를 상위 호출자에게 다시 전달하여 처리 기회를 주어야 합니다. 절대로 예외를 잡고 아무것도 하지 않고 넘어가서는 안 됩니다(예외 삼키기 - exception swallowing). 이는 문제의 원인을 파악하기 매우 어렵게 만듭니다.

리소스 정리는 finally 또는 using을 사용하세요.

  • 파일 핸들, 네트워크 소켓, 데이터베이스 연결, 그래픽 객체 등 시스템 리소스는 사용 후 반드시 해제해야 합니다. 예외 발생 여부와 관계없이 항상 리소스가 해제되도록 보장하기 위해 finally 블록이나 using 문을 사용하세요.
  • IDisposable 인터페이스를 구현하는 객체에 대해서는 using 문을 사용하는 것이 코드가 간결하고 실수를 줄일 수 있어 강력히 권장됩니다.

예외 메시지는 명확하게 작성하세요.

  • throw new Exception(...)을 사용하여 예외를 직접 던질 때는, 무엇이 잘못되었는지 이해하는 데 도움이 되는 명확하고 구체적인 메시지를 포함해야 합니다. 필요하다면 매개변수 값 등 문제 해결에 도움이 될 만한 컨텍스트 정보도 메시지에 포함시키는 것이 좋습니다.

사용자 정의 예외는 신중하게 사용하세요.

  • 애플리케이션 고유의 오류 상황을 표현하기 위해 사용자 정의 예외를 만드는 것은 유용하지만, 너무 남발하면 오히려 혼란을 야기할 수 있습니다. 기존 .NET 표준 예외로 충분히 표현 가능한 상황이라면 표준 예외를 사용하는 것이 좋습니다. 사용자 정의 예외는 표준 예외로 표현하기 어렵거나 추가적인 정보 전달이 꼭 필요한 경우에 사용하는 것을 고려하세요.

로깅(Logging)을 적극 활용하세요.

  • 예외가 발생했을 때 단순히 사용자에게 오류 메시지를 보여주는 것만으로는 부족합니다. 예외 발생 시간, 예외 타입, 메시지, 스택 추적, 관련 데이터 등 상세 정보를 로그 파일이나 데이터베이스에 기록해 두면 나중에 문제를 분석하고 디버깅하는 데 큰 도움이 됩니다. NLog, Serilog, log4net과 같은 로깅 프레임워크를 활용하는 것이 좋습니다.

예외 처리는 단순히 try-catch를 사용하는 기술적인 문제를 넘어, 프로그램의 안정성과 유지보수성에 큰 영향을 미치는 설계의 문제입니다. 위 지침들을 고려하여 상황에 맞게 적절하고 효과적인 예외 처리 전략을 수립하는 것이 중요합니다.