C++ 이야기 서른한번째: 왜 예외를 쓰는 게 좋을까요?

Posted at 2009.09.25 07:59 // in S/W개발/C++ 이야기 // by 김윤수


If you're an English speaker, please follow this link

이글의 최신본은 항상 여기에 있습니다. 최신본은 코드에 syntax highlighting도 되어 있고, 철자법도 맞추고 부가정보도 계속 업데이트 하고 있습니다.

여러분은 서브루틴에 에러가 발생했다는 걸 알려주려 할 때 C++ 예외를 사용하시나요? 쓰시지 않는다면, 왠가요? 에러를 나타내는 특별한 에러 코드를 되돌려주면 되기 때문에 전혀 사용할 필요가 없다라고 답변하실지도 모르겠습니다. 사용하신다면, 왠가요? 이 질문에 대해서는 다른 방법이 있나요?라고 오히려 반문하시는 분이 있을지도 모르겠네요.

C++에 예외가 도입된 이유를 밝하기 위해 이 글을 작성하게 됐습니다. 이 설명을 통해 위에 두 부류의 분들 모두 예외에 대해 더 잘 이해하게 되고, 예외를 이용하여 더 좋은 코드를 작성하는데 도움이 되시리라 생각합니다.

C++에 왜 예외에 도입되었다고 생각하세요? 왜 아직도 Java나 C#같은 언어가 C++의 예외를 물려 받았다고 생각하세요? 그런데는 틀림없이 어떤 이유가 있을 것입니다. 그 이유는 바로 결함내성(fault-tolerant) 소프트웨어를 쉽게 작성하도록 하기 위한 것입니다.

C/C++ 같이 상태를 갖는 프로그래밍 언어에서 에러 처리를 한 후에도 일관성있는 상태를 유지해야 한다는 건 결함내성을 달성하는데 있어 정말 까다로운 문제입니다.

예를 들어, 다음과 같이 어떤 파일을 열고 데이터를 읽어들이는 짤막한 C 코드를 생각해 보기로 하겠습니다.

char*
OpenAndRead(char* fn, char* mode)
{
  FILE* fp = fopen(fn, mode);
  char *buf = malloc(BUF_SZ);
  fgets(buf, BUF_SZ, fp);
  return buf;
}

OpenAndRead()의 목적이 워낙 간단하고 분명하니, 그 구현도 무척이나 간단하고 읽기도 쉽고 이해하기도 쉽습니다. 위 코드는 모든 서브루틴이 잘 수행된다면 잘 작동할 것입니다. 그렇지만 'fn' 파일이 없거나 파일에 대한 접근 권한이 없다면 어떻게 될까요? malloc() 이나 fgets() 이 실패하면 어떻게 될까요? 위 코드를 결함내성이 있게 만들려면 각 서브루틴 호출 후에 에러가 발생했는지 확인해보고, 에러가 발생했다면 어떤 행동을 취해야할 것인지 결정해야 합니다. 그 취해야할 행동이 여기서는 단순히 에러 메시지를 출력하고 획득했던 자원들을 해제하고, -1 을 리턴하는 걸로 가정해 보겠습니다. 그렇다면 코드를 다음과 같이 수정할 수 있겠지요.

int
OpenAndRead(char* fn, char* mode, char** data)
{
  *data = NULL;
  char* buf = NULL;
  FILE* fp = fopen(fn, mode);
  if (!fp) {
    printf("Can't open %s\n", fn);
    return -1;
  }
 
  buf = malloc(BUF_SZ);
  if (!buf) {
    printf("Can't allocate memory for data\n");
    fclose(fp);
    return -1;
  }
 
  if (!fgets(buf, BUF_SZ, fp)) {
    printf("Can't read data from %s\n", fn);
    free(buf);
    fclose(fp);
    return -1;
  }
 
  *data = buf;
 
  return 0;
}

두 개의 코드를 비교해 보니 느낌이 어떠세요? 두번째 버전은 주 알고리즘을 처리하는 부분과 에러를 처리하는 부분이 막 섞여 있어서 읽기도 힘들고 이해도 힘들어졌습니다. 게다가 라인수도 많아져서 일을 더 많이해야하네요.

예외를 사용하면 이런 문제가 발생하지 않습니다. 한 번 fopen(), malloc(), fgets() 이 FileDoesNotExist, FileNotAccessible, MemoryExhausted, EndOfFile 라는 예외를 발생시킨다고 가정해 보겠습니다. 그렇다면 다음과 같이 코드를 변화시킬 수 있을 것입니다.

int
OpenAndRead(char* fn, char* mode, char** data)
{
  *data = NULL;
  char* buf = NULL;
  FILE* fp = NULL;
  try {
    fp = fopen(fn, mode);
    buf = malloc(BUF_SZ);
    fgets(buf, BUF_SZ, fp);
    *data = buf;
    return 0;
  }
  catch (const FileDoesNotExist&) {
    printf("Can't open %s\n", fn);
  }
  catch (const FileNotAccessible&) {
    printf("The access %s is not permissible for %s\n", mode, fn);
  }
  catch (const MemoryExhausted&) {
    printf("Can't allocate memory for data\n");
    fclose(fp);
  }
  catch (const EndOfFile&) {
    printf("Can't read data from %s\n", fn);
    fclose(fp);
    free(buf);
  }
 
  return -1;
}

OpenAndRead() 주 제어 흐름이 이제 에러 처리와 깔끔하게 분리가 된 걸 보실 수 있겠죠? OpenAndRead() 이 버전은 좀 더 읽기 쉽게 됐네요. 그렇지만 아직도 라인수가 좀 많은 편입니다. 실상 예외가 발생했을 때 이루어지는 "스택 풀기(stack unwinding)"이라는 과정을 이해하시면 더 적은 코드로 동일한 효과를 낼 수 있답니다.

예외가 발생하고 나서 그 예외를 처리할 수 있는 예외 처리기를 만날 때까지 스택을 풀어나가면서, C++ 실행 환경은 try 블록부터 생성됐던 모든 자동 객체들에 대해 소멸자를 불러주게 됩니다. 이 과정을 "스택 풀기"라고 합니다. 자동 객체들은 생성됐던 반대 순서로 소멸됩니다.

이런 원리에 따라 자원을 소유하는 객체들을 자동 객체로 정의하게 되면 예외가 발생하더라도 그런 객체들이 자동적으로 소멸되는 것이지요. 이런 프로그래밍을 가능케 하는 자원 소유 객체들이 파일이나 메모리에 대해 이미 정의되어 있습니다. 파일 자원용으로는 std::fstream을 사용할 수 있고, 메모리 자원용으로는 boost::scoped_ptr을 사용할 수 있습니다. OpenAndRead()를 다시 한 번 아래와 같이 구현할 수 있을 것입니다.

char*
OpenAndRead(const std::string& fn, std::ios_base::openmode mode)
{
  try {
    std::fstream fs(fn.c_str(), mode);
    boost::scoped_ptr buf(new char[BUF_SZ+1]);
    fs.getline(buf.get(), BUF_SZ);
    return buf.get();
  }
  catch (const std::ios_base::failure&) {
    std::cout << "Can't open or read data from " << fn << std::endl;
  }
  catch (const std::bad_alloc&) {
    std::cout << "Can't allocate memory for data" << std::endl;
  }
 
  return 0;
}

C++ 실행기(run-time)가 모든 자원 해제를 알아서 하게 됩니다. 그래서 에러 처리 코드에 자원 해제 코드를 추가할 필요가 없게 됩니다. OpenAndread() 가 상당히 간단해지고, 읽기도 쉬워졌죠?

예외가 발생하게 되면 C++ 실행기가 예외 처리기를 만날 때까지 소멸자를 역순으로 호출하면서 스택 풀기를 수행할 것입니다. 위 경우에는 동일한 스택 프레임에 예외 처리기가 있고, 두 개의 자동 객체, fs와 buf가 있습니다. 예외 처리기로 제어권이 넘어가기 전에 fs와 buf의 소멸자가 호출될 것이고, 소멸자는 내부 자원을 해제하게 될 것입니다.

위와 같은 방식은 예외에 대한 반응이 워낙 간단하기 때문에 만족스럽지 않을 거라 생각합니다. 위와 같은 방식으로는 OpenAndRead()의 호출자가 그저 성공 또는 실패 정도만 나타낼 수 있는 정상 포인터 또는 널 포인터를 받을 뿐이기 때문입니다. 호출자 입장에서 생각해 보면, 왜 실패했는지를 알지 못한 상태에서 널 포인터로 할 수 있는 게 없습니다.

위에서 알 수 있듯이, 에러에 어떻게 반응할지를 결정하는 것이 결함 내성 소프트웨어를 작성하는 데 겪는 또 다른 어려움입니다. 에러가 발생한 상황(context)과 원인이 에러에 대한 반응을 결정하는 데 있어 필수적인 정보라 할 수 있습니다. 그런데 그런 정보들은 여기 저기 흩어져 있기 마련입니다. 에러 처리에는 보통 두가지 부분의 코드가 관련이 있습니다. 하나는 호출자이고 또 하나는 피호출자입니다. 피호출자는 에러가 발생한 원인은 알지만 상황은 잘 모를 가능성이 높고, 반대로, 호출자는 상황은 알지만 이유를 모를 가능성이 높습니다. 이런 상황을 어떻게 극복할 수 있을까요?

피호출자가 충분한 상황을 알고 있는 호출자에게 최대한 정확히 에러가 발생한 원인을 알려준다면 분명한 해결책이 될 수 있을 것 같습니다. 그렇지만 호출자가 피호출자를 직접 호출한 게 아니었다면 어떻게 될까요? 충분한 상황 정보를 가지고 에러에 대한 반응을 결정할 수 있는 호출자가 에러가 발생한 원인을 정확히 알고 있는 피호출자로부터 멀리 떨어져 있다면, 중간 함수가 에러가 발생한 상세 원인을 무시해 버리고 그냥 성공 또는 실패만을 나타내는 코드를 되돌려 주는 일이 자주 발생합니다. 예제를 통해서 확인해 볼까요?

int
OpenAndRead(char* fn, char* mode, char** data)
{
  *data = NULL;
  char* buf = NULL;
  FILE* fp = fopen(fn, mode);
  if (!fp) {
    printf("Can't open %s\n", fn);
    return -1;
  }
 
  buf = malloc(BUF_SZ);
  if (!buf) {
    printf("Can't allocate memory for data\n");
    fclose(fp);
    return -2;
  }
 
  if (!fgets(buf, BUF_SZ, fp)) {
    printf("Can't read data from %s\n", fn);
    free(buf);
    fclose(fp);
    return -3;
  }
 
  *data = buf;
 
  return 0;
}
 
char* foo(char* fn, char* mode)
{
  char* data = 0;
  if (OpenAndRead(fn, mode, &data) >= 0)
    return data;
  else
    return 0;
}
 
void bar()
{
  ...... // fn과 mode를 얻어낸다
  char* data = foo(fn, mode);
  if (data) {
    ...... // 정상 처리
  }
  else {
    ...... // bar()가 뭘 할 수 있을까요?
  }
}

OpenAndRead()는 되돌림 코드(return code)를 통해 에러를 구분하려고 하고 있습니다. 근데 foo()는 그걸 무시하고 그냥 널 포인터나 정상 포인터를 되돌려 주고 마네요. 이래서는 bar()가 진짜 무슨 일이 벌어지고 있는지 알아낼 수가 없게 되고, 널 포인터로는 뭔가 할 수 있는 게 없어집니다. 아마도 bar()가 할 수 있는 거라곤 에러 메시지를 출력하고 그냥 종료하거나 가능하다면 에러를 무시해 버리는 거겠죠. 호출 경로 상의 각 함수들은 어떻게든 에러 처리에 관여할 수 밖에 없기 때문에 예외가 없이는 이런 상황이 더 심각해 질 수 밖에 없습니다. 어떤 함수 작성자는 되돌림 코드가 뭘 의미하지 모를 수도 있고, 에러가 뭘 의미하는지 별로 상관하지 않을 수도 있고, 아니면 충분한 상황을 알지 못해 어떤 처리를 해야할지 결정을 못할 수 있습니다. 그래서 호출 경로 상의 어딘가에서 되돌림 에러 코드가 그냥 실패를 나타내는 값으로 바뀌어 버리게 되는 거죠.

예외를 사용한다면 이런 문제를 해결할 수 있습니다. 호출 경로상의 어떤 함수가 예외를 처리하지 못하겠다면 그 함수는 그냥 예외를 잡지(catch) 말고 무시해 버리면 됩니다. 진짜 처리할 수 있는 예외만 처리하면 되는 것이지요.

자~ 이제 마지막으로 한번 만 더 OpenAndRead()를 변형시켜 보겠습니다. 일단 OpenAndRead()와 foo()는 예외를 어떻게 처리해야 할지 모른다고 가정하고, bar()는 처리할 수 있다고 가정해 보겠습니다.

char*
OpenAndRead(const std::string& fn, std::ios_base::openmode mode)
{
  std::fstream fs(fn.c_str(), mode); // std::ios_base::failure() 가 발생할지 모릅니다.
  boost::scoped_ptr buf(new char[BUF_SZ+1]);  // std::bad_alloc() 가 발생할지 모릅니다
  fs.getline(buf.get(), BUF_SZ);
  return buf.get();
}
 
char* foo(char* fn, char* mode)
{
  // 아무런 예외도 잡지 않고 있습니다
  return OpenAndRead(fn, mode);
}
 
void bar()
{
  ...... // fn과 mode를 알아냅니다
  try {
    char* data = foo(fn, mode);
  }
  catch (const std::ios_base::failure&) {
    std::cout << "Can't open or read data from " << fn << std::endl;
    // 왜 fn 열어서 데이터를 읽으려고 했는지를 안다면 좀 더 정확한 진단 메시지를 출력하고,
    // 사용자에게 뭔가 조치를 취하라고 알려줄 수 있습니다
  }
  catch (const std::bad_alloc&) {
    std::cout << "Can't allocate memory for data" << std::endl;
    // 이런 경우를 대비해 메모리를 미리 따로 마련해 놓았다면 bad_alloc()를 
    // 좀 더 부드럽게 처리할 수도 있을 것입니다.
  }
}

위 코드에서 볼 수 있듯이, OpenAndRead()와 foo()가 다시 간단해 졌고, bar()만 에러 처리에 관여하고 있습니다. 게다가 예외가 뭘 의미하는지 알고 충분한 상황 정보기 있기 때문에 예외를 정말 절 처리할 수 있게 됐습니다.

예외의 또 한 가지 장점은 예외는 그 자체로서 객체라는 것입니다. 이건 예외를 발생시킬 때 될 수 있으면 많은 정보를 담아서 발생시킬 수 있다는 것입니다. 다시 말해 예외는 되돌림 코드보다 훨씬 표현력이 좋다고 할 수 있습니다. 그렇다면, 예외 처리기는 그 정보들을 접근할 수 있게 되고, 그 정보들은 어떤 일이 벌어지고 있는지를 정확히 알아내는 데 큰 도움이 될 것입니다.

이제 여러분에게 "C++ 예외를 사용하고 싶으세요"라고 묻고 싶군요. 뭐라고 답변하시겠어요? "당연히 써야죠"라는 답변을 들었으면 하네요.

저작자 표시 비영리 변경 금지
신고
  1. 풀리비

    2009.10.06 21:33 신고 [수정/삭제] [답글]

    예외 처리기능이 C++에 들어가서 좋지만, 속도 저하가 약간 있어서 꺼리는 경우도 있어요.
    스트롭 할아버지는 별로 속도 저하 없으니 꼭 쓰라고 하지만 RTTI처럼 속도 때문에 꺼려지는 점이 있긴 하죠!
    STL도 vector에 대해 at 메소드는 예외처리를 하지만, 완전 동일한 기능인 [] 연산자에서는 예외처리를 하지 않는 것처럼
    많은 반복 예상되는 단순한 코드같은 경우 예외 처리가 꼭 필요하지는 않으면서도 예외 처리를 할 경우 성능을 떨어뜨리는 경우도 있습니다.

  2. 뇨릉

    2011.12.05 16:48 신고 [수정/삭제] [답글]

    이런 좋은 글에 댓글이 없다니요~~
    예외를 써야되는 이유를 아주 쉽게 설명해주신 것 같습니다.
    예외를 안쓰는 개발자들이 저를 포함해서 상당히 많은데요. ㅋ(사실 저는 요즘 익숙해지는 중 이에요. )
    예외를 안쓰는 개발자들이 많이 봤으면 좋겠습니다~

  3. unknown

    2012.02.01 07:33 신고 [수정/삭제] [답글]

    제 의견은 이렇습니다.

    (1) 궂이 멀리 예외를 던져서 처리하는 것보다 예외 상황에 대해서는 호출측이 즉시 처리해서 코드의 무결성을 보장해야 합니다. 즉시 무결성을 보장하지 않는 코드는 좋은 코드가 아닙니다. 예외는 메모리 할당 실패와 같은 어쩔 수 없이 전체적으로 롤백해서 끝내야 하는 경우와 같은 아주 특별한 경우에 종료 시퀀스를 단순화 시키기위해 쓰는것이 좋습니다.

    (2) 멀리 예외를 던지지만 않는다면(각 부분에서 무결성만 보장된다면) C++ 에서 C++예외처리와 관계없이 오류코드 리턴과 같은 경우에도 C++이기에 어차피 동적 메모리 포인터를 사용하지 않는 모든 자원 및 객체는 자동소멸되므로 자원해제 처리에 대해서 별 걱정할 필요가 없습니다.

    (3) 대부분의 오류 핸들링에 있어서는 숫자 형식으로 오류를 반환하는 것이 오류에 대해서 단순한 시각으로 접근하게 해주는 경우가 많습니다. (어떤 오류코드가 아닐 경우에 대해 처리하거나, 성공하지 않았다면 다른 처리를 한다든가)

    (4) 어떤 메소드 혹은 함수가 예외를 발생시킬지 모르고, 또 어떤 예외를 발생시키지 모르며(예외의 객체 종류가 다를 수 있으므로), 거기에 대한 일반적인 방법론을 따르는 것도 아니기 때문에(표준 예외 모델을 다 사용하진 않는걸 많이 보게됩니다) 최대한 해당 메소드/ 함수 정의 뒤에 어떤 예외를 발생시키는지 코드상에 명시해야 하고 그것을 작성하는것도, 읽어대는것도 매우 피곤한 일이 됩니다.

    한가지 생각해봅시다. 위에서 예로 든 코드에서.. 애초에 관련된 코드덩어리들이 객체화 되어있었고 동적객체가 없었다고 해봅시다. 그냥 return ERROR_CODE;만 하면 스스로 적절하게 파괴됩니다. 코드가 길어지지 않으며, 오류에 대해서는 일반적인 성공/실패와 같은 단순한 시각으로 바라볼 수 있고 좀 더 복잡하게 다루더라도 그것은 선택의 문제가 됩니다.

    또한, 이런 경우도 생각해봅시다. 어떤 루프를 돌고 있었습니다. 루프 회전 중 어떤 예외를 발생시키는 서브루틴을 호출 했습니다. 호출한 함수에서 발생시켰을지 그 더 앞쪽에서 발생시켰을지는 모릅니다. 그런데 단지 예외가 발생했을때에 어떤 오류가 아니면 루프를 지속시키고 싶습니다. 몇가지 예외 핸들러를 구성하거나 동일한 한가지 예외 핸들러를 가뜩이나 {도 많은데 거기다가 또 쓴다고 생각해보세요. 오히려 코드만 더 길어지고 복잡해질 수 있습니다. (위 경우와는 대조적이죠)

    간혹 생성자에서도 예외(오류)를 만들어낼 수 있어서 좋다는 이야기를 듣는 경우도 있는데, 대부분의 사람들은 그런 경우를 일반적으로 생각하지 않기 때문에 예외에 대해서 대처하지 못해버리는 경우가 많습니다. 역시 단점이 되버릴 수 있습니다.

    뭐든지 무조건적으로 좋은건 없는게 당연하지만, C++ 예외처리는 득보다는 실이 더 많을 수 있다는 것 때문에 매우 제한적으로 쓰는거라고 생각합니다. 최소한 제 경우는 그런 관계로 아예 쓰지 않거나 극히 제한적으로 씁니다.

  4. Unknown Error

    2012.07.19 00:24 신고 [수정/삭제] [답글]

    그래서 Google C++ Style Guide에서는 C++ 예외를 사용하지 말라는 건가 보군요.

    • 김윤수

      2012.07.20 07:41 신고 [수정/삭제]

      구글 C++ style guide에는 exception 자체에 문제가 있어서 그런 거라고 밝히고 있지 않습니다. 그대로 옮겨와 보면....

      On their face, the benefits of using exceptions outweigh the costs, especially in new projects. However, for existing code, the introduction of exceptions has implications on all dependent code. If exceptions can be propagated beyond a new project, it also becomes problematic to integrate the new project into existing exception-free code. Because most existing C++ code at Google is not prepared to deal with exceptions, it is comparatively difficult to adopt new code that generates exceptions.

      Given that Google's existing code is not exception-tolerant, the costs of using exceptions are somewhat greater than the costs in a new project. The conversion process would be slow and error-prone. We don't believe that the available alternatives to exceptions, such as error codes and assertions, introduce a significant burden.

      Our advice against using exceptions is not predicated on philosophical or moral grounds, but practical ones. Because we'd like to use our open-source projects at Google and it's difficult to do so if those projects use exceptions, we need to advise against exceptions in Google open-source projects as well. Things would probably be different if we had to do it all over again from scratch.

댓글을 남겨주세요.

티스토리 툴바