Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
05-17 00:00
관리 메뉴

nomad-programmer

[Programming/C++] 예외 처리 본문

Programming/C++

[Programming/C++] 예외 처리

scii 2023. 1. 21. 18:36

예외 처리가 프로와 아마추어를 구분하는 기준이 될 수 있다는 점을 기억하자. 

반환 값을 사용한 예외 처리

다음의 예제를 살펴보자.

// DynamicArray.h

#pragma once

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

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

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

#include "DynamicArray.h"

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

    size = arraySize;
}

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

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

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

int DynamicArray::GetSize() const {
    return size;
}
// main.cpp

#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;
}

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

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

bool GetAt(int index, int& value);

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

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

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


구조적 예외 처리

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

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

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

// DynamicArray.cpp

void DynamicArray::SetAt(int index, int value) {
    // 인덱스의 범위가 맞지 않으면 예외를 던진다.
    if (0 > index || index <= GetSize())
        throw "out of range.";

    arr[index] = value;
}

int DynamicArray::GetAt(int index) const {
    // 인덱스의 범위가 맞지 않으면 예외를 던진다.
    if (0 > index || index <= GetSize())
        throw "out of range.";

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

#include "DynamicArray.h"

#include <iostream>

using namespace std;


int main() {
    DynamicArray arr(10);

    try {
        arr.SetAt(1, 100);
        arr.SetAt(5, 300);
        arr.SetAt(8, 500);
    }
    catch (const char* ex) {
        cout << "예외 종류: " << ex << endl;
    }

    return 0;
}


// 결과
예외 종류: out of range.

throw는 예외를 던지는 명령이다. 예외라고 하면 매우 추상적인 말이지만 여기서는 예외 상황을 알릴 수 있는 값을 의미한다. 예제에서는 문자열 리터럴을 던졌는데 문자열 리터럴은 const char* 타입의 값일 뿐이다. 그냥 정수를 하나 던져도 되고 객체를 하나 던져도 된다.

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

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

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

const char* ex = "out of range.";

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

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


예외 객체의 사용

기본 타입 값 대신에 객체를 예외로써 던지는것도 가능한데 이렇게 하는 것은 많은 장점이 있다. 실제 현장에서도 기본 타입의 값을 예외로 던지는 경우는 거의 없고 대부분 객체를 던진다. 객체를 던지는 것이 어떤 장점을 가지는지 알아보자.

다양한 정보를 전달

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

// MyException.h

#pragma once

class MyException
{
public:
    MyException(const void* sender, const char* description, int info) {
        this->sender = sender;
        this->description = description;
        this->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;                   // 부가 정보
};
// DynamicArray.cpp

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

DynamicArray::DynamicArray(const int size) {
    arr = new int[size];
    this->size = 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 (0 > index || index >= GetSize())
        // 생성자를 사용해 임시 객체 생성 후 던진다.
        throw MyException(this, "out of range!", index);

    arr[index] = value;
}

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

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

#pragma warning(disable: 4996)

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

#include <iostream>

using namespace std;

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

int main(void) {
    // 배열 객체 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);
    }
    // MyException& 타입의 예외를 받는다. 
    // 객체를 예외로 받을 때는 레퍼런스 타입으로 받는 것이 좋다.
    catch (const MyException& ex) {
        // 두 배열의 주소 출력
        cout << "&arr1 = " << &arr1 << endl;
        cout << "&arr2 = " << &arr2 << endl;

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


// 결과
&arr1 = 0000007162CFF7C8
&arr2 = 0000007162CFF7F8
instance address: 0000007162CFF7F8
description: out of range!
information: 8

MyException 클래스의 정의를 보면 예외를 발생시킨 객체의 주소와 예외를 설명하는 문자열 그리고 부가 정보를 보관할 수 있게 해두었다. MyException 객체를 예외로 던지면 이 정보들이 모두 던져지는 셈이다. 그렇기 때문에 예외를 받은 곳에서는 이 정보들을 사용해서 보다 구체적으로 예외를 파악할 수 있다.

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


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

객체를 예외로 던질 때도 다형성을 사용할 수 있다. 다음 예제에는 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 {
    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 (0 > index || index >= GetSize())
        throw OutOfRangeException(this, index);

    arr[index] = value;
}

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

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

... 생략

void UseMemory();

int main(void) {
    // 배열 객체 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& ex) {
        // OutOfRangeException과 MemoryException 모두 여기서 잡을 수 있다.
        cout << "description: " << ex.GetDescription() << endl;
    }
}

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


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

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

#pragma warning(disable: 4996)

#include <iostream>

using namespace std;

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

int main(void) {
    try {
        A();
    }
    catch (int ex) {
        cout << "except: " << ex << 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 블럭은 서로 다른 타입의 예외를 받게 된다.

#pragma warning(disable: 4996)

#include "MyException.h"

#include <iostream>

using namespace std;

void A();

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

    return 0;

}

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


// 결과
catch, OutOfRangeException.

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

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

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

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

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


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

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

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

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

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

MyException ex = e;

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

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

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

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

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

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

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

MyException& ex = e;

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


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

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

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

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

#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;
};
Comments