C++ 예외 처리(Error Handling) 가이드 + exception safety rules

개요

C++에서는 항상 논쟁거리가 되는 주제가 하나 있다. 바로 에러 핸들링에 관한 이야기이다. 워낙에 많은 이야기들이 오가고, 나 역시도 수많은 C++ 포럼과 개발자들, 그리고 외부 기업들의 C++ 스타일 가이드라인들을 찾아봤지만, 이 에러 핸들링에 관한 부분만큼은 모두의 생각이 첨예하게 다른 것을 알 수 있었다. 좋게 말하면 정답이 없는 문제이고, 나쁘게 말하면 그만큼 생각할 것이 많은 복잡한 문제이긴 하다.

그러나 인터넷을 통해 이 논쟁에 대해서 찾아보면, 그만큼 잘못된 정보도 많거나 쉽게 이해하기 어려운 정보들도 많이 산재해있고, 이를 합리적이고 비판적으로 정보를 필터링하기는 쉽지 않다. 그래서 편향된 시각을 갖게 될 수가 있다. 그래서 이번 기회에 내가 알아본 C++의 예외 처리(에러 핸들링, Error Handling)에 관해서 이야기해보고, 다양한 사례들, 그리고 마이크로소프트와 구글의 가이드라인들도 찾아보며 학습하였다. 그리고 나서 내가 생각하는 C++의 예외 처리에 대해서 이야기해보려고 한다.

C++에서의 예외 처리

C++ exception 처리

C++에서 예외는 어떻게 처리하는가?
먼저 으레 현대적인 언어가 대부분 지원하듯 C++ 역시 try, catch, throw 구문으로 대표되는 예외 처리 기법을 지원한다. 예외를 throw를 통해서 던지면, catch 문의 블록에서 이를 잡아서 처리하는 것이다. 이를 익셉션 핸들링, 예외 처리라고 한다.

C++에서는 std::exception 객체를 던진다. C++ exception 공식 문서에서 std::exception에서 파생된 다양한 익셉션들을 확인할 수 있다. 이들 모두가 std::exception을 베이스 클래스로 두고 있어서, std::exception으로 catch 문에서 받는게 가능하다.

또한 C++에서 exception 객체를 던질 때는, 무조건 r-value로 던져서 받을 때는 reference로 받아야 한다. 이는 exception이 어떻게 처리되는 지를 정확히 알면, 왜 꼭 그래야만 하는 지 알 수 있다.

exception이 어떻게 처리되는 지 정확히 알려면, 스택 언와인딩(stack unwinding, 스택 풀기)이라는 개념에 대해서 알아야 한다. 스택 언와인딩이란 exception이 발생했을 때 이를 처리할 수 있는 catch 문을 찾을 때까지, 스택을 풀어가는 과정을 말한다.
다음 예제를 보자.

int divide(const int x, const int y)
{
    if (y == 0) throw std::runtime_error("divide by zero");
    return x / y;
}

void fn(const int divider)
{
    std::cout << divide(divider) << std::endl;
}

int main()
{
    try
    {
        fn(0);
    } 
    catch (const std::exception &e)
    {
        std::cout << e.what() << std::endl;
    }
}

위 코드를 그림으로 나타내면 위 그림처럼 될 것이다. 스택 위로 main(), fn(), divide() 함수가 쌓였다. 그리고 divide 함수에서 exception 객체를 만들고 있다. 이때 exception이 던져지면서, 바로 스택 언와인딩이 실행된다.

스택 언와인딩 과정은 다음과 같다:

  1. 스택 언와인딩이 실행되면서, 우선 divide가 스택 위에서 해제된다.
  2. 함수 fn 역시 익셉션을 처리할 수 없으므로, 스택 위에서 해제된다.
  3. main 함수에서는 익셉션을 처리할 수 있다. 이제 main 함수가 직접 std::runtime_error라는 exception을 처리할 것이다.

한편 만약에 스택 언와인딩 과정을 모두 마쳐도, 즉 main 함수도 exception을 처리할 수 없다면 어떻게 될까? 그럴 때는 std::terminate 함수가 실행되면서, 프로그램의 비정상적인 종료를 알린다. C++ 공식 문서 terminate 부분을 찾아보면, std::terminate 함수가 언제 실행되는 지 알 수 있다. 보면 처리되지 않은 예외 객체가 던져졌을 때, 합류되지 않은 스레드의 파괴가 일어났을 때 등 주로 비정상적인 상황에서 실행됨을 알 수 있다.

한편 위 과정을 통해서 왜 exception을 던질 때는 r-value로, 받을 때는 reference로 받아야 하는 지를 알 수 있다. 예외 객체가 던져질 때 객체의 복사가 일어나면 안 되고, 또한 임시 객체의 생성 역시 없어야 한다. 그래서 r-value로 던지고, reference로 받는 것이다.

예외를 처리하는 다른 방법

사실 C++에서는 exception 외에 다양한 방법으로 예외 상황을 처리할 수 있다. std::optional, std::variant, std::pair 등을 사용할 수도 있고, 또 대중적이고 고전적인 방법으로 Error Code를 리턴하는 방식도 있다.

이 방식은 다른 글을 통해서 작성하겠다.

C++에서 exception, 사용해야 할까 말아야 할까?

exception의 장점

우선 exception을 사용하는 것은 장점을 가진다. 여러가지 장점이 있는데, 우선 예외 처리의 시점을 자유롭게 정할 수 있다는 것이다. 스택 언와인딩덕분인데, 스택 언와인딩 덕분에 프로그래머는 자유롭게 어느 시점에 예외를 처리할 것인지에 대해 정할 수 있다. 그런데 이를 만약 error code를 리턴하는 식으로 프로그램을 작성했다면, 매번 함수에서는 에러 코드를 확인하고, 이를 다시 리턴하는 식으로 프로그램을 작성했을 것이다. 그런 면에서 코드의 간결성과 가독성을 높일 수 있다.

그러나 코드의 간결성은 주관적인 것이라서, 누군가는 exception을 던지는 것이 가독성을 떨어뜨린다고 본다. 함수의 자연스러운 흐름을 방해하기에, 오히려 코드의 복잡성을 높이는 역할을 하기도 함을 기억해야 한다.

한편 exception을 사용하는 추가적인 장점으로, 비즈니스 로직과 에러 처리 코드를 분리할 수 있다는 장점이 있다. 만약 error code를 리턴하는 식으로 사용했으면 이를 처리하는 로직과 비즈니스 로직이 혼재되어 있었을 것이다. 그러나 exception을 사용하면, 함수 안에는 오직 비즈니스 로직만 남기고, 에러 코드의 처리는 throw를 통해 외부에서 처리하게끔 맡길 수 있다.

그러나 이러한 장점들에도 불구하고, C++에서 exception을 사용하기 전 몇 가지 고려해야 할 중요한 사항들이 존재한다.

C++에서 exception을 사용할 때 고려해야 할 점들

1. 오버헤드

exception은 상당히 비싼 연산이다. exception도 일종의 객체이므로, 이 객체를 생성하는 비용, 그리고 스택 언와인딩 과정에서 발생하는 오버헤드의 비용 역시 고려해야 한다.

이 비용은 상대적이다. 누군가는 이 비용이 무시할 만 하다고 하고, 누군가는 이 비용이 크고 비싸다고 한다. 그런데 이건 상대적인 문제이므로, 상황에 맞추어서 사용하면 된다.

예를 들어서 만약에 exception을 던지는 함수가 수학적 연산을 수행하는 함수여서 매우 자주 호출되거나, 렌더링에 관련된 함수여서 화면을 그리기 위해 1초에 몇 번씩 불리는 함수라고 가정하자. 그렇다면 이 함수가 exception을 던지는 오버헤드는 꽤 부담이 되는 수준일 것이다. 그러나 비즈니스 로직에서 이 함수가 몇 번 반복되지 않고, 성능이 중요하지 않은 곳에서 사용된다면, 이 비용은 무시해도 좋을 수준일 것이다.

2. 메모리 누수

exception으로 발생하는 오버헤드는 상대적인 문제이지만, 메모리 누수에 관한 문제는 심각하게 재고해야 한다. 다음 코드를 보자.

void f(const size_t size, const int status)
{
    int* arr = new int[size];
    Animal *cat = new Cat();
    // ...
    if (status == 0)
    {
        throw std::runtime_error();
    }

    delete[] arr;
    delete cat;
}

위 코드에서 일반적인 경우에는 문제가 발생하지 않는다. 그러나 만약 status가 0이 들어올 때 문제가 발생한다. exception이 발생하면서 arr가 해제되지 않고, 메모리 누수가 발생한다. 특히 C++에서는 가비지 컬렉터가 없으므로, 자원의 해제에 대해서 특히 신경써주어야 한다.

메모리 누수를 해결하는 방법

위 상황에서 메모리 누수 등의 자원 문제를 해결하기 위해서는 어떻게 해야 할까? 우선 매번 exception이 던져질 때마다, 자원의 해제를 각별히 신경써서 하는 방법이 있다. 그러나 예를 들어서, f()g()을 부르고, g()h()을 호출하는 상황에서 h()이 던진 exception을 f()이 받아서 처리한다고 가정하자. 그렇다면 g()에서는 자원을 할당하는데 매우 조심해야 한다. h에서 exception이 발생했을 때 g에서 이를 적절하게 핸들링하지 않고 f로 넘긴다면 그 과정에서 g가 할당한 자원이 곧바로 메모리 누수로 이어진다.

그렇기 떄문에 exception을 사용할 때에는 RAII 원칙을 요구한다.

void f(const size_t size, const int status)
{
    std::vector<int> arr(size);
    std::unique_ptr<Animal> cat = std::make_unique<Cat>();
    // ...
    if (status == 0)
    {
        throw std::runtime_error();
    }
}

위와 같이 RAII를 준수하며 코드를 작성하면, exception이 발생하더라도 문제 없이 자원을 해제할 수 있다.

그러나 모든 코드가, 특히 레거시 코드들은 RAII를 준수하지 않는 경우가 많다. 그렇기 떄문에 RAII가 존재하더라도 exception의 장점을 도입하기 위해서는 고려해야 할 점들이 많이 존재한다.

그렇기 때문에 exception을 도입하는 과정에서 exception safety의 개념이 만들어지게 되었다.

Exception Safety Rules

exception safety란, exception을 도입하면서 발생하는 문제들을 해결하기 위한 방법론이다. 이 방법론은 크게 3가지로 나뉜다.

  1. Basic Exception Safety: 주로 자원에 관한 규칙이다.
  2. Strong Exception Safety: 자원에 관한 규칙과 더불어 프로그램의 상태 불변성에 대한 규칙이다.
  3. No-Throw Guarantee: exception을 던지지 않는다는 규칙이다.

Basic Exception Safety(Basic Guarantee)

Basic Exception Safety, 혹은 Basic Guarantee, 기본(느슨한) 예외 처리 규칙이라고 한다. 이 규칙은 exception이 발생하더라도 자원의 누수가 발생하지 않는다는 것을 보장한다. 이를 보장하는 가장 간단한 방법은 RAII를 준수하는 것이다.

Strong Exception Safety(Strong Guarantee)

Strong Exception Safety, 혹은 Strong Guarantee, 강력한 예외 처리 규칙이라고 한다. 이 규칙은 exception이 발생하더라도 Basic Guarantee에서 보장하는 자원의 누수가 발생하지 않음은 물론이고, 프로그램의 상태 불변성을 보장한다. 즉 어떤 로직을 실행했을 때 예외가 발생하더라도, 로직을 실행하기 전 프로그램의 상태로 롤백할 수 있음을, 즉 프로그램의 상태가 변화하지 않음을 보장한다.

Basic Exception Safety는 자원의 해제는 보장하지만, 프로그램의 상태가 변화하지 않음 자체는 보장하지 않는다. 그러나 Strong Exception Safety는 예외가 발생할 떄 상태가 변하지 않음을 보장해야 하므로, 어쩌면 로직의 복잡성을 증가시키거나 재설계를 요구할 수도 있다.

프로그램의 상태 변화란 말이 어려울 수 있는데, 예를 들어서 다음 코드를 보자.

void User::reduceCredit(const int usage)
{
    this->credit -= usage;
    if (this->credit < 0)
    {
        throw std::runtime_error("credit is not enough");
    }
}

위 코드는 Basic Guarantee인 자원의 해제 문제에 대해서는 보장을 한다. 그러나 exception이 발생했을 때 프로그램의 상태가 변화하므로, Strong Guarantee를 보장하지 않는다. 이를 Strong Guarantee를 보장하도록 수정하면 다음과 같다.

void User::reduceCredit(const int usage)
{
    const auto credit_result = this->credit - usage;
    if (credit_result < 0)
    {
        throw std::runtime_error("credit is not enough");
    }
    this->credit = credit_result;
}

위와 같이 코드가 작성되면, exception이 발생하더라도 프로그램의 상태가 변하지 않음을 보장한다. 이때 strong exception safety를 보장하는 것이다.

참고로 strong exception safety를 보장한다는 말은, 위에서는 국소적인, 함수 내에서의 상태 변화가 일어나지 않음을 예시로 들었지만, 실제로는 전체적인 규모에서, exception이 발생했을 때 이를 핸들링하는 과정까지 프로그램의 상태가 변화하지 않음을 보장해야 한다. 그렇기 떄문에, strong exception safety를 지원하려면 다소 설계가 복잡해질 수 있다.

No-Throw Guarantee

No-Throw Guarantee란 함수 혹은 로직이 예외를 던지지 않음을 보장한다는 규칙이다. 이를 설명하기 위해 맨 위에 dividefn 함수를 다시 갖고 와보자.

int divide(const int x, const int y)
{
    if (y == 0) throw std::runtime_error("divide by zero");
    return x / y;
}

void fn(const int divider)
{
    std::cout << divide(divider) << std::endl;
}

int main()
{
    try
    {
        fn(0);
    } 
    catch (const std::exception &e)
    {
        std::cout << e.what() << std::endl;
    }
}

위 함수에서 fn은 직접 exception을 발생시키지는 않지만, divide 함수를 호출하는 과정에서 exception이 발생할 경우 이를 그대로 통과시킨다. 그렇기 때문에 fn은 No-Throw Guarantee를 보장하지 않는다. 참고로 이렇게, 자기 자신은 직접 exception을 발생시키지는 않지만, 호출하는 다른 함수들에서 exception이 발생할 수 있는 경우, 그리고 이 exception을 처리하지 않고 그대로 통과시킬 때 이 함수를 예외 중립적이라고 한다.

그러나 이 예외에 중립적인 fn 함수를 다음과 같이 수정하면 No-Throw Guarantee를 보장할 수 있다.

int divide(const int x, const int y)
{
    if (y == 0) throw std::runtime_error("divide by zero");
    return x / y;
}

void fn(const int divider)
{
    try
    {
        std::cout << divide(divider) << std::endl;
    }
    catch (const std::exception &e)
    {
        std::cout << e.what() << std::endl;
    }
}

int main()
{
    fn(0);
}

위 코드에서는 fndivide에서 발생한 exception을 직접 받아서 처리하고 있음을 알 수 있다. 즉 fn 그 자체는 exception을 발생시키지 않는다. 이때 fn은 No-Throw Guarantee를 보장한다고 말한다.

noexcept

한편 C++에서는 이렇게 No-Throw Guarantee를 보장하는 함수를 noexcept 키워드를 이용해서 표시할 수 있다. 이 noexcept 키워드는, const 키워드만큼이나 중요한 인터페이스의 일부이다. 사용자가 noexcept 키워드를 보고 로직을 설계할 수 있게 되므로, 중요한 인터페이스이자 키워드이다.

다만 주의해야 할 점은, 우리가 사용하는 대부분의 함수가 예외에 중립적임을 기억해야 한다. 예외에 중립적이라는 말은 위에서 설명했듯이, 함수 자체가 스스로는 exception을 발생시키지는 않지만, 호출하는 다른 함수에서 발생한 exception을 그대로 통과시킨다는 것이다. 이러한 함수들에서 noexcept를 붙이면, 프로그램은 그대로 std::terminate를 호출하여 종료시킨디. 그래서 noexcept 키워드는, 프로그램의 비즈니스 로직이 안정화되었을 때, 확실하게 예외가 발생하지 않음을 보장하는 시점에 가서 noexcept를 붙이는 게 좋다.

C++ Exception, 사용해야할까?

C++의 exception에 대해서 수많은 말들이 많았고, 또 이 글을 쓰기 위해서도 수 많은 자료를 찾아보았는데, 결국 이 문제는 선택의 문제라는 말이 가장 정답인 것 같다.

Google C++ Style Guide

구글의 C++ 스타일 가이드 Exception 부분을 살펴보자. 구글은 결론적으로 exception을 사용하지 않는다. 그 이유 중 몇 가지는 눈 여겨 보아야 할 포인트들이 있다.

  1. exception을 사용하게 되면, caller가 이를 지속적으로 상기하고 주의하면서 코드를 작성해야 한다. 예를 들어 f()g()을 호출하고, g()h()을 호출한다고 가정하자. h가 던진 에러를 f가 받게 되면, 이 과정에서 g는 매우 조심해서 작성되어야 한다.
  2. 이 부분은 흥미롭게 봐야 할 지점인데, 구글에서는 exception을 사용하게 되면, 프로그래머들이 exception을 던지는데 있어 덜 주의를 기울여서 던진다는 설명이다. exception을 별로 고민하지 않고 섣불리 던진다는 뜻이고, 코드의 완결성을 평가하는데 있어서 소홀해진다고 설명한다. 즉 어떠한 문제가 발생했을 때, 이 문제가 잘못된 로직을 수정해서 해결할 수 있는지를 평가하지 않고, 그냥 exception을 던짐으로써 문제의 해결 책임을 넘겨버리게 된다는 설명이다. 이로 인해 시간이 지나면서 코드의 유지 보수성이 감소하고 복잡성이 증가한다. 흥미로운 설명같다. 실제로 exception을 던져버리면 나의 문제는 일단락되지만, 전체적인 프로세스에서의 문제는 해결되지 않고 뒤엉켜버릴 수 있다.
  3. exception은 RAII 등과 같은 별도의 코딩 스타일을 요구한다. 특히 구글에서는 이미 존재하는 수많은 레거시 코드들이 많은데, exception의 장점을 도입하기 위해서 레거시 코드들을 RAII 스타일로 변경하는 비용이 더 많이 든다는 것이다.

이 외에도 코드의 전체적인 컴파일 시간이 증가하는 등 여러 자잘한 이유들이 존재하는데, 주요한 설명으로는 위와 같다. 흥미로운 점은 구글 역시 새로운 프로젝트에서 exception을 도입하였을 때 얻게 되는 이점들이 존재한다고 한다. 그럼에도 불구하고 위에서 열거한 단점들로 인해서, 구글에서는 exception을 사용하지 않기로 결정했다고 한다.

대신 구글에서는 exception을 던지는 대신, 상태를 리턴하는 식으로 프로그램을 작성한다. 구글은 자체적으로 개발한 absl 라이브러리를 사용하고 있는데, 이 absl의 Status 객체를 리턴한다고 설명한다.

absl::Status ReadSomethingAt(int i) {
  if (i >= some_vec.size()) {
    return absl::InternalError("Out of range");
  }

  // ...
}

그리고 이 Status 객체는 [[no_discard]] 속성으로 체크되어 있어서, 프로그래머들은 반드시 이 Status를 읽어서 상태를 체크해야 한다. 이를 통해서 에러 처리를 무시하거나 책임을 위임/전가하지 않고 직접 처리하게끔 강제하는 구글의 정책을 확인할 수 있다.

더 나아가서 C++ 표준 라이브러리에서 발생하는 예외는 그냥 segmentation fault(세그폴트)를 내면서 죽인다고 설명하는데, 구글의 코딩 스타일에 관한 더 자세한 설명은 위에 링크한 구글의 C++ 스타일 가이드를 참고하거나 이 포스팅을 읽어보면 도움이 될 것이다.

그리고 에러 코드에 대한 구글의 정책은, 구글에서 직접 만든 언어인 go에서도 잘 드러난다. go 언어에는 exception이 없다. 대신 함수에서 매번 error code를 리턴하게끔 하여, caller가 직접 이를 확인하는 책임을 갖게 한다.

다음 프로그램은 파일 내용을 읽어서 출력하는 프로그램이다. 이떄 파일이 없다면 임시 파일을 생성한 다음 파일을 다시 읽어들인다.

package main
import (
    "fmt"
    "os"
    "bufio"
)

func ReadFile(filename string) (string, error) {
    file, err := os.Open(filename) // 파일 열기
    if err != nil {
        return "", err // 에러 발생시 에러 리턴
    }
    defer file.Close() // 함수 종료시 파일 닫기
    rd := bufio.NewReader(file)
    line, _ := rd.ReadString('\n') // 한 줄 읽기
    return line, nil
}

func WriteFile(filename string, line string) error {
    file, err := os.Create(filename) // 파일 생성
    if err != nil {
        return err // 에러 발생시 에러 리턴
    }
    defer file.Close() // 함수 종료시 파일 닫기
    _, err = fmt.Fprintln(file, line) // 파일에 문자열 쓰기
    return err
}

func main() {
    const filename string = "data.txt"
    line, err := ReadFile(filename) // 파일 읽기
    if err != nil {
        err = WriteFile(filename, "Temp write file") // 파일 읽기 오류 시 임시 파일 생성
        if err != nil {
            panic(err) // panic은 go에서 프로그램을 죽이는 함수임
        }
        line, err = ReadFile(filename) // 파일 읽기
        if err != nil {
            panic(err)
        }
    }

    fmt.Println("file contents:", line)
}

위 예제에서 무수히 많은 if 문의 단점을 볼 수 있지만... caller가 에러 코드를 직접 확인함으로써 에러를 무시하지 않고 처리한다는 원칙을 확인할 수 있다.

Microsoft C++ Core Guidelines

그러나 Microsoft에서는 exception을 사용한다. C++ Core Guidelines에서는 exception handling에 대한 Microsoft 사의 정책과, 모범 사례들을 볼 수 있다. 여러가지 이유들이 존재하는데, 결론적으로 Microsoft에서는 예외를 사용한다. 대신 예외에 대한 여러 가지 가이드라인과 원칙들(RAII, Exception Safety Rules 등)을 사용해서, 예외 처리에 대한 안정성을 높이면서 예외가 갖는 장점들을 유지한다.

Exception을 사용하면 안 되는 상황들

마지막으로, exception을 사용하면 안 되는 상황들, 그리고 적지만 반드시 exception을 사용해야만 하는 경우에 대해서는 다시 한 번 짚고 넘어가도록 하자. 대부분은 이미 선술한 내용들인데, 다시 한 번 정리해보자.

exception을 통해서 객체 전달하기

throw 문은 기본적으로 std::exception이 아니여도, 모든 객체를 던진다. 그래서 간혹 가다가 이를 오용해서, 객체를 throw하여 전달하는 식으로 프로그램을 짜는 경우도 있는데, 이는 정말 해서는 안 될 일이다. return 문으로 충분하지 않다면 그건 프로그램을 잘못 작성한 경우이다. 애초에 throw를 발생시키는 것 자체가 스택 언와인딩까지 포함해서 굉장히 성능에 무리한 부담을 주는 행동인데, 이를 통해서 객체를 주고 받는 행동은 하지 말아야 한다.

객체를 throw로 주고 받는 예시로 다음 코드를 살펴보자.

std::unique_ptr<Zookeeper> Zoo::assignAnimal(AnimalType animal_type) {
    switch (animal_type)
    {
    case AnimalType::Cat:
        throw Cat;
    case AnimalType::Dog:
        throw Dog;
    case AnimalType::Bird:
        throw Bird;
    default:
        return std::make_unique<Zookeeper>();
    }
}
// caller
try {
    auto zookeeper = zoo.assignAnimal(AnimalType::Cat);
    // Do something with zookeeper
} catch (const Cat& cat) {
    // Handle Cat
} catch (const Dog& dog) {
    // Handle Dog
} catch (const Bird& bird) {
    // Handle Bird
}

assignAnimal 함수는 animal_type이 유효하다면 그에 맞는 Animal 파생 객체를 throw하고, 아니라면 Zookeeper 포인터 객체를 반환한다. 함수를 사용하는 방법은 바로 아래 코드에서 확인할 수 있다.

여기서는 억지로 잘못된 예시를 들기 위해서 임의로 작성된 함수이니, SOLID의 단일 책임 원칙 등은 무시하자. 서로 다른 타입의 객체를 반환하기 위해서 위와 같이 작성했을 때 스마트하다고 생각할 수 있는데, 아주 잘못된 방법이다. 차라리 다음과 같이 std::variant를 사용해서 작성해보자.

std::variant<std::unique_ptr<Zookeeper>, std::unique_ptr<Animal>> Zoo::assignAnimal(AnimalType animal_type) {
    switch (animal_type) {
    case AnimalType::Cat:
        return std::make_unique<Cat>();
    case AnimalType::Dog:
        return std::make_unique<Dog>();
    case AnimalType::Bird:
        return std::make_unique<Bird>();
    default:
        return std::make_unique<Zookeeper>();
    }
}
auto zookeeper_or_animal = zoo.assignAnimal(AnimalType::Cat);
std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, std::unique_ptr<Zookeeper>>) {
        // Handle Zookeeper
        // arg can be used as a std::unique_ptr<Zookeeper> here
    } else if constexpr (std::is_same_v<T, std::unique_ptr<Animal>>) {
        // Handle Animal
        // arg can be used as a std::unique_ptr<Animal> here
        if (typeid(*arg) == typeid(Cat)) {
            // Handle Cat
        } else if (typeid(*arg) == typeid(Dog)) {
            // Handle Dog
        } else if (typeid(*arg) == typeid(Bird)) {
            // Handle Bird
        }
    }
}, zookeeper_or_animal);

어떻게 작성하든, 객체를 throw 를 통해서 주고 받는 건 심하게 말해서 정신 나간 짓이다. 절대 하지 말자. 애초에 std::variant, std::optional, std::pair, std::tuple 등 다양한 옵션이 나온 현재에서 객체를 throw 해서 주고 받을 이유가 전혀 없기도 하다.

자주 던져지는 exception은 사용하지 않기

우리가 exception을 사용하기로 결정했다면, 그로 인해 발생하는 오버헤드는 피할 수 없음을 받아들여야 한다. 아무리 그 크기가 작더라도 결국 exception을 던지기 위해서는 exception 객체가 만들어져야 하기 때문이다. 그래서 만약 자주 exception이 발생하는 함수 f()가 자주 반복되는 for 루프 안에 있다면, 이때는 매번 exception이 발생할 때마다 이를 처리해주어야 하기 때문에 성능 저하가 일어날 것이다. 그래서 자주 발생하는 에러는 exception으로 처리하지 않는 게 좋다.

함수 내에서 처리가 가능한 에러

만약 굳이 함수 내에서 처리가 가능한 에러라면, exception을 사용할 이유가 전혀 없다. 그냥 함수 내에서 처리하면 된다. exception은 오직 함수 내에서 처리가 불가능한 에러에 대해서, 처리가 가능한 상위 함수에게 처리를 위임하기 위해서 사용해야 한다.

버그를 exception으로 잡지 마라

예를 들어 nullptr 에러, std::out_of_range 에러 등을 exception으로 던지는 경우가 있다. 그런데 이와 같이 "일어나면 안 되는 행동들"은, 예외로 처리해야 할 상황이 아니다. 그냥 실수이자 버그이다. 이런 문제들은 exception으로 처리하는 게 아니라, 그냥 코드를 수정해서 버그를 고쳐야 한다.

절대 일어나지 않을 에러를 가정하고 exception을 사용하지 마라

절대 일어나지 않는 일은 절대 일어나지 않는다. 이 상황을 굳이 가정해서 exception을 발생시키고 핸들링하는 것은 코드의 복잡성만 늘릴 뿐이다.

한편, 절대로 일어나지 않을 상황 말고, 절대로 일어나지 않아야 할 상황도 있다. 이때도 exception을 발생시키면 안 된다. 절대로 일어나지 않아야 할 상황은 exception을 통해서 처리하는 게 아니라, 그냥 잘못된 코드와 로직을 고치는 게 맞다. std::assertion을 사용해서 디버그 과정 중에서 절대 일어나면 안 되는 상황을 확인하거나 그래야지, 잘못된 로직을 가지고 exception으로 핸들링하고 있으면 안 된다.

절대로 일어나지 않을 상황을 가정하여 exception을 발생시키지 말라. 또한 일어나면 안 되는 상황을 exception을 통해 처리하지 말고, 잘못된 코드와 로직을 고쳐라.

destructor, swap, move, default constructor에서는 exception을 사용하지 말라

얘네들은 절대로 exception을 던지면 안 된다. 위 네 가지 함수들에서 exception을 던지면 C++의 exception handling이 전부 꼬여버린다. 이 함수들은 그렇기 때문에 명시적으로 표시하지 않아도 자체적으로 noexcept 키워드가 붙는다. 위 함수들은 그냥 외우고 exception을 사용하지 말자.

각각 exception을 사용했을 때 어떤 문제가 발생하는 지를 간략히 정리해보자면:

  • destructor: 만약 destructor가 예외를 던진다고 해보자. 그 예외가 처리되지 않으면 프로그램은 std::terminate를 통해 종료된다. 그런데 이때 std::terminate는 프로그램이 종료되기 전 모든 객체들의 destructor를 호출된다. 그런데 이때, 이미 예외가 발생한 상황에서 destructor가 호출되면서 또 예외가 발생한다. 이 예외가 다시 한 번 std::terminate 함수를 호출시키고, 또 예외가 던져지고... 이런 식으로 무한 반복이 일어난다. C++에서는 이런 식으로 발생한 두 개 이상의 예외를 처리할 수 없다. 그래서 destructor에서는 절대로 exception을 던지면 안 된다.
  • Swap/Move: swap과 move는 대부분 자원 관리와 관련되어 있다. 따라서 실패하지 않고 빠르게 수행되어야 한다. 그런데 이 과정에서 예외가 발생한다면, 결과가 이미 undefined behavior가 되어서 예상이 불가능하고 복구하기도 어려워진다. 특히 move 연산은 객체를 복구하는 게 거의 불가능하다. 그래서 이런 함수들은 절대로 exception을 던지면 안 된다. 또한 r-value와 관련된 함수들은, 왠만하면 noexcept로 처리하는 게 좋음을 기억하자.
  • Default Constructor: default constructor는 객체를 생성하는 과정에서 예외가 발생하면, 객체가 생성되지 않은 채로 남아있게 된다. 그런데 이 객체를 사용하려고 하면, undefined behavior가 발생한다. 그래서 default constructor에서는 절대로 exception을 던지면 안 된다. 또한 default constructor는 종종 다른 컨테이너나 데이터 스트럭쳐에서 객체 생성에 사용되는데, 이떄 이 데이터 구조의 정상적인 작동을 방해할 수 있다.

위와 같이 정리할 수 있는데, 알 필요는 굳이 없고, 그냥 절대 쓰지 않는다는 것만 기억하자.

exception을 사용해야만 하는 경우

constructor

반드시 exception을 사용해야만 하는 경우가 있다. 생성자 함수, constructor의 경우 반환값이 없기 때문에, 생성 과정에서 에러가 발생했을 때 이를 외부에서 알 수 있는 방법은 오직 exception밖에 없다.

결론

결론적으로 이 문제는 옳고 그름의 문제가 아니라, 결국 팀의 스타일과 정책, 프로젝트의 특성, 그리고 개인의 취사 선택에 달린 문제이다. 결국 exception이 가진 단점과 장점에 대해서 정확하게 알고 본인이 이에 대해서 결정할 수 있다면, 그것이 가장 좋은 방법이 될 것이다.

필자는 개인적으로 굳이 사용하지는 않기로 결정했다. 왜냐면 이미 충분히 많은 옵션들이 존재하기 때문이다. 다만, 그럼에도 불구하고 exception 자체는 이미 다른 언어에서 너무나 많이 사용되고 있기에, 자세히 알아둘 필요는 있다고 생각한다. 또한 적절한 타이밍에 코드의 간결성을 줄일 수 있는 exception의 사용은 언제나 하나의 옵션으로 고려되어야 한다.

또한, 만약 exception을 사용할 때는 항상 exception safety를 고려해야 한다. exception을 사용하면서도 exception safety를 고려하지 않으면, exception을 사용하는 의미가 없어진다. exception을 사용할 때는 항상 exception safety를 고려하자.