구조적 예외 처리
는 예외 상황을 효율적으로 처리할 수 있게 도와주지만 잘못 사용하는 경우에는 화를 입을 수도 있다. 구조적 예외 처리
에는 어떤 문제점이 존재하고 또 어떻게 해결해야 하는지 알아보자.
구조적 예외 처리에서 가장 빈번하게 일어나는 문제는 리소스를 정리하기 전에 함수가 종료되는 경우다. 다음의 예제는 예외로 인해서 할당된 메모리가 해제되지 못하는 문제점을 가지고 있다.
#include <iostream>
using namespace std;
void A();
void B();
int main()
{
try
{
A();
}
catch (const char *e)
{
cout << "catch: " << e << endl;
}
}
void A()
{
// memory allocation
char *p = new char[100];
cout << "[begin exception]" << endl;
// 예외 발생 함수 호출. 여기서 예외가 던져지면 곧바로 A() 함수가 종료되고,
// 실행 흐름을 main() 함수로 이동한다. 그렇기 때문에 동적 할당된 메모리를 해제하지 않는다.
B();
cout << "[end exception]" << endl;
// memoery deallocation
delete[] p;
p = nullptr;
}
void B()
{
throw "Exception!";
}
/* 결과
[begin exception]
catch: Exception!
*/
결과를 보면 어떤 방식으로 실행하는지 알 수 있다. 처음 A()
함수가 실행되면 메모리를 할당하고 곧바로 begine exception
이라는 문제열을 출력한다.
이어서 B()
함수를 호출하는데 이 함수에서는 예외를 던진다. 그래서 A()
함수는 더 이상 실행되지 않고 바로 종료된다.
결국 A()
함수의 앞에서 할당된 메모리가 해제되지 않는 문제가 발생했다. 메모리 누수(Memory Leak)
이다. 꼭 메모리 할당 뿐만 아니라 함수의 끝에서 무언가 정리 작업을 해주는 경우라면 모두 이 문제점에 노출이 되어 있는 셈이다. 이 문제점을 효율적으로 해결할 수 있는 방법을 알아보자.
릭(Leak)
은 물 같은 것이 새는 상황을 말하는 단어다. 예를 들어 water leak이라고 하면 우리말로누수
와 같은 뜻으로 물통에 담아놓은 물이 한 방울씩 새어 나가는 장면을 생각하면 된다.
결국 메모리 누수
란 메모리가 조금씩 새어 나가는 현상을 말하는데, 위의 예제에서처럼 할당한 메모리를 해제해주지 않을 때 발생한다. 할당만하고 해제해주지 않는 상황을 반복하면 사용 가능한 메모리가 조금씩 줄어들다가 결국은 컴퓨터의 메모리가 고갈될 수 있다. 그러므로 메모리 누수
는 반드시 예방
해야 한다.
비슷한 말로 리소스 누수 (Rsource Leak)
라는 용어가 있다. 리소스는 일반적으로 메모리
, 하드디스크
와 같은 포괄적인 의미의 자원을 뜻한다. 즉, 메모리
뿐만 아니라 다른 종류의 자원
이 새어 나가는 현상까지 통틀어서 일컫는 용어가 바로 리소스 누수
이다.
객체의 경우 소멸자
가 자동으로 호출된다. 예외 때문에 종료되는 경우에도 객체 소멸자
는 반드시 호출된다. 바로 이 점을 이용해 리소스를 해제하는 용도의 클래스를 만들 수 있다. 일반적으로 이런 용도의 클래스를 스마트 포인터(Smart Pointer)
라고 부른다. 다음 예제는 스마트 포인터 클래스를 간단하게 구현하고 있다.
#include <iostream>
using namespace std;
class SmartPointer
{
public:
SmartPointer(char *ptr) : ptr(ptr)
{
}
~SmartPointer()
{
cout << "delete memory!" << endl;
delete[] ptr;
}
public:
char *const ptr;
};
void A();
void B();
int main()
{
try
{
A();
}
catch (const char *e)
{
cout << "catch: " << e << endl;
}
}
void A()
{
// memory allocation
char *p = new char[100];
// 메모리를 스마트 포인터에 보관
SmartPointer s_ptr(p);
cout << "[begin exception]" << endl;
// 예외 발생 함수 호출. 여기서 예외가 던져지면 곧바로 A() 함수가 종료되고,
// 실행 흐름을 main() 함수로 이동한다. 그렇기 때문에 동적 할당된 메모리를 해제하지 않는다.
B();
cout << "[end exception]" << endl;
// memoery deallocation
delete[] p;
p = nullptr;
}
void B()
{
throw "Exception!";
}
/* 결과
[begin exception]
delete memory!
catch: Exception!
*/
Smart Pointer
라는 이름의 스마트 포인터 클래스를 정의하고 있다. 이 클래스가 하는 일은 아주 간단한데 생성자에서 동적으로 할당된 메모리의 주소를 인자로 받아 멤버 변수에 저장하는 것이 전부다. 또 소멸자에서는 보관한 주소
를 사용해서 메모리를 해제
해주는 것이 전부다.
Smart Pointer
클래스를 사용하는 방법 역시 매우 단순하다. Smart Pointer
객체를 생성하면서 동적으로 할당된 메모리의 주소를 인자로 넘겨준다. 함수가 정상 종료이건 예외에 의해서 종료되건 이 객체 소멸자는 반드시 호출될 것이고 객체에 보관된 메모리도 반드시 해제된다.
C++
에서는 이런 용도의 스마트 포인터를 이미 제공하고 있다. 그 클래스의 이름은 unique_ptr
이다.
일반적으로 객체는 예외에 안전하다고 볼 수 있다. 예외가 발생한 경우에도 객체 소멸자가 반드시 호출될 것이고, 자신의 리소스도 모두 해제할 것이기 때문이다. 하지만 생성자에서 예외가 발생한 경우에는 이 가정이 깨지고 만다.
DynamicArray 파일의 전체 코드는 이곳에 가면 볼 수 있다.
// DynamicArray.cpp
DynamicArray::DynamicArray(const int size) : size(size)
{
arr = new int[size];
// 예외 발생
throw MemoryException(this, 0);
}
DynamicArray::~DynamicArray()
{
std::cout << "DynamicArray Destruction!" << std::endl;
delete[] arr;
arr = nullptr;
size = 0;
}
int main()
{
try
{
DynamicArray arr1(10);
}
catch (const MyException &e)
{
cout << "description: " << e.GetDescription() << endl;
}
return 0;
}
결과는 다음과 같다.
description: out of memory!
위의 예제에서는 DynamicArray
클래스의 생성자
에서 메모리를 할당 한 후 고의적으로 예외
를 발생시킨다. 언뜻 생각하기에는 어차피 소멸자
에서 메모리를 해제
할 것이기 때문에 아무런 문제가 없어 보인다.
그런데 결과를 보면, DynamicArray
객체의 소멸자가 호출되지 않았다.
즉, 생성자
에서 할당한 메모리도 해제되지 않았다는 뜻이다. 이는 C++
에 다음과 같은 규칙
이 있기 때문이다.
생성자가 올바르게 종료된 경우에만 객체를 생성한 것으로 간주한다.
생성자
에서 예외가 발생한 경우라면, 정상적으로 조애료되지 않은 것이고 객체도 생성되지 않은 것이다. 객체가 생성되지 않았으니 소멸자도 호출될 일이 없다.
생성자
에서 예외를 던지는 것은 매우 바람직한 일이다. 일반적인 함수라면 반환 값이 있기 때문에 예외를 던지는 대신 반환 값을 사용해서 예외 상황을 알릴 수 있지만, 생성자
는 반환 값이 없기 때문에 예외를 던지는 것이 예외 상황을 알리는 유일한 수단이 된다.
즉, 예외를 던지는 것은 그대로 내버려 두고 메모리 누수
를 막는 방법을 생각해볼 수 있다.
// DynamicArray.cpp
DynamicArray::DynamicArray(const int size) : size(size)
{
try
{
arr = new int[size];
// 예외 발생
throw MemoryException(this, 0);
}
// 모든 종류의 예외를 잡아낸다.
catch (...)
{
// 소멸자 호출
DynamicArray::~DynamicArray();
// 받은 예외를 그대로 다시 던진다.
throw;
}
}
받은 예외를 다시 던지는 사용 예이다. 이렇게하면 생성자
에서 발생한 예외
가 외부로 던져지는 것에는 아무런 변함이 없다. 다만, 예외
를 중간에 가로채서
필요한 정리 작업
을 한 후에 예외
를 다시 던지는 것이다.
구조적 예외 처리
의 내부적인 실행 방식 때문에, 객체의 소멸자
에서 예외가 던져지는 경우에는 프로그램이 비정상 종료
될 수 있다.
그러므로 소멸자
밖으로는 예외
가 던져지지 않도록 막아야 한다. 아주 중요한 것이다.
그렇게 하기 위해서 생성자
에서 했던 것처럼 소멸자
의 모든 코드를 try
블럭으로 감쌀 필요가 있다. 그리고 (❗) 이렇게 잡아낸 예외는 절대로 소멸자 밖으로 다시 던져서는 안된다.
DynamicArray::~DynamicArray()
{
// 소멸자에서 발생하는 모든 예외를 잡아야 하는 경우
try
{
delete[] arr;
arr = nullptr;
size = 0;
}
catch (...)
{
}
}
C++
에서 제공해주는 기능들 중에서 예외
와 관련한 것을 살펴보자. 가장 빈번하게 사용하는 기능 두 가지를 소개한다.
unique_ptr
클래스는 C++
에서 제공하는 스마트 포인터(Smart Pointer)
이다. unique_ptr
을 사용하는 예를 살펴보자. 참고로 C++11
이전에는 auto_ptr
클래스로 사용되었다. 그런데 문제가 있어 C++11
이후로 unique_ptr
로 변경되었다. 따라서 C++11
이후 버전에서는 auto_ptr
클래스는 사용할 수 없다.
#include <memory>
using namespace std;
int main()
{
// 스마트 포인터 생성
// int 타입을 가리킬 수 있는 스마트 포인터 p를 정의한다.
// int 타입의 값을 하나 할당해서 생성자에 인자로 넘겨준다.
unique_ptr<int> p(new int);
// 스마트 포인터 객체가 마치 진짜 포인터인 것처럼 사용할 수 있다.
*p = 100;
// 메모리를 따로 해제해줄 필요가 없다.
return 0;
}
unique_ptr
클래스는 모든 타입의 포인터를 보관할 수 있는데, 위의 예제는 int
타입의 포인터를 보관하기 위해 unique_ptr<int>
처럼 해주었다.
new int
를 통해 int
타입의 값을 하나 할당하고, 그 주소를 p
의 생성자
로 전달한다. 이제 이 메모리는 스마트 포인터
에 의해서 관리
되므로 더 이상 신경쓸 것이 없다.
동적
으로 메모리를 할당
할 때, 컴퓨터에 충분한 메모리가 남아있지 않다면 어떻게 될까?
이런 경우에 new
, new[]
연산자는 bad_alloc
이라는 예외
를 던진다. 다음 예제에서는 이 예외
를 받아 처리하는 방법을 보여준다.
#include <iostream>
#include <new>
using namespace std;
int main()
{
try
{
// 많은 양의 메모리를 할당해서 bad_alloc 예외가 발생하게 만든다.
char *p = new char[0xFFFFFFF0];
}
// bad_alloc & 타입으로 예외를 받는다.
catch (bad_alloc &e)
{
// bad_alloc 클래스에는 예외에 대한 설명 문자열을 반환하는 what() 이라는 멤버 함수가 있다.
cout << e.what() << endl;
}
return 0;
}
bad_alloc
예외를 잡기 위해서 new
, new[]
연산자를 사용해서 메모리를 동적
으로 할당
하는 부분을 try
블럭으로 감싸주어야 한다. 그리고 bad_alloc &
타입의 예외를 받을 수 있게 catch
블럭을 준비한다.
만약 시스템에 사용 가능한 메모리가 부족해서 메모리 할당을 수행할 수 없는 경우에는 bad_alloc
예외가 발생하고 catch
블럭에서 예외를 잡게 된다. bad_alloc
클래스의 멤버 함수 what()
을 호출함으로써 예외의 대한 설명
을 볼 수 있다.
bad_alloc
예외를 잡는 방법은 위에서 본 것처럼 아주 간단하다. 그런데 중요한 사실이 하나 있다. 이 규칙은 반드시 따라야 한다.
new
,new[]
연산자를 사용할 때마다 반드시 예외 처리를 해야 한다.
지금까지 등장한 예제에서는 그렇게 하지 않았지만 실제 현장에서 프로그램을 만들 때는 new
, new[]
연산자를 사용하는 모든 코드에서 bad_alloc
예외가 던져질 수 있다고 가정을 해야 한다.
일반적으로 컴퓨터에 메모리가 부족한 경우는 거의 발생하지 않기 때문에 초보자 뿐만 아니라 경력이 있는 개발자도 이 부분을 놓치기 쉽다. 하지만 100% 발생하지 않는다고 보장할 수는 없는 일이고 개발자인 이상 0.1%의 확률까지도 모두 처리해줘야 한다.
많은 개발자들이 예외 처리
를 소홀히 하는 경향이 있다. 하지만 다음의 질문은 예외 처리
가 얼마나 중요한 것인지 깨닫게 해준다.
📌 만약, 부가 기능이 많이 있지만 가끔씩 죽는 프로그램과 부가 기능은 조금 부족하지만 절대로 죽지 않는 프로그램이 있다면... 어떤 프로그램을 구입하여 사용하겠는가?
예외 처리
는 그만큼 필수적인 항목
이다. 이 사실을 깨닫고 있는 것만으로도 다른 사람들보다 한 발 앞선 셈이다.
MFC
를 사용한 윈도우즈 프로그래밍을 한다면 bad_alloc
대신 CMemory Exception
이라는 클래스를 사용해야 한다. C++
은 아주 유연해서 bad_alloc
대신 다른 객체를 던지는 것도 허용하는데, MFC
의 경우에는 CMemoryException
을 던지게 고쳐 놓았다.