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

Posted at 2009. 9. 25. 23: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++ 예외를 사용하고 싶으세요"라고 묻고 싶군요. 뭐라고 답변하시겠어요? "당연히 써야죠"라는 답변을 들었으면 하네요.