C++에서의 예외( exception ) 처리
C++에서는 프로그램의 오류나 기대하지 못했던 상황이 발생한 경우, 이를 처리하기 위한 예외( exception )를 발생시키면, 프로그램이 자동으로, 이 예외를 처리할 수 있는 코드를 찾아 문제를 해결하는 메커니즘을 제공합니다.
이 메커니즘을 위해 만든 것이 throw와 try, catch 키워드입니다.
이 키워드들을 이용해서 문제를 처리하는 과정은 다음과 같습니다.
● 먼저, 프로그램 실행 중, 기대하지 못한 상황이 발생되면, throw 키워드를 사용해서 프로그램에게 예외가 발생되었음을 알리게 됩니다.
이 throw 키워드 바로 뒤에는, 발생한 상황을 알리는 데 도움이 되는 데이터가 따라옵니다.
이러한 데이터에는 어떤 종류의 데이터도 상관이 없습니다.
일반적으로 여기에 사용하는 데이터는, 문제의 종류를 구분할 수 있는 예외 코드( 주로 int ), 예외를 설명하는 문자열, 사용자 정의 예외 객체 등이 있습니다.
throw 1; // simply int data
throw ENUM_DIVIDE_BY_ZERO; // enum data
throw "Cannot divide by Zero"; // string data
throw CMyException(3.14); // 사용자 예외 객체
● 그럼, 프로그램은 실행을 중지시키고, 예외를 발생시킨 코드를 감싸고 있는 try 코드 블록을 찾습니다.
그래서, try 블록을 찾으면, 이 try 블록에 붙어있는 catch 코드 블록에서 이 예외를 처리할 수 있는지 검사를 합니다.
만약, catch 블록의 매개 변수 타입과 throw 키워드가 던지는 예외 데이터의 타입이 일치하면, 프로그램은 그 catch 블록이 예외를 처리할 수 있는 코드라고 인정합니다.
이러한 예외를 처리하는 catch 블록의 개수에 제한은 없습니다.
그렇지만, 적어도 1
개 이상의 catch 블록이 항상 try 블록에 붙어있어야 합니다.
● 이렇게 예외를 처리할 수 있는 catch 블록을 찾으면, 실행 제어가 그 블록으로 넘어가 예외를 처리하게 됩니다.
그 후, 실행 제어는 catch 블록이 끝난 위치에서부터 다시 정상적인 실행으로 넘어가게 됩니다.
이 과정을 예문을 통해 보도록 하겠습니다.
#include <iostream>
using namespace std;
int main(){
int a = 10, d;
cout << "10 will be divided by the inputed number : ";
cin >> d;
int result;
try{
if ( d == 0)
throw 0; // 예외 발생
result = a / d; // 예외가 발생하면 이 코드는 실행되지 않습니다.
}
catch( int x){
cerr << "cannot divide by Zero\n";
result = x;
}
catch( double ){
cerr << "cannot divide by 0.0\n";
result = -1;
}
cout << "divided num : " << result << endl;
}
▼출력
10 will be divided by the inputed number : 1
divided num : 10
10 will be divided by the inputed number : 0
cannot divide by Zero
divided num : 0
위에서, 사용자가 0
이 아닌 값 d
를 입력하게 되면 10
을 d
로 나눈 값을 출력하게 됩니다.
이 때는, 예외를 발생시키지 않으므로, 어떠한 catch 블록도 실행되지 않습니다.
만약, 사용자가 0
을 입력하면, 10
을 0
으로 나누는 오류가 발생하므로, 이 오류를 막기 위해 예외( exception )를 발생시킵니다.
그럼, 프로그램은 즉시 실행을 중지하고, 예외를 발생시킨 코드를 둘러싼 try 블록을 찾습니다.
그리고, 현재 함수 내에서 그러한 try 블록을 찾게 되면, try 블록에 붙어 있는 catch 블록들 중에서 예외를 알리는 데이터의 타입과 같은 타입의 매개 변수를 갖는 catch 블록을 찾고, 그 catch 블록으로 실행 제어를 이동합니다.
그렇기 때문에, 예외가 발생하면 throw 구문 아래의 코드들은 전혀 실행되지 않습니다.
위 예문에서는, throw에서 예외의 값으로 0
을 던졌기 때문에, int
타입의 매개 변수를 가진 catch
블록을 찾아 실행합니다.
그래서, result
에 매개 변수 x
의 값인 0
이 입력될 것입니다.
그리고, 이제 정상적인 실행 제어로 돌아가, result]
값을 출력하고 프로그램이 종료됩니다.
● 일단, 위와 같이 예외를 처리할 수 있는 try-catch 블록을 찾으면, 실제로 어떠한 작업도 하지 않는, 빈 catch 블록이라고 하더라도, 예외가 해결된 것으로 처리됩니다.
따라서, 정상적인 프로그램이 계속 실행됩니다.
그런데 만약, 예외를 처리할 수 있는 try-catch 블록을 찾지 못하게 되면, std::terminate() 함수가 호출되고 프로그램은 바로 종료됩니다.
● 참고로, catch 블록에서 매개 변수를 직접 사용할 일이 없는 경우, 매개 변수명을 생략할 수도 있습니다.
위에서는 double
타입의 매개 변수를 가진 catch 블록이 그 예입니다.
catch( double ){
cerr << "cannot divide by 0.0\n";
result = -1;
}
그렇지만, 예외를 처리할 수 없는지, 있는지는 매개 변수 타입 비교로 검사하기 때문에, 타입을 생략할 수는 없습니다.
또한, catch 블록에 전달되는 인자는 암시적인 타입 변환이 발생하지 않습니다.
위에서 catch( int x )
블록이 없다고 해서, catch( double )
블록이 예외를 처리할 수는 없다는 뜻입니다.
그래서, 이 경우 예외를 처리할 수 있는 catch 블록을 못 찾게 되고, 프로그램은 종료됩니다.
전반적인 예외( exception ) 처리의 흐름
만약, 예외를 발생시킨 함수 내에 try 블록이 없으면 발생된 예외는 어떻게 되는 걸까요?
다음의 예문을 통해서, 이런 경우 어떻게 처리되는지를 따라가 보겠습니다.
이 예문은 main 함수에서 Func2 함수를 호출하고, Func2 함수에서 다시, 예외를 발생하는 Divide 함수를 호출하는, Func1 함수를 호출합니다.
그리고, 이 프로그램의 실행 제어( execution control )를 추적하기 위해서, NamedObj
객체를 각 함수의 지역 변수로 선언해 두었습니다.
이 변수는 함수가 종료되면 자동으로 파괴되기 때문에, 함수가 종료되는 시점을 알려줍니다.
#include <iostream>
#include <string>
using namespace std;
class NamedObj{ // 스택의 상태를 관찰하기 위한 클래스
string m_str;
public:
NamedObj( const string& str) : m_str(str) {}
~NamedObj(){
cout << m_str << " was destoryed\n";
}
};
// 예외를 발생시키는 함수
int Divide( int a, int b){
NamedObj obj("Divide");
if ( b == 0)
throw b; // 예외 발생
return a / b; // 절대 실행되지 않습니다.
}
// Divide 함수를 호출하는 함수
int Func1( int a, int b){
NamedObj obj("Func1");
try{
return Divide(a, b); // 예외를 발생하는 함수 호출
}
catch( double ){
cerr << "Func1 try-catch block\n";
}
try{
// do nothing
}
catch( int x){
cerr << "Func1 another try-catch block\n";
}
return 10;
}
// Func1 함수를 호출하는 함수
int Func2(){
NamedObj obj("Func2");
try{
return Func1(10, 0); // 예외를 발생하는 함수의 함수 호출
}
catch( int x){
cerr << "Func2 try-catch block\n";
}
return 0;
}
// Func2 함수 호출
int main(){
try{
int nRet = Func2();
cout << "result = " << nRet << endl;
}
catch( int x){
cerr << "main try-catch block\n";
}
cout << "normally exit with 0";
return 0;
}
이제, Divide 함수를 호출할 때, 매개 변수 b
에 값 0
을 전달하면, 예외가 발생합니다.
그럼, 프로그램은 실행을 멈추고, 함수 내 try 블록을 탐색합니다.
그런데, Divide 함수 내에는 try-catch 블록이 없습니다.
이럴 경우, 프로그램은 호출 스택을 따라 내려가면서, 먼저 Divide함수를 호출한 Func1 함수를 찾게 되고, 이 함수 내에 try-catch 블록이 있나 찾게 됩니다.
다행히 Divide 함수를 호출한 try 블록이 있으므로, 그다음은 이와 연결된 catch 블록들 중에서, 발생한 예외를 처리할 수 있는 블록이 있는지 검사합니다.
하지만, 찾은 try 블록과 연결된 catch 블록은 double
타입의 예외만 처리할 수 있으므로, Func1은 이 예외를 처리할 수 없습니다.
한편, Func1 함수 내의, 첫 번째 try-catch 블록 아래의, 또 다른 try 블록은 예외를 발생한 Divide 함수를 호출하지 않았으므로, 발생한 예외와 아무런 상관이 없는 코드입니다.
그래서, 프로그램은 다시 호출 스택을 더 내려가 Func2 함수를 찾습니다.
이 함수 내에도, 예외를 발생시킨 Divide 함수를 호출한, Func1 함수를 호출한 try 블록이 있습니다.
그러므로, 이 try 블록에 연결된 catch 블록들 중에 예외를 처리할 수 있는 catch 블록을 찾습니다.
다행히, 이 함수 내에는 예외의 값( int
타입 )을 처리할 수 있는 catch 블록이 있습니다.
catch( int x){
cerr << "Func2 try-catch block\n";
}
그럼, 프로그램은 이 catch 블록으로 실행 제어를 건너뛰기 위해서, 스택 언와인딩( stack unwinding )을 수행합니다.
● 참고로, 함수가 호출될 때, 호출한 함수의 호출 주소 및 로컬 변수 등의 정보가 스택에 저장됩니다.
이것을 스택 와인딩( stack winding )이라고 합니다.
그런데, 만일 함수 종료 시 그냥 함수를 벗어나면, 이 스택에 들어있는 정보들이 쌓이게 되는 문제가 발생합니다.
그래서, 함수를 벗어나기 전에 스택을 비워야 하는데, 이 과정을 스택 언와인딩( stack unwinding )이라고 합니다.
위에서 말한 대로, 이 과정이 정말 수행되는지를 보기 위해서, 각 함수의 첫 문장에 NamedObj
객체를 만들어 두었습니다.
만약, 함수가 종료되지도 않았는데 스택 언와인딩이 발생한다면, 스택에 저장되어 있는 NamedObj
객체는 파괴될 것입니다.
그리고, 다음이 위의 예문을 실행 결과를 출력한 내용입니다.
Divide was destoryed
Func1 was destoryed
Func2 try-catch block
Func2 was destoryed
result = 0
normally exit with 0
위에서 보는 바와 같이, Func2 함수에서 catch 블록이 예외를 처리하기 전에, 호출 스택에 있던 객체와, 함수 위치 정보 등이 전부 삭제되는 것을 볼 수 있습니다.
이제 프로그램의 실행 제어가 Func2 함수의 catch 블록으로 넘어와서, Divide 함수에서 발생한 예외를 처리합니다.
그리고, 정상적인 실행 제어로 돌아가서, catch 블록 다음의 return 문을 만나서, Func2 함수를 종료하게 됩니다.
마지막으로, main 함수로 돌아와 정상적으로 프로그램이 종료됩니다.
이 main 함수에도 예외를 처리할 수 있는 catch 블록이 있습니다.
하지만 이미, 발생했던 예외가 처리되었으므로 다시 처리할 필요는 없습니다.
따라서, 이 함수의 catch( int x )
블록은 실행되지 않습니다. ( 예외를 처리할 수는 있지만 )
이것이 throw, try, catch를 통한 전체적인 예외 처리 흐름입니다.
예외( exception )의 발생과 처리를 분리해서 구현하는 이유
그런데, 왜 예외 처리는 예외가 발생되는 함수에서 처리하지 않는 걸까요?
만약, 위 예문의 Divide 함수에서 예외가 발생했을 때, 그 함수 내에서 처리하면, 예외를 처리할 try-catch 블록을 찾아서 호출 스택을 검색할 필요도 없을 텐데 말이죠.
물론 위와 같이 생각할 수도 있습니다.
그렇지만, 이러한 C++의 예외 처리 메커니즘은 어디서, 어떻게 예외를 처리해야 할지를 스스로 결정하도록, 발생과 처리 부분을 분리하는 유연성을 제공합니다.
예를 들어, 사용자에게 예외가 발생한 상황을 전달하는 경우를 생각해 봅시다.
어떤 프로그램은 터미널( terminal )을 통해서 메시지를 전달하거나 로그 파일을 전송할 것이고, 다른 프로그램은 그래픽 인터페이스를 통해서 메시지를 보내고 싶을 것입니다.
따라서, 예외가 발생할 가능성이 있는 함수를, 위와 같은 인터페이스를 구현한 함수와 분리할 필요가 있습니다.
그리고, 이러한 필요성에 throw, try-catch 예외 처리 방식이 잘 맞아떨어진다는 것을 알 수 있습니다.
● 다음 예문은 C++ 표준 라이브러리의 std::vector
를 사용할 때, 예외를 처리하는 방법을 보여줍니다.
int main(){
vector<int> vec = {1,2,3,4,5};
try{
int& element = vec.at(6); // throw out_of_bound exception
element++;
}
catch(...){ // catch-all handler
cout << "exception occurred\n";
}
}
▼출력
exception occurred
위의 예에서, vector
의 at 함수는 vector
의 원소 범위를 벗어난 인덱스를 사용하면 예외를 발생시킵니다.
그런데, 이 예외가 vector
클래스의 at 함수 내에서 어떤 식으로 처리된다고 가정해 봅시다.
그러면, 이 클래스를 사용하는 어떤 프로그램은 이러한 vector
의 예외를 처리하는 방식이 잘 맞을 것입니다.
하지만, 다른 프로그램은 vector
의 이 예외 처리 방식 때문에, at 함수를 사용할 때마다 추가적인 코드를 작성해야 하거나, 아예 이 at 함수 자체를 사용할 수 없을 수도 있습니다.
따라서, 포괄적인 코드를 원한다면, 예외 발생 부분과 처리 부분을 분리하는 것이 당연합니다.
catch-all 처리기
위에서, throw 구문에서, 예외를 처리하는 catch에 전달할 수 있는 값으로, 어떤 값이든 사용할 수 있다고 했었습니다.
그렇기 때문에, 함수에서 발생할 수 있는 예외를 설명하기 위한 값들을 미리 알아야 하거나, 코드를 직접 들여다봐야 하는 경우가 있습니다.
이런 경우의 불편함을 줄이기 위해, catch-all 처리기를 작성할 수 있습니다.
이 처리기는, 이름 그대로, 발생하는 모든 예외를 처리할 수 있는 catch 블록입니다.
그러므로, 다른 처리기가 먼저 특정 종류의 예외를 처리할 수 있는 기회를 얻게 하기 위해, 이 catch 블록은 다른 catch 블록들의 맨 마지막에 위치해야 됩니다.
또한, 이 catch-all 처리기는 위와 같은 불편함을 덜어줄 뿐만 아니라, 예외 발생으로 인한 프로그램을 즉시 종료하는 상황을 피할 수 있도록 해주기도 합니다.
int main(){
try{
StartProgramLoop(); // program loop
}
catch(...){ // catch-all handler
cerr << "unhandled exception occurred\n";
}
SaveProgramState(); // 현재 상태 저장
return 0;
}
위의 예문에서는, 프로그램의 실행 루프 동안 처리되지 않는 예외를 전부 잡아낼 수 있는 catch-all 처리기를 볼 수 있습니다.
이 처리기는 프로그램이 즉시 종료되는 것을 막을 뿐만 아니라 종료되지 않음으로써, 스택 언와인딩( stack unwinding )을 통해, 아직 파괴되지 않은 객체들을 프로그램 종료 전에 안전하게 정리할 수 있는 기회를 제공합니다.
그리고 위의 예에선, 예외로 인한 프로그램의 종료 전에 현재 프로그램 상태를 저장함으로써, 다음 프로그램 실행 시, 같은 환경에서 시작하는 기능을 제공할 수도 있을 것입니다.
정리
- C++에서는 프로그램에서 오류가 발생될 때 예외를 던지고( throw ), 이를 감시하다가( try ), 예외가 던져지면 잡아서( catch ) 처리하는 메커니즘을 제공합니다.
- 예외가 발생하는 곳과 처리하는 곳을 분리하면, 다양한 처리방법을 제공할 수 있는 유연성을 가질 수 있습니다.
- catch-all 처리기를 제공함으로써, 예외의 종류를 모르더라도, 예외를 처리할 수 있습니다.
이 글과 관련 있는 글들
생성자와 소멸자에서의 예외( exception ) 처리
'C++' 카테고리의 다른 글
[C++] 예외가 발생되지 않는 함수를 선언하는 noexcept (0) | 2025.04.03 |
---|---|
[C++] 생성자와 소멸자에서의 예외( exception ) 처리 (1) | 2025.04.02 |
[C++] 불필요한 중간 단계를 건너뛰기 위한 복사 생략( copy elision ) (0) | 2025.03.31 |
[C++] 기존의 객체를 복사할 때 호출되는 복사 생성자( copy constructor ) (0) | 2025.03.30 |
[C++] null 포인터와 nullptr 리터럴( literal ) (0) | 2025.03.26 |