C++에서 오류 코드를 반환해보자 - optional, variant, pair, tuple, absl::Status, expected

C++에서 오류 코드를 리턴하는 다양한 방법들

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

이전 글에서, C++에서 에러(std::exception)를 던지는 방법에 대해서 알아보았고, 구글은 에러를 사용하지 않는다고 하였다. 그렇다면 에러를 던지지 않고, 오류 코드를 반환하는 방법은 무엇이 있을까?

당연하지만 고전적인 C언어 식의 방법, 즉 매크로로 오류 코드를 정의해놓고, 이를 반환하는 방법은 굳이 설명하진 않겠다. 이 방법은 C++에서도 사용할 수 있지만, C++에서는 더 좋은 방법들이 존재한다.

결론부터 말하면 이 글에서는 std::optional, std::variant, std::pair, std::tuple, absl::Status, std::expected에 대해서 살펴볼 것이다.

std::optional

공식 문서: std::optional

std::optional은 C++11부터 도입된, 선택적인 자료형이다. 여기서 선택적이라는 말은, 값이 들어있을 수도 있고, 들어있지 않을 수도 있음을 의미한다. 값이 들어있지 않을 떄는 std::nullopt로 표현된다. 만약 값이 들어있다면, std::optional::value() 함수로 값을 꺼내온다(이떄 만약 값이 들어있지 않다면, std::bad_optional_access라는 exception을 던진다).

값이 들어있는지 여부를 확인할 때에는 has_value() 라는 함수로 체크해주면 된다. 그냥 예시를 직접 보자. 아래 에러를 던지는 코드의 형태를, std::optional을 사용하여 바꿔보자.

#include <iostream>

int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("Cannot divide by zero");
    }
    return a / b;
}

int main() {
    int a, b;
    std::cin >> a >> b;
    try {
        std::cout << divide(a, b) << '\n';
    } catch (const std::exception& e) {
        std::cout << e.what() << '\n';
    }
}

위 코드를 std::optional을 사용해서 바꾸어보자.

#include <iostream>
#include <optional>

std::optional<int> divide(int a, int b) {
    if (b == 0) {
        return std::nullopt;
    }
    return a / b;
}

int main() {
    int a, b;
    std::cin >> a >> b;
    auto result = divide(a, b);
    if (result.has_value()) {
        std::cout << result.value() << '\n';
    } else {
        std::cout << "Cannot divide by zero\n";
    }
}

만약 divide 함수가 std::nullopt를 반환했다면, 이것이 에러가 되는 거고, 정상적인 처리 과정을 통해 값을 반환했다면, std::optional::value 로 값을 꺼내옴을 알 수 있다.

여기서 더 나아가서, dart의 ? 관련 연산자처럼 사용하는 것도 가능하다. 무슨 말이냐면, 아래 dart 코드를 보자.

void printer(String? str) {
    print(str ?? "null");
}

위 dart 코드에서 만약 str이 null이면, print 함수는 "null" 을 출력한다. 반대로 str이 값이 들어있다면, str 값을 출력할 것이다. 위 코드를 C++의 optional 스타일로 바꾸어보면 아래와 같다.

void printer(std::optional<std::string> str) {
    std::cout << (str.value_or("null")) << '\n';
}

std::optional::value_or 함수는 값이 있다면 그 값을 쓰고, 없다면 지정한 대체 값을 반환하는 함수이다.

std::optional은 도입된 이후로 꾸준히 사랑받고 있으며, 유용하고 많이 사용돼서 알아두는 게 좋다. 여러 연산자들을 지원하는데, 자세한 내용은 공식 문서를 참고하되, 몇 가지만 살펴보고 가자.

만약 value로 매번 값을 접근하기 귀찮다면, operator* 연산자를 쓰거나 operator->를 쓰면 된다.

std::optional<User> user = getCurrentUser();
if (user) { // user.has_value랑 같은 의미임
    std::cout << user->name << '\n'; // 같은 결과
    std::cout << *user.name << '\n'; // 같은 결과
}

std::variant

공식 문서: std::variant

std::variant는 C++17부터 도입된, 여러 가지 타입을 동시에 저장할 수 있는 타입이다. 사실 원래는 Union(유니온)이라는 구조체를 더 안전하게 사용하기 위한 타입이다.

유니온이 뭔지부터 알아보자. 유니온은 사실 C언어에서부터 존재했는데, 여러 타입을 동시에 저장할 수 있는 구조체이다. 예를 들어, 아래와 같은 유니온을 정의할 수 있다.

union MyUnion {
    int a;
    double b;
    char c;
};

위에서 MyUnionint, double, char 메모리들이 한 공간을 공유한다. 그러면서 메모리 공간을 절약할 수 있다. 사용할 때는 아래와 같이 사용할 수 있다.

MyUnion u;
u.a = 1;
std::cout << u.a << '\n'; // 1
u.b = 3.14;
std::cout << u.b << '\n'; // 3.14

그런데 유니온 자체는 여러가지 구조적 문제점을 안고 있는데, 일단 만약에 double 형의 값을 지정해놓고서 int 형태로 읽는다면, 이는 undefined behavior, 즉 정의되지 않은 행동이라면서 예측할 수 없는 결과를 가져온다. 그래서 이 취약점을 해결하기 위해 std::variant가 도입됐다.

#include <iostream>
#include <variant>

struct S {
    int a;
    double b;
    char c;
};

union U {
    int a;
    double b;
    char c;
};

int main() {
    std::variant<int, double, char> v;
    std::cout << sizeof(S) << '\n'; // 24
    std::cout << sizeof(U) << '\n'; // 8
    std::cout << sizeof(v) << '\n'; // 16
}

혹시 위의 코드를 실행했을 때 나오는 결과를 예측할 수 있겠는가?

24
8
16

왜 이런 결과가 나오는 지 잠깐 설명하고 가자면, 먼저 structunion은 크게 어려울 게 없다. struct의 경우 메모리 공간을 공유하지 않고 따로 각각 할당된다. 이때 컴파일러 최적화를 위해서, 메모리 공간 상에 패딩이라는 영역을 주게 된다. 패딩을 주어서, 전체적인 크기는 구조체 중 가장 큰 메모리 공간을 차지하는 원소의 배수로 주어진다. 여기서는 double이 8 바이트 크기로 가장 큰 영역을 차지하고 있다. 그래서 int, char은 각각 4바이트, 1바이트지만, 이 과정에서 그냥 8바이트로 패딩이 추가되면서 총 24바이트가 그 크기가 8byte의 배수로 정해진다.

그림 상으로 표현하면 아래와 같다.

그리고 union은 메모리 공간을 공유하기 때문에, 가장 큰 메모리 공간을 차지하는 double의 크기인 8바이트만 할당된다. 그래서 8바이트가 그 크기가 된다.

하지만 std::variant는, 내부적으로 어떤 타입으로 할당되었는지를 알려주는 정보가 추가로 들어가있다. 그래서 8바이트의 메모리가 추가로 할당되어 있어서, union보다 8바이트가 더 많은 16바이트가 할당된다.

이제 std::variant를 사용해보자. union 대신 std::variant를 사용하는 가장 큰 장점은, 할당된 타입이 아닌 다른 타입으로 값을 읽을 때 union에서는 이를 undefined behavior로 정의했지만, std::variant는 그대로 에러를 던진다는 점이다.

std::variantstd::get() 함수를 통해서 값을 읽어온다.

int main() {
    std::variant<int, double, char> v;

    v = 1; // v에 1 할당
    std::cout << std::get<double>(v) << '\n'; // 에러 발생
}

여기서 int를 v에 할당해놓고 double 형식으로 값을 읽어오려고 시도하고 있으므로, 에러가 발생한다. 즉 이 상황을 에러 핸들링을 통해 적절하게 처리할 수 있다는 건데, 이 에러 핸들링 자체가 싫다면(우리는 에러 핸들링 대신 사용할 대체 방법을 찾고 있으므로) std::get_if 함수를 사용하면 된다.

int main() {
    std::variant<int, double, char> v;

    v = 10;
    if (auto value = std::get_if<double>(&v)) {
        std::cout << *value << '\n';
    }
    else {
        std::cout << "double 타입이 아닙니다.\n";
    }

    v = 3.14;
    if (auto value = std::get_if<double>(&v)) {
        std::cout << *value << '\n';
    }
    else {
        std::cout << "double 타입이 아닙니다.\n";
    }
}

결과:

double 타입이 아닙니다.
3.14

std::get_if 함수는 std::get 함수와 달리, 에러를 던지지 않고, 에러가 발생하면 nullptr를 반환한다. 그래서 이를 활용해서 타입 체크를 할 수 있다.

std::variantunion 대비 더 좋은 점은, 호출자가 직접 constructor/destructor를 호출해주지 않아도 알아서 관리가 된다는 점이다.

std::variant<std::vector<int>, std::string> v;

v = std::string("hello");
std::cout << std::get<std::string>(sv) << std::endl;
// hello

v = std::vector<int>{1, 2, 3, 4, 5};
std::cout << std::get<std::vector<int>>(sv).size() << std::endl;
// 5

원래 만약 union 함수를 사용했다면 destructor/constructor를 직접 호출해야 한다.

단순히 이렇게 union을 대체하는 역할 외에도, std::variant는 에러 코드를 리턴하는 방식으로 사용 가능하다.

위에서 보여준 std::optional의 예시를 다시 가져와보자.

std::optional<int> divide(int a, int b) {
    if (b == 0) {
        return std::nullopt;
    }
    return a / b;
}

위 함수는 문제는 없긴 한데, std::optional의 경우 그 값이 유효한지 무효한지만 체크해주기 때문에, 더 많은 오류에 대한 정보를 담고 있지는 않는다. 이를 std::variant로 바꾸어보면

std::variant<int, ErrorCode> divide(int a, int b) {
    if (b == 0) {
        return ErrorCode::kDivideByZero;
    }
    return a / b;
}

이렇게 바꿀 수 있다.
사용할 때는 이렇게 사용하자.

auto result = divide(10, 0);
if (auto value = std::get_if<ErrorCode>(&result)) {
    std::cout << "에러 발생: " << *value << std::endl;
}
else {
    std::cout << "결과: " << std::get<int>(result) << std::endl;
}

std::pair

공식 문서: std::pair

std::pair는 두 개의 값을 묶어서 하나의 값으로 만들어주는 클래스이다. 사실 std::pair는 은근히 많이 사용되는 타입이라 익숙할 것이다.

std::pair<int, double> p(1, 3.14);

std::cout << p.first << std::endl; // 1
std::cout << p.second << std::endl; // 3.14

const auto [x, y] = p;
std::cout << x << std::endl; // 1
std::cout << y << std::endl; // 3.14

참고로 위에 예시에서 볼 수 있듯이, std::pair는 기본적으로 자동 언팩킹을 지원한다. auto 키워드와의 조합으로, 굳이 first, second로 값에 접근하는 것보다, 언팩킹을 통해서 값을 가져오는 것이 더 편리하고 바람직하다.

이를 이용해서 에러 코드를 리턴하는 함수를 만들어보자.

std::pair<int, ErrorCode> divide(int a, int b) {
    if (b == 0) {
        return {0, ErrorCode::kDivideByZero};
    }
    return {a / b, ErrorCode::kSuccess};
}

사용할 때는 이렇게 사용하자.

auto [result, error] = divide(10, 0);
if (error != ErrorCode::kSuccess) {
    std::cout << "에러 발생: " << error << std::endl;
}
else {
    std::cout << "결과: " << result << std::endl;
}

참고로 std::pair는 다른 STL 컨테이너들, std::unordered_map이나 std::map 등을 구성할 때 매우 유용하게 사용되니깐, 꼭 기억해두자.

std::tuple

공식 문서: std::tuple

std::tuple은 사실 std::pair와 거의 비슷한데, std::pair는 값을 두 개만 들고 있는 한편 std::tuple은 값을 여러 개 들고 있다는 점이 다르다. 튜플도 마찬가지로 자동 언팩킹을 지원한다.

std::tuple<int, double, std::string> t(1, 3.14, "hello");

const auto &[number, pi, message] = t;

std::cout << number << std::endl; // 1
std::cout << pi << std::endl; // 3.14
std::cout << message << std::endl; // hello

물론 자동 언팩킹을 사용하지 않고, std::get 함수를 사용하는 것도 가능하다. 다만 굳이 싶긴 하다. 가독성이 더 좋은 언팩킹이 있는데 싶다.

std::cout << std::get<0>(t) << std::endl; // 1
std::cout << std::get<1>(t) << std::endl; // 3.14
std::cout << std::get<2>(t) << std::endl; // hello

이를 이용해서 에러 코드를 리턴해보면

std::tuple<int, ErrorCode> divide(int a, int b) {
    if (b == 0) {
        return {0, ErrorCode::kDivideByZero};
    }
    return {a / b, ErrorCode::kSuccess};
}
auto [result, error] = divide(10, 0);

if (error != ErrorCode::kSuccess) {
    std::cout << "에러 발생: " << error << std::endl;
}
else {
    std::cout << "결과: " << result << std::endl;
}

다만 이럴 떄는 그냥 std::pair를 사용하는 게 더 일반적이다. 보통 튜플은 함수가 여러 개의 값을 동시에 리턴하고 싶을떄 사용한다.

absl::Status

공식 문서: absl::Status

이전 글에서 구글은 absl::Status, 혹은 absl::StatusOr을 이용해서 에러 상태를 반환한다고 설명했다. 이에 대해서 더 자세히 알아보자.

absl::Statusabsl에서 정의하고 있는 에러 상태를 반환하는 함수이다.

absl::Status MyFunction(absl::string_view filename, ...) {
  ...
  // encounter error
  if (error condition) {
    return absl::InvalidArgumentError("bad mode");
  }
  // else, return OK
  return absl::OkStatus();
}

만약에 정상적인 상태인 경우, absl::OkStatus를 반환한다.

그리고 중요한 것은, absl::Status는 그 자체로 [[nodiscard]]로 마크되어 있어서, 프로그래머는 이 에러를 반드시 읽어야 한다. 읽지 않으면 컴파일조차 되지 않는다. 즉 프로그래머가 에러를 그냥 무시하고 넘기는 것 자체를 허용하지 않는다.

에러를 체크할 때는 이렇게 하면 된다.

const auto status = MyFunction(...);

// Don't do this:
//
//   if (my_status.code() == absl::StatusCode::kOk) { ... }
//
// Use the Status.ok() helper function:
if (!status.ok()) {
  // handle error
}

공식 문서에서도 나와있지만, my_status.code() == absl::StatusCode::kOk 이런 식으로 비교할 필요 없이, ok() 함수로 직접 비교하라고 한다.

혹시나 에러 코드 확인하는 것을 무시하고 싶다면, IgnoreError() 함수를 사용하자. 이렇게 되면 [[nodiscard]] 속성이 무시될 것이다.

// Don't let caching errors fail the response.
StoreInCache(request, response).IgnoreError();

다만 에러 코드를 무시하는 것은 신중히 고려하자. exception을 사용하지 않는 구글의 코딩 스타일 가이드 상, 특별한 이유 없이 에러를 무시하는 것은 자칫 큰 문제를 일으킬 수 있다.

상태 혹은 값 반환하기

한편 absl::StatusOr<T>는 에러 코드 혹은 T 타입의 값을 반환하는 함수에서 사용된다. 예를 들어서 absl::StatusOr<int>int 타입의 값을 반환하거나, 에러 코드를 반환한다.

StatusOr<Foo> result = Calculation();
if (result.ok()) {
  result->DoSomethingCool();
} else {
  LOG(ERROR) << result.status();
}
StatusOr<std::unique_ptr<Foo>> result = FooFactory::MakeNewFoo(arg);
if (!result.ok()) {
  LOG(ERROR) << result.status();
} else if (*result == nullptr) {
  LOG(ERROR) << "Unexpected null pointer";
} else {
  (*result)->DoSomethingCool();
}

참고로 absl::Status에 관한 모든 예제들은 공식 문서에서 직접 긁어온 것들이다. 그러니 자세한 사용법을 더 알고 싶다면, 꼭 공식 문서를 참고하자.

그리고 하나 주의할 것이, std가 붙지 않고 absl이 붙었다. 이 말은 cpp에서 기본적으로 지원해주는게 아니라, 직접 absl 라이브러리를 설치해야 한다는 의미다.

참고로 absl은 구글에서 자체 개발한 cpp 라이브러리인데, boost 라이브러리만큼이나 다양하고 큰 자체적인 생태계를 가지고 있다. Status 외에도, absl::string_viewabsl::flat_hash_map 등의 자체적인 구조체를 갖고 있기도 하다. 특히 이러한 해시맵에 관련돼서는, CPP의 std::unordered_map은 선형 탐색에 굉장히 취약한 모습을 보이는 등 여러 성능적인 문제점을 갖고 있는데, absl 라이브러리의 여러 컨테이너 옵션들은 STL의 단점이나 한계들을 보완해준다.

그 외에도 다양한 유틸성 기능들을 지원해주기도 하니, 궁금하다면 absl의 공식 문서를 참고하자.

std::exception

공식 문서: std::exception

마지막으로, C++23에 도입된 std::exception에 대해서 이야기해보자. 결론부터 말하면, 굳이 사용할 필요는 없다. absl::StatusOr<T>와 비슷한데, 문제는 C++23에 도입됐다는 이야기는 아직 충분히 검증되지 않았다는 이야기다. 사실 새 기능이 도입되면 무작정 이를 사용하기 보다는, 2-3년 정도는 지켜보다가 충분히 생태계가 구축되고 사용하는 사람들이 많아지면 그때부터 도입을 검토해보는 것을 추천한다.

template< class T, class E >
class expected;

위와 같이 정의되어 있고, 각각 T는 값, E는 에러 타입이다.

std::expected<int, ErrorCode> divide(int a, int b) {
  if (b == 0) {
    return std::unexpected(ErrorCode::kDivideByZero);
  }
  return a / b;
}

에러 체크할 때는 이렇게 체크하면 된다.

auto result = divide(10, 0);

if (result.has_value()) {
    std::cout << *result << '\n';
}
else {
    std::cout << "Error: " << result.error() << '\n';
}

그 외에도 value_or() 함수도 지원한다. 자세한 사항은 공식 문서를 꼭 참고하자.

마치며

C++에서 사용 가능한, std::exception 외에 다양한 옵션들을 알아보았다. 언제나 그렇지만, 새로운 것을 무작정 도입하기 보다는, 기존 개발 환경과 스타일, 팀의 정책 등을 검토하고서 도입할 만한 장점이 있을 때 도입하는 게 좋다. 그럼에도 단순히 익셉션을 던지는 것 외에 다양한 옵션들이 있다는 것은 알아둘 필요가 있다.

그리고 항상 이런 글을 쓸 때마다 붙이진 않았지만 강조하고 싶은 것은, 모든 기능을 다 알 필요가 없다. 단지 이러한 옵션이 있다는 것을 기억해두었다가, 나중에 필요할 때 검색해서 사용할 수 있어야 한다. 그리고 그렇기 때문에 언제든지 필요할 때마다 공식 문서를 검색해서 참고할 수 있는 능력 역시 중요하고, 또한 2023년 현재는 ChatGPT를 활용하는 것도 중요한 능력이 될 것이다.

참고 자료