[Effective C#] Ch5. 예외 처리

2022. 4. 15. 13:42카테고리 없음

오류는 항상 발생한다.

예외가 발생했을 때 이를 어떻게 처리해야 할지 정확히 이해하는 것은 C# 개발자의 핵심 역량 중 하나다.

우리가 작성하는 코드에서 예외를 직접 발생시켜야 하는 경우도 있다.

.NET Framework의 설계 지침에 따르면 요청된 작업을 올바르게 수행할 수 없다면 예외를 발생시키라고 가이드한다.

이 경우 실패의 근본 원인을 진단하고 가능하다면 오류 상황을 수정하는 데 필요한 모든 정보를 제공해야 한다.

또한 응용 프로그램이 복구 가능한 상태인지를 명확히 알려줘야 한다.

 

⭐메서드가 실패했음을 알리기 위해서 예외를 이용하라

메서드가 요청된 작업을 제대로 수행할 수 없는 경우 예외를 발생시켜 실패가 발생했음을 알려야 한다.

하지만 일반적인 실행 흐름을 제어하는 매커니즘으로 예외를 사용해서는 안된다.

추가적으로 다른 개발자가 사용할 라이브러리를 작성하는 경우에는 정상적인 운영 환경에서 예외가 발생할 가능성을 최소화하는 것이 좋다.

런타임에 발생하는 예외는 상당히 값비쌀 뿐 아니라 이에 대응하는 코드를 작성하는 것이 쉽지 않다.

따라서 개발자가 try/catch 블록을 작성하지 않고도 정상적으로 메서드가 수행될 수 있는지를 확인할 수 있는 API를 함께 제공하는 것이 좋다.

 

오류가 발생했음을 알리는 수단으로 예외를 사용하기로 결정했다 하더라도 항상 예외를 통해 보고해야 하는 것은 아니다.

ex) 

- File.Exists() 메서드는 매개변수로 주어진 파일이 존재하면 true, 그렇지 않으면 false를 반환한다.

- File.Open()의 경우는 파일이 존재하지 않는 경우 예외를 유발한다.

 

두 메서드가 이처럼 서로 다르게 구현된 이유는 File.Exists()메서드의 경우 그 목적이 파일이 실제로 존재하는지를 확인하기 위한 메서드이기 때문이다. 개발자가 원하지 않는 답변이 반환되는 것과 작업 자체가 실패하는 것은 엄연히 다르다.

원하지 않는 답변이 반환되더라도 파일의 존재 여부를 확인했다는 측면에서 추가적인 정보를 획득한 것이기 때문이다.

 

예외를 발생시키는 메서드를 작성할 때는 항상 예외를 유발하는 조건을 사전에 검사할 수 있는 메서드를 함께 작성할 것을 권장한다.

 

⭐리소스 정리를 위해 using과 try/finally를 활용하라

관리되지 않는 시스템 리소스를 사용하는 타입은 IDisposable 인터페이스가 제공하는 Dispose() 메서드를 이용하여 명시적으로 리소스를 해제해야 한다. IDisposable 인터페이스를 구현한 타입을 사용할 때는 리소스 해제를 위해서 반드시 Dispose()를 호출해야 한다. 사용자 입장에서 Dispose() 메서드가 항상 호출되도록 코드를 작성하기 위한 최선의 방법은 using문이나 try/finally 블록을 활용하는 것이다.

더불어 사용자들이 Dispose() 메서드 호출하는 것을 혹시라도 잊어버린 경우에 대비하기 위해서 finalizer를 방어적으로 작성해야 한다. Dispose() 메서드를 호출하는 것을 잊은 경우에도 finalizer가 수행될 때 리소스가 해제될 수 있도록 하기 위함이다. 하지만 이 경우 해당 리소스가 메모리 상에 더 오래 살아남을 것이기 때문에 리소스가 낭비될 뿐 아니라 프로그램의 수행 성능에도 나쁜 영향을 미치게 된다.

 

Dispose() 메서드는 리소스를 해제하는 작업 외에도 GC.SuppressFinalize()를 호출하여 가비지 수집기에게 이 객체에 대해서는 finalizer를 호출할 필요가 없음을 알리는 작업을 추가로 수행하는데, Close() 메서드는 일반적으로 이러한 작업을 수행하지 않는다.

따라서 단순히 Close()만 하면 이 객체에 대해서는 finalizer를 호출할 필요가 없음에도 불구하고 여전히 finalizer 큐에 이 객체가 남게 된다. 만약 두 메서드 중 하나를 선택할 수 있는 상황이라면 Close()보다 Dispose()를 호출하는 것이 좋다.

 

Dispose()가 객체를 메모리에서 제거해주지는 못한다. 다만 관리되지 않는 리소스를 해제할 수 있도록 기회를 주는 메서드일 뿐이다. 따라서 연결이 닫힌 이후에도 SqlConnection 객체는 여전히 메모리 상에 남아있는데 이 객체는 사실상 남아있는데 이 객체는 사실상 데이터베이스로의 연결을 소실한 상태다. 메모리에 남아있지만 쓸모가 없다는 뜻이다.

만약 해당 객체를 다른 부분에서 재사용할 가능성이 조금이라도 있다면 절대 Dispose()를 호출해서는 안된다.

 

⭐사용자 지정 예외 클래스를 완벽하게 작성하라

예외는 오류를 보고하기 위한 매커니즘이며 예외가 발생한 위치로부터 상당히 떨어진 위치에서조차 발생한 예외를 처리할 수 있는 방법을 제공한다. 오류가 발생한 원인을 나타내기 위한 정보는 반드시 예외 객체 내에 포함돼야 한다.

우선 catch 문을 작성할 때 예외의 런타임 타입에 따라 서로 다른 작업을 수행하도록 코드를 작성하는 것이 일반적이다. 즉 예외를 발생시킬 때 어떤 예외 클래스를 사용하느냐에 따라 서로 다른 작업이 수행된다는 점을 알아야 한다.

 

try {
	Foo();
    Bar();
}
catch (MyFirstApplicationException e1)
{
	FixProblem(e1);
}
catch (AnotherApplicationException e2)
{
	ReportErrorAndContinue(e2);
}
catch (Exception e)
{
	ReportGenericError(e);
    throw;
}
finally
{
	CleanupResources();
}

발생된 예외를 더욱 면밀히 살펴보고 극복할 수 있는 오류 상황인지를 판단하여 추가 조치를 할 수 있다면 더 좋을 것 같다.

 

private static void SampleTwo()
{
	try {
    }
    catch(Exception e)
    {
    	swith(e.TargetSite.Name)
        {
        	case "Foo":
            	break;
            ...
        }
    }
    finally
    {
    	CleanupResources();
    }
}

이 코드는 사실 별로 견고하지 못하다.

루틴의 이름을 변경해도 문제가 생기고, 에러를 유발하는 코드를 공유 라이브러리로 옮기기만 해도 문제가 생긴다.

이러한 문제는 예외를 유발하는 코드의 콜스택이 깊어지면 깊어질수록 더욱 더 취약해진다.

 

두 가지 전제 조건

1) 먼저 우리가 직면하게 될 모든 오류 상황을 예외로 표현할 필요가 없다.

2) throw 문을 이용하여 코드를 작성한다고 해서 새로운 예외 클래스를 만들 시점이 됐다고 생각하지는 않기를 바란다.

 

서로 다른 예외 클래스를 활용하여 예외를 발생시키는 유일한 이유는 catch문을 사용하여 예외를 다루는 코드를 작성할 개발자가 그 각각을 구분하여 서로 다른 작업을 수행할 수 있도록 해주기 위함이다.

따라서 에러가 발생한 시점에 복구 가능성을 염두에 두고 추가적인 정보를 담도록 예외 클래스를 작성하는 것이 좋다.

 

1) 사용자 정의 예외 클래스를 고유한 책임을 명확하게 규정해야 한다.

2) 모든 예외 클래스의 이름은 Exception으로 끝나야 한다.

3) System.Exception 클래스나 혹은 더 적절한 예외 클래스를 상속해서 구현해야 한다.

 

✨예외 변환(Exception Translation)

저수준의 예외에 대해서 보다 세부적인 상태 정보를 포함하는 고수준의 예외로 변경하는 작업

➡ 저수준에서 발생한 오류 상황 이외에도 추가적인 정보를 전달 가능

 

⭐ 강력한 예외 보증을 준수하는 것이 좋다

데이브 에이브람스는 예외에 대한 보증을 아래와 같이 구분하여 정의했다.

✨기본 보증

특정 함수 내에서 발생한 예외가 이 함수를 빠져나오더라도 어떤 리소스도 누수되지 않으며, 모든 객체의 상태가 유효한 상태를 유지함

✨강력한 보증

기본 보증 + 예외 발생 시에도 프로그램의 상태가 변경되지 않음을 추가로 보증

✨예외 없음 보증

작업이 결코 실패하지 않으며 따라서 예외가 발생하지도 않음을 보증

 

.NET CLR은 기본보증을 준수한다.

관리환경은 메모리 전반을 관리하므로 IDisposable을 구현한 리소스를 소유한 상태에서 예오를 유발하는 경우를 제외한다면 리소스가 유출될 가능성은 없다.

 

앞서 살펴보았던 권고사항은 강력한 예외 보장을 준수하는 데 상당한 도움이 된다.

그리고 프로그램이 사용하는 데이터 요소는 변경 불가능한 값 타입에 저장하는 것이 좋다.

LINQ 쿼리를 이용하는 함수형 프로그래밍 스타일을 사용하면 기본적으로 강력한 예외 보증 요건을 준수하게 된다.

 

때로는 함수형 프로그래밍 스타일을 사용할 수 없는 경우도 있는데, 이 경우 방어적인 프로그램을 위해 기존 데이터에 대한 복사본을 유지하고 예외를 유발할 가능성이 있는 작업을 온전히 완료한 후 값을 교환하는 방법을 사용하면 된다.

데이터를 수정하는 과정을 일반화해보면 다음과 같다.

 

1. 방어적인 프로그램을 위해 수정할 데이터에 대한 복사본을 마련한다.

2. 복사해둔 데이터를 수정한다. 수정과정에서 예외가 발생할 수도 있다.

3. 수정된 복사본과 원본 데이터를 교환한다. 이 교환작업은 예외를 일으켜서는 안된다.

 

예외가 발생하면 응용프로그램의 제어 흐름이 완전히 뒤바뀌게 된다.

최악의 경우 무슨 일이 발생했는지도 알기 어렵고, 무엇이 제대로 수행되지 않았는지도 알기 어렵다. 

예외가 발생했을 때 응용프로그램의 상태가 변경되지 않도록 강력한 예외 보증을 준수하는 것이 좋다.

즉, 작업이 온전히 완료되지 않으면 응용프로그램의 상태가 변경되지 않도록 코드를 작성하는 것이다.

finalizer, Dispose(), when 절, 그리고 델리게이트의 타깃이 되는 메서드는 매우 특별한 경우이므로 어떠한 상황에서도 절대로 예외를 발생시켜서는 안된다.

마지막으로 참조 타입의 값을 교환해야 하는 상황에서는 더욱 신중하게 코드를 작성해야 한다.

이로 인해 발견하기 어려운 버그가 발생할 수 있기 때문이다.

 

 

⭐ catch 후 예외를 다시 발생시키는 것보다 예외 필터가 낫다

예외 필터는 catch 문 이후에 when 키워드를 이용하여 구성하게 되는데 catch 문에 지정한 예외 타입에 대해서만 필터가 수행된다. 컴파일러는 스택 되감기를 수행하기 이전에 예외 필터를 수행하도록 코드를 생성한다.

만약 예외 필터가 false를 반환하면 런타임은 콜스택을 따라 올라가면서 앞서 발생한 예외의 타입에 부합하는 catch 문을 계속 찾아나간다. 이 과정에서 응용 프로그램의 상태는 변경되지 않으며 그대로 유지된다.

만약 catch 문 내에서 처리할 수 있는 예외인지를 판별한 후, 그렇지 않은 경우 예외를 다시 발생시키는 기존 방식과는 상당히 다르다.

두 방식은 분석과 디버깅을 수행할 때 매우 다른 양상을 나타낸다.

만약 예외를 다시 발생시키는 경우에는 지역 변수의 값을 모두 잃을 뿐 아니라 수행과정에 대한 정보도 모두 소실된다. 또한 호출 스택 상의 예외 발생 위치가 다르게 나타나서 빈 throw을 호출한 위치가 나타나게 된다.

 

기존 코드 중 예외 필터를 적용하면 즉각적으로 개선 효과를 볼 수 있는 예가 꽤 있다.

예외의 특정 속성 값을 통해 예외 처리가 필요한지의 여부를 결정하는 경우가 있다.

 

1) 태스크(task) 기반의 비동기 프로그램

Task 클래스 내의 Exception 속성은 AggregateException 타입으로 선언된다.

이 속성의 InnerExceptions 속성을 살펴보면 자식 태스크들이 유발한 예외 전체에 대한 내용을 살펴볼 수 있고, 예외 처리가 필요할지를 사전에 확인할 수 있다.

 

2) COMException 클래스

이클래스는 HResult 속성을 가지고 있는데, COM 객체 호출에 의해 반환되는 HRESULT을 담고 있으며 이 값을 통해 예외 처리를 할 것인지의 여부를 결정할 수 있다.

예외 필터를 사용하면 catch 문 내로 진입하기 이전에 이 값을 조회할 수 있으며 이를 통해 예외 처리를 수행할지 여부를 결정할 수 있다. 

 

3) HTTPException 클래스

HTTP의 응답 코드를 얻어오는 GetHttpCode()를 가진다.

 

⭐ 예외 필터의 다른 활용 예를 살펴보라

예외 필터를 잘 활용하면 예외가 발생했을 때 어떤 일이 벌어지고 있는지를 자세히 살펴볼 수 있다.

규모가 큰 코드를 작성할 때 이번 아이템에서 살펴본 예와 같이 예외 필터를 활용하면 문제의 원인을 찾는 데 큰 도움이 될 것이다. 어느 부분에서 문제가 발생했는지를 확인할 수만 있다면 디버깅은 훨씬 수월해진다.