-
[C++] 예외 처리C++/이것이 C++이다 2021. 11. 25. 13:06
프로그램의 흐름이 개발자가 의도한 방향대로 흘러가지 않는 경우는 흔한 일입니다.
프로그램이 실행되는 기기의 환경이 다르거나
사용자의 입력이 개발자가 의도한 것과 다르다거나 하는 등의 이유로, 프로그램에는 언제나 예외가 발생할 수 있습니다.
이번 글에서는 예외 상황에 대응하는 방법에 대하여 다룰 것 입니다.
1. try, catch, throw
더보기C++는 문법적으로 예외상황에 대한 대응책을 제공합니다.
try, catch, throw 예약어가 바로 예외 처리에 사용되는 예약어 입니다.
예제를 살펴보겠습니다.
#include <iostream> using namespace std; int main() { try { // Line 5 : Logic execution int a, b; cin >> a >> b; if (b == 0) throw b; // Line 9 : Exception throw cout << (float)a / (float)b; } catch (int exception) { // Line 12 : Exception handling cout << "Cannnot divide by " << exception << "\n"; } catch (...) { // Line 15 : Default exception cout << "Default Exception\n"; } return 0; }
1 0 Cannnot divide by 0
사용자 입력에 의해 0으로 나누는 예외를 처리하는 소스코드 입니다.
try블록에 실행할 소스코드를, catch 블록에 발생한 예외를 처리하는 소스코드를 작성합니다.
throw 예약어는 catch블록으로 인자를 전달하는 (=예외를 발생시키는) 예약어 입니다.
try, catch 문법에는 다음과 같은 규칙이 적용됩니다.
- catch 블록은 한 개의 try 블록에 대하여 여러개 존재할 수 있습니다.
- catch 블록에 전달할 수 있는 인자는 한 개 입니다.
- 한 개의 try 블록은 반드시 한 개 이상의 catch 블록이 필요합니다.
- catch 블록의 인자값을 ...으로 기술하는 것으로, throw하는 모든 예외를 받을 수 있습니다. (위 코드 Line 15)
- catch 블록으로의 진입은 throw에 의한 예외 발생으로만 가능합니다. goto, switch-case 예약어 등으로의 진입은 불가능합니다.
위 예제의 경우는, try, catch를 사용하지 않고 if, else의 조건문으로도 충분히 처리할 수 있습니다.
하지만 try, catch가 조금 더 구조적으로 간결해지는 상황이 존재합니다.
이에 관해서는 이후 문단에서 다루도록 하겠습니다.
2. 구조적 예외 처리
더보기간단한, 몇 개의 예외상황들은 조건문을 통해서 처리할 수 있으며, 그것이 프로그램의 실행 속도 또한 빠릅니다.
하지만 처리해야 할 예외상황이 많거나 예외에 대한 처리 로직이 복잡할 경우에 조건문을 사용하여 예외처리를 한다면 비슷한 코드가 반복되거나, 비즈니스 로직 사이에 예외 처리에 관한 코드가 들어갈 수 있습니다. 이것은 가볍게는 소스코드의 가독성을 해칠 수 있고, 조금 더 나아가면 설계 범위를 넘어간 구현, 협업에서의 혼란 등을 야기할 수 있습니다.
try, catch 블록을 통해 이런 예외를 처리하는 것으로, 구조적으로 간결한 프로그램을 구성할 수 있습니다.
예제를 살펴보겠습니다.
#include <iostream> using namespace std; void function_a() { int input; cout << "Enter number 1 to 10 : "; cin >> input; if ((input > 10) | (input < 1)) throw input; } void function_b() { char input; cout << "Select [A] [B] [C] : "; cin >> input; if ((input != 'A') & (input != 'B') & (input != 'C')) throw input; } int main() { try { function_a(); function_b(); } catch (int exception) { cout << exception << " is out of range\n"; } catch (char exception) { cout << exception << " is invalid input\n"; } return 0; }
Enter number 1 to 10 : 2 Select [A] [B] [C] : D D is invalid input
사용자의 입력을 받고, 입력의 유효성을 판단하는 소스 코드 입니다.
위 소스 코드는 함수 내부에서 예외가 발생 시 예외 처리를 하지 않았습니다.
예외 처리를 하지 않고 예외만 발생시킴으로써, 함수 내부의 기능에만 집중할 수 있고, 발생하는 예외에 대한 처리는 사용자가 할 수 있도록 분리했습니다.
결과적으로는 함수의 기능과 예외상황에 대한 처리가 분리된 구조적으로 간결한 코드가 되었습니다.
위의 소스 코드는 예시입니다.
아래 문단에 서술하겠지만, 예외 발생 및 처리는 비용이 많이 드는 작업입니다.
소스 코드의 가독성도 중요하지만, 사소한 예외는 조건문을 이용하는 방법이 더 나을 수 있습니다.
선택은 개발자의 몫이지만, 선택에 대한 충분한 이유가 뒷받침 되어야 할 것입니다.
3. 예외 클래스
더보기발생한 예외를 받는 catch 예약어의 인자는 한개로 제한됩니다.
하지만, 자료형에 대한 제한은 존재하지 않습니다.
예외 발생에 이용할 클래스를 추가로 정의한다면 추가적인 정보를 동반한 예외 발생이 가능해집니다.
예제를 살펴보겠습니다.
#include <iostream> using namespace std; class divide_zero_exception { public: divide_zero_exception(int dividend, int divisor, const char* message) : dividend(dividend), divisor(divisor) { strcpy_s(msg, sizeof(msg), message); } char* get_message() { return msg; } private: int divisor, dividend; char msg[128]; }; float division(int dividend, int divisor) { if (divisor == 0) throw new divide_zero_exception(dividend, divisor, "Cannot divide by zero\n"); return (float)dividend / (float)divisor; } int main() { try { int a, b; cin >> a >> b; cout << division(a, b); } catch (divide_zero_exception* exp) { cout << exp->get_message(); } return 0; }
1 0 Cannot divide by zero
사용자로부터 두 정수를 입력받아 나눗셈을 수행하는 소스 코드 입니다.
나눗셈 함수 내부에는 divisor가 0인지 검사해서, 예외를 발생시키는 소스 코드가 있으며 발생하는 예외는 추가적으로 만든 divide_zero_exception 클래스 입니다.
위와 같이 예외 처리를 위한 클래스를 만들게 되면 기본 자료형보다 더 많은 정보를 담을 수 있습니다.
위 소스 코드는 예외 발생 메시지만 출력했지만, 인스턴스에는 divisor와 dividend 또한 존재하므로, 정책에 따라 추가적인 예외 처리도 가능할 것 입니다.
위와 같이 예외 처리를 클래스로 관리한다면 여러 종류의 예외를 효과적으로 관리할 수 있으며, 그것은 곧 디버깅이 편해진다는 장점이 됩니다.
4. Stack unwinding
더보기Stack unwinding에 대해 이야기하기 전에, Microsoft 기술 문서의 Exception and Stack Unwinding in C++ 문서에 기술된 try, catch 블록의 진행 순서를 살펴보겠습니다.
- 프로그램의 제어가 try 블록에 다다르면 우선 try 블록을 실행합니다.
- 실행 중 예외가 발생하지 않을 경우, try 블록에 맞물린 catch 블록은 실행되지 않습니다.
- 실행 중 예외가 발생했을 경우, throw 연산자에 의해 예외 처리 인스턴스가 생성됩니다.
- 이후 컴파일러는 실행 컨텍스트를 하나씩 높혀가며 해당 예외 인스턴스를 핸들링 할 수 있는 catch 블록을 찾습니다.
- 적절한 핸들러를 찾지 못하거나, Unwinding 프로세스 도중 핸들러에 제어가 넘어가거나, 예외가 전달되기 전에 Unwinding 프로세스가 실행되었을 경우 런타임 함수인 terminate가 호출됩니다.
- 적절한 핸들러를 찾아 catch 핸들러의 파라미터가 초기화 되면 Stack unwinding이 시작됩니다.
- Stack unwinding은 catch 블록에 관련된 try 블록 내부에서 생성된, 아직 해제되지 않은 자동변수들에 대한 해제를 포함합니다.
- 해제는 생성의 역순으로 수행되며, 해제가 완료되면 catch 블록 바깥으로 제어가 이동되어 프로그램이 진행됩니다.
함수를 호출하면 호출 정보, 매개변수, 자동변수 등의 정보가 스택 메모리에 쌓입니다.
예를들어 main 함수의 try 블록에서 A() B() C() 라는 함수를 순서대로 호출할 경우 스택의 맨 위에는 C가 있습니다.
여기서 C()에서 예외가 발생할 경우, 제어는 main함수에 있는 적절한 catch 블록을 찾아 이동하고, 이 때 메모리의 상태는 A, B, C를 호출하기 이전의 상태로 돌아와야 합니다.
위와 같은 상황에서 스택 메모리를 되돌리는 프로세스가 Stack unwinding 입니다.
문서에 첨부된 예제를 살펴보겠습니다.
#include <iostream> using namespace std; class MyException{}; class Dummy { public: Dummy(string s) : MyName(s) { PrintMsg("Created Dummy:"); } Dummy(const Dummy& other) : MyName(other.MyName){ PrintMsg("Copy created Dummy:"); } ~Dummy(){ PrintMsg("Destroyed Dummy:"); } void PrintMsg(string s) { cout << s << MyName << endl; } string MyName; int level; }; void C(Dummy d, int i) { cout << "Entering FunctionC" << endl; d.MyName = " C"; throw MyException(); cout << "Exiting FunctionC" << endl; } void B(Dummy d, int i) { cout << "Entering FunctionB" << endl; d.MyName = " B"; C(d, i + 1); cout << "Exiting FunctionB" << endl; } void A(Dummy d, int i) { cout << "Entering FunctionA" << endl; d.MyName = " A" ; //Dummy* pd = new Dummy("new Dummy"); //Not exception safe!!! B(d, i + 1); //delete pd; cout << "Exiting FunctionA" << endl; } int main() { cout << "Entering main" << endl; try { Dummy d(" M"); A(d,1); } catch (MyException& e) { cout << "Caught an exception of type: " << typeid(e).name() << endl; } cout << "Exiting main." << endl; char c; cin >> c; }
Entering main Created Dummy: M Copy created Dummy: M Entering FunctionA Copy created Dummy: A Entering FunctionB Copy created Dummy: B Entering FunctionC Destroyed Dummy: C Destroyed Dummy: B Destroyed Dummy: A Destroyed Dummy: M Caught an exception of type: class MyException Exiting main.
A, B, C의 함수를 호출할때마다 복사 생성자를 통해 Dummy가 늘어나는 것을 볼 수 있습니다.
또한 C에서 예외가 발생할 경우 A, B, C의 나머지 코드가 실행되지 않고 catch 블록으로 되돌아오며, 이때 Stack unwinding이 발생하여 생성된 Dummy가 소멸하는 것을 볼 수 있습니다.
이것보다 눈여겨보아야 할 것은 주석처리된 A 함수의 두 코드입니다.
Dummy를 새로 생성하고, 함수가 끝나기 전에 Dummy를 해제하는 코드입니다.
주석처리 된 위 두 줄의 코드를 실행해보면, new Dummy가 소멸하지 않는 것을 볼 수 있습니다.
이것을 Exception safety하지 않다고 말합니다.
이에 관련하여, Exception safety한 설계를 하는 방법에 대하여 다른 글에서 다룰 수 있도록 하겠습니다.
5. 예외 처리에서의 오버헤드
더보기위 문단에서, 예외처리의 비용에 대하여 짧게 언급하였습니다.
예외처리는 유의미한 속도 저하를 일으키는 기능입니다.
이에 관해서는 잘 정리된 글이 있어 링크와 함께 글의 내용을 인용하려 합니다.
예외가 성능에 미치는 영향
위 글에서는 예외 처리가 구체적으로 어느정도의 속도 저하를 일으키는지 실험하였습니다.
실험에 사용된 프로그램의 예시는 이하와 같습니다.
- 예외 처리를 사용하지 않는 프로그램
- 예외 처리를 사용하나 예외를 발생시키지 않는 프로그램
- 예외 처리를 사용하고 예외를 발생시키지만 재발생시키지 않는 프로그램
- 예외 처리를 사용하고 예외를 발생시키고 재발생시키는 프로그램
위의 네 가지 경우를, 재귀호출을 이용하여 호출 깊이를 나누어 실험하였습니다.
결과적으로, 단순히 try, catch 블록을 이용하는 것 만으로는 유의미한 오버헤드가 발생하지 않습니다.
예외의 발생에는 유의미한 오버헤드가 존재하며, 이것이 호출 깊이에 따라 선형적으로 증가함을 볼 수 있었습니다.
자세한 실험 내용, 소스코드, 결과 그래프는 원본 글을 참조해주시기 바랍니다.
안정성이 높은 프로그램을 만드는 일은 매우 중요합니다.
하지만 프로그램의 규모가 커질수록, 완벽한 프로그램을 만드는 것은 불가능에 가까워집니다.
예외 처리는 비록 오버헤드가 많은 기능이지만, 프로그램이 다운되는 것 보다는 나을 것 입니다.
성능에 대한 우려로 사용하지 않는 것 보다는, 적재적소에 알맞게, 테스트를 거친 후 사용하는 것이 좋겠습니다.
읽어주셔서 감사합니다.
'C++ > 이것이 C++이다' 카테고리의 다른 글
[C++] 람다 표현식 (0) 2021.12.06 [C++] 함수 (0) 2021.11.28 [C++] 스마트 포인터 (0) 2021.11.03 [C++] 템플릿 (0) 2021.10.26 [C++] 객체 간 관계 (0) 2021.10.19