모두의 코드
씹어먹는 C ++ - <12. C++ 에서 예외 처리>

이번 강좌에서는

안녕하세요 여러분! 오래 간만에 인사 드립니다. 이번 강좌에서는 C++ 에서 예외 처리를 어떠한 방식으로 하는지에 대해 알아보도록 하겠습니다.

예외란?

우리가 이상적인 세상에서 살고 있다면, 그 어떤 예외적인 상황도 없을 것입니다. 프로그램 혹은 라이브러리 사용자들은 언제나 올바른 값을 입력값으로 줄 것이고, 컴퓨터 역시 무한한 자원을 사용할 수 있어서 어떠한 상황에서도 데이터들을 정상적으로 처리할 수 있을 것입니다.

하지만, 안타깝게도 이 세상은 그리 녹록하지 않습니다. 사람들은 실수를 하기 마련이고, 컴퓨터 역시 언제나 프로그램에 필요한 자원을 제공할 수 있는 것이 아닙니다. 예를 들어서 아래와 같은 vector 의 사용 예시를 살펴봅시다.

std::vector<int> v(3);  // 크기가 3 인 벡터 만듦
std::cout << v.at(4);   // ??

위 경우, 크기가 3 인 vector 를 만들었지만 4 번째 원소를 요청하고 있습니다. 위와 같은 코드는 문법 상 아무 문제가 없는 코드이지만, 막상 실행하게 되면 오류가 발생하게 됩니다.

다른 예로 아래와 같이 큰 메모리를 할당하는 경우를 생각해봅시다.

std::vector<int> v(1000000000);
// ?

여러분이 사용하는 대부분의 시스템의 경우 위와 같이 큰 메모리를 할당할 수 없습니다. 따라서, 위 코드 역시 문법 상 틀린 것이 없는 코드 이지만, 실제로 실행해보면 오류가 발생하게 됩니다.

이렇게 정상적인 상황에서 벗어난 모든 예외적인 상황들을 예외(exception) 이라고 부릅니다.

기존의 예외 처리 방식

C 언어에서는 언어 차원에서 제공하는 예외 처리 방식이라는 것이 딱히 따로 존재하지 않았습니다. 따라서 아래와 같이, 어떤 작업을 실행한 뒤에 그 결과값을 확인하는 방식으로 처리하였습니다. 예를 들어서 아래 malloc 으로 메모리를 동적으로 할당하는 경우를 생각해봅시다.

char *c = (char *)malloc(1000000000);
if (c == NULL) {
  printf("메모리 할당 오류!");
  return;
}

malloc 의 경우 메모리 할당 실패시에 NULL 을 리턴하므로, 위와 같이 cNULL 인지 확인함으로써 예외적인 상황을 처리할 수 있었습니다.

하지만 이러한 방식으로 예외를 처리한다면, 함수가 깊어지면 깊어질 수 록 꽤나 귀찮게 됩니다. 예를 들어서 아래와 같은 예시를 살펴보세요.

bool func1(int *addr) {
  if (func2(addr)) {
    // Do something
  }
  return false;
}
bool func2(int *addr) {
  if (func3(addr)) {
    // Do something
  }
  return false;
}
bool func3(int *addr) {
  addr = (int *)malloc(100000000);
  if (addr == NULL) return false;
  return true;
}
int main() {
  int *addr;
  if (func1(addr)) {
    // 잘 처리됨
  } else {
    // 오류 발생
  }
}

위 코드의 경우 func3 에서 '예외가 발생할 수 있는 작업' 을 수행하는데, 만약에 예외가 발생하게 된다면 false 를 리턴하게 되고, 잘 처리 되었다면 true 를 리턴합니다.

여기까지는 좋은데, 문제는 이 func3func2 에서 호출되고, 다시 func2func1 에서 호출되고, func1main 에서 호출된다는 점입니다. 만약에 main 의 입장에서 func3 에서 문제가 발생했을 때 이를 캐치하기 위해서는, 각각의 함수들에서 처리 결과를 모두 리턴해야 할 것입니다.

위 코드는 예외가 func3 에서만 발생해서 간단하였지만, 만약에 func2 도 어떤 다른 작업을 해서 예외를 발생시킬 수 있다면 어떻게 해야 할까요? 상당히 골치 아픈 일입니다.

하지만 다행이도 C++ 에서는 위와 같은 불편한 예외 처리 방식을 획기적으로 해결시켰습니다.

예외 발생시키기 - throw

C 언어에서는 예외가 발생했을 때, 다른 값을 리턴하는 것으로 예외를 처리하였지만, C++ 에서는 예외가 발생하였다는 사실을 명시적으로 나타낼 수 있습니다. 바로 throw 문을 사용하면 됩니다.

예를 들어서 아래와 같이 매우 간단한 vector 클래스를 생각해봅시다.

template <typename T>
class Vector {
 public:
  Vector(size_t size) : size_(size) {
    data_ = new T[size_];
    for (int i = 0; i < size_; i++) {
      data_[i] = 3;
    }
  }
  const T& at(size_t index) const {
    if (index >= size_) {
      throw out_of_range("vector 의 index 가 범위를 초과하였습니다.");
    }
    return data_[index];
  }
  ~Vector() { delete[] data_; }

 private:
  T* data_;
  size_t size_;
};

만들어진 vector 의 요청한 위치에 있는 원소를 리턴하는 함수인 at 함수를 생각해봅시다

인자로 전달된 index 가 범위 이내라면, 간단하게 data[index] 를 리턴하면 되겠지만, 범위 밖이라면 어떻게 해야 할까요?

문제는 at 함수가 const T& 를 리턴하기 때문에, 따로 '오류 메세지' 를 리턴할 수 없다는 점입니다. 하지만 C++ 에서는 다음과 같이 예외가 발생하였음을 명시적으로 알릴 수 있습니다.

// 생략 ...
const T& at(size_t index) const {
  if (index >= size) {
    // 예외를 발생시킨다!
    throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");
  }
  return data[index];
}
// 생략 ...
}
;

먼저, 예외를 발생시키는 부분을 자세히 살펴보겠습니다.

throw std::out_of_range("vector 의 index 가 범위를 초과하였습니다.");

C++ 에는 예외를 던지고 싶다면, throw 로 예외로 전달하고 싶은 객체를 써주면 됩니다. 예외로 아무 객체나 던져도 상관 없지만, C++ 표준 라이브러리에는 이미 여러가지 종류의 예외들이 정의되어 있어서 이를 활용하는 것도 좋습니다. 예를 들어서, 위 경우 out_of_range 객체를 throw 합니다. C++ 표준에는 out_of_range 외에도 overflow_error, length_error, runtime_error 등등 여러가지가 정의되어 있고 표준 라이브러리에서 활용되고 있습니다.

이렇게 예외를 throw 하게 되면, throw 한 위치에서 즉시 함수가 종료되고, 예외 처리하는 부분까지 점프하게 됩니다. 따라서 throw 밑에 있는 모든 문장은 실행되지 않습니다. 한 가지 중요한 점은 이렇게 함수에서 예외 처리하는 부분에 도달하기 까지 함수를 빠져나가면서, stack 에 생성되었던 객체들을 빠짐없이 소멸시켜 준다는 점 입니다. 따라서 예외가 발생하여도 사용하고 있는 자원들을 제대로 소멸시킬 수 있습니다! (소멸자만 제대로 작성하였다면)

예외 처리 하기 - try 와 catch

그렇다면 이렇게 발생한 에외를 어떻게 처리할까요?

#include <iostream>
#include <stdexcept>

using namespace std;

template <typename T>
class Vector {
 public:
  Vector(size_t size) : size_(size) {
    data_ = new T[size_];
    for (int i = 0; i < size_; i++) {
      data_[i] = 3;
    }
  }
  const T& at(size_t index) const {
    if (index >= size_) {
      throw out_of_range("vector 의 index 가 범위를 초과하였습니다.");
    }
    return data_[index];
  }
  ~Vector() { delete[] data_; }

 private:
  T* data_;
  size_t size_;
};
int main() {
  Vector<int> vec(3);

  int index, data = 0;
  cin >> index;

  try {
    data = vec.at(index);
  } catch (out_of_range& e) {
    cout << "예외 발생 ! " << e.what() << endl;
  }
  // 예외가 발생하지 않았다면 3을 이 출력되고, 예외가 발생하였다면 원래 data 에
  // 들어가 있던 0 이 출력된다.
  cout << "읽은 데이터 : " << data << endl;
}

성공적으로 컴파일 하였다면

1 입력 시에 읽은 데이터로 3 이 나오고, 4 입력 시에 예외가 발생하며 읽은 데이터로 0 이 나온다.

와 같이 나옵니다.

위에서도 볼 수 있듯이, 범위에 벗어난 값 (위 경우 3 이상) 을 입력하게 되었다면, 범위를 초과하였다는 메세지를 볼 수 있습니다. 그렇다면, 예외가 어떤 식으로 처리되었는지 살펴봅시다.

try {
  data = vec.at(index);
}

먼저 try 부분 입니다. try 안에서 무언가 예외가 발생할만한 코드가 실행 됩니다. 만약에 예외가 발생하지 않았다면 마지 try .. catch 부분이 없는 것과 동일하게 실행 됩니다. data 에는 vecindex 번째 값이 들어가고 밑에 있는 catch 문은 무시 됩니다.

반면에 예외가 발생할 경우 이야기가 달라집니다. 예외가 발생하게되면, 그 즉시 stack 에 생성된 모든 객체들의 소멸자들이 호출되고, 가장 가까운 catch 문으로 점프합니다. 따라서, 위 경우

if (index >= size_) {
  throw out_of_range("vector 의 index 가 범위를 초과하였습니다.");
}

throw 다음으로 실행되는 문장이 바로

catch (out_of_range& e) {
  cout << "예외 발생 ! " << e.what() << endl;
}

catch 부분이 됩니다. 여기서 catch 문은 throw 된 예외를 받는 부분인데, 어떤 예외를 받냐면, catch 문 안에 정의된 예외의 꼴에 맞는 객체를 받게 됩니다. 우리의 Vector 의 경우 out_of_rangethrow 하였는데, 위 catch 문이 out_of_range 를 받으므로, 잘 받을 수 있습니다.

out_of_range 클래스는 아주 간단한데, 그냥 내부에 발생엔 예외에 관한 내용을 저장하는 문자열 필드가 달랑 하나 있고 이 역시 what() 함수로 그 값을 들여다 볼 수 있습니다. 위 경우 우리가 전달한 문장인 'vectorindex가 범위를 초과하였습니다' 가 나오게 됩니다.

스택 풀기 (stack unwinding)

앞서 throw 를 하게 된다면, 가장 가까운 catch 로 점프한다고 하였습니다. 이 말의 뜻이 무엇인지 아래 예제로 살펴봅시다.

#include <iostream>
#include <stdexcept>

using namespace std;

class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { cout << "리소스 해제 : " << id_ << endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  throw std::runtime_error("Exception from 3!\n");
}
int func2() {
  Resource r(2);
  func3();
  cout << "실행 안됨!" << endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  cout << "실행 안됨!" << endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (exception& e) {
    cout << "Exception : " << e.what();
  }
}

성공적으로 실행하였으면

리소스 잘 해제됨

와 같이 나옵니다.

먼저 살펴보아야할 부분으로,

int func3() {
  Resource r(3);
  throw std::runtime_error("Exception from 3!\n");
}

에서 보시다싶이, func3 함수에서 예외를 발생시키고 있습니다. 그런데, 이 func3func2 가 호출하고, func2func1 이 호출하고, 마지막으로 func1main 에서 호출됩니다.

앞에서 말했듯이 예외가 발생하게 되면 가장 가까운 catch 에서 예외를 받는다고 하였습니다. 그런데, func1, 2 모두 예외를 받는 catch 구문이 없습니다. 따라서, 가장 가까운 catch 부분은, main 함수에 있는 catch 구문이 되고, 실제로도 예외가 main 함수에까지 잘 전달되어서 출력되었습니다.

또 한 가지 중요한 점은, 예외가 전파되면서 각 함수들에 정의되어 있던 객체들이 잘 소멸되었다는 점입니다.

먄약에 예외가 발생하지 않았을 경우 어떻게 나오게 되냐면

#include <iostream>
#include <stdexcept>

using namespace std;

class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { cout << "리소스 해제 : " << id_ << endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  return 0;
}
int func2() {
  Resource r(2);
  func3();
  cout << "실행!" << endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  cout << "실행!" << endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (exception& e) {
    cout << "Exception : " << e.what();
  }
}

성공적으로 컴파일 하였다면

리소스 잘 해제되고 실행도 잘 됨

와 같이 나옵니다.

위와 비교해보면, 정상적인 상황에서는 객체의 소멸자들은 함수가 종료될 때 호출되므로 "실행!" 이 먼저 출력되고, 그 뒤에 리소스 해제 되었다는 문장이 출력됩니다.

반면에 예외가 전파되는 과정에서는 바로 catch 부분으로 점프 하면서, 각 함수들에 있던 객체들만 해제하기 때문에 리소스 해제 되었다는 것은 정상적으로 출력되지만, 그 "실행 안됨!" 부분은 실행되지 않습니다.

이와 같이 catch 로 점프 하면서 스택 상에서 정의된 객체들을 소멸시키는 과정을 스택 풀기(stack unwinding) 이라고 부릅니다.

여러 종류의 예외 받기

앞서 catch 는 여러 종류의 예외들을 받을 수 있다고 하였습니다. 이를 위해선, 한 개의 try 안에 받고자 하는 모든 종류의 예외를 catch 문으로 주렁 주렁 달면 됩니다. 아래 예제를 보실까요.

#include <iostream>
#include <string>

using namespace std;

int func(int c) {
  if (c == 1) {
    throw 10;
  } else if (c == 2) {
    throw string("hi!");
  } else if (c == 3) {
    throw 'a';
  } else if (c == 4) {
    throw "hello!";
  }
  return 0;
}

int main() {
  int c;
  cin >> c;

  try {
    func(c);
  } catch (char x) {
    cout << "Char : " << x << endl;
  } catch (int x) {
    cout << "Int : " << x << endl;
  } catch (string& s) {
    cout << "String : " << s << endl;
  } catch (const char* s) {
    cout << "String Literal : " << s << endl;
  }
}

성공적으로 컴파일 하였다면

리소스 잘 해제되고 실행도 잘 됨

와 같이 나옵니다.

마치 switch 문 처럼 catch 역시 여러 종류의 throw 된 객체를 모두 받을 수 있습니다. 위 경우,

catch (char x) {
  cout << "Char : " << x << endl;
}
catch (int x) {
  cout << "Int : " << x << endl;
}
catch (string& s) {
  cout << "String : " << s << endl;
}
catch (const char* s) {
  cout << "String Literal : " << s << endl;
}

첫번째 catch 문에서는 char 형 값을, 두 번째에서는 int 형 값을, 세 번째 에서는 string 객체를, 마지막에서는 const char* 형 값을 받게 됩니다. 실제로도 각기 다른 값들을 throw 하였을 때, 작동하는 catch 가 달라지는 것을 확인할 수 있습니다.

또한 한 가지 흥미로운 점은, 부모 클래스와 자식 클래스의 경우 처리하는 방식입니다.

#include <iostream>
#include <string>
using namespace std;

class Parent {
 public:
  virtual void who() const { cout << "Parent!" << endl; }
};

class Child : public Parent {
 public:
  virtual void who() const { cout << "Child!" << endl; }
};

int func(int c) {
  if (c == 1) {
    throw Parent();
  } else if (c == 2) {
    throw Child();
  }
  return 0;
}

int main() {
  int c;
  cin >> c;

  try {
    func(c);
  } catch (Parent& p) {
    cout << "Parent Catch!" << endl;
    p.who();
  } catch (Child& c) {
    cout << "Child Catch!" << endl;
    c.who();
  }
}

성공적으로 컴파일 하였다면

Child 도 Parent 에서 catch 됨

와 같이 나옵니다.

이번에는 경우에 따라서 ParentChild 클래스 객체를 리턴합니다. Parent 클래스 객체를 throw 하엿을 때에는 예상했던데로 Parent 를 받는 catch 문이 실행되어서 "Parent Catch!" 가 출력되었습니다.

반면에 Child 객체를 throw 하였을 때에는 예상과는 다르게, Child 를 받는 catch 문이 아닌, Parent 를 받는 catch 문이 실행되어서 이 역시 "Parent Catch!" 가 출력되었습니다.

이와 같은 일이 발생한 이유는, catch 문의 경우 가장 먼저 대입될 수 있는 객체를 받는데;

Parent& p = Child();

는 가능하기 때문에 Parent catch 가 먼저 받아버리는 것입니다. 따라서, 위와 같은 문제를 방지하기 위해서는 언제나 Parent catchChild catch 보다 뒤에 써주는 것이 좋습니다. 왜냐하면 이를 통해서 Child 객체가 Parent catch 에 들어가는 것을 막을 수 있고,

Child &c = Parent();  // 오류

위는 성립되지 않기 때문에 Child catchParent 객체가 들어가지도 않습니다. 실제로 예를 보면;

#include <iostream>
#include <string>
using namespace std;

class Parent {
 public:
  virtual void who() const { cout << "Parent!" << endl; }
};

class Child : public Parent {
 public:
  virtual void who() const { cout << "Child!" << endl; }
};

int func(int c) {
  if (c == 1) {
    throw Parent();
  } else if (c == 2) {
    throw Child();
  }
  return 0;
}

int main() {
  int c;
  cin >> c;

  try {
    func(c);
  } catch (Child& c) {
    cout << "Child Catch!" << endl;
    c.who();
  } catch (Parent& p) {
    cout << "Parent Catch!" << endl;
    p.who();
  }
}

성공적으로 컴파일 하였다면

Child 가 제대로 처리됨

와 같이 잘 처리됨을 알 수 있습니다.

모든 예외 받기

만약에 어떤 예외를 throw 하였는데, 이를 받는 catch 가 없다면 어떻게 될까요?

#include <iostream>
#include <stdexcept>
using namespace std;

int func() { throw runtime_error("error"); }

int main() {
  try {
    func();
  } catch (int i) {
    cout << "Catch int : " << i;
  }
}

성공적으로 컴파일 하였다면

프로그램 비정상적으로 종료!

와 같이 runtime_error 예외를 발생시키며 프로그램이 비정상적으로 종료되었다고 뜨게 됩니다. 따라서, 언제나 예외를 던지는 코드가 있다면 적절하게 받아내는 것이 중요합니다. 하지만, 때로는 예외 객체 하나 하나 처리할 필요 없이 그냥 나머지 전부다! 라고 쓰고 싶을 때가 있습니다. 마치 switch 문의 default 이나 if-else 문에서 마지막 else 와 같이 말입니다.

재미있게도 try .. catch 문에서도 이를 잘 지원합니다.

#include <iostream>
#include <stdexcept>
using namespace std;

int func(int c) {
  if (c == 1) {
    throw 1;
  } else if (c == 2) {
    throw "hi";
  } else if (c == 3) {
    throw runtime_error("error");
  }
  return 0;
}

int main() {
  int c;
  cin >> c;

  try {
    func(c);
  } catch (int e) {
    cout << "Catch int : " << e << endl;
  } catch (...) {
    cout << "Default Catch!" << endl;
  }
}

성공적으로 컴파일 하였다면

예외가 제대로 처리됨

와 같이 나옵니다.

마지막 catch(...) 에서 try 안에서 발생한 모든 예외들을 받게 됩니다. 당연히도, 어떠한 에외도 다 받을 수 있기 때문에 특정한 타입을 찝어서 객체에 대입 시킬 수 는 없겠지요.

자 그럼 이것으로 C++ 에서의 예외 처리에 관한 강좌를 마치도록 하겠습니다. C++ 스타일 예외 처리를 통해 좀 더 안정적인 프로그램을 만들 수 있습니다!

생각 해보기

문제 1

C++ 표준 라이브러리에 자주 사용할만한 예외 객체들이 정의가 되어있습니다. 여기 를 참고해서 읽어보세요!

프로필 사진 없음
댓글에 글쓴이에게 큰 힘이 됩니다