출처 : 마이크로소프트

 

앞으로 몇 회에 걸쳐 C++의 중요한 기능들을 하나씩 살펴볼 생각이다. 실제로는 별로 쓰이지 않으면서 지식욕을 채우는 데만 도움이 되는 내용보다는 실무에 도움이 되고 일반적으로 받아들여지고 있는 내용을 중심으로 엮어갈 계획이다. 이번 주제는 필자가 평소에 전하고 싶어했던 ‘예외’로 결정했지만, 앞으로의 주제들은 최대한 독자 여러분의 의견을 반영하고 싶다.

이현창 | 포씨소프트에서 근무하는 필자는 화상 통신 및 e-러닝 관련 소프트웨어를 제작하고 있다. 2003년에는 3년간의 병역특례 근무를 마치고 아주대학교에서의 마지막 대학 생활을 멋지게 마무리하기 위해 열심히 준비중이다.

C++를 만든 Stroustrup은 그의 저서 ‘The Design and Evolution of C++’에서 예외는 반환 값을 대체하기 위한 것이 아니라 결함을 허용하는(fault-tolerance) 시스템을 만들 수 있도록 하기 위한 것이라고 말했다. 에러가 발생한 경우에도 올바르게 동작할 수 있는 프로그램을 만드는 것은 중요한 주제이며 예외는 그러한 일에 적당한 도구이다. 하지만 예외는 기존의 반환 값을 사용한 에러 처리를 훌륭하게 대체할 수 있는 도구이기도 하다.
우선 예외를 사용하지 않은 코드를 한 번 보자. 그리고는 이 코드에서 예외를 사용하도록 고쳐보자. 다이어트 광고에 나오는 두 장의 사진처럼 명백한 차이점을 볼 수 있도록 말이다. <리스트 1>에서는 반환 값을 사용해 에러 처리를 하고 있다. 이러한 방식은 몇 가지 문제점을 가지고 있는데, 먼저 예외를 사용하도록 고친 다음 <리스트 2>와 비교해 보는 것이 좋을 것 같다.
<리스트 2>를 보면 작업 코드는 try 블럭에 모였고 에러 처리 코드는 catch 블럭에 모였다. try 블럭을 보면 이 함수에서 하는 일이 무엇인지 쉽게 알아볼 수 있다. 반면에 <리스트 1>은 예외 처리 코드와 작업 코드가 섞여 있어 함수를 읽기 어렵게 만들고 있다. 그 다음으로 <리스트 2>에서는 여러 군데 중복되어 흩어져 있던 에러 처리 코드들이 하나로 통합됐다. 그렇기 때문에 Log()를 호출하는 대신 다른 일을 하고자 할 때도 수정 작업이 쉽게 이뤄질 것이다. 반면에 <리스트 1>에서는 여러 군데 흩어져 있는 에러 처리 코드를 일일이 수정해야 한다. 소프트웨어 개발에 있어 중복은 개발자가 언제나 피해야 할 것 중에 하나다.
작업 효율에 대해서도 한 번 생각해 보자. <리스트 1>처럼 무슨 일을 할 때마다 비교문을 둬 성공여부를 체크하는 것은 개발자에게 있어서 매우 피곤한 일이며 에러 처리 코드를 작성하는 데 많은 시간을 빼앗기도록 만든다. 예외를 올바르게 사용하면 개발자의 시간이 더 창조적인 일에 사용될 수 있을 것이다.
사실 <리스트 2>처럼 단순히 로그를 찍기 위해 예외를 잡는 것은 납득할 만한 설계가 아니다. 상위의 함수에서 예외 처리할 수 있는 경우에는 굳이 예외를 잡을 필요가 없다. 그런 경우에는 말 그대로 그냥 잡지 않으면 된다. 다음은 예외가 그냥 지나쳐 가도록 변경한 예제 코드다.

// sUserInfo 문자열을 분석해 USER_INFO 구조체를 채우는 함수
bool UserList::ParseUserInfo(MyString sUserInfo, USER_INFO& ui)
{
// ? 문자열 분석기를 구한다.
MyParser& parser = MyParser::GetParser(sUserInfo);

// ? 사용자 ID를 얻는다.
parser.Find ( “ID”, ui.userID );

// ? 사용자 이름을 얻는다.
parser.Find ( “Name”, ui.name );

// ? 사용자 나이를 얻는다.
parser.Find ( “Age”, ui.age );
}

<리스트 1>과 비교했을 때 명백한 차이점을 볼 수 있다. 보다 간결하고, 이해하기 쉽고, 유지 보수하기 쉬운 함수가 됐다. 앞의 코드는 기사에 맞게 작성된 것이긴 하지만 꾸며낸 상황은 아니다. 대부분의 함수에서는 예외를 던지거나 잡을 일이 없다(그리고 이것은 정말 중요한 특징이다). 에러가 발생한 곳에서는 예외를 던지면 되고, 예외가 필요한 곳에서만 예외를 받으면 된다.
언제 예외를 처리해야 하는지 잘 이해되지 않는다면 다음의 가이드라인이 도움이 될 것이다. 다음은 어떤 함수에서 예외를 처리해야 하는 지에 대한 간단한 가이드라인이다.

◆ 함수가 예외를 가지고 무엇을 해야 할 지 아는 경우
◆ 함수가 예외를 적당히 처리할 수 있고, 상위 함수가 예외를 가지고 무엇을 해야 할 지 모르는 경우
◆ 예외를 던지는 것이 프로그램을 비정상 종료시킬 가능성이 있는 경우
◆ 함수가 자신의 일을 계속 진행시킬 수 있는 경우
◆ 리소스를 해제할 필요가 있는 경우

결국은 논리적으로 생각해 알맞게 하는 것이 정답일 것이다. 프로그램 전체적인 관점에서 고려한다면 다음과 같은 가이드라인이 도움이 될 것이다.

◆ 보통의 저수준 함수에서는 예상할 수 있는 예외만을 처리한다.
◆ 그 밖의 예외는 고수준 함수에서 처리한다.
◆ 정말 중요한 저수준 함수에서는 예상할 수 없는 예외도 처리한다. 그리고 적당한 예외를 사용해 외부에 다시 던져준다.

앞서 저수준 함수란 엔진이나 라이브러리 같이 낮은 계층에 속하는 함수에 해당하고, 고수준 함수란 라이브러리를 사용하는 높은 계층의 함수에 해당한다.

예외에 안전하게 설계하기
다음은 예외로 인해 리소스의 유실이 발생할 수 있는 경우에 대한 예제이며 아주 흔한 것이다.

void Func()
{
// 객체의 생성
MyObj* obj = new MyObj();

// 메모리 블럭을 가지고 무엇인가 한다. 실패한 경우 예외를 발생시킨다.
MayThrowException(obj);

// 메모리 해제
delete obj;
}

만약 MayThrowException() 함수에서 예외가 발생하게 된다면 앞서 obj에 할당된 메모리가 해제될 기회를 놓치게 될 것이다. 실제로 프로그램 전반적으로 예외를 사용하고 있다면 이런 상황에 놓일 가능성이 크다. 이런 상황에 대한 일반적인 해결책은 스택을 이용하는 것이다. 다음은 리소스 유실을 막기 위해 클래스를 도입한 예제 코드다.

// 리소스 유실을 막기 위한 클래스
class AvoidLeak
{
public:
AvoidLeak(MyObj* p) : _p(p) {} // 포인터 보관
~AvoidLeak() {delete _p;} // 보관된 포인터 해제
private:
MyObj* _p;
};

void Func()
{
// 객체의 생성
MyObj* obj = new MyObj();

// 리소스 유실 방지용 객체 생성
AvoidLeak al(obj);

// 메모리 블럭을 가지고 무엇인가 한다. 실패한 경우 예외를 발생시킨다.
MayThrowException(obj);

// 메모리를 수동으로 해제할 필요가 없다
}
AvoidLeak 클래스의 소멸자에서는 보관된 포인터를 해제한다. 함수가 정상적으로 종료하는 경우나 예외가 발생해 종료하는 경우 모두 지역 변수의 소멸자는 반드시 호출되도록 정해져 있다. 그렇기 때문에 MayThrowException() 함수에서 예외가 발생하는 경우라도 AvoidLeak 클래스의 소멸자는 올바르게 호출되고 메모리 블럭도 올바르게 해제된다.
예외를 사용해 에러 처리를 한다면 예외에 안전하도록 설계하는 데 신경써야 한다. 간단하게는 <리스트 3>처럼 예외가 발생하는 경우라도 리소스 유실이 일어나지 않도록 설계해야 한다. 더 나아가 예외가 발생하는 경우라도 객체가 올바른 상태를 유지할 수 있도록 설계하는 것이 중요하다. 예를 들어 A라는 작업이 실제로는 a1 - a2 - a3라는 작업으로 이뤄진다고 가정해 보자. 작업 a2가 수행되는 과정에서 에러가 발생했다면 a1의 작업은 올바르게 복구(rollback)될 수 있도록 설계돼야 한다.
<리스트 3>은 내부적으로 필요한 리소스를 초기화하는 함수다. 이 함수에서는 예외가 발생할 수 있는 곳이 여러 군데 있는데, new 연산자에서 메모리 할당을 실패하는 경우에도 예외가 발생할 수 있고 Commit() 내에서 실패하는 경우에도 예외가 발생할 수 있다.
만약에 이 함수가 실행되고 리소스 ?번을 초기화하는 과정에서 예외가 발생한다면 어떻게 될까? LargeObject 객체는 일부만 초기화되어 있는 올바르지 않은 상태가 될 것이고 이는 여러 경우에 문제를 발생시킬 것이다. <리스트 4>는 이러한 문제에 대한 일반적인 해결책이다.
auto_ptr은 <리스트 4>에 나오는 AvoidLeak과 같은 클래스로서 예외가 발생하는 경우에도 리소스가 올바르게 해제될 수 있도록 하는 역할을 한다(auto_ptr에 대한 자세한 설명은 박스 기사에서 볼 수 있다). 그리고 MyResource를 LargeObject 객체의 멤버 변수에 보관하는 대신 지역 변수에 보관했기 때문에 예외가 발생하는 경우라도 LargeObject 객체의 내부 상태는 올바르게 유지된다. 모든 작업이 성공적으로 완료된 후에야 LargeObject 객체의 내부 상태가 변경된다. 이러한 패턴을 다음과 같이 요약해 볼 수 있다.

짾 예외가 발생할 만한 작업은 지역 변수를 사용해 진행한다.
짿 안전한 방법만을 사용해 클래스의 내부 상태를 변경한다.

예외에 관한 이런 저런 이야기
생성자에서 예외 발생
모두가 알고 있듯 생성자는 반환 값을 가지고 있지 않다. 그렇기 때문에 생성자에서 발생한 에러를 알리기 위해 예외를 사용하는 것은 아주 좋은 생각이다. 그러나 그것이 의도적이든 의도적이지 않든 간에 생성자에서 예외가 발생하는 경우에는 다음과 같은 몇 가지 규칙을 알아둬야 한다.

짾 생성자에서 예외가 발생한 경우 객체는 생성되지 않는다 : 객체의 생존 기간은 생성자의 끝(닫는 괄호)에서부터 소멸자의 시작(여는 괄호)까지이다. 생성자에서 예외가 발생한 경우에는 생성자의 끝에 도달하지 못한 것이고 결국 생성되지 못한 것이 된다. 그렇기 때문에 객체의 소멸자는 호출되지 않는다. 소멸자가 호출되지 않기 때문에 생성자에서 할당한 리소스가 올바르게 제거되지 않을 수 있다는 점을 기억해야 한다. 생성자에서 예외가 발생할 가능성이 있는 경우에는 catch문을 두어 리소스를 깨끗이 정리하고 다시 예외를 던져야 한다.

짿 생성자에서 예외가 발생한 경우 이미 생성된 멤버들은 어떻게 처리될까 : 생성자에서 예외가 발생한 경우 어떤 멤버는 이미 생성되었고, 어떤 멤버는 아직 생성되지 않았을 수 있다. 예를 들어 이미 생성된 멤버가 메모리를 할당받았다면 이 메모리는 어떻게 해제할 수 있을까? 다행히도 우리가 신경써줘야 하는 부분은 없다. 이미 생성된 멤버의 경우에는 올바르게 소멸자가 호출되는 것을 확인해 볼 수 있다(<리스트 5>).

Member 클래스는 두 번째 멤버 객체를 생성할 때 생성자에서 예외를 던지도록 제작된 클래스다. 그렇기 때문에 Container 클래스의 생성자에서 두 번째 멤버 객체인 m2를 생성할 때 예외가 발생될 것이다. <리스트 5>의 결과에서 볼 수 있듯 완전하게 생성된 m1은 올바르게 소멸자가 호출됐다. m2는 자신의 생성자에서 예외가 발생했기 때문에 생성되지 못했다. m3는 생성자조차 호출되지 못했다.
new[] 연산자를 사용해 객체의 배열을 생성하는 경우에도 같은 원칙이 적용된다. 객체의 배열을 생성하는 도중에 예외가 발생한다면 이미 생성된 객체가 있을 수 있고, 아직 생성되지 않은 객체가 있을 수 있다. 이 경우에도 이미 생성된 객체에 대해서는 소멸자가 올바르게 호출되는 것을 확인할 수 있다.

쨁 생성자의 초기화 리스트에서 발생하는 예외는 어떻게 잡을까 : 생성자의 초기화 리스트에서는 부모 클래스의 생성자나 멤버 생성자를 직접 호출할 수 있으며 생성자의 본문 밖에 위치한다. 초기화 리스트에서 예외가 발생하는 경우에는 어떻게 잡을 수 있을까? <리스트 6>은 생성자의 초기화 리스트에서 발생하는 예외를 잡는 방법을 보여준다.

필자에게도 그다지 익숙하지 않은 문법이지만 초기화 리스트에서 발생하는 예외를 잡을 필요가 있을 때는 유용하게 사용할 수 있을 것이다. 한 가지 아쉬운 것은 비주얼 C++ 6.0에서는 이와 같은 문법을 지원하지 않는다는 점이다. 그러나 다음 버전인 비주얼 C++.NET에서는 사용 가능하다.

소멸자에서 예외 발생
스택을 정리하는 과정에서 예외가 발생하면 응용 프로그램이 비정상적으로 종료될 수 있기 때문에 소멸자에서는 절대로 예외가 발생해서는 안 된다. 소멸자에서 예외를 발생시킬 수 있는 함수를 호출하는 경우에는 반드시 catch로 잡아주도록 하자. 또한 delete나 delete[]를 오버로드한 경우 역시 절대로 예외가 발생하지 않도록 주의해 제작해야 한다.

Win32 구조적 예외 처리
Win32 구조적 예외 처리(Structured Exception Handling, 이하 SEH)는 윈도우 운영체제 수준에서 구현되어 있는 예외 처리 기능이다. 여기서의 예외는 C++의 예외와는 다른 것이며, 잘못된 메모리 참조나 스택 오버플로우와 같은 시스템 수준의 예외를 말한다. 우선 SEH를 사용해 잘못된 메모리 참조 예외를 잡는 방법을 살펴보자.

void Func()
{
__try
{
// 잘못된 주소 참조 오류를 발생시킨다.
char* p = NULL;
*p = ‘A’;
}
__except ( EXCEPTION_ACCESS_VIOLATION == GetExceptionCode()
? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH )
{
// Access violation
}
}

SEH는 C++의 try, catch와는 달리 __try, __except 키워드를 사용한다. GetExceptionCode()는 예외의 종류를 알려주는 함수이고, 이 예제에서는 잘못된 메모리 참조인지 비교하고 있다. 잘못된 메모리 참조인 경우에는 EXCEPTION_EXCUTE_HANDLER 값을 반환함으로써 __except 블럭이 실행되도록 하고, 잘못된 메모리 참조가 아닌 경우에는 EXCEPTION_CONTINUE_SEARCH 값을 반환해 다른 __except 블럭을 찾아보도록 하고 있다.
SEH는 시스템에서 발생하는 예외를 잡을 수 있을 만큼 강력하지만, C++ 객체가 선언된 함수에서는 함께 사용할 수 없다(좀더 자세히 말해 함수 종료시 호출돼야 할 소멸자가 있는 함수를 말한다). 다행히도 SEH 예외를 C++ 예외처럼 다룰 수 있는 방법이 있는데, 이 방법을 사용하면 같은 함수 안에서도 SEH와 C++ 예외를 함께 사용할 수 있을 뿐더러 일관된 예외 처리 방식을 사용할 수 있어서 좋다. 다음은 SEH 예외를 C++ 예외로 만든 예제다.

#include “stdafx.h”
#include
#include

// SEH 예외는 이 예외 클래스로 던져진다.
class CSEHException
{
public:
CSEHException(UINT excode, PEXCEPTION_POINTERS exp)
{
// SEH 예외와 관련된 코드를 저장한다.
m_exceptionCode = excode;
m_exceptionPointer = *exp;
}

int GetExceptionCode() {return m_exceptionCode;}

UINT m_exceptionCode; // 예외 코드
EXCEPTION_POINTERS m_exceptionPointer; // 부가 정보
};

// SEH를 C++ 예외로 변경하기 위한 함수(시스템에 의해 호출된다)
void _cdecl SEH2CppException(UINT excode, PEXCEPTION_POINTERS exp)
{
throw CSEHException(excode, exp);
}

void main()
{
// SEH 변경 함수를 등록한다.
_set_se_translator(&SEH2CppException);

try
{
// 잘못된 주소 참조 예외를 발생시킨다.
int* i = NULL;
*i = 123;
}
catch( CSEHException& e)
{
switch(e.GetExceptionCode() )
{
case EXCEPTION_ACCESS_VIOLATION:
// 여기가 실행된다.
break;
}
}
}

SEH 예외를 C++ 예외로 만드는 방법은 아주 간단하다. 시스템은 SEH 예외가 발생할 때마다 정해진 함수를 호출하도록 되어 있는데, _set_se_translator()를 사용하면 우리가 작성한 함수를 호출하도록 만들 수 있다. 그리고 우리가 작성한 함수 안에서는 C++ 예외를 던지면 된다.
앞 예제에서는 SEH 에외가 발생한 경우 SEH2CppException()을 호출하도록 지정했으며, SEH2CppException() 안에서는 CSEHException 객체를 예외로 던지는 것을 확인할 수 있다. 또한, main() 함수를 보면 잘못된 메모리 참조 예외가 CSEHException로 던져지는 것을 확인할 수 있다. CSEHException을 사용하는 것이 얼마나 편리한지 보여주기 위해 이 기능을 사용하지 않은 경우와 사용하는 경우를 비교해 보았다(<리스트 7, 8>).
누가 보아도 <리스트 8>의 경우가 훨씬 이해하기 쉽다. 게다가 더욱 안전하다. 포인터가 NULL이 아닌 경우라도 얼마든지 잘못된 주소를 가리키고 있을 수 있다는 사실을 명심하자! 이런 경우에 <리스트 7>은 심각한 문제를 일으키게 되지만 <리스트 8>은 안전하게 예외를 잡아낼 수 있다. 그렇다고 해서 <리스트 8>이 성능 상의 문제를 가지고 있는 것은 아니다. 예외가 빈번히 일어나는 것이 아니라는 가정에서 <리스트 8>은 비슷하거나 더 빠르게 수행된다.

기사에 대한 많은 조언을 당부하며
이번 호에는 많은 전문가들을 통해 밝혀지고 알려진 올바른 예외 사용법에 대해 알아봤다. 안타깝게도 지면이 부족한 관계로 몇 가지 중요한 정보를 전달하지 못했는데, 다음과 같은 것들이다. 이런 부분은 참고 서적을 통해 충분한 지식을 습득할 수 있을 것이라고 생각한다.

◆ new 연산자에서 할당에 실패한 경우의 올바른 처리 방법
◆ 프로젝트에 사용할 기반 예외 클래스를 작성하는 방법
◆ 예외에 안전한 클래스를 작성하는 방법에 대한 보다 깊은 지식

이번 기사를 작성하면서 가장 어려운 부분은 어떤 내용이 실무 개발자들에게 도움이 될지에 대한 정보가 부족했다는 점이다. 이번 기사에 대한 질책이나 다음 호에 대한 조언, 희망 사항을 들을 수 있다면 좀더 좋은 기사를 쓰는 데 도움이 될 것이다.


[ auto_ptr 클래스 ]
AvoidLeak은 MyObj*에 대해서만 사용할 수 있지만, 템플릿을 사용하면 쉽게 여러 가지 타입을 담을 수 있는 클래스로 만들 수 있다. 실제로 C++ 표준 라이브러리에는 auto_ptr 클래스가 이러한 용도로 제공된다.
하지만 auto_ptr은 배열에 대해 사용할 수 없으며, 소유권 이전 방식의 복사 기능을 제공하기 때문에 STL 컨테이너에 보관할 수 없다. http://boost.org에서는 이러한 문제점을 해결한 scoped_array이나 shared_ptr과 같은 클래스를 제공한다. 자세한 정보는 해당 사이트에서 얻을 수 있다.

[ 컴파일 옵션의 사용 ]
<리스트 8>을 컴파일하기 위해서는 컴파일 옵션으로 /EHa를 주어야 한다. 그렇지 않은 경우 비주얼 C++.NET에서는 다음과 같은 경고 메시지를 출력한다.
warning C4535: calling _set_se_translator() requires /EHa
the command line options /EHc and /GX are insufficient

다음은 예외와 관련된 컴파일 옵션이다.
/EHa 비동기 모드
/EHs 동기 모드
/GX (/EHsc) 동기 모드. 끝에 붙은 c는 extern “C” 함수는 예외를 발생시키지 않음을 의미

비동기 모드는 함수 또는 함수에서 호출하는 자식 함수에 throw문이 없어도 예외가 발생할 수 있다고 가정한다. SEH 예외는 throw문이 없어도 언제든지 발생할 수 있기 때문에 비동기 모드로 컴파일해야 한다.

블로그 이미지

요다할아범

,