본문 바로가기

프로그래밍 언어들/C++

CHAPTER15 - 예외처리(Exception Handling)

1. 예외상황과 예외처리의 이해

예외처리에서의 '예외'는 프로그램 실행 도중에 발생하는 '예외적인 상황'을 의미한다. 그리고 C++은 이러한

예외적인 상황의 처리를 위한 문법을 별도로 제공하고 있다.


1.1 예외상황을 처리하지 않았을 때의 결과

C++에서 말하는 '예외(exception)'는 프로그램의 실행 도중에 발생하는 문제상황을 의미한다. 따라서 컴파일 시

발생하는 문법적인 에러는 예외의 범주에 포함되지 않는다. 몇 가지 상황을 예로 들면 다음과 같다.

(1) 나이를 입력하라고 했는데, 0보다 작은 값이 입력되었다.

(2) 나눗셈을 위한 두 개의 정수를 입력 받는데, 제수(나누는 수)로 0이 입력되었다.

(3) 주민등록번호 13자리만 입력하라고 했더니, 중간에 -를 포함하여 14자리를 입력하였다.

위의 (2)의 경우에서 제수가 0이면, 프로그램이 강제로 종료된다.


1.2 if문을 이용한 예외의 처리

 

if문을 통해서 예외상황의 발생유무를 확인한 다음에 그에 따른 처리를 진행하는 것이 우리가 알고 있는 예외의 처리방식이다.

그러나 이러한 예외처리 방식은 다음의 단점을 지닌다.

"예외처리를 위한 코드와 프로그램의 흐름을 구성하는 코드를 쉽게 구분하지 못한다."


2. C++의 예외처리 메커니즘

C++은 구조적으로 예외를 처리할 수 있는 메커니즘을 제공한다. 이 메커니즘을 이용하면, 코드의 가독성과

유지보수성을 높일 수 있다.


2.1 C++의 예외처리 메커니즘 이해 : try와 catch 그리고 throw의 이해

예외처리 메커니즘과 관련해서 익숙해져야 할 세가지 키워드는 다음과 같다.

- try

- catch

- throw


(1) try 블록

try 블록은 예외발생에 대한 검사의 범위를 지정할 때 사용된다. 즉, try 블록 내에서 예외가 발생하면,

이는 C++의 예외처리 메커니즘에 의해서 처리가 된다.

try
{
    // 예외발생 예상 지역
}


(2) catch 블록

try 블록에서 발생한 예외를 처리하는 코드가 담기는 영역이다.

catch
{
    // 예외처리 코드의 삽입
}


사실 try와 catch는 하나의 문장이다. 따라서 항상 이어서 등장해야 하며, 중간에 다른 문장이 오면 안 된다.
따라서 다음과 같이 하나의 문장을 구성해서 try와 catch를 이해하기 바란다.

"try 블록 내에서 발생하는 예외는 이어서 등장하는 catch 블록에 의해 처리된다."


(3) throw

키워드 throw는 예외가 발생했음으 알리는 문장의 구성에 사용된다.

=> throw expn;

위의 문장에서 expn은 변수, 상수 그리고 객체 등 표현 가능한 모든 데이터가 될 수 있으나,

예외상황에 대한 정보를 담은, 의미 있는 데이터이어야 한다. 그래서 위 문장에서 expn의 위치에 오는 데이터를

가리켜 그냥 '예외'라고 표현하기도 한다. 그러나 이는 혼란을 줄 수 있어 필자는 이를 '예외 데이터' 또는

'예외객체'라고 구분 지어 표현하겠다.

그리고 위의 문장이 실행되면 C++의 예외처리 메커니즘이 동작하여, 일반적인 프로그램의 흐름과는 다른 예외처리의

흐름이 시작된다. 따라서 try와 catch 그리고 throw는 다음의 한 문장으로 정리될 수 있다.

"throw에 의해 던져진 '예외 데이터'는, '예외 데이터'를 감사는 try 블록에 의해서 감지가 되고,

이어서 등장하는 catch 블록에 의해 처리된다."


2.2 예외처리 메커니즘의 적용

int main(void)
{
    int num1, num2;

    cin >> num1 >> num2;

    try
    {
        if(num2 == 0)
        {
            throw num2;
        }
        cout << "몫 : " << num1/num2 << endl;
        cout << "나머지 : " << num1%num2 << endl;
    }
    catch(int expn)
    {
        cout << "제수는 0이 될 수 없습니다." << endl;
    }
    return 0;
}
10 0
제수는 0이 될 수 없습니다.


이 실행결과는 try 블록 내에서 예외가 발생하면, catch 블록이 실행되고 나서, 예외가 발생한 지점 이후를

실행하는 것이 아니라, catch 블록의 이후가 실행됨을 보인다.
또한, throw에서 던져진 예외 데이터의 자료형과 catch 블록의 매개변수 자료형은 일치해야 한다.


2.3 try를 묶는 기준

앞서 보인 예제를 통해서 다음 사실을 파악할 수 있었다.

- try 블록을 만나면 그 안에 삽입된 문장이 순서대로 실행된다.

- try 블록 내에서 예외가 발생하지 않으면 catch 블록 이후를 실행한다.

- try 블록 내에서 예외가 발생하면, 예외가 발생한 지점 이후의 나머지 try 영역은 건너뛴다.

즉, 예외가 발생할만한 영역만 묶는 것이 아니라, 그와 관련된 모든 문장을 하나로 묶어서 이를 하나의

'일(work)'의 단위로 구성하는 것이다.


3. Stack Unwinding(스택 풀기)

throw절에 의해서 예외가 발생은 했는데, 이를 처리하지 않으면 어떻게 될까?


3.1 예외의 전달

호출한 함수 내에서 throw절이 실행되면서 예외가 발생했다. 그런데 이 함수 내에는 예외처리를 위한 try~catch문이

존재하지 않는다. 그렇다면 이 상황에서, 발생한 예외는 어떻게 처리되겠는가?

이러한 경우 예외처리에 대한 책임은 해당 함수를 호출한 영역으로 넘어가게 된다.

void divide(const int n, const int m)
{
    if(m == 0)
    {
        throw m;
    }
    cout << "몫 : " << n/m << endl;
    cout << "나머지 : " << n%m << endl;
}
int main(void)
{
    int num1, num2;

    cin >> num1 >> num2;

    try
    {
        divide(num1, num2);
    }
    catch(int expn)
    {
        cout << "제수는 " << expn << "이 될 수 없습니다." << endl;
    }
    return 0;
}
20 0
제수는 0이 될 수 없습니다.


위 예제를 통해서 알게 된 또 하나의 결론을 정리하면 다음과 같다.

"예외가 처리되지 않으면, 예외가 발생한 함수를 호출한 영역으로 예외

데이터가(더불어 예외처리에 대한 책임까지) 전달된다."

그리고 이러한 특성은 예외가 발생한 위치와 예외를 처리해야 하는 위치가 달라야만 하는 경우에 유용하다.

참고로 대부분의 경우에 있어서 예외의 발생위치와 처리위치는 다르다.

"함수 내에서 함수를 호출한 영역으로 예외 데이터를 전달하면, 해당 함수는 더 이상 실행되지 않고 종료가 된다."

즉, 함수 내에서 예외 데이터를 전달하면, return문의 실행을 통한 함수의 종료와 마찬가지로 완전히 빠져나온다.


3.2 스택 풀기(Stack Unwinding)

예외가 처리되지 않아서, 함수를 호출한 영역으로 예외 데이터가 전달되는 현상을 가리켜 '스택 풀기'라고 한다.

void first(void);
void second(void);
void third(void);

int main(void)
{
    try
    {
        first();
    }
    catch(int expn)
    {
        cout << "에러코드 " << expn << endl;
    }
    return 0;
}

void first(void)
{
    cout << "first(void)" << endl;
    second();
}
void second(void)
{
    cout << "second(void)" << endl;
    third();
}
void third(void)
{
    cout << "third(void)" << endl;
    throw -1;
}
first(void)
second(void)
third(void)
에러코드 -1


위 예제는 에러가 발생할 수 밖에 없도록 구현되어 있다. 일단, 위 예제의 함수호출 순서는 다음과 같다.

main -> first -> second -> third

그리고, third 함수에서는 무조건 예외를 발생시킨다. 그런데 이 예외를 처리하기 위한 try~catch문이 main 함수에

정의되어 있어서 다음의 경로로 예외 데이터가 전달된다.

third -> second -> first -> main

이렇듯 예외가 처리될 때까지, 호출된 함수의 역순으로 예외 데이터가 전달된다. 그리하여 결국 예외는 third 함수에서

발생했지만, 처리는 main함수에서 이뤄지는 형태가 된다. 그런데 예외 데이터가 전달되면, 예외 데이터를 전달한

함수는 종료되기 때문에, 예외 데이터를 전달한 함수의 스택이 반환되는 것은 당연하다.

그래서 예외 데이터의 전달을 가리켜, '스택 풀기(스택의 반환)'라고 하는 것이다.

※ 앞의 예제에서 main함수에도 예외 처리가 되어있지 않다면, terminate 함수가 호출되면서 프로그램이 종료된다.


3.3 자료형이 일치하지 않아도 예외 데이터는 전달된다.

앞서 예외 데이터의 자료형과 catch의 매개변수 형이 일치해야 함을 설명하였다. 그러나 자료형의 불일치로

인해서 예외는 처리되지 않는다. 따라서 함수를 호출한 영역으로 예외 데이

터가 전달된다.


3.4 하나의 try 블록과 다수의 catch 블록

하나의 try 블록에서 유형이 다른 둘 이상의 예외상황이 발생할 수도 있고, 이러한 경우 각각의 예외를 표현하기 위해

사용되는 예외 데이터의 자료형이 다를 수 있기 때문에, try 블록에 이어서 등장하는 catch 블록은 둘 이상이 될 수 있다.

#include <iostream>
#include <cmath>
using std::ostream;
using std::cin;
using std::cout;
using std::endl;

int StoI(const char *str)
{
    int len = strlen(str);
    int num = 0;

    if(len != 0 && str[0] == '0')
    {
        throw 0;
    }

    for(int i = 0 ; i < len ; i ++)
    {
        if(str[i] < '0' || str[i] > '9')
        {
            throw str[i];
        }

        num += (int)(pow((double)10, (len-1)-i) * (str[i] + (7 - '7')));
    }

    return num;
}

int main(void)
{
    char str1[100];
    char str2[100];

    while(true)
    {
        cin >> str1 >> str2;

        try
        {
            cout << str1 << " + " << str2 << " = " << StoI(str1) + StoI(str2) << endl;
            break;
        }
        catch(char ex)
        {
            cout << "문자 " << ex << "가 입력되었습니다." << endl << endl;
        }
        catch(int ex)
        {
            if(ex == 0)
            {
                cout << "0으로 시작하는 숫자는 입력불가." << endl << endl;
            }
        }
    }

    return 0;
}
12A 342
문자 A가 입력되었습니다.

0234 5434
0으로 시작하는 숫자는 입력불가.

34@2 4232
문자 @가 입력되었습니다.

5235 1232
5235 + 1232 = 6467


3.5 전달되는 예외의 명시

함수 내에서 발생할 수 있는 예외의 종류도 함수의 특징으로 간주된다. 따라서 이미 정의된 특정 함수의 호출을 위해서는

함수의 이름, 매개변수 선언, 반환형 정보에 더해서, 함수 내에서 전달될 수 있는 예외의 종류와 그 상황도 알아야 한다.

그래야 해당 함수의 호출문장을 감싸는 적절한 try~catch 블록을 구성할 수 있다.

int throwfunc1(int num) throw (int, char) {...}
int throwfunc2(void) thorw () {...}


위의 첫 번째 함수의 선언은, 해당 함수가 int형 예외 데이터와 char형 예외 데이터를 전달할 수 있음을 알리는 것이다.

만약 다른 자료형의 예외 데이터가 전달될 경우, 'terminate' 함수의 호출로 인해 프로그램은 종료될 것이다.

두 번째 선언의 경우는, 어떠한 예외도 전달하지 않음을 의미한다.


※ unexpected 함수 : 함수의 선언에 명시되지 않은 예외가 전달될 경우 unexpected 함수(프로그램 종료)가 호출된다.


4. 예외 클래스의 설계

지금까지는 기본 자료형 데이터만을 예외 데이터로 사용했는데, 클래스의 객체도 예외 데이터가 될 수 있고,

이것이 보다 일반적인 방법이다.

class depositexception
{
private:
    int req;
public:
    depositexception(const int money) : req(money) {}
    void showreason(void) const
    {
        cout << "[예외 메시지 : " << req << "은(는) 입금불가]" << endl;
    }
};
class withdrawexception
{
private:
    int req;
public:
    withdrawexception(const int money) : req(money) {}
    void showreason(void) const
    {
        cout << "[예외 메시지 : " << req << "은(는) 출금불가, 잔액부족]" << endl;
    }
};
class account
{
private:
    char accNum[50];
    int balance;
public:
    account(const char *acc, int money) : balance(money)
    {
        strcpy(accNum, acc);
    }
    void deposit(int money) throw(depositexception)
    {
        if(money < 0)
        {
            throw depositexception(money);
        }
       
        balance += money;
    }
    void withdraw(int money) throw(withdrawexception)
    {
        if(balance < money)
        {
            throw withdrawexception(money);
        }

        balance -= money;
    }
    void showmymoney(void) const
    {
        cout << "잔고 : " << balance << endl;
    }
};
int main(void)
{
    account myacc("123123-123", 5000);

    try
    {
        myacc.deposit(5000);
        myacc.deposit(-1000);
    }
    catch(depositexception &ex)
    {
        ex.showreason();
    }
    myacc.showmymoney();

    try
    {
        myacc.withdraw(5000);
        myacc.withdraw(10000);
    }
    catch(withdrawexception &ex)
    {
        ex.showreason();
    }
    myacc.showmymoney();

    return 0;
}
[예외 메시지 : -1000() 입금불가]
잔고 : 10000
[예외 메시지 : 10000() 출금불가, 잔액부족]
잔고 : 5000


4.1 상속 관계에 있는 예외 클래스

예외 클래스도 상속의 관계를 구현할 수 있다.

class accountexception
{
public:
    virtual void showreason(void) const = 0;
};
class depositexception : public accountexception
{
private:
    int req;
public:
    depositexception(const int money) : req(money) {}
    void showreason(void) const
    {
        cout << "[예외 메시지 : " << req << "은(는) 입금불가]" << endl;
    }
};
class withdrawexception : public accountexception
{
private:
    int req;
public:
    withdrawexception(const int money) : req(money) {}
    void showreason(void) const
    {
        cout << "[예외 메시지 : " << req << "은(는) 출금불가, 잔액부족]" << endl;
    }
};
class account
{
private:
    char accNum[50];
    int balance;
public:
    account(const char *acc, int money) : balance(money)
    {
        strcpy(accNum, acc);
    }
    void deposit(int money) throw(accountexception)
    {
        if(money < 0)
        {
            throw depositexception(money);
        }
       
        balance += money;
    }
    void withdraw(int money) throw(accountexception)
    {
        if(balance < money)
        {
            throw withdrawexception(money);
        }

        balance -= money;
    }
    void showmymoney(void) const
    {
        cout << "잔고 : " << balance << endl;
    }
};
int main(void)
{
    account myacc("123123-123", 5000);

    try
    {
        myacc.deposit(5000);
        myacc.deposit(-1000);
    }
    catch(accountexception &ex)
    {
        ex.showreason();
    }
    myacc.showmymoney();

    try
    {
        myacc.withdraw(5000);
        myacc.withdraw(10000);
    }
    catch(accountexception &ex)
    {
        ex.showreason();
    }
    myacc.showmymoney();

    return 0;
}


4.2 예외의 전달방식에 따른 주의사항

try 블록의 뒤를 이어서 등장하는 catch 블록이 둘 이상인 경우, 적절한 catch 블록을 찾는 과정은

위에서 부터 순차적으로 찾는다. 따라서 다음과 같이 블록을 구성하면 안된다.

class A
{
public:
    void showreason(void){ cout << "A exception!" << endl; }
};
class B : public A
{
public:
    void showreason(void){ cout << "B exception!" << endl; }
};
class C : public B
{
public:
    void showreason(void){ cout << "C exception!" << endl; }
};
void exceptiongenerator(void)
{
    throw C();
}
int main(void)
{
    try
    {
        exceptiongenerator();
    }
    catch(A& ex)
    {
        cout << "catch(&A)" << endl;
        ex.showreason();
    }
    catch(B& ex)
    {
        cout << "catch(&B)" << endl;
        ex.showreason();
    }
    catch(C& ex)
    {
        cout << "catch(&C)" << endl;
        ex.showreason();
    }

    return 0;
}
catch(&A)
A exception!


위 예제의 문제점은, B와 C 클래스가 A 클래스를 상속하므로, 첫 번째 catch가 실행된다는데 있다.

만약 B 예외 객체는 B catch 블록에서, C 예외 객체는 C catch 블록에서 처리되길 원하다면,

다음과 같이 변경해야 한다.

    try
    {
        exceptiongenerator();
    }
    catch(C& ex)
    {
        cout << "catch(&C)" << endl;
        ex.showreason();
    }
    catch(B& ex)
    {
        cout << "catch(&B)" << endl;
        ex.showreason();
    }
    catch(A& ex)
    {
        cout << "catch(&A)" << endl;
        ex.showreason();
    }


5. 예외처리와 관련된 또 다른 특성들

5.1 new 연산자에 의해서 발생하는 예외

new 연산에 의한 메모리 공간의 할당이 실패하면, bad_alloc이라는 예외가 발생한다. 이는 헤더파일 'new'에

선언된 예외 클래스로써 메모리 공간의 할당이 실패했음을 알리는 의도로 정의되었다.


5.2 모든 예외를 처리하는 catch 블록

다음과 같이 catch 블록을 선언하면, try 블록 내에서 전달된느 모든 예외가 자료형에 상관없이 걸려든다.

try
{
}
catch(...)
{
}


'...'은 모든 예외를 다 받아주겠다는 선언이다. 따라서 마지막에 catch 블록에 덧붙여지는 경우가 많은데,

대신 catch의 매개변수 선언에서 보이듯이, 발생한 예외와 관련해서 어떠한 정보도 전달받을 수 없다.


5.3 예외 던지기

catch 블록에 전달된 예외는 다시 던져질 수 있다. 그리고 이로 인해서 하나의 예외가 둘 이상의 catch 블록에 의해서

처리되게 할 수 있다.

void devide(const int n, const int m)
{
    try
    {
        if(m == 0)
        {
            throw 0;
        }
        cout << "몫 : " << n / m << endl;
        cout << "나머지 : " << n % m << endl;
    }
    catch(int ex)
    {
        cout << "first catch!" << endl;

        throw; // rethrow!
    }
}

int main(void)
{
    try
    {
        devide(10, 3);
        devide(20, 0);
    }
    catch(int ex)
    {
        cout << "second catch!" << endl;
    }
    return 0;
}
: 3
나머지 : 1
first catch!
second catch!


예외처리는 가급적 간결한 구조를 띠는게 좋다. 따라서 정말로 필요한 상황이 아니라면,

굳이 예외를 다시 던지기 위해서 노력할 필요는 없다.


출처 : 윤성우 열혈 C++ 프로그래밍