본문 바로가기
공부/C#

이것이 C#이다 Chapter 12 예외 처리하기

by bokob 2024. 3. 9.

12.1 예외에 대하여

프로그램 사용자는 프로그래머가 생각한 대로만 프로그램을 다루지 않는다. 예를 들어, 숫자만 입력해야 하는데 문자를 입력하기도 한다. 사용자뿐만 아니라 갑자기 네트워크가 다운되기도 하는 등 프로그래머가 생각한 시나리오에서 벗어나는 사건을 예외(Exception)이라 부른다. 그리고 예외가 프로그램의 오류나 다운으로 이어지지 않도록 적절하게 처리하는 것을 예외 처리(Exception Handling)라고 한다.

using System;

namespace PracticeCSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] arr = { 1, 2, 3 };

            for(int i=0;i<5;i++)
            {
                // i가 배열의 크기 - 1 을 넘어서면 예외를 일으키고 종료된다.
                // 이후 코드들은 더 이상 실행되지 않는다.
                Console.WriteLine(arr[i]);
            }
            // 이 코드는 실행되지 않는다. 위에서 예외가 일어나기 때문
            Console.WriteLine("종료");
        }
    }
}

예외 메시지는 CLR이 출력한 것이다. 잘못된 인덱스를 통해 배열의 요소에 접근하려 들면 배열 객체가 이 문제에 대한 상세 정보를 IndexOutOfRangeException의 객체에 담은 후 Main() 메소드에 던지는데, 이 예제의 Main() 메소드는 이 예외를 처리할 방도가 없기 때문에 다시 CLR에 던진다. CLR까지 전달된 예외는 '처리되지 않은 예외'가 되고 CLR이 이것을 받으면 예외 객체에 담긴 내용을 사용자에게 출력한 후 프로그램을 강제로 종료한다.

 

12.2 try~catch로 예외 받기

위의 예제에서 예외를 던졌는데 Main() 메소드가 처리하지 못했다. 예외를 Main() 메소드가 받으면 된다.

C#에서는 예외를 받을 때 try~catch문을 이용한다.

try
{
    // 실행하려는 코드
}
catch(예외_객체_1)
{
    // 예외가 발생했을 때의 처리
}
catch(예외_객체_2)
{
    // 예외가 발생했을 때의 처리
}

try 절의 코드 블록에는 예외가 일어나지 않을 경우에 실행되어야 할 코드들이 들어가고, catch 절에는 예외가 발생했을 때의 처리 코드가 들어간다. try는 '시도하다'라는 뜻이고 catch는 '잡다' 또는 '받다'라는 뜻이다.

try 절에서 원래 실행하려 했던 코드를 쭉 처리해 나가다가 예외가 던져지면 catch 블록이 받아낸다. 이때 catch 절은 try 블록에서 던질 예외 객체와 타입이 일치해야 한다. 그렇지 않으면 던져진 예외는 아무도 받지 못해 '처리되지 않은 예외'로 남게 된다. 만약 try 블록에서 실행하는 코드에서 여러 종류의 예외를 던질 가능성이 있다면, 이를 받아낼 catch 블록도 여러 개 둘 수 있다.

using System;

namespace PracticeCSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] arr = { 1, 2, 3 };

            try
            {
                for(int i=0;i<5;i++)
                {
                    Console.WriteLine(arr[i]);
                }
            }
            catch(IndexOutOfRangeException e)
            {
                Console.WriteLine($"예외가 발생 : {e.Message}");
            }
            Console.WriteLine("종료");
        }
    }
}

 

12.3 System.Exception 클래스

System.Exception 클래스는 모든 예외의 조상이다. C#에서 모든 예외 클래스는 반드시 이 클래스로부터 상속받아야 한다.

상속 관계로 인해 모든 예외 클래스들은 System.Exception 타입으로 간주할 수 있고 System.Exception 타입의 예외를 받는 catch 절 하나면 모든 예외를 다 받아낼 수 있다.

 

try
{
}
catch(IndexOutOfRangeException e)
{
    // ...
}
catch(DivideByZeroException e)
{
    // ...
}

 

System.Exception 클래스를 이용하면 다음과 같이 하나의 catch 절로 처리할 수 있다.

try
{
}
catch(Exception e)
{
    // ...
}

예외 타입을 사용할 것 없이 System.Exception 클래스 하나면 될 것 같은 생각이 들지만, 좋은 생각이 아니다.

예외 상황에 따라 섬세한 예외 처리가 필요한 코드에서는 Exception 클래스만으로 대응하기 어려우므로, 무조건 Exception 클래스를 사용하면 안된다.

System.Exception 타입은 프로그래머가 발생할 것으로 계산한 예외 말고도 다른 예외까지 받아낼 수 있다.

만약 그 예외가 현재 코드가 아닌 상위 코드에서 처리해야 할 예외라면, 이 코드는 예외를 처리하는 대신 버그를 만들고 있는 셈이 된다. System.Exception 예외를 사용할 때는 코드를 면밀히 검토해서 처리하지 않아야 할 예외까지 처리하는 일이 없도록 해야 한다.

 

12.4 예외 던지기

try~catch 문으로 예외를 받는다는 것은 어디선가 예외를 던진다는 것이다.

예외는 throw문을 이용해서 던진다.

try
{
    // ...
    throw new Exception("예외를 던진다.");
}
catch(Exception e) // throw문을 통해 던져진 예외 객체는 catch문을 통해 받는다.
{
    Console.WriteLine(e.Message);
}

메소드 안에서 특정 조건을 만족하면(또는 만족하지 못하면) 예외를 던지고, 던져진 예외는 메소드를 호출하는 try~catch문에서 받아낸다. 

static void DoSomething(int arg)
{
    if(arg<0)
        Console.WriteLine("arg : {0}", arg)
    else
        throw new Exception("arg가 10보다 크다.");
        // 예외를 던졌지만 DoSomething() 메소드 안에서는 이 예외를 처리할 수 있는 코드가 없다.
        // 이 예외는 DoSomething() 메소드의 호출자에게 던져진다.
}

static void Main()
{
    try
    {
        DoSomething(13);
    }
    catch(Exception e) // DoSomething() 메소드에서 던진 호출자의 try~catch 블록에서 받는다.
    {
        Console.WriteLine(e.Message);
    }
}

 

throw는 보통 문(Statement)으로 사용하지만, C# 7.0부터는 식(Expression)으로도 사용할 수 있도록 개선했다.

int? a = null;

// a는 null이므로, b에 a를 할당하지 않고 throw 식이 실행된다.
int b = a ?? throw new ArgumentNullException();

 

throw 식은 조건 연산자 안에서도 사용할 수 있다.

int[] array = new[] {1, 2, 3};
int index = 4;

int value = aray[
    index >=0 && index < 3
    ? index : throw new IndexOutOfRangeException()
    ];

 

12.5 try~catch와 finally

try 블록에서 코드를 실행하다가 예외가 던져지면 프로그램의 실행이 catch 절로 바로 뛰어넘어온다.

만약 예외 때문에 try 블록의 자원 해제 같은 중요한 코드를 미처 실행하지 못한다면 이는 곧 버그를 만드는 원인이 된다.

예를 들어 다음 코드와 같이 try 블록 끝에 데이터베이스의 커넥션을 닫는 코드가 있었는데 갑자기 발생한 예외 때문에 이것을 실행하지 못했다면 사용할 수 있는 커넥션이 점점 줄어 나중에는 데이터베이스에 전혀 연결할 수 없는 상태에 이를 수 있다.

try
{
    dbconn.Open(); // dbconn은 데이터베이스 커넥션
    // ...
    dbconn.Close(); // 이런 코드는 버그를 일으킬 가능성이 높다.
}
catch(XXXException e)
{
    // ...
}
catch(YYYException e)
{
    // ...
}

 

그렇다고 자원을 해제하는 코드를 모든 catch 절에 배치하는 것은 똑같은 코드를 반복해서 작성하므로 좋은 것은 아니다.

try
{
    dbconn.Open(); // dbconn은 데이터베이스 커넥션
    // ...
    dbconn.Close();
}
catch(XXXException e)
{
    dbconn.Close();
}
catch(YYYException e)
{
    dbconn.Close();
}

 

C#에서는 예외 처리를 할 때 자원 해제 같은 뒷마무리를 우아하게 실행할 수 있도록 finally 절을 try~catch문과 함께 제공한다. finally 절은 try~catch문의 마지막에 연결해서 사용하는데, 다음과 같이 뒷정리 코드를 넣어두면 된다.

try
{
    dbconn.Open(); // dbconn은 데이터베이스 커넥션
}
catch(XXXException e)
{
}
catch(YYYException e)
{
}
finally
{
    dbconn.Close();
}

 

자신이 소속된 try절이 실행된다면 finally절은 어떤 경우라도 실행된다. 심지어 try절 안에서 return문이나 throw문이 사용되더라도 finally절은 꼭 실행된다.

static int Divide(int dividend, int divisor)
{
    try
    {
        Console.WriteLine("Divide() 시작");
        return dividend / divisor;
        // 예외가 일어나지 않고 정상적으로 return 하더라도 finally 절은 실행된다.
    }
    catch(DivideByZeroException e)
    {
        Console.WriteLine("Divide() 예외 발생");
        throw e; // 예외가 일어나더라도 finally 절은 실행된다.
    }
    finally
    {
        Console.WriteLine("Divide() 끝");
    }
}

 

finally 안에서 예외가 또 일어나면 어떻게 될까?

finally 블록에서 예외가 일어나면 받아주거나 처리해주는 코드가 없으므로 이 예외는 '처리되지 않은 예외'가 된다.

코드를 면밀히 살펴 예외가 일어나지 않도록 하거나 현재 수준의 코드에서 예외가 일어날 가능성을 완전히 배제할 수 없다면 이 안에서 다시 한번 try~catch 절을 사용하는 것도 방법이다.

 

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

Exception 클래스를 상속받기만 하면 새로운 예외 클래스를 만들 수 있다.

class MyException : Exception
{
    // ...
}

사용자 정의 예외는 그렇게 자주 필요하지 않다. .NET이 100여 가지가 넘는 예외 타입을 제공하기 때문이다.

하지만 특별한 데이터를 담아서 예외 처리 루틴에 추가 정보를 제공하고 싶거나 예외 상황을 더 잘 설명하고 싶을 때는 사용자 정의 예외 클래스가 필요하다.

예를 들어 웹사이트의 회원 가입 페이지를 C#으로 작성했는데 회원의 이메일 주소가 잘못 기재됐을 때의 예외를 받고 싶다고 해보자. .NET은 InvalidEmailAddress 같은 예외 클래스를 제공하지 않기 때문에, 이 경우 사용자 정의 예외 클래스를 만들어 처리할 필요가 있다.

using System;

namespace PracticeCSharp
{
    class InvalidArgumentException : Exception
    {
        public InvalidArgumentException()
        {
        }

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

        public object Argument
        {
            get;
            set;
        }

        public string Range
        {
            get;
            set;
        }
    }
    class Program
    {
        static uint MergeARGP(uint alpha, uint red, uint green, uint blue)
        {
            uint[] args = new uint[] { alpha, red, green, blue };

            foreach(uint arg in args)
            {
                if (arg > 255)
                    throw new InvalidArgumentException()
                    {
                        Argument = arg,
                        Range = "0~255"
                    };
            }

            return (alpha << 24 & 0xFF000000) |
                   (red << 16 & 0x00FF0000) |
                   (green << 8 & 0x0000FF00) |
                   (blue & 0x000000FF);
        }
        
        static void Main(string[] args)
        {
            try
            {
                Console.WriteLine("0x{0:X}", MergeARGP(255, 111, 111, 111));
                Console.WriteLine("0x{0:X}", MergeARGP(1, 65, 192, 128));
                Console.WriteLine("0x{0:X}", MergeARGP(0, 255, 255, 300));
            }
            catch(InvalidArgumentException e)
            {
                Console.WriteLine(e.Message);
                Console.WriteLine($"Argument:{e.Argument}, Range:{e.Range}");
            }
        }
    }
}

 

12.7 예외 필터하기

C# 6.0부터는 catch 절이 받아들일 예외 객체에 제약 사항을 명시해서 해당 조건을 만족하는 예외 객체에 대해서만 예외 처리 코드를 실행할 수 있도록 하는 예외 필터(Exception Filter)가 도입되었다. 예외 필터를 만드는 데는 많은 코드가 필요하지 않다. catch()절 뒤에 when 키워드를 이용해서 제약 조건을 기술하면 된다. (when을 if라고 생각하면서 읽으면 예외 필터 코드를 이해하기가 쉽다).

class FilterableException : Exception
{
    public int ErrorNo {get; set;}
}

try
{
    int num = GetNumber();
    
    if(num<0 || num >10)
        throw new FilterableException() {ErrorNo = num};
    else
        Console.WriteLine($"Output : {num}");
}
catch(FilterableException e) when (e.ErrorNo < 0)
{
     Console.WriteLine("Negative input is not allowed.");
}

이 코드는 try 블록 안에서  num이 0보다 작거나 10보다 크면 FilterableException 예외 객체를 던진다. 이어지는 catch 블록은 FilterableException 객체를 받도록 되어 있지만 when을 이용해서 예외 객체의 ErrorNo가 0보다 작은 경우만 걸러내고 있다. 그럼 그 외의 경우(ErrorNo가 10보다 큰 경우)에는 예외가 처리되지 않은 상태 그대로 현재 코드의 호출자에게 던져진다.

 

12.8 예외 처리 다시 생각해보기

C#이 예외 처리를 지원하지 않았다면 어떻게 예외를 다뤄야 했을까?

다음의 Divide() 메소드에서 try~catch, 그리고 throw 문을 빼도록 수정한다고 해보자.

static int Divide(int dividend, int divisor)
{
    try
    {
        return dividend / divisor;
    }
    catch(DivideByZeroException e)
    {
        throw e;
    }
}

 

메소드 내부에서 문제가 생기면 어떻게 호출자에게 그 문제를 알릴 수 있을까? 에러 코드를 반환하자.

// 제수가 0이면 음수를 반환한다. 그렇지 않으면 몫을 반환한다.
static int Divide(int dividend, int divisor)
{
    if(divisor == 0)
        return -5;
    else
        return dividend / divisor;
}

 

이런 코드는 문제가 있다고 생각이 들 것이다. 그래서 다음과 같이 Divide() 메소드의 반환값은 에러 코드로만 사용하고 나눗셈의 결과는 출력 전용 매개변수에 담는 것으로 문제를 해결할 수 있다.

// 제수가 0이면 음수를 반환한다. 그렇지 않으면 몫을 반환한다.
static int Divide(int dividend, int divisor, out int result)
{
    if(divisor == 0)
    {
        result = 0;
        return -5;
    }
    else
    {
        return dividend / divisor;
        return 0;
    }
}

 

위의 예제 코드들을 통해 볼 수 있듯이, try~catch문을 이용한 예외 처리는 실제 일을 하는 코드와 문제를 처리하는 코드를 깔끔하게 분리시킴으로써 코드를 간결하게 만들어준다. 예외 처리의 장점은 이것뿐이 아니다. 예외 객체의 StackTrace 프로퍼티를 통해 문제가 발생한 부분의 소스 코드 위치를 알려주기 때문에 디버깅이 아주 용이하다.

using System;

namespace PracticeCSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                int a = 1;
                Console.WriteLine(3 / --a);
            }
            catch(DivideByZeroException e)
            {
                Console.WriteLine(e.StackTrace); // 문제가 발생한 위치를 알 수 있다.
            }
        }
    }
}

 

 

마지막으로 예외 처리는 여러 문제점을 하나로 묶거나 코드에서 발생할 수 있는 오류를 종류별로 정리해주는 효과가 있다.

예를 들어 try 블록의 코드 중에서 DivideByZeroException 예외를 일으킬 수 있는 부분은 둘 이상일 수도 있지만, 이 타입의 예외를 받는 catch 블록 하나면 모두 처리할 수 있다. 이렇게 예외 처리를 이용해서 오류를 처리하는 코드는 작성하기에도 쉽고, 나중에 다시 읽기에도 좋다.