에러 메시지 쌓기

에러 메시지를 풍부하게 만들어 주는 Exception Chaining 기법

소프트웨어 개발에서 에러 핸들링은 어떻게 하는 게 좋을까? 언어나 프로젝트, 어떤 종류의 에러인지에 따라 다르겠지만, 현재 내가 진행하고 있는 C++ 프로젝트에서는 어떤 에러든지 catch하면 메시지를 출력하고 즉시 종료하는 방법을 택했다. 상시 실행되는 서버 프로그램과 달리 일회성으로 실행되는 프로그램에서는 이것이 가장 단순명료한 방법일 것이다.

그런데 이렇게 출력한 한 줄짜리 에러 메시지만으로는 문제 해결에 도움이 되지 않는 경우가 있었다. 작은 유틸리티 함수에서 에러가 발생하면 어떤 작업을 하다가 에러가 발생했는지 알 수 없었고, 보다 큰 작업에서 에러가 발생하면 구체적인 원인을 찾기 어려웠다.

그렇다면 두 가지 에러 메시지를 다 보여줄 수는 없을까?

한 줄짜리 에러 메시지의 문제

예를 들어, XML 문법으로 프로그램 UI를 작성하는 기능을 만든다고 가정하자. 텍스트 박스 등의 UI 컴포넌트는 width나 color 따위의 속성을 가질 수 있다. 이때 color 속성이 #RRGGBB 형식을 따르지 않는 경우 에러를 throw하는 상황을 생각할 수 있다.

<UI>
  <TextBox width="100" color="#7f7f7f" />
  <TextBox width="50" color="100" /> <!-- Error -->
</UI>

이 같은 에러가 발생하는 상황에서 stack trace가 다음과 같다고 하자.

ParseXML()
ParseComponent()
ParseAttribute()
...
ValidateColorString()

ValidateColorString() 은 아래의 에러 메시지를 throw할 것이다.

"100" is not in the color string format "#RRGGBB".

이 메시지만으로는 문제의 맥락을 파악할 수 없다. "100" 이 무엇을 가리키는지 모호하기 때문이다. 첫 번째 텍스트 박스에 있는 width="100" 속성일까? 두 번째 텍스트 박스에 있는 color="100" 속성일까? 심지어는 XML 파싱이 아닌 다른 작업을 하다가 ValidateColorString() 을 호출할 일이 생긴 것은 아닐까?

어떤 일을 하다가 에러가 발생했는지를 알기 위해서는 상위 단계에서 에러 메시지를 출력해 줘야 한다. ParseXML() 에서 모든 에러를 catch한다면 다음과 같은 에러 메시지를 다시 throw할 수 있다.

An error occurred while parsing "ui.xml".

이러면 맥락은 알 수 있겠지만 반대로 구체적인 원인을 알 수 없다. 더 깊숙한 곳에서 던진 에러를 확인해야만 한다.

한 가지 방법은 에러의 종류에 따라 클래스를 나누고 여러 개의 catch 블록을 놓는 것이다. 하지만 그러면 에러 처리 로직이 복잡해질 뿐만 아니라, 하위 함수들이 어떤 종류의 에러를 뱉는지도 세세히 알아야 한다. 기능 구현하기도 바쁜데 에러 처리까지 세심하게 신경써 주고 싶지는 않다. 모듈 간 결합도(coupling)가 증가하기 때문에 소프트웨어 디자인적 측면에서도 나쁘다.

해결책: 에러 메시지 쌓기

고민 끝에 내린 해결책은 하위에서 던진 에러 메시지를 버리지 않고 재활용하는 것이다. 하위 함수의 에러 메시지에 더 큰 맥락을 담은 새로운 에러 메시지를 쌓고 쌓아서, 여러 줄로 조합해 에러 메시지를 완성하는 것이다.

[Error] An error occurred while parsing "ui.xml".
- Invalid <TextBox> component at line 3.
- Invalid attribute value color="100".
- "100" is not in the color string format "#RRGGBB".

이를 구현하기 위해서 아래와 같이 메시지의 배열을 담은 Error 클래스를 만들었다. 이 클래스의 constructor는 현재 발생한 에러의 메시지 뿐만 아니라, 원인이 된 다른 Error 오브젝트를 받을 수 있다. 그리고 이 모든 에러 메시지들을 모아서 배열에 저장한다. (참고로 뒤에서 설명하겠지만 더 나은 구현 방법이 있다. 일단은 내가 처음에 구상한 방법을 설명한다.)

class Error : public runtime_error {
public:
  Error(const string &msg) : runtime_error(msg), msg_list_({msg}){};
  Error(const string &msg, const exception &cause) : Error(msg) {
    try {
      const auto &e = dynamic_cast<const Error &>(cause);
      msg_list_.insert(msg_list_.end(),
                       e.msg_list_.begin(),
                       e.msg_list_.end());
    } catch (const bad_cast &) {
      msg_list_.push_back(cause.what());
    }
  }

  vector<string> msg_list_;
};

부연 설명을 하자면 Error 클래스 뿐만 아니라 표준 라이브러리에서 발생하는 에러들도 원인이 될 수 있으므로 인자로 std::exception & 레퍼런스를 받았다. 그리고 Error 클래스의 인스턴스인지 확인하기 위해 dynamic_cast 를 활용했다. 에러 메시지를 앞의 예시처럼 출력하려면 PrintErrorMessage() 함수를 정의해서 사용한다.

void PrintErrorMessage(const exception &err) {
  try {
    const auto &e = dynamic_cast<const Error &>(err);
    for (size_t i = 0; i < e.msg_list_.size(); i++) {
      cerr << (i == 0 ? "[Error] " : "- ") << e.msg_list_[i] << endl;
    }
  } catch (const bad_cast &) {
    cerr << "[Error] " << err.what() << endl;
  }
}

이제 에러가 발생한 catch 블록에서 아래와 같이 에러를 re-throw하면 된다.

void ValidateColorString(const string &str) {
  if (str[0] != '#')
    throw Error("Invalid color string: " + str);
}

void ParseComponentAttribute(const string &name, const string &value) {
  try {
    ValidateColorString(value);
  } catch (const exception &e) {
    throw Error("Invalid attribute: " + name + "=" + value, e);
  }
}

void ParseXML(const string &file_name) {
  try {
    ParseComponentAttribute("color", "100");
  } catch (const exception &e) {
    throw Error("Invalid UI file: " + file_name, e);
  }
}

int main() {
  try {
    ParseXML("ui.xml");
    return 0;
  } catch (const exception &e) {
    PrintErrorMessage(e);
    return 1;
  }
}
[Error] Invalid UI file: ui.xml
- Invalid attribute: color=100
- Invalid color string: 100

짠! 에러 메시지를 쌓아서 에러가 발생한 맥락과 구체적인 원인을 한 눈에 볼 수 있게 되었다.

예시에서는 모든 함수에 try-catch문을 사용하였지만, 실제로는 맥락이 중요한 함수 몇 개에만 사용하는 것을 추천한다. 중간에 거치는 함수들에서는 그냥 에러가 자연스럽게 전파되도록 내버려 두자.

물론 이것은 내가 최초로 생각한 아이디어가 아니다. ChatGPT에게 물어보니 이 기법은 예외 체이닝(Exception Chaining)으로 불린다고 한다. 왜 chaining이냐 하면, 보통은 내가 한 것처럼 메시지 배열을 들고 다니는 것이 아니라, 원인 에러에 대한 레퍼런스를 가지도록 구현하기 때문이다. 메시지를 출력할 때에는 재귀적으로 레퍼런스를 따라 내려가면서 출력하는 것이다. 이 방법이 조금 더 효율적이고 안전하다.

C++에서는 예외 오브젝트의 lifetime과 관련한 이슈 때문에 단순히 exception & 레퍼런스나 exception * 포인터를 들고 있을 수는 없다. 대신 std::exception_ptr 라는 shared ownership을 가진 포인터를 이용해야 한다. 다행히도 우리가 직접 이런 걸 신경쓰면서 Error 클래스를 구현할 필요는 없다. C++11부터는 표준 라이브러리에서 std::nested_exception 클래스 및 관련 함수들을 통해 이 기능을 자체적으로 제공하고 있기 때문이다.

C++11의 std::nested_exception

레퍼런스 페이지에 사용 예시가 잘 나와 있는데, 위 코드에 하나씩 적용해 보면 다음과 같다.

먼저 PrintErrorMessage() 는 다음과 같이 작성한다.

void PrintErrorMessage(const exception &e, int level = 0) {
  cerr << (level == 0 ? "[Error] " : "- ") << e.what() << endl;
  try {
    rethrow_if_nested(e);
  } catch (const exception &nested) {
    PrintErrorMessage(nested, level + 1);
  } catch (...) {
    cerr << "Unknown error" << endl;
  }
}

std::rethrow_if_nested() 함수는 인자가 nested_exception 인지 확인하고, 만약 그렇다면 내부에 있는 에러를 다시 throw하는 함수이다. 이 함수 덕분에 dynamic_cast 를 호출할 필요 없이 코드가 좀 더 간결해진 것을 볼 수 있다.

catch 블록에서는 throw Error() 대신 std::throw_with_nested() 함수를 사용한다.

throw_with_nested(runtime_error("Error message"));

여기서 에러 메시지를 인자로 넘겨주는 것이 아니라 std::runtime_error 를 사용하는 이유는 nested_exception 이 단일 클래스가 아니라 mixin 클래스이기 때문이다. runtime_error 클래스를 nesting이 가능하게 업그레이드하여 throw한다고 생각하면 된다. runtime_error 뿐만 아니라 다른 표준 에러 클래스를 사용할 수도 있어서 적절한 것을 선택하면 된다.

예시 코드에 적용해 보면 다음과 같다.

void ValidateColorString(const string &str) {
  if (str[0] != '#')
    throw runtime_error("Invalid color string: " + str);
}

void ParseComponentAttribute(const string &name, const string &value) {
  try {
    ValidateColorString(value);
  } catch (const exception &e) {
    throw_with_nested(
        runtime_error("Invalid attribute: " + name + "=" + value));
  }
}

void ParseXML(const string &file_name) {
  try {
    ParseComponentAttribute("color", "100");
  } catch (const exception &e) {
    throw_with_nested(runtime_error("Invalid UI file: " + file_name));
  }
}

int main() {
  try {
    ParseXML("ui.xml");
    return 0;
  } catch (const exception &e) {
    PrintErrorMessage(e);
    return 1;
  }
}
[Error] Invalid UI file: ui.xml
- Invalid attribute: color=100
- Invalid color string: 100

회사에서 새롭게 진행하고 있는 프로젝트에 이 방법을 적용해 보고 있는데 확실히 장점이 체감된다.

  • 구체적인 맥락이나 원인은 다른 에러가 대신해 주므로 에러 메시지를 간결하게 쓸 수 있었다.
  • 에러 메시지만을 위해 함수에서 불필요한 정보에 접근할 필요가 없어져서, 캡슐화 및 결합도 감소에 도움이 되었다.
  • 늘 헷갈렸던 에러 처리에 신경을 덜 써도 돼서 메인 로직 개발에 집중할 수 있게 되었다.

앞으로 애용해야겠다.