C++ 예외 처리

Seongcheol Jeon·2024년 9월 27일
0

CPP

목록 보기
5/47
post-thumbnail

반환 값을 사용한 예외 처리

다음의 예제를 살펴보자.

// main.cpp

#include "include/DynamicArray.h"

#include <iostream>

using namespace std;

int main()
{
    DynamicArray arr(10);

    bool b;
    b = arr.SetAt(5, 0);
    if (!b)
        cout << "arr[5] fail" << endl;

    b = arr.SetAt(25, 0);
    if (!b)
        cout << "arr[25] fail" << endl;

    return 0;
}
// DynamicArray.h

#ifndef TEST_DYNAMICARRAY_H
#define TEST_DYNAMICARRAY_H

class DynamicArray
{
  public:
    DynamicArray(int arraySize);
    ~DynamicArray();

    bool SetAt(int index, int value);
    int GetAt(int index) const;
    int GetSize() const;

  protected:
    int *arr;
    int size;
};

#endif // TEST_DYNAMICARRAY_H
// DynamicArray.cpp

#include "../include/DynamicArray.h"

DynamicArray::DynamicArray(int arraySize) : size(arraySize)
{
    arr = new int[arraySize];
}

DynamicArray::~DynamicArray()
{
    delete[] arr;
    arr = nullptr;
}

bool DynamicArray::SetAt(int index, int value)
{
    if (index < 0 || index >= GetSize())
        return false;
    arr[index] = value;
    return true;
}

int DynamicArray::GetAt(int index) const
{
    return arr[index];
}

int DynamicArray::GetSize() const
{
    return size;
}

실행 결과는 다음과 같다.

arr[25] fail

SetAt() 함수에서는 잘못된 인덱스를 받았을 때 계속해서 작업을 진행하는 대신 자신을 호출한 곳에 문제가 발생했음을 알려주어야 한다. 그리고 전통적으로 이런 일은 함수의 반환 값을 통해서 이루어졌다.
하지만 이 방법은 몇 가지 문제점이 있기 때문에, C++에 새롭게 추가된 구조적인 예외 처리 방법을 적용하는 것이 옳다.

🚨 반환 값을 사용한 예외의 문제점

첫번째로 반환 값을 사용한 예외 처리를 사용하게 되면 함수를 호출할 때마다 매번 반환 값을 비교해야 하는 번거로움이 있다.

void UserArray(DynamicArray &arr)
{
    bool b;
    b = arr.SetAt(5, 100);
    if (!b)
        cout << "fail" << endl;

    b = arr.SetAt(8, 100);
    if (!b)
        cout << "fail" << endl;

    b = arr.SetAt(10, 100);
    if (!b)
        cout << "fail" << endl;
}

딱 3줄을 제외하고 나머지 줄은 모두 예외 처리와 관련된 코드다. 코드가 쓸데 없이 길어져서 읽기도 어렵고 이 함수에서 하려고 하는 일이 무엇인지 파악하기도 힘들다. 앞으로 나올 구조적인 예외 처리를 알면 이 함수를 보다 깔끔하게 리팩토링(Refactoring)할 수 있다.

다음 문제점은 함수가 이미 다른 용도로 반환 값을 사용하는 경우다. 예를 들어 GetAt() 함수는 반환 값을 사용해서 원소의 값을 반환하고 있다. 이 경우 부득이하게 반환 값을 사용해서 예외 처리를 해야 한다면 다음과 같이 함수의 원형을 바꾸는 수 밖에 없다.

bool GetAt(int index, int& value);

반환 값은 예외 상황을 알리는 데 사용하고 두 번째로 있는 레퍼런스 인자를 사용해서 원소의 값을 얻어 오는 것이다. 예외 처리 때문에 함수의 원형을 바꿔야 한다는 점도 문제지만 이렇게 레퍼런스 인자를 사용하는 것도 매운 불편한 일이다. 값을 얻어오기 위해서 매번 int 타입의 변수를 정의한 후에 GetAt() 함수에 넘겨줘야 하기 때문이다. 정리하면 다음과 같다.

  • 본연의 소스 코드와 예외 처리 코드가 뒤엉켜서 지저분하고 읽기 어렵다.
  • 예외 처리 때문에 반환 값을 본래의 용도로 사용할 수 없다.

이제 구조적 예외 처리를 사용해서 이 문제점들을 해결하는 동시에 보다 효율적으로 예외를 처리할 수 있는 방법도 알아보자.

구조적 예외 처리

구조적 예외 처리란 예외 처리의 한 방법이라고 생각하면 된다. 구조적 예외 처리는 이름에서 풍기는 것처럼 보다 계획적이면서 체계가 잡혀있는 예외 처리 방법이다.

일반적으로 C++이라는 전체 하에서 예외 처리 라고 말하면 구조적 예외 처리를 의미한다.

구조적 예외 처리를 위해서 C++에는 세 가지 키워드를 추가했다. 각각 throw, try, catch 이다.

// main.cpp

#include "include/DynamicArray.h"

#include <iostream>

using namespace std;

int main()
{
    DynamicArray arr(10);

    try
    {
        arr.SetAt(1, 100);
        arr.SetAt(5, 300);
        arr.SetAt(18, 500);
    }
    catch (const char *e)
    {
        cout << "예외 종류: " << e << endl;
    }
    catch (const std::out_of_range &e)
    {
        cout << "예외 종류: " << e.what() << endl;
    }

    return 0;
}
// DynamicArray.h

#ifndef TEST_DYNAMICARRAY_H
#define TEST_DYNAMICARRAY_H

class DynamicArray
{
  public:
    DynamicArray(int arraySize);
    ~DynamicArray();

    void SetAt(int index, int value);
    int GetAt(int index) const;
    int GetSize() const;

  protected:
    int *arr;
    int size;
};

#endif // TEST_DYNAMICARRAY_H
// DynamicArray.cpp

#include "../include/DynamicArray.h"

#include <iostream>

DynamicArray::DynamicArray(int arraySize) : size(arraySize)
{
    arr = new int[arraySize];
}

DynamicArray::~DynamicArray()
{
    delete[] arr;
    arr = nullptr;
}

void DynamicArray::SetAt(int index, int value)
{
    if (index < 0 || index >= GetSize())
        throw std::out_of_range("out of range.");
    arr[index] = value;
}

int DynamicArray::GetAt(int index) const
{
    if (index < 0 || index >= GetSize())
        throw std::out_of_range("out of range.");
    return arr[index];
}

int DynamicArray::GetSize() const
{
    return size;
}

결과는 아래와 같다.

예외 종류: out of range.

throw는 예외를 던지는 명령이다. 예외라고 하면 매우 추상적인 말이지만 여기서는 예외 상황을 알릴 수 있는 값을 의미한다. 예제에서는 std::exception을 상속받은 클래스인 out_of_range를 던졌다.
const char*를 던지지 않고, out_of_range를 던진 이유는 C++에서 const char* 타입의 예외를 던지는 것을 권장하지 않기 때문이다.

C++에서 예외는 일반적으로 std::exception을 상속받는 클래스여야 하며, 그렇지 않으면 일관된 예외 처리가 어려워진다.

문자열을 던지는 것이 왜 문제인가?

  • 비표준 예외 타입
    • std::exception에서 파생되지 않은 예외를 던지면, 표준적인 방식으로 예외를 처리하기 어렵다.
  • 제한된 정보
    • const char*는 단순히 문자열 메시지일 뿐, 오류 코드나 예외의 유형 등 추가적인 정보를 포함하지 않는다.

try, catch는 항상 짝을 이뤄서 사용하는데, catch 키워드부터 살펴보자. catch 키워드와 중괄호로 이루어지는 블록이 바로 예외를 받는 곳이 된다. catch 블럭은 오직 한 가지 타입의 값만 받을 수 있다.

try는 예외가 던져지는 범위를 지정하는 역할을 한다. 다시 말해 try 블럭안에서 발생하는 예외만 이어지는 catch 블럭에 잡힌다.

예외가 던져지면 DynamicArray::SetAt() 함수는 바로 실행을 종료한다. 그리고 실행의 흐름은 catch 블럭으로 이동한다. 이 때 예외도 전달하는데, 함수에 인자를 전달하는 것과 비슷하게 다음과 같은 가상의 코드를 실행한다고 생각할 수 있다.

const std::out_of_range &e = <던져진 객체>;

맨 처음 예제와 비교하면 가장 중요한 차이점은 이 함수에서 하는 일이 명확하게 보인다는 것이다. 그러기에 소스 코드를 읽기도 수월하다. 예외 처리와 관련된 코드도 잘 분리되어 있어서 지저분하지 않고 그 양도 많이 줄어들었다.
다음으로 SetAt(), GetAt() 함수는 더 이상 예외 처리를 위해 반환 값을 정의할 필요가 없어졌다.

throw에 의해서 예외가 던져지면, 그 함수는 바로 종료된다는 사실을 알 수 있다. 또한 throw에 의해서 던져진 예외는 함수를 뛰어넘어서까지 전달된다는 점도 알 수 있다.

예외 객체의 사용

기본 타입 값 대신에 객체를 예외로써 던지는 것도 가능한데, 이렇게 하는 것은 많은 장점이 있다.

실제 현장에서도 기본 타입의 값을 예외로 던지는 경우는 거의 없고 대부분 객체를 던진다.

객체를 던지는 것이 어떤 장점을 있을까?

한 마디로 말하자면 다양한 정보 전달 이다.

객체를 예외로 던지는 것의 첫번째 장점은 다양한 정보를 전달할 수 있다는 점이다. 객체를 던질 때는 객체의 멤버 변수들이 모두 던져지는 것이므로 필요한 만큼 멤버 변수를 만들어서 사용할 수 있다.

// MyException.h

#ifndef MYEXCEPTION_H
#define MYEXCEPTION_H

#include <exception>

class MyException : public std::exception
{
public:
    MyException(const void *sender, const char *description, int info) :
        sender(sender), description(description), info(info)
    {
    }

    const void *GetSenderAddr() const
    {
        return sender;
    }

    const char *GetDescription() const
    {
        return description;
    }

    int GetInfo() const
    {
        return info;
    }

protected:
    const void *sender;
    const char *description;
    int info;
};


#endif // MYEXCEPTION_H
// DynamicArray.h

#ifndef DYNAMICARRAY_H
#define DYNAMICARRAY_H


class DynamicArray
{
public:
    DynamicArray(const int);
    ~DynamicArray();
    int GetSize() const;
    void SetAt(const int, const int);
    int GetAt(const int) const;

private:
    int *arr;
    int size;
};
// DynamicArray.cpp

#include "DynamicArray.h"
#include "MyException.h"

DynamicArray::DynamicArray(const int size) : size(size)
{
    arr = new int[size];
}

DynamicArray::~DynamicArray()
{
    delete[] arr;
    arr = nullptr;
    size = 0;
}

int DynamicArray::GetSize() const
{
    return size;
}

void DynamicArray::SetAt(const int index, const int value)
{
    if (index < 0 || index >= GetSize())
        // 생성자를 사용해 임시 객체 생성 후 던진다.
        throw MyException(this, "out of range!", index);

    arr[index] = value;
}

int DynamicArray::GetAt(const int index) const
{
    if (index < 0 || index >= GetSize())
        throw MyException(this, "out of range!", index);

    return arr[index];
}
// main.cpp

#include "DynamicArray.h"
#include "MyException.h"

#include <iostream>

using namespace std;

void UseArray(DynamicArray &arr1, DynamicArray &arr2);

int main()
{
    // 배열 객체 2개 생성
    DynamicArray arr1(10);
    DynamicArray arr2(8);

    UseArray(arr1, arr2);

    return 0;
}

void UseArray(DynamicArray &arr1, DynamicArray &arr2)
{
    try
    {
        arr1.SetAt(5, 100);
        arr2.SetAt(5, 100);

        arr1.SetAt(8, 100);
        arr2.SetAt(8, 100);

        arr1.SetAt(10, 100);
        arr2.SetAt(10, 100);
    }
    catch (const MyException &e)
    {
        // 두 배열의 주소 출력
        cout << "&arr1 = " << &arr1 << endl;
        cout << "&arr2 = " << &arr2 << endl;

        cout << "instance address: " << e.GetSenderAddr() << endl;
        cout << "description: " << e.GetDescription() << endl;
        cout << "information: " << e.GetInfo() << endl;
    }
}

결과는 다음과 같다.

&arr1 = 0xdd8abff710
&arr2 = 0xdd8abff700
instance address: 0xdd8abff700
description: out of range!
information: 8

MyException 클래스의 정의를 보면, 예외를 발생시킨 객체의 주소와 예외를 설명하는 문자열 그리고 부가 정보를 보관할 수 있게 해두었다.

MyException 객체를 예외로 던지면, 이 정보들이 모두 던져지는 셈이다. 그렇기 때문에 예외를 받은 곳에서는 이 정보들을 사용해서 보다 구체적으로 예외를 파악할 수 있다.

UsingArray() 함수의 catch 블럭에서는 arr1arr2의 주소를 출력한 다음에 예외 객체의 멤버 변수들도 출력한다. 여기에는 예외를 던진 객체의 주소도 포함되어 있기 때문에 arr1arr2 중에서 어떤 객체가 예외를 던졌는지 알 수 있다. 또한 예외 객체의 info 멤버에는 예외가 발생할 당시의 인덱스가 담겨있기 때문에 try 블럭 안의 어떤 코드에서 예외가 발생했는지도 알 수 있다.

다형성을 사용해서 일관되게 관리하는 법

객체를 예외로 던질 때도 다형성 (Polymorphism)을 사용할 수 있다. 다음 예제에는 MyException 클래스를 상속 받은 2개의 예외 클래스를 추가했다.

// MyException.h

// 인덱스와 관련된 예외
class OutOfRangeException : public MyException
{
public:
    OutOfRangeException(const void *sender, const int index) : MyException(sender, "out of range!", index)
    {
    }
};


// 메모리와 관련된 예외
class MemoryException : public MyException
{
public:
    MemoryException(const void *sender, const int bytes) : MyException(sender, "out of memory!", bytes)
    {
    }
};
// DynamicArray.cpp

void DynamicArray::SetAt(const int index, const int value)
{
    if (index < 0 || index >= GetSize())
        throw OutOfRangeException(this, index);

    arr[index] = value;
}

int DynamicArray::GetAt(const int index) const
{
    if (index < 0 || index >= GetSize())
        throw OutOfRangeException(this, index);

    return arr[index];
}
// main.cpp

... 생략

void UseMemory();

int main()
{
    // 배열 객체 2개 생성
    DynamicArray arr1(10);
    DynamicArray arr2(8);

    UseArray(arr1, arr2);

    return 0;
}

void UseMemory()
{
    // 1000바이트를 할당하려다 실패했다고 가정
    throw MemoryException(nullptr, 1000);
}


void UseArray(DynamicArray &arr1, DynamicArray &arr2)
{
    try
    {
        arr1.SetAt(5, 100);
        arr2.SetAt(5, 100);

        UseMemory();

        arr1.SetAt(8, 100);
        arr2.SetAt(8, 100);

        arr1.SetAt(10, 100);
        arr2.SetAt(10, 100);
    }
    // MyException 예외를 받는다.
    // 객체를 예외로 받을 때는 레퍼런스 타입으로 받는 것이 좋다.
    catch (const MyException &e)
    {
        cout << "description: " << e.GetDescription() << endl;
    }
}

catch 블럭에서 MyException & 타입의 예외를 받을 수 있게 되어 있다. 이렇게 하면 MyException, OutOfRangeException, MemoryException 객체 모두를 받을 수 있다. 자식 객체를 부모 클래스 타입의 레퍼런스로 가리킬 수 있기 때문이다. 다형성의 편리함을 다시 한번 느끼는 부분이다.

구조적 예외 처리와 관련된 규칙

예외는 함수를 여러 개 건너서도 전달할 수 있다.

#include <iostream>

using namespace std;

void A();
void B();
void C();

int main()
{
    try
    {
        A();
    }
    catch (const int e)
    {
        cout << "except: " << e << endl;
    }

    return 0;
}

void A()
{
    cout << "begin, A()" << endl;
    B();
    cout << "end, A()" << endl;
}

void B()
{
    cout << "begin, B()" << endl;
    C();
    cout << "end, B()" << endl;
}

void C()
{
    cout << "begin, C()" << endl;
    throw 337;
    cout << "end, C()" << endl;
}

결과는 다음과 같다.

begin, A()
begin, B()
begin, C()
except: 337

위의 예제를 보면 3개의 함수를 건너 뛰어서 예외를 전달하는 것을 확인할 수 있다.

main() -> A() -> B() -> C() 의 순서로 함수를 호출했다. 그리고 C()에서는 정수 값을 예외로 던진다.
이 예외가 던져진 순간 C() 함수가 종료된다. 이어서 B(), A() 함수도 차례로 종료된다. 그리고 main() 함수의 catch 블럭으로 실행 흐름이 이동된다.

이렇게 예외는 자신의 타입에 맞는 catch 블럭을 찾을 때까지 함수를 거슬러 올라간다.

그런데 만약 main() 함수까지 갔는데도 알맞은 catch 블럭을 찾을 수 없다면 어떻게 될까?

이 경우에는 프로그램이 비정상 종료되어버린다. main() 함수에서는 반드시 모든 예외를 잡아주어야 한다. 그 예외가 예상할 수 있는 것이 아니라 할지라도 프로그램이 비정상 종료하게 내버려 둘 수는 없기 때문이다.

예외 다시 던지기

void A() {
	try {
    	B();
    }
    catch (char c)
    {
    	cout << "A() 함수에서 잡은 예외: " << c << endl;
        
        // 받을 예외를 그대로 다시 던진다. 또 다시 함수를 거슬러 올라가면서 알맞은 catch 블럭을 찾게 된다.
        throw;
    }
}

catch 블럭 안에서 throw 라고만 적어주면 받을 예외를 다시 던지게 된다.

일반적으로 예외를 다시 던지는 이유는 이 예외를 자신이 받아서 처리했지만 외부에도 예외 상황을 알릴 필요가 있기 때문에다.

catch가 여럿인 이유

하나의 try 블럭에는 여러 개의 catch 블럭이 따라올 수 있다. 그리고 각 catch 블럭은 서로 다른 타입의 예외를 받게 된다.

#include <iostream>
#include "MyException.h"

using namespace std;

void A();

int main()
{
    try
    {
        A();
    }
    catch (const MemoryException &e)
    {
        cout << "catch, MemoryException" << endl;
    }
    catch (const OutOfRangeException &e)
    {
        cout << "catch, OutOfRangeException" << endl;
    }
    catch (...)
    {
        // 그 밖의 타입
        cout << "other errors..." << endl;
    }

    return 0;
}

void A()
{
    throw OutOfRangeException(nullptr, 0);
}

결과는 다음과 같다.

catch, OutOfRangeException

마지막 catch (...)모든 타입예외를 받는다. 앞의 두 catch문 모두 예외를 받을 수 없는 경우에는 이 catch 블럭이 실행된다.
catch 블럭이 반드시 있어야 하는 것은 아니지만 나머지 모든 예외를 받고 싶은 경우에 매우 유용하게 사용할 수 있다.

try 블럭 안에서 예외가 던져지면 제일 앞에 있는 catch 블럭부터 시작해서 예외를 받을 수 있는지 비교한다. 만약 타입이 일치한다면 해당 catch 블럭을 실행한다.

// 잘못된 catch 블럭
catch(MyException &e) {
	cout << "MyException catch!" << endl;
}
catch(OutOfRangeException &e) {
	cout << "OutOfRangeException catch!" << endl;
}
catch(...) {
	cout << "그 밖의 모든 예외" << endl;
}

만약 위와 같이 작성했다면 OutOfRangeException catch 블럭은 영원히 실행될 수 없다. 다형성 때문에 OutOfRangeException 객체는 부모 타입인 MyException처럼 다룰 수 있기 때문이다.

또 다른 예로 catch(...) 블럭이 맨 앞에 오는 경우도 마찬가지다. 이 블럭이 모든 예외를 받을 것이기 때문에 뒤에 오는 어떤 catch 블럭도 예외를 받을 수 있다.

예외 객체는 레퍼런스로 받자

객체를 예외로 던지고 받을 때는 크게 세 가지 경우를 생각해볼 수 있는데 각각 객체, 포인터, 레퍼런스를 사용하는 경우다. 이 중에서 레퍼런스를 사용하는 것이 가장 바람직하다. 왜 그런지 알아보자.

객체를 예외로 던지고 받는 경우

try {
	MyException e(this, "test", 0);
    throw e;
}
// 던져진 객체가 e에 복사되면서 복사 생성자 호출
catch(MyException e) {
	...
}

위의 경우의 문제점은 불필요한 복사발생한다는 것이다. catch 블럭이 시작되면서 다음과 같은 가상의 코드를 실행한다고 생각할 수 있는데, 이 때 MyException 클래스의 복사 생성자가 실행되면서 멤버들을 1:1로 복사한다.

MyException e = e;

반면에 포인터레퍼런스를 사용하는 경우에는 불필요한 복사가 발생하지 않는다. 이번에는 포인터를 던지고 받는 경우를 보자.

객체의 포인터를 던지고 받는 경우

try {
	MyException *p = new MyException(this, "test", 0);
    throw p;
}
catch(MyException *pe) {
	cout << pe->GetDescription() << endl;
    delete pe;
}

포인터를 던지고 받을 때는 불필요한 복사가 일어나지 않는다. 하지만 메모리 할당과 해제를 신경 써야 하는 단점이 있다.

객체를 예외로 던지고 레퍼런스로 받는 경우

try {
	MyException e(this, "test", 0);
    throw e;
}
catch(MyException &e) {
	cout << e.GetDescription() << endl;
}

이 경우에는 catch 블럭의 시작에서 다음과 같은 가상의 코드가 실행된다고 생각해볼 수 있다.

MyException &e = e;

레퍼런스를 사용한 경우에는 불필요한 복사가 발생하지 않는다. 또한 메모리 관리를 신경쓸 필요도 없다. 그래서 객체를 던지고 레퍼런스로 받는 방법이 제일 유용하다.

📌 예외를 받을 때는, 되도록 레퍼런스를 사용하자!

C++에서 제공하는 예외 클래스

C++에서는 많은 예외 클래스들을 제공하는데, 그 클래스들은 모두 exception 클래스를 부모로 가지고 있다.

클래스 이름요약포함된 파일
exception최상위 예외 클래스<exception>
bad_allocnew 연산자가 메모리 할당에 실패했을 경우<new>
bad_castdynamic_cast 연산자가 형 변환에 실패했을 경우<typeinfo>
invalid_argument잘못된 인자를 입력한 경우<stdexcept>
length_error제한된 길이를 넘어섰을 경우. 예를 들어 10개의 노드만 가질 수 있게 되어 있는 링크드-리스트에 새 노드를 추가하려고할 경우<stdexcept>
overflow_error오버플러우가 발생한 경우<stdexcept>
range_error정해진 범위를 넘어섰을 경우. 예를 들어 원소가 10개인 배열에게 11번째 원소를 요구한 경우<stdexcept>

이 클래스들을 직접 사용하거나 이 클래스들을 상속받아 사용할 수 있다. 이 클래스들을 사용할 때는 표의 가장 오른쪽 열에 나오는 헤더 파일포함(include)시켜야 한다.

#include <exception>
#include <string>

class MyException : public exception {
public:
	MyException(const string& msg) : _str(msg) { }
    
    virtual ~MyException() { }
    
    virtual const char* what() const override {
    	return _str.c_str();
    }

protected:
	string _str;
};

0개의 댓글