[C++] 15. 예외처리

kkado·2023년 10월 20일
0

열혈 C++

목록 보기
15/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


예외 상황이란?

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

몇 가지 예외 상황을 예로 들면 다음과 같다.

  • 나이를 입력하라고 했는데 0보다 작은 값이 입력됨
  • 어떤 수를 0으로 나누려고 함
  • 주민등록번호 13자리를 입력해야 하는데 -을 포함해서 14자리를 입력함

이러한 예외 상황은 문법적 오류와는 상관없이 발생할 수 있다.

예외 상황에 대해 별다른 처리를 하지 않으면 프로그램이 그냥 종료된다.

#include <iostream>
using namespace std;

int main()
{
    int n1, n2;
    cin >> n1 >> n2;

    cout << "나눗셈의 몫 : " << num1 / num2 << "\n";
}

if문을 이용한 예외 처리

우리가 이미 알고 있는 예외 처리 방법에는 if문이 대표적이다.
if문을 통해 예외 상황의 발생 여부에 따라 실행 코드를 분기할 수 있다.

#include <iostream>
using namespace std;

int main()
{
    int n1, n2;
    cin >> n1 >> n2;
	
    if(n2 == 0)
    	cout << "0으로 나눌 수 없습니다.\n";
    else
    	cout << "나눗셈의 몫 : " << num1 / num2 << "\n";
}

여기서 눈여겨 볼 점은 '예외가 발생한 지점' 과 '예외를 발견한 지점' 은 다르다는 것이다.

예외가 발생한 지점은 '0이 입력된 순간' 이고, 예외를 발견한 지점은 '0으로 나누기를 시도한 순간' 이라고 할 수 있겠다.

그러나 이런 방식의 예외 처리 방식은, 예외 처리를 위한 코드와 프로그램의 흐름을 구성하는 코드를 쉽게 구분하기 어렵다는 것이다.

if문 중에는 위처럼 예외 처리를 위한 if문도 있지만 정상적인 실행의 시퀀스에 사용되는 것들도 물론 많다. 따라서 한 눈에 '이 부분은 예외처리 하는 부분이다!' 라는 것을 알 수 없다.

그러나 이어서 설명할 C++의 예외처리 매커니즘을 이용하면 이런 것이 가능해진다.


C++에서의 예외처리

try, catch, throw

위 세 가지 키워드를 한 줄씩으로 요약하면 다음과 같다.

  • try : 예외를 발견한다.
  • catch : 예외를 잡는다. (?)
  • throw : 예외를 던진다. (???)

먼저 try부터 살펴보면, '예외 발생에 대한 검사의 범위를 지정' 할 때 사용된다. try 블록 내에서 예외가 발생하면 C++의 예외 처리 매커니즘에 의해 처리된다.

try
{
	// 예외가 발생할 가능성이 있는 부분
}

catch 블록은 'try 블록에서 발생한 예외가 처리되는 코드가 담기는 영역'이다.

catch(처리할 예외의 종류)
{
	// 예외 처리 코드의 삽입
}

이 둘은 항상 이어서 등장해야 하며 사이에 어떤 다른 문장이 들어가서는 안된다.

try
{
	// 예외가 발생할 가능성이 있는 부분
}
catch(처리할 예외의 종류)
{
	// 예외 처리 코드의 삽입
}

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

그럼 throw는 어떤 친구일까. throw는 '예외가 발생했음을 알리는 문장의 구성' 에 사용된다.

throw expn;

위 문장에서 expn에는 예외상황에 대한 정보를 담은 의미 있는 데이터여야 한다. 또는 그냥 '예외' 라고 부르기도 한다. 다만 이 책과 이 글에서는 어휘의 혼동을 주지 않기 위해 '예외변수' 라고 지칭한다. 그리고 위 문장이 실행되면 예외처리 매커니즘이 동작하여 일반적 프로그램의 흐름이 아니라 예외 처리 흐름이 시작된다.

즉 try와 catch, throw는 다음의 한 문장으로 요약이 가능하다.

"throw에 의해 던져진 '예외 데이터'는, '예외 데이터'를 감싸는 try 블록에 의해 예외가 감지되어 이어서 등장하는 catch 블록에 의해 처리된다.


이제 이것들을 위의 예시에 적용해서 0으로 나누는 상황을 막아보자.

int main()
{
    int n1, n2;
    cin >> n1 >> n2;

    try
    {
        if (n2 == 0)
            throw n2;

        cout << "나눗셈의 몫 : " << n1 / n2 << "\n";
        cout << "나눗셈의 나머지 : " << n1 % n2 << "\n";
    }
    catch(int expn)
    {
        cout << "제수는 0이 될 수 없습니다.\n";
    }
}
(입력) 3 0 
제수는 0이 될 수 없습니다.

물론 n2가 0이 아니면 if문 조건이 발동하지 않아 정상적으로 나눗셈의 몫과 나머지가 출력된다.

그리고 한 가지 더 알아두어야 할 점은 '예외가 발생해서 catch 블록이 실행되고 나면, 예외가 발생한 지점 이후를 실행하는 것이 아닌 catch 블록의 이후가 실행된다는 것이다.

예외가 발생하면 예외가 발생한 지점 이후의 나머지 try문은 그냥 건너뛴다.

그리고 throw에 의해 던져진 예외변수의 자료형과 catch 블록의 매개변수는 일치해야 한다.

만약 일치하지 않으면 던져진 예외변수는 catch 블록에 전달되지 않는다. (이것의 효과는 나중에 설명)

try 블록을 묶는 기준

try 블록이 프로그램에 미치는 영향은 세 가지이다.

  • try 블록을 만나면 그 안에 삽입된 코드가 순서대로 실행됨
  • try 블록에서 예외가 발생하면 예외가 발생한 지점 이후의 나머지 try 영역은 건너뜀
  • try 블록 내에서 예외가 발생하지 않으면 catch 블록 이후를 실행함

따라서 예외가 발생할 수 있는 부분을 try 블록으로 묶는 것이 옳아 보이지만, 엄밀히 이야기하면 '그와 관련된 모든 문장을 하나로 묶어서 하나의 일(work)로 구성해야' 한다.


Stack Unwinding (스택 풀기)

예를 들어 myFunc 함수 안에서 예외가 발생했는데 이 함수 내에는 예외처리를 위한 try-catch 문이 존재하지 않는다고 하자. 그러면 이 상황에서 발생한 예외는 어떻게 처리될까.

이 경우 예외처리에 대한 책임은 'myFunc을 호출한 영역' 으로 넘어간다. 즉 발생한 예외는 'myFunc을 호출한 영역'으로 전달이 된다.

이와 관련해 다음 예제를 보자.

void divide(int num1, int num2)
{
    if (num2 == 0)
        throw num2;
    
    cout << "나눗셈의 몫 : " << num1 / num2 << "\n";
    cout << "나눗셈의 나머지 : " << num1 % num2 << "\n";
}

int main()
{
    int n1, n2;
    cin >> n1 >> n2;

    try
    {
        divide(n1, n2);
        cout << "나눗셈을 마침\n";
    }
    catch(int expn)
    {
        cout << "제수는 0이 될 수 없습니다.\n";
    }
}

divide 함수 내에서는 매개변수 num2가 0인 경우 예외가 발생하는데, 이 부분을 감싸는 try-catch문은 없다.

따라서 main 함수 쪽에서 divide 함수를 호출한 위치로 예외 데이터가 전달된다. 물론 예외 데이터가 전달되었으니 예외 처리의 책임도 함께 넘어가, try-catch문에 의해 예외가 처리된다.

그림으로 나타내면 위와 같다.

main 함수로 넘어가 예외 처리를 할 때도 동일하게 그 이후의 try문은 실행하지 않는다. 따라서 '나눗셈을 마침' 이 출력되지 않는다.

예외가 처리되지 않으면 예외가 발생한 함수를 호출한 영역으로 예외 데이터 및 예외 처리에 대한 책임이 전달된다.


예외상황이 발생한 위치와 예외상황을 처리해야 하는 위치가 다른 경우

사실 대부분의 경우 예외의 발생 위치와 처리 위치는 다르다.
그 한가지 예를 들어보자.

int stoI(char* str)
{
    int len = strlen(str);
    int num = 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()
{
    char str1[100];
    char str2[100];
    
    while(1)
    {
        cout << "두 개의 숫자 입력 : ";
        cin >> str1 >> str2;

        try
        {
            cout << str1 << " + " << str2 << " = " << stoI(str1) + stoI(str2) << "\n";
            break;
        }
        catch(char ch)
        {
            cout << "문자 " << ch << "가 입력됨.\n";
            cout << "재입력 진행 \n\n";
        }
    }

    cout << "프로그램 종료\n";
}

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


스택 풀기

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

왜 스택 풀기라는 이름이 붙었는고 알아보자.

예외가 처리될 때까지 호출된 함수의 역순으로 데이터가 전달된다. 예외 데이터를 전달하는 함수는 종료되기 때문에 예외 데이터를 전달한 함수의 스택은 반환된다.

그래서 예외 데이터가 처리될 때까지 예외 데이터를 넘겨주고 그에 따라 함수가 종료되고, 스택 자원이 반환되기를 반복하기 때문에 스택을 푼다고 하는 것이다.

그리고 스택이 풀리고 풀리고 해서 메인 함수까지 예외 데이터가 전달됐는데 메인 함수에도 try-catch를 이용한 예외 상황을 처리하는 구문이 없으면 terminate 함수가 호출되며 프로그램이 종료된다.


자료형이 일치하지 않아도 예외 데이터는 전달됨

throw하는 예외 데이터의 자료형과 catch의 매개변수형이 일치하지 않으면 어떻게 될까

int simpleFunc()
{
	...
    try
    {
    	if(...)
        	throw -1;
    }
    catch(char expn)
    {
    	// ?
    }
}

이 결과 자료형의 불일치로 인해서 예외가 처리되지 않고, 이 함수를 호출한 곳으로 스택 풀기가 진행된다.


하나의 try, 여러 개의 catch

하나의 try 블록 내에서 여러 가지 유형의 예외상황이 발생할 수 있고 그에 따른 예외처리 로직 역시 다를 수 있으므로 try 블록 이후에 등장하는 catch 블록은 둘 이상이 될 수 있다.

앞서 보인 예제를 조금 수정해보자.

int stoI(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()
{
    char str1[100];
    char str2[100];
    
    while(1)
    {
        cout << "두 개의 숫자 입력 : ";
        cin >> str1 >> str2;

        try
        {
            cout << str1 << " + " << str2 << " = " << stoI(str1) + stoI(str2) << "\n";
            break;
        }
        catch(char ch)
        {
            cout << "문자 " << ch << "가 입력됨.\n";
            cout << "재입력 진행 \n\n";
        }
        catch(int expn)
        {
            cout << "0으로 시작하는 숫자는 입력 불가\n"; 
        }
    }

    cout << "프로그램 종료\n";
}

0으로 시작되는 숫자를 검증하고 throw 0을 날리는 부분을 추가했다. 따라서 stoI 함수에서는 char형과 int형 예외 데이터를 날릴 수 있다.

따라서 catch문도 두 가지 자료형에 대해 모두 예외처리 로직을 구현해준 모습이다.


예외 클래스의 설계

지금까지는 기본 자료형만을 예외 데이터로 사용했는데, 클래스의 객체도 예외 데이터가 될 수 있다. 그리고 사실 이것이 보다 일반적인 방법이다.

예외 상황을 알리는 데 사용되는 객체를 '예외객체' 라고 하며, 이 예외객체의 생성을 위해 정의된 클래스를 '예외 클래스' 라고 한다.

그리고 객체를 이용해서 예외상황을 알리면 예외가 발생한 원인에 대한 정보를 보다 자세히 담을 수 있다.

다음 예제는 현금인출기에서 돈을 인출하는 과정을 시뮬레이션 한 것이다.

#include <iostream>
using namespace std;


class DepositException
{
private:
    int reqDep;
public:
    DepositException(int money) : reqDep(money)
    {}
    void showExceptionReason()
    {
        cout << "[예외 메시지 : " << reqDep << "는 입금 불가.]\n";
    }
};

class WithdrawException
{
private:
    int balance;
public:
    WithdrawException(int money) : balance(money)
    {}
    void showExceptionReason()
    {
        cout << "[예외 메시지 : 잔액 " << balance << ", 잔액 부족.]\n";
    }
};

class Account
{
private:
    char accNum[50];
    int balance;
public:
    Account(char* acc, int money) : balance(money)
    {
        strcpy(accNum, acc);
    }

    void deposit(int money) noexcept(false)
    {
        if (money < 0)
        {
            DepositException expn(money);
            throw expn;
        }
    }

    void withdraw(int money) noexcept(false)
    {
        if (money > balance) {
            throw WithdrawException(balance);
        }
        balance -= money;
    }

    void showMyMoney()
    {
        cout << "잔고 : " << balance << "\n";
    }
};


int main()
{
    Account myAcc("56789-12345", 5000);

    try
    {
        myAcc.deposit(2000);
        myAcc.deposit(-300);
    }
    catch(DepositException &expn)
    {
        expn.showExceptionReason();
    }
    myAcc.showMyMoney();

    try
    {
        myAcc.withdraw(3500);
        myAcc.withdraw(4500);
    }
    catch(WithdrawException &expn)
    {
        expn.showExceptionReason();
    }
    myAcc.showMyMoney();      
}

참고 : 책에는 void withdraw(int money) throw (WithdrawException) {} 이라는 함수 정의를 사용하고 있는데 C++14 이후로는 이와 같은 문법을 지원하지 않는다고 하여 모든 형식의 예외를 throw 할 수 있다는 뜻의 noexcept(false) 를 사용하였음

[예외 메시지 : -300는 입금 불가.]
잔고 : 5000
[예외 메시지 : 잔액 1500, 잔액 부족.]
잔고 : 1500

위 예제에서 보듯 예외 클래스라고 해서 다를 것은 전혀 없고, 클래스 내부에 설명을 해 주는 함수를 추가할 수 있다는 장점이 있다.


상속 관계의 예외 클래스

예외 클래스도 클래스의 일종이기 때문에 상속 관계를 구성할 수 있다.
앞서 보인 예제를 상속 관계로 묶으면 다음과 같이 묶을 수 있다.

class AccountException
{
public:
    virtual void showExceptionReason() = 0;
};

class DepositException : public AccountException
{
private:
    int reqDep;
public:
    DepositException(int money) : reqDep(money)
    {}
    void showExceptionReason()
    {
        cout << "[예외 메시지 : " << reqDep << "는 입금 불가.]\n";
    }
};

class WithdrawException : public AccountException
{
private:
    int balance;
public:
    WithdrawException(int money) : balance(money)
    {}
    void showExceptionReason()
    {
        cout << "[예외 메시지 : 잔액 " << balance << ", 잔액 부족.]\n";
    }
};

그리고 이렇게 상속관계로 묶게 되면 예외 처리를 단순화시킬 수 있다.

int main()
{
    Account myAcc("56789-12345", 5000);

    try
    {
        myAcc.deposit(2000);
        myAcc.deposit(-300);
    }
    catch(AccountException &expn)
    {
        expn.showExceptionReason();
    }
    myAcc.showMyMoney();

    try
    {
        myAcc.withdraw(3500);
        myAcc.withdraw(4500);
    }
    catch(AccountException &expn)
    {
        expn.showExceptionReason();
    }
    myAcc.showMyMoney();      
}

두 부분 모두 catch 블록의 매개변수를 AccountException 으로 통일할 수 있다.


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

try 블록의 뒤를 이어 등장하는 catch 블록이 둘 이상인 경우 적절한 catch 블록을 찾는 과정은 위에서부터 검사하며 내려오는 방식으로 진행된다.

마치 if, else if, else문처럼 하나씩 검사하고 찾지 못하면 다른 영역으로 전달한다.

그리고 이러한 특성 때문에 다음과 같이 catch 블록을 구성해선 안 된다.

class AAA
{
public:
    void showYou()
    {
        cout << "AAA exception\n";
    }
};

class BBB : public AAA
{
public:
    void showYou()
    {
        cout << "BBB exception\n";
    }
};

class CCC : public BBB
{
public:
    void showYou()
    {
        cout << "CCC exception\n";
    }
};

void exceptionGenerator(int expn)
{
    if(expn == 1)
        throw AAA();
    else if(expn == 2)
        throw BBB();
    else
        throw CCC();
}

int main()
{
    try
    {
        exceptionGenerator(3);
        exceptionGenerator(2);
        exceptionGenerator(1);
    }
    catch(AAA &expn)
    {
        cout << "Catch (AAA& expn)\n";
        expn.showYou();
    }
    catch(BBB &expn)
    {
        cout << "Catch (BBB& expn)\n";
        expn.showYou();
    }
    catch(CCC &expn)
    {
        cout << "Catch (CCC& expn)\n";
        expn.showYou();
    }
}
Catch (AAA& expn)
AAA exception

exceptionGenerator(3); 에서 CCC 클래스를 인자로서 받고 있는 catch 블록을 실행하고 싶었으나 AAA 클래스를 받는 catch 블록이 실행 되었다.

이는 CCC 클래스가 BBB를 상속하고 BBB 클래스는 AAA 클래스를 상속하기 때문에 AAA형 참조자가 CCC 클래스 객체를 참조할 수 있기 때문이다.

따라서 A->B->C 형태로 상속 구조가 구성되어 있다면 catch 블록을 정의하는 순서는 C->B->A 순서가 되어야지 올바르게 실행이 가능하다.


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

new 연산자

new 연산에 의한 메모리 할당이 실패하면 bad_alloc 이란 예외가 발생한다. bad_alloc은 헤더 파일 <new>에 선언된 예외 클래스로, 메모리 공간의 할당이 실패했음을 알릴 의도로 정의되었다.

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

try
{
	...
}
catch (...)
{
	...
}

위 구조처럼 catch(...) 라고 조건을 명시하면 모든 예외가 자료형에 상관 없이 걸려든다.

따라서 마지막 catch 블록에 else 처럼 사용하기 위해 덧붙여지는 경우가 많은데, 매개변수 선언에서 볼 수 있듯이 예외에 관련해서 어떠한 정보도 전달받을 수 없다.

예외 던지기

catch 블록에 전달된 예외는 다시 던져질 수 있다. 그래서 하나의 예외가 두 개 이상의 catch 블록에서 처리되게끔 할 수 있다.

void Func() {
  try
  {
      ...
  }
  catch (int expn)
  {
      cout << "First catch \n";
      throw;
  }
}

int main()
{
	try
    {
    	Func();
        ...
    }
    catch(int expn)
    {
    	cout << "Second catch \n";
    }
}

예외처리는 가급적이면 간결한 것이 좋으므로 굳이 이렇게 다시 던질 필요가 있을지 고민해 보는 것은 필요하다.


profile
울면안돼 쫄면안돼 냉면됩니다

0개의 댓글