전문가를 위한 C++/ 에러 처리하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

에러와 예외

익셉션의 정체

익셉션이란 코드에서 발생한 ‘예외’ 상황이나 에러가 코드의 정상적인 실행 흐름에 퍼지지 않도록 알려주는 메커니즘이다. 익셉션 메커니즘을 적용하면 에러가 발생한 코드는 익셉션을 던지고(throw) 이를 처리하는 코드는 발생한 익셉션을 받아서 처리(catch) 하는 식으로 작동한다.

익셉션을 처리하는 과정은 기존 프로그램과 달리 순차적으로 실행되지 않는다. 어떤 코드가 익셉션을 던지면 프로그램의 정상 실행 흐름을 잠시 멈추고 익셉션 핸들러(exception handler, 예외처리기)로 제어권을 넘긴다.

이때 핸들러의 위치는 다양하다. 함수 바로 뒤에 나올 수도 있고, 연속된 함수 호출(스택 프레임)을 거슬러 올라가야 나올 수도 있다.

예컨대 아래 그림 14-1처럼 세 함수가 연달아 호출됐을 때 스택 상태를 살펴보자. 익셉션 핸들러가 있는 A()를 호출한 다음 B()를 호출하고, B()는 다시 C()를 호출했는데, C()에서 익셉션이 발생했다고 하자.

위 그림 14-2는 핸들러가 익셉션을 받는 상황을 보여준다. 이 상태를 보면 C()와 B()에 대한 스택 프레임은 삭제됐고 A()에 대한 스택 프레임만 남았다.

C++에서 익셉션이 필요한 이유

기존에 작성된 C 또는 C++ 프로그램을 보면 에러를 처리하는 방식이 제각각이고 체계가 없는 경우가 많다. 함수가 정수 코드를 리턴하거나, errno 매크로를 사용해서 에러를 표시하는 것처럼 C 프로그래밍에서 표준처럼 굳어진 방식이 있는데, 이를 C++에 그대로 적용한 사례도 많다. 또한 스레드를 다룰 때 스레드의 로컬 정수 변수인 errno를 하나씩 만들어주고, 각 스레드가 함수를 호출하 측에 에러를 알려주는데 이 변수를 활용하기도 한다.

하지만 이렇게 정수 타입 리턴 코드나 errno를 사용하는 방식으로 구현하면 에러 처리 과정의 일관성을 유지하기 어렵다.

(예시 생략)

이처럼 일관성 없이 나름대로 정한 관례대로 구현한 함수들이 뒤섞이면 문제가 발생할 수 있다. 호출한 함수가 예상과 다른 방식으로 코드를 리턴하기 때문이다.

(이하 내용 생략)

익셉션 메커니즘을 사용하면 에러를 쉽고 일관성 있고 안전하게 처리할 수 있다. 기존에 C나 C++에서 활용하던 비공식 에러 처리 기법에 비해 익셉션 메커니즘이 뛰어난 점은 다음과 같다.

  • 에러를 리턴값으로 표현하면 호출한 측에서 깜빡하고 리턴값을 검사하지 않거나 상위 함수로 전달하지 못할 수 있다. 반면 익셉션은 깜박 잊고 처리하지 않거나 무시할 수 없다. 발생한 익셉션을 처리하지 않으면 프로그램이 멈추기 때문이다.
  • 에러를 저웃 타입 리턴 코드로 표현하면 구체적인 정보를 담기 힘들다. 반면 익셉션은 에러를 처리하는데 필요한 정보를 마음껏 담을 수 있다.
  • 익셉션 메커니즘은 콜 스택의 중간 단계를 건너뛸 수 있다. 다시 말해 여러 함수가 연속적으로 호출 됐을 떄 중간에 호출된 함수에서 에러를 처리하지 않고 콜 스택의 최상위 함수에서 에러를 처리하게 만들 수 있다.

바람직한 에러 처리 방식

익셉션을 효과적인 에러 처리 기법으로 적극 활용하기 바란다.

익셉션 처리 과정

특히 파일 입출력 과정에서 익셉션이 발생하기 쉽다. 

(예시 코드 생략)

익셉션 던지고 받기

프로그램에 익셉션을 구현한느 코드는 두 부분으로 나뉜다. 하나는 발생한 익셉션을 처리하는 try/catch 문이고, 다른 하나는 익셉션을 던지는 throw 문이다. 둘 다 반드시 지정된 형식에 맞게 작성해야 한다. 하지만 throw 문이 실행되는 지점은 대부분 C++ 런타임과 같이 어떤 라이브러리의 깊숙한 곳에 있어서 프로그래머가 직접 볼 수 없을 때가 많다. 그렇다 하더라도 try/catch 구문으로 반드시 처리해줘야 한다.

try/catch 문은 다음과 같이 구성된다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (익셉션_타입_1 익셉션_이름)
{
// 익셉션_타입_1 익셉션을 처리하는 코드
}
catch (익셉션_타입_2 익셉션_이름)
{
// 익셉션_타입_2 익셉션을 처리하는 코드

예외 상황이 발생할 수 있는 코드에 throw 문으로 익셉션을 직접 던져도 된다.

(이하 exception 관련 내용 생략. C#과 개념은 동일하다)

익셉션 타입

던질 수 있는 익셉션의 타입에는 제한이 없다. 앞의 예제에서는 std::exception 타입으로 던졌지만, 다음과 같이 간단히 int 타입 객체를 던져도 된다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw 5;
}

// 나머지 코드 생략
}

그러면 catch 문도 다음과 같이 변경한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (int e)
{
cerr << "Unable to open file " << fileName << " (" << e << ")" << endl;
return 1;
}

(이하 예시 코드 생략)

여러 가지 익셉션 던지고 받기

readIntegerFile()에서 파일 열기 실패 말고도 다른 문제가 발생할 수 있기 땜누에 readIntegerFile()을 다음과 같이 수정할 수 있다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw runtime_error("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

앞서 main() 함수를 작성할 때 catch 구문이 runtime_error의 베이스 클래스인 exception 타입을 받도록 지정했기 때문에 여기서는 변경할 필요 없다. 이렇게 하면 catch 문은 두 가지 상황을 모두 처리하게 된다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const exception& e)
{
cerr << e.what() << endl;
return 1;
}

이렇게 하지 않고 readIntegerFile()에서 익셉션을 두 가지 타입으로 따로 나눠서 던져도 된다. 다음 코드를 보면 파일을 열수 없으면 invalid_argument 익셉션을 던지고, 정수를 읽을 수 없으면 runtime_error 익셉션을 던진다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw invalid_argument("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

이제 main()에 invalid_argument를 받는 catch 문과 runtime_error를 받는 catch문을 따로 만든다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const invalid_argument& e)
{
cerr << e.what() << endl;
return 1;
}
catch (const runtime_error& e)
{
cerr << e.what() << endl;
return 2;
}

익셉션 타입 매칭과 const

처리하려는 익셉션 타입에 const가 지정됐는지 여부는 매칭 과정에 영향을 미치지 않는다. 다시 말해 다음 문장은 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (const runtime_error& e)

다음 문장도 마찬가지로 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (runtime_error& e)

모든 익셉션 매칭하기

catch 문에서 모든 종류의 익셉션에 매칭하려면 다음과 같이 특수한 문법으로 작성한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (...)
{
cerr << "Error reading or opening file " << fileName << endl;
return 1;
}

점 세 개를 연달아 쓴 부분은 모든 익셉션 타입에 매칭하라는 와일드카드다. 하지만 발생할 익셉션을 확실히 알 수 있다면 이렇게 구현하지 않는 것이 좋다. 필요 없는 익셉션까지 처리하기 때문이다. 항상 익셉션 타입을 구체적으로 지정해서 꼭 필요한 익셉션만 받도록 작성하는 것이 바람직하다.

모든 종류 익셉션을 매칭하는 catch(…) 구문은 디폴트 catch 핸들러를 구현할 때도 유용하다. 익셉션이 발생하면 catch  핸들러가 코드에 나열된 순서대로 검색하면서 조건에 맞는 것을 실행한다. 다음 예는 invalid_argument와 runtime_error만 catch 문을 별도로 작성하고 나머지 익셉션은 디폴트 catch 핸들러로 처리하는 방법을 보여준다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (const invalid_argument& e)
{
// invalid_argument 익셉션을 처리하는 핸들러 코드
}
catch (const runtime_error& e)
{
// runtime_error 익셉션을 처리하는 핸들러 코드
}
catch (...)
{
// 나머지 모든 익셉션을 처리하는 핸들러 코드
}

처리하지 못한 익셉션

catch 구문으로 처리하지 못한 익셉션이 남아 있다면 프로그램을 다르게 실행하도록 구현하는 방법도 있다. 예컨대 프로그램이 잡지 못한 익셉션을 만나면 terminate() 함수를 호출하게 만들 수 있다. 이 함수는 C++에서 기본으로 제공하며, 내부적으로 <cstdlib> 헤더에 정의된 abort() 함수를 호출해서 프로그램을 죽인다.

또는 set_terminate()에 인수를 받지 않고 리턴값도 없는 콜백 함수를 포인터로 지정하는 방식으로 terminate_hander를 직접 구현해도 된다. 사용법은 다음과 같다.

try
{
main(argc, argv);
}
catch (...)
{
if (terminate_handler != nullptr)
{
terminate_handler();
}
else
{
terminate();
}
}

// 정상 종료 코드

아쉽지만 여기서 지정한 콜백 함수도 결국 에러를 무시하지 못하고 프로그램을 종료시킨다. 그래도 최소한 종료 직전에 유용한 정보를 담은 에러 메시지를 출력할 기회는 있다.

예컨대 다음 코드를 보면 main()은 커스텀 콜백 함수인 myTerminate()를 terminate_handler로 지정했다. 이 핸들러는 readIntegerFile()이 던지는 익셉션을 제대로 처리하지 않고 그냥 에러 메시지만 출력한 뒤 exit()를 호출해서 프로그램을 종료시킨다. exit() 함수는 프로세스를 종료하는 방식을 표현하는 정숫값을 인수로 받는다. 이렇게 지정한 값은 OS로 전달된다.

void myTerminate()
{
cout << "Uncaught exception!" << endl;
exit(1);
}

int main()
{
set_terminate(myTerminate);

const string fileName = "IntegerFile.txt";
vector<int> myInts = readIntegerFile(fileName);

for (const auto& element : myInts)
{
cout << element << " ";
}
cout << endl;
return 0;
}

여기에는 나오지 않았지만 set_terminate() 함수로 새로운 terminate_handler를 지정하면 기존에 설정된 핸들러를 리턴한다. terminate_handler는 프로그램 전체에서 접근할 수 있기 때문에 처리할 일이 끝나면 이를 리셋하는 것이 바람직하다.

set_terminate()는 반드시 알아야 할 기능 중 하나지만 에러 처리에 가장 효과적인 수단은 아니다. 그보다는 처리할 익셉션을 try/catch 구문에 구체적으로 지정해서 꼭 필요한 익셉션만 제대로 처리하는 것이 바람직하다.

Note) 상용 소프트웨어를 구현할 때는 프로그램이 종료되기 전에 크래시 덤프(crash dump)를 생성하기 위해 terminate_handler를 설정하는 경우가 많다. 이렇게 생성된 크래시 덤프를 디버거에 입력하면 프로그램에서 놓친 익셉션이나 문제의 원인을 알아낼 수 있다.

noexcept

기본적으로 함수가 던질 수 있는 익셉션의 종류에는 제한이 없다. 하지만 함수에 noexcept 키워드를 지정해서 어떠한 익셉션도 던지지 않는다고 지정할 수 있다. 예컨대 앞서 본 readIntegerFile() 함수에 noexcept를 지정하면 익셉션을 하나도 던지지 않는다.

vector<int> readIntegerFile(string_view fileName) noexcept;

noexcept 키워드가 지정된 함수에 익셉션을 던지는 코드가 있으면 C++ 런타임은 terminate()를 호출해서 프로그램을 종료시킨다.

파생 클래스에서 virtual 메서드를 오버라이드할 떄 베이스 클래스에 정의된 메서드에 noexcept가 지정되지 않았더라도 오버라이드하는 메서드에 noexcept를 지정할 수 있다. 하지만 그 반대로는 할 수 없다.

throw 리스트(현재 지원 중단 및 삭제 됨)

이전 버전의 C++에서는 함수나 메서드에서 던질 수 있는 익셉션을 지정할 수 있다. 이를 throw 리스트 또는 익셉션 명세라 부른다.

C++ 17부터 익셉션 명세 기능이 완전히 삭제됐기 때문에 설명하지 않는다. 사실 이전 버전에서도 익셉션 명세 기능을 거의 사용하지 않았다.

(이하 레거시 내용 생략)

익셉션과 다형성

앞서 설명 했듯이 던질 수 있는 익셉션 타입에는 제한이 없지만 대부분 클래스로 정의한다. 익셉션 타입을 클래스로 정의하면 계층 구조를 형성할 수 있기 때문에 익셉션을 처리하는 코드에서 다형성을 활용할 수 있다.

표준 익셉션 클래스의 계층 구조

다음 그림은 표준 익셉션 클래스의 계층 구조를 나타낸다.

C++ 표준 라이브러리에서 던지는 익셉션 객체의 클래스는 모두 이 계층에 속한다. 여기 나온 클래스는 모두 what() 메서드를 갖고 있다. 이 메서드는 익셉션을 표현하는 const char* 타입의 스트링을 리턴하며 에러 메시지 출력에 활용할 수 있다.

익셉션 클래스는 대부분 what() 메서드가 리턴할 스트링을 생성자의 인수로 지정해야 한다. 베이스 클래스인 exception은 반드시 생성자에 이 값을 전달해야 한다.

(이하 설명 및 예시 코드 생략)

클래스 계층 구조에서 정확한 익셉션 타입 선택하기

익셉션 타입을 클래스 계층으로 구성하면 catch 구문에서 다형성을 활용할 수 있다. 

(이하 설명 및 예시 코드 생략)

이렇게 catch 구문이 인수를 exception 레퍼런스로 받으면 exception을 상속한 모든 파생 클래스 타입을 인수로 받을 수 있다. 단 익셉션 계층에서 베이스로 올라갈수록 에러를 구체적으로 처리하기 힘들어지기 때문에 catch 문에서 처리할 추상화 수준에 최대한 맞게 익셉션 타입을 지정하는 것이 바람직하다.

다형성을 이용한 catch 구문이 여러 개라면 코드에 나온 순서대로 매칭된다. 다시 말해 가장 먼저 매칭되는 구문으로 결정된다. 먼저 나온 catch 문이 뒤에 나온 catch 문보다 추상적이면 앞의 것을 먼저 선택한다. 따라서 구체적인 타입을 뒤에 적으면 한 번도 실행되지 않는다.

(이하 설명 및 코드 생략)

익셉션 클래스 직접 정의하기

익셉션을 직접 정의할 때는 반드시 표준 exception 클래스를 직접 또는 간접적으로 상속하는 것이 좋다. 프로젝트 구성원이 모두 이 원칙을 따르면 프로그램에서 발생하는 익셉션이 모두 exception을 상속하게 만들 수 있다. 이렇게 하면 에러 처리 코드에서 다형성을 이용하기 훨씬 쉽다.

(이하 설명 및 코드 생략)

중첩된 익셉션

익셉션을 처리하는 도중에 또 다른 에러가 발생해서 새로운 익셉션이 전달될 수 있다. 아쉽게도 이렇게 중간에 익셉션이 발생하면 현재 처리하고 있던 익셉션 정보가 사라진다. 이를 해결하기 위해 C++는 먼저 잡은 익셉션을 새로 발생한 익셉션의 문맥 안에 포함시키는 중첩된 익셉션(nested exception)이라는 기능을 제공한다.

이 기능은 다음 상황에서도 활용할 수 있다. 현재 구현하는 프로그램에서 A 타입 익셉션을 던지는 서드파티 라이브러리의 함수를 사용하는데, 현재 작성하는 프로그램에서는 B 타입 익셉션만 처리하게 만들고 싶을 수 있다. 이럴 때는 서드파티라이브러리에서 발생하는 익셉션을 모두 B 타입 안에 중첩 시키면 된다.

어떤 익셉션을 처리하는 catch 문에서 새로운 익셉션을 던지고 싶다면 std::throw_with_nested()를 사용하면 된다. 나중에 발생한 익셉션을 처리하는 catch 문에서 먼저 발생했던 익셉션에 접근할 때는 dynamic_cast()를 이용하면 된다. 이때 먼저 발생한 익셉션은 nested_exception으로 표현한다. 구체적인 예를 통해 살펴보자. 먼저 다음과 같이 exception을 상속하고 생성자에서 스트링을 인수로 받는 MyException 클래스를 정의한다.

class MyException : public std::exception
{
public:
MyException(string_veiw message) : mMessage(message) {}
virtual const char* what() const noexcept override
{
return mMessage.c_str();
}

private:
string mMessage;
};

먼저 발생한 익셉션을 처리하다가 새로운 익셉션을 던져야 하는데 그 안에 앞서 발생한 익셉션을 담아 던지려면 std::throw_with_nested() 함수를 호출해야 한다. 다음 코드에 나온 doSomething() 함수에서 runtime_error를 던지는데 바로 다음에 나온 catch 핸들러가 이를 잡아서 처리한다. 

이 핸들러는 몇 가지 메시지를 작성하고 나서 새로운 익셉션을 던지는데 이때 throw_with_nested() 함수를 이용하여 새로운 익셉션 안에 먼저 발생한 익셉션을 담아서 던진다. 익셉션을 중첩시키는 작업은 자동으로 처리된다.

void doSomething()
{
try
{
throw runtime_error("Throwing a runtime_error exception");
}
catch (const runtime_error& e)
{
cout << __func__ << " caught a runtime_error" << endl;
cout << __func__ << " throwing MyException" << endl;
throw_with_nested(MyException("MyException with nested runtime_error"));
}
}

다음 main() 함수는 중첩된 익셉션을 처리하는 방법을 보여준다. 여기서는 doSomething()을 호출하는 코드가 있고, 그 아래에 MyException 익셉션을 처리하는 catch 핸들러가 나온다. 이 핸들러가 익셉션을 잡으면 메시지를 작성한뒤 dynamic_cast()를 이용하여 현재 익셉션에 중첩된 익셉션에 접근한다. 그 안에 중첩된 익셉션이 없다면 널 포인터를 리턴한다. 중첩된 익셉션이 있다면 nested_exception의 rethrow_nested() 메서드를 호출해서 중첩된 익셉션을 다시 던진다. 그러면 다른 try/catch 구문에서 이 익셉션을 처리할 수 있다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;
const auto* pNested = dynamic_cast<const nested_exception*>(&e);

if (pNested)
{
try
{
pNested->rethrow_nested();
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
}
return 0;
}

여기의 main() 함수는 dynamic_cast()를 이용하여 중첩된 익셉션이 있는지 확인했다. 이렇게 중첩된 익셉션을 확인하기 위해 dynamic_cast()를 호출할 일이 많기 때문에 이 작업을 수행하는 std::rethrow_if_nested()란 간단한 헬퍼 함수를 표준에 정의해뒀다. 이 헬퍼 함수의 사용법은 다음과 같다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;

try
{
rethrow_if_nested(e);
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
return 0;
}

익셉션 다시 던지기

throw 키워드는 현재 발생한 익셉션을 다시 던질 때 사용한다. 예컨대 다음과 같다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const invalid_argument& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "caught in main: " << e.what() << endl;
}
return 0;
}

여기서 throw e; 와 같은 문장으로 익셉션을 다시 던지면 된다고 생각하기 쉽지만 그러면 안 된다. 익셉션 객체에 대한 슬라이싱이 발생하기 때문이다. 예컨대 f()에서 std::exception을 잡고, main()에서 exception과 invalid_argument 익셉션을 모두 잡으려면 다음과 같이 수정한다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const exception& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "invalid_argument caught in main: " << e.what() << endl;
}
catch (const exception& e)
{
cout << "exception caught in main: " << e.what() << endl;
}
return 0;
}

위 코드를 실행하면 다음과 같이 출력된다.

caught in f: Some exception
invalid_argument caught in main: Some exception

그런데 f()에서 throw; 문장을 throw e;로 바꾸면 실행 결과가 다음과 같다.

caught in f: Some exception
exception caught in main: Some exception

이렇게 하면 main()이 exception 객체를 잡긴 하는데 invalid_argument 객체는 아니다. throw e; 문장에서 슬라이싱이 발생해서 invalid_argument가 exception으로 돼버렸기 때문이다.

Caution) 익셉션을 다시 던질 때는 항상 throw;로 적어야 한다.

스택 풀기와 청소

어떤 코드가 익셉션을 던지면 이를 받아서 처리할 catch 핸들러를 스택에서 찾는다. 이때 catch 핸들러는 현재 스택 프레임에 바로 있을 수도 있고, 몇 단계의 함수 호출 스택을 거슬러 올라가야 나타날 수도 있다. 어떻게든 catch 핸들러를 발견하면 그 핸들러가 정의된 스택 단계로 되돌아가는데, 이 과정에서 중간 단계에 있던 스택 프레임을 모두 풀어버린다. 이를 스택 풀기(stack unwinding)이라 부르며, 스코프가 로컬인 소멸자를 모두 호출하고, 각 함수에서 미처 실행 못한 코드는 건너뛴다.

그런데 스택 풀기가 발생할 때 포인터 변수를 해제하고 리소스를 정리하는 작업은 실행되지 않는다. 때문에 다음과 같은 경우 문제가 발생할 수 있다.

int main()
{
try
{
funcOne(;
}
catch (const exception& e)
{
cerr << "Exception caught!" << endl;
return 1;
}
return 0;
}

void funcOne()
{
string str1;
string* str2 = new string();
funcTwo();
delete str2;
}

void funcTwo()
{
ifstream fileStream;
fileStream.open("fileName");
throw exception();
fileStream.close();
}

funcTwo()에서 익셉션을 던질 때 가장 가까운 핸들러는 main()에 있다. 그래서 실행 흐름은 즉시 funcTwo()에 있던 throw exception(); 문장에서 main()의 cerr << “Exception caught!” << endl; 로 건너뛴다.

funcTwo()의 실행 지점은 익셉션을 던진 문장에 여전히 머물러 있다. 따라서 그 뒤에 나온 다음 문장은 실행되지 않는다.

fileStream.close();

다행히 ifstream 소멸자는 호출된다. fileStream이 스택에 있는 로컬 변수이기 때문이다. ifstream 소멸자가 파일을 대신 닫아주므로 리소스 누수가 발생하지는 않는다. fileStream을 동적으로 할당했다면 제거되지 않기 때문에 파일은 닫히지 않고 그대로 남게 된다.

funcOne()에서 실행 지점이 funcTwo() 호출에 있으므로 그 뒤에 나온 다음 문장은 실행되지 않는다.

delete str2;

따라서 메모리 누수가 발생한다. 스택 풀기 과정에서 str2에 대해 delete를 자동으로 호출해주지 않기 때문이다. 그런데 str1은 제대로 해제된다. 이는 스택에 있는 로컬 변수이기 때문이다. 스택 풀기 과정에서 로컬에 있는 변수는 모두 제대로 해제된다.

바로 이 때문에 C 언어에서 사용하던 할당 모델과 익셉션 같은 최신 프로그래밍 기법을 섞어 쓰면 안 된다. C 방식에서 new를 호출해서 C++ 처럼 보이게 만들어도 마찬가지다. C++로 코드를 작성할 때는 반드시 스택 기반 할당 방식을 적용해야 한다. 그게 힘들다면 다음 두 절에 나온 기법 중 하나를 활용한다.

스마트 포인터 활용

스택 기반 할당 기법을 사용할 수 없다면 스마트 포인터를 활용한다. 그러면 익셉션 처리 과정에 메모리나 리소스 누수 방지 작업 등을 자동으로 처리할 수 있다. 스마트 포인터 객체가 제거될 때마다 그 포인터에 할당된 리소스도 해제된다. 

(이하 설명 및 코드 생략)

Note) 스마트 포인터나 28장에서 소개할 RAII 객체를 사용할 때는 리소스 해제하는 신경 쓰지 않아도 된다. 익셉션이 발생하든 아니면 함수가 정상 종료하든 상관없이 RAII 객체의 소멸자가 알아서 처리하기 때문이다.

익셉션 잡고, 리소스 정리한 뒤, 익셉션 다시 던지기

메모리 및 리소스 누수를 방지하기 위한 또 다른 기법은 각 함수마다 발생 가능한 익셉션을 모두 잡아서 리소스를 제대로 정리한 뒤 그 익셉션을 다시 스택의 상위 핸들러로 던지는 것이다.

(이하 설명 및 코드 생략)

Caution) 익셉션을 잡고 리소스를 정리한 뒤 익셉션을 다시 던지기보다 스마트 포인터나 RAII 클래스를 사용하는 방법이 더 좋다.

익셉션 처리 과정에서 흔히 발생하는 문제

메모리 할당 에러

지금까지 소개한 예제는 모두 메모리 할당 에러가 발생하지 않는다고 가정했다. 현재 흔히 사용하는 64비트 플랫폼에서 이런 일이 발생할 일은 거의 없지만, 모바일 시스템이나 레거시 시스템에서는 메모리 할당 에러가 드물지 않게 발생한다. 이런 시스템에서는 반드시 메모리 할당 에러에 대처하는 코드를 구현해야 한다. C++는 메모리 할당 에러를 처리하기 위한 다양한 기능을 제공한다.

new나 new[]에서 메모리를 할당할 수 없을 때 기본적으로 수행하는 동작은 <new> 헤더 파일에 정의된 bad_alloc 익셉션을 던지는 것이다. 따라서 이 익셉션을 적절히 처리하는 catch 구문을 작성한다.

new나 new[]를 호출할 때마다 try/catch 문으로 감싸도 되지만, 할당하려는 메모리 블록의 크기가 클 때만 이렇게 하는 것이 좋다. 메모리 할당 익셉션을 잡는 방법은 다음과 같다.

int* ptr = nullptr;
size_t integerCount = numeric_limits<size_t>::max();

try
{
ptr = new int[integerCount];
}
catch (const bad_alloc& e)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

위 코드는 미리 정의된 전처리 기호인 __FILE__과 __LINE__을 사용하고 있는데, 컴파일하면 이 기호 자리에 파일 이름과 현재 줄 번호가 들어간다. 이렇게 하면 디버깅이 편하다.

물론 구현하는 프로그램에 따라 실행 과정에서 발생할 수 있는 모든 에러를 프로그램의 최상위 코드에서 try/catch 블록 하나만으로 처리해도 된다.

한 가지 주의할 점은 에러 로깅 과정에 메모리 할당이 발생할 수 있는데, new 과정에 에러가 발생했다면 에러 메시지를 로깅하는데 필요한 메모리도 없을 가능성이 높다.

익셉션을 던지지 않는 new

익셉션 메커니즘을 사용하지 않고 예전 C 방식처럼 메모리 할당에 실패하면 널 포인터를 리턴하도록 작성해도 된다. C++은 익셉션을 던지지 않는 nothrow 버전의 new와 new[]도 제공한다. 이 버전은 메모리 할당에 실패하면 익셉션을 던지지 않고 nullptr을 리턴한다. 이렇게 하려면 new 대신 new (nothrow) 구문을 사용한다.

int* ptr = new (nothrow) int[intergerCount];

if (ptr == nullptr)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

메모리 할당 에러 처리 방식 커스터마이즈 하기

C++은 new 핸들러 콜백 함수를 커스터마이즈 하는 기능을 제공한다. 기본적으로 new나 new[]는 new 핸들러를 따로 사용하지 않고 bad_alloc 익셉션을 던지기만 한다. 그런데 new 핸들러를 정의하면 메모리 할당 루틴에서 에러가 발생했을 때 익셉션을 던지지 않고 저으이된 new 핸들러를 호출한다.

new 핸들러가 리턴하면 메모리 할당 루틴은 메모리를 다시 할당하려 시도하는데, 이때 실패해도 다시 new 핸들러를 호출한다. 따라서 new 핸들러에서 다음 세 가지 중 한 가지 방식으로 구현하지 않으면 무한 루프가 발생할 수 있다. 이 중 몇 가지 방식은 다른 방식보다 낫다.

  • 메모리 추가하기
    • 공간을 확보하기 위한 한 가지 방법은 프로그램 구동시 큰 덩어리의 메모리를 할당했다가 new 핸들러로 해제하게 만드는 것이다. 구체적인 활용 예로 메모리 할당 에러가 발생할 때 현재 사용자의 상태가 사라지지 않도록 저장해야 할 때가 있다. 여기서 핵심은 프로그램을 구동할 때 원하는 상태를 저장할 수 있을 정도로 충분한 양의 메모리를 할당하는데 있다. new 핸들러가 호출되면 이 블록을 해제한 뒤 상태를 저장하고 프로그램을 다시 구동해서 저장된 상태를 불러오면 된다.
  • 익셉션 던지기
    • C++ 표준에서는 new 핸들러에서 익셉션을 던질 때 반드시 bad_alloc이나 이를 상속한 익셉션을 던지도록 명시하고 있다.
  • 다른 new 핸들러 설정하기
    • 이론적으로 new 핸들러를 여러 개 만들어서 각각 메모리를 생성하고 문제가 발생하면 다른 new 핸들러를 설정할 수 있다. 하지만 실제 효과에 비해 코드가 복잡하다는 단점이 있다.

new 핸들러에서 위 세 가지 작업 중 어느 하나라도 하지 않으면 메모리 할당 에러가 발생할 때 무한 루프에 빠진다.

메모리 할당 에러가 발생할 때 new 핸들러를 호출하지 않게 하고 싶다면 new를 호출하기 전에 new 핸들러의 디폴트 값인 nullptr로 잠시 되돌려 준다.

new 핸들러는 <new> 헤더 파일에 선언된 set_new_handler()를 호출해서 설정한다. 예컨대 에러 메시지를 로그에 기록하고 익셉션을 던지도록 new 핸들러를 작성하면 다음과 같다.

class please_terminate_me : public bad_alloc { };

void myNewHandler()
{
cerr << "Unable to allocate memory" << endl;
throw please_terminate_me();
}

new 핸들러에는 반드시 인수와 리턴값이 없어야 한다. 이렇게 하면 앞서 설명한 세 가지 처리 방식 중 두 번째처럼 please_terminate_me가 발생한다.

이렇게 작성한 new 핸들러를 설정하는 방법은 다음과 같다.

int main()
{
try
{
// 새로 만든 new 핸들러를 설정하고 예전 것은 저장해둔다.
new_handler oldHandler = set_new_handler(myNewHandler);

// 할당 에러를 발생시킨다.
size_t numInts = numeric_limits<size_t>::max();
int* ptr = new int[numInts];

// 예전 new 핸들러로 되돌린다.
set_new_handler(oldHandler);
}
catch (const please_terminate_me& e)
{
cerr << __FILE__ << "(" << __LINE << "): Terminating program" << endl;
return 1;
}
return 0;
}

여기서 new 핸들러는 함수 포인터 타입에 대한 typedef이며 set_new_handler()는 이를 인수로 받는다.

생성자에서 발생하는 에러

생성자가 값을 리턴하지 못해도 익셉션을 던질 수는 있다. 익셉션을 활용하면 클라이언트가 객체의 정상 생성 여부를 쉽게 알 수 있다. 하지만 한 가지 심각한 문제가 있다. 익셉션이 발생해서 생성자가 정상 종료되지 않고 중간에 실행을 멈추고 빠져나와버리면 그 객체의 소멸자가 호출될 수 없다. 따라서 익셉션이 발생해서 생성자를 빠져나올 때는 반드시 생성자에서 할당했던 메모리와 리소스를 정리해야 한다. 

(예시 코드 생략)

생성자를 위한 함수 try 블록

지금까지 소개한 익셉션 기능만으로도 함수에서 발생한 익셉션을 처리하는데 충분하다. 그렇다면 생성자 이니셜라이저에서 발생한 익셉션은 어떻게 처리해야 할까? 여기서는 함수 try 블록이란 기능으로 이런 익셉션을 처리하는 방법을 소개한다. 

함수 try 블록은 일반 함수뿐만 아니라 생성자에 적용할 수도 있다. 이 기능이 추가된지 상당히 오래 됐음에도 숙련된 C++ 프로그래머조차 이 기능을 모르는 경우가 많다.

생성자 함수에 대한 함수 try 블록을 작성하는 방법을 의사코드로 표현하면 다음과 같다.

MyClass::MyClass()
try : <생성자 이니셜라이저>
{
/* 생성자 본문 */
}
catch (const exception& e)
{
/* ... */
}

여기서 try 키워드를 생성자 이니셜라이저 바로 앞에 적었다. catch 문은 반드시 생성자를 닫는 중괄호 뒤에 나와야 한다. 그러므로 실질적으로 생성자 밖에 놓인다. 함수 try 블록을 생성자에 적용할 때는 다음과 같은 점에 주의한다.

  • catch 문은 생성자 이니셜라이저나 생성자 본문에서 발생한 익셉션을 잡아서 처리한다.
  • catch 문은 반드시 현재 발생한 익셉션을 다시 던지거나 새 익셉션을 만들어 던져야 한다. catch 문에서 이렇게 처리하지 않으면 런타임이 자동으로 현재 익셉션을 다시 던진다.
  • catch 문은 생성자에 전달된 인수에 접근할 수 있다.
  • catch 문이 함수 try 블록에서 익셉션을 잡으면 생성자의 실행을 정상적으로 마친 베이스 클래스나 그 객체로 된 멤버는 catch 문을 싲가하기 전에 소멸된다.
  • catch 문 안에서는 객체로 된 멤버 변수에 접근하면 안 된다. 바로 앞서 설명한 것처럼 catch 문이 실행되기 전에 소멸되기 때문이다. 그런데 익셉션이 발생하기 전에 그 객체에 논 클래스 타입(예: 일반 포인터 타입) 데이터 멤버를 초기화했다면 여기에 접근할 수 있다. 단, 이런 리소스를 정리하는 작업은 catch 문에서 처리해야 한다. 뒤에 나온 코드가 이렇게 처리한다.
  • 함수 try 블록에 있는 catch 문은 그 안에 담긴 함수에서 값을 리턴할 때 return 키워드를 사용할 수 없다. 생성자는 원래 아무것도 리턴하지 않기 때문이다.

앞서 나열한 제약사항을 감안하면 생성자에 대한 함수 try 블록은 다음과 같은 제한된 상황에만 적합하다.

  • 생성자 이니셜라이저에서 던진 익셉션을 다른 익셉션으로 변환할 때
  • 메시지를 로그 파일에 기록할 때
  • 생성자 이니셜라이저에서 할당한, 소멸자로 자동 제거할 수 없는 리소스를 익셉션을 던지기 전에 해제할 때

다음 예는 함수 try 블록을 구현하는 방법을 보여준다. 여기서 SubObject 클래스는 runtime_error 익셉션을 던지는 생성자 하나만 갖고 있다.

class SubObject
{
public:
SubObject(int i);
};

SubObject::SubObject(int i)
{
throw std::runtime_error(error("Exception by SubObject ctor"));
}

MyClass는 다음과 같이 int* 타입의 멤버 변수 하나와 SubObject 타입의 멤버 변수 하나를 갖고 있다.

class MyClass
{
public:
MyClass();
private:
int* mData = nullptr;
SubObject mSubObject;
}

SubObject 클래스에는 디폴트 생성자가 없다. 다시 말해 mSubObject를 MyClass의 생성자 이니셜라이저로 초기화해야 한다. MyClass 생성자는 함수 try 블록을 이용하여 생성자 이니셜라이저에서 발생한 익셉션을 처리한다.

MyClass::MyClass()
try : mData(new int[42]{ 1, 2, 3 }), mSubObject(42)
{
/* 생성자 바디 */
}
catch (const std::exception& e)
{
// 메모리 정리
delete[] mData;
mData = nullptr;
cout << "function-try-block caught: '" << e.what() << "'" << endl;
}

여기서 명심할 점은 생성자에 대한 함수 try 블록 안에 있는 catch 문은 반드시 현재 익셉션을 다시 던지거나 새 익셉션을 생성해서 던져야 한다. 앞서 나온 catch 문을 보면 아무 익셉션도 던지지 않는데, 그러면 C++ 런타임이 현재 익셉션을 대신 던져준다. 앞서 정의한 클래스를 사용하는 예는 다음과 같다.

int main()
{
try
{
MyClass m;
}
catch (const std::exception& e)
{
cout << "main() caught: '" << e.what() << "'" << endl;
}
return 0;
}

이 코드를 실행하면 다음과 같이 출력된다.

function-try-block caught: 'Exception by SubObject ctor'
main() caught: 'Exception by SubObject ctor'

참고로 이 예제처럼 코드를 작성하면 위험하다. 초기화 순서에 따라 catch 문에 진입할 때 mData에 이상한 값이 할당될 수 있다. 이렇게 알 수 없는 값을 가진 포인터에 대해 delete를 호출하면 예상치 못한 동작이 나타날 수 있다. 그래서 위 예제에서 이런 문제를 방지하려면 mData 멤버를 std::unique_ptr과 같은 스마트 포인터로 선언하고 함수 try 블록을 제거하는 것이다.

Caution) 함수 try 블록은 사용하지 않는 것이 좋다.

함수 try 블록은 생성자 뿐만 아니라 일반 함수에도 적용할 수 있지만 일반 함수에서 이를 사용해서 나아질 것은 없다. 함수 본문 안에서 간단히 try/catch 문으로 표현해도 되기 때문이다. 단 함수 try 블록을 생성자에서 사용할 때와 달리 일반 함수에서 사용하면 현재 익셉션을 다시 던지거나 catch 문에서 새 익셉션을 던질 필요가 없고, C++ 런타임에서 대신 던져주지도 않는다.

소멸자에서 익셉션을 처리하는 방법

소멸자에서 발생하는 에러는 반드시 소멸자 안에서 처리해야 한다. 소멸자에서 익셉션을 다른 곳으로 던지면 안 된다. 그 이유는 다음과 같다.

  1. 소멸자를 명시적으로 noexcept(false)로 지정하지 않거나 그 클래스에 있는 객체 중 소멸자에 noexcept(false)가 지정된 것이 없다면 내부적으로 noexcept로 선언된 것으로 취급한다. noexcept 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 프로그램을 종료한다.
  2. 소멸자는 이미 다른 익셉션이 발생해서 스택 풀기를 수행하는 과정에서도 실행될 수 있다. 스택 풀기를 하는 도중에 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션을 종료한다. 
    • 실험 정신이 투철한 독자를 위해 부연 설명하면 C++는 소멸자가 호출되는 원인이 일반 함수의 정상적인 종료 때문인지 아니면 delete를 호출했기 때문인지 아니면 스택 풀기 때문인지 알아내는 기능을 제공한다.
    • <exception> 헤더 파일에 선언된 uncaught_exceptions() 함수를 호출하면 아직 잡지 않은 익셉션, 즉 이미 발생했지만(던져졌지만) 아직 catch 문에는 매칭되지 않은 익셉션 수를 리턴한다. uncaught_exceptions()의 리턴값이 0보다 크면 스택 풀기 과정에 있다는 뜻이다. 하지만 이 함수를 제대로 활용하기 힘들고 코드도 지저분해져서 사용하지 않는 것이 좋다.
    • 참고로 C++ 17 이전에는 이 함수의 이름이 단수형인 uncaught_exception()이었고 bool 타입의 값을 리턴했다. 즉, true를 리턴하면 현재 스택 풀기 중에 있다는 뜻이다.
  3. 그렇다면 클라이언트는 어떤 액셥을 취해야 할까? 클라이언트는 소멸자를 직접호출하지 않고 delete를 이용하여 간접적으로 소멸자를 호출한다. 그런데 소멸자에서 익셉션을 던지면 클라이언트를 어떻게 처리해야 할까? 이미 delete를 호출한 객체에 다시 delete를 호출할 수도 없고, 소멸자를 직접 호출할 수도 없다. 이처럼 클라이언트가 할 수 있는 ㅇ리이 없기 땜누에 굳이 익셉션 처리의 부담을 줄 이유가 없다.
  4. 소멸자는 객체에서 사용할 메모리나 리소스를 해제할 마지막 기회다. 함수 실행 도중에 익셉션을 던져 이 기회를 놓쳐버리면 다시 돌아가 메모리나 리소스를 해제할 수 없다.

모두 합치기

(앞선 내용들을 합한 예시 코드 생략)

 

 

 

 

 

 

 

[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.