Published on

C++23 std - -expected로 예외 없는 에러처리

Authors

예외 기반 에러 처리는 강력하지만, 시스템/네트워크/게임 서버처럼 실패가 “정상적인 경로”로 자주 발생하는 코드에서는 비용과 복잡도가 빠르게 커집니다. 특히 다음 상황에서 예외는 팀과 코드베이스에 부담을 줍니다.

  • 실패가 흔한 I/O에서 try/catch가 여기저기 흩어져 가독성이 떨어짐
  • ABI 경계(플러그인, DLL, C API)에서 예외 전파가 위험하거나 금지됨
  • “예외를 안 쓰는 빌드 옵션” 또는 “예외 없는 런타임 정책”을 강제하는 환경이 존재
  • 실패를 로깅/메트릭으로 구조화해 수집하고 싶은데 예외 타입이 제각각

C++23의 std::expected는 이런 문제를 겨냥해 “성공 값 또는 실패 값”을 타입으로 명시합니다. 즉, 실패를 제어 흐름이 아니라 데이터로 다루게 해줍니다.

이 글에서는 std::expected의 기본 사용법부터, 조합 함수(and_then, transform, or_else)로 에러 전파를 단순화하는 방법, 그리고 실무에서의 에러 타입 설계 팁까지 정리합니다.

std::expected 한 줄 정의

std::expected<T, E>는 다음 둘 중 하나를 담습니다.

  • 성공: T 타입 값
  • 실패: E 타입 에러

핵심은 “함수가 실패할 수 있음”을 시그니처로 드러내고, 호출자가 실패를 반드시 고려하도록 만드는 것입니다.

기본 예제: 파일 읽기

먼저 C++23에서 std::expected를 쓰려면 헤더는 #include <expected> 입니다.

#include <expected>
#include <fstream>
#include <sstream>
#include <string>

enum class ReadError {
    NotFound,
    PermissionDenied,
    IoError
};

std::expected<std::string, ReadError> read_file_text(const std::string& path) {
    std::ifstream in(path);
    if (!in.is_open()) {
        // 실제로는 errno 등을 보고 더 세분화 가능
        return std::unexpected(ReadError::NotFound);
    }

    std::ostringstream ss;
    ss << in.rdbuf();

    if (!in.good() && !in.eof()) {
        return std::unexpected(ReadError::IoError);
    }

    return ss.str();
}

호출부는 이렇게 됩니다.

#include <iostream>

void print_config(const std::string& path) {
    auto r = read_file_text(path);
    if (!r) {
        // r.error()로 에러 접근
        std::cerr << "read failed\n";
        return;
    }

    std::cout << r.value() << "\n";
}

여기서 중요한 포인트:

  • 성공/실패가 if (!r)로 분기 가능
  • 성공 값은 r.value() 또는 *r로 꺼낼 수 있음
  • 실패 값은 r.error()로 꺼냄
  • 실패를 만들 때는 std::unexpected(e)를 반환

value() 대신 안전한 접근: value_or

value()는 실패 상태에서 호출하면 프로그램이 종료될 수 있습니다(구현에 따라 예외 또는 terminate). 예외 없는 정책이라면 특히 조심해야 합니다. 기본값이 자연스러운 경우 value_or가 유용합니다.

std::string text = read_file_text("config.json").value_or("{}");

다만 이 방식은 “실패를 무시하고 기본값으로 진행”하는 의미이므로, 설정 파일처럼 실패가 치명적일 수 있는 곳에는 남용하지 않는 것이 좋습니다.

에러 전파를 깔끔하게: and_thentransform

std::expected의 진짜 장점은 여러 단계의 처리를 체이닝으로 연결할 수 있다는 점입니다.

  • transform: 성공 값 T를 다른 값 U로 변환 (실패면 그대로 실패)
  • and_then: 성공 값 T를 받아서 또 다른 expected<U, E>를 반환하는 함수를 연결
  • or_else: 실패 에러 E를 받아 복구하거나 다른 실패로 매핑

예제: 파일 읽기 후 JSON 파싱

아래는 “읽기 성공하면 파싱, 실패면 그대로 반환” 패턴입니다.

#include <expected>
#include <string>

struct Json {
    // 예시용
};

enum class ParseError {
    InvalidJson
};

// 읽기 에러와 파싱 에러를 한 타입으로 합치기 위한 예시
struct ConfigError {
    enum class Kind { Read, Parse } kind;
    int code; // 단순화한 코드
};

std::expected<Json, ParseError> parse_json(const std::string& text) {
    if (text.empty()) {
        return std::unexpected(ParseError::InvalidJson);
    }
    return Json{};
}

ConfigError map_read_error(ReadError e) {
    return ConfigError{ConfigError::Kind::Read, static_cast<int>(e)};
}

ConfigError map_parse_error(ParseError e) {
    return ConfigError{ConfigError::Kind::Parse, static_cast<int>(e)};
}

std::expected<Json, ConfigError> load_config(const std::string& path) {
    return read_file_text(path)
        .transform_error(map_read_error)
        .and_then([](const std::string& text) {
            return parse_json(text).transform_error(map_parse_error);
        });
}

여기서 눈여겨볼 점:

  • and_then은 “성공일 때만 다음 단계 실행”을 표현
  • 서로 다른 에러 타입을 transform_error로 공통 에러 타입으로 합칠 수 있음
  • 중간에서 if 분기와 조기 return이 줄어들어, 파이프라인이 명확해짐

이런 스타일은 재시도/백오프 같은 정책을 넣을 때도 유리합니다. 예를 들어 실패를 값으로 다루면 “어떤 에러일 때만 재시도” 같은 규칙을 함수 조합으로 구현하기 쉬워집니다. 재시도 설계 관점은 Claude API 529 Overloaded 재시도·백오프 설계 글의 아이디어를 C++ 에러 처리에도 그대로 가져올 수 있습니다.

or_else로 복구 전략 넣기

실패를 “대체 경로로 복구”할 수 있다면 or_else가 깔끔합니다.

std::expected<std::string, ReadError> read_or_default(const std::string& path) {
    return read_file_text(path)
        .or_else([](ReadError) {
            return std::expected<std::string, ReadError>("{}");
        });
}

주의할 점은 or_else가 에러를 삼켜버릴 수 있다는 것입니다. 운영에서 문제를 추적해야 한다면, or_else 내부에서 로깅/메트릭을 남기거나, “복구했음”을 별도 필드로 표현하는 설계가 필요합니다.

실무에서 가장 중요한 설계: 에러 타입 E

std::expected 자체보다 더 중요한 건 E를 어떻게 잡느냐입니다.

1) enum class는 단순하고 빠르다

상태 코드가 명확하고 종류가 고정적이면 enum class가 가장 관리하기 쉽습니다.

  • 장점: 비교/스위치가 쉽고, 할당 비용이 거의 없음
  • 단점: 추가 정보(원인 문자열, errno, 재시도 가능 여부)를 담기 어려움

2) 구조체로 “운영 친화적” 에러 만들기

관측 가능성을 위해 최소한 아래는 담는 것을 권합니다.

  • 에러 카테고리(예: io, parse, network)
  • 코드(정수 또는 enum)
  • 재시도 가능 여부
  • 사람이 읽을 메시지(필요 시)
#include <string>

struct Error {
    enum class Category { Io, Parse, Network } category;
    int code;
    bool retryable;
    std::string message;
};

이렇게 하면 상위 계층에서 “재시도 가능한 에러만 backoff 적용” 같은 정책을 일관되게 적용할 수 있습니다. 클라우드 런타임에서 지연/타임아웃을 다루는 감각은 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 운영 글과도 연결됩니다. 결국 에러 처리는 코드 품질뿐 아니라 SLO와도 직결됩니다.

3) 문자열 에러는 마지막 수단

std::expected<T, std::string>은 빠르게 만들 수 있지만, 나중에 분기/집계/대응이 어려워집니다. 문자열은 “표시용”으로만 두고, 분기 가능한 코드는 enum이나 정수로 두는 편이 안전합니다.

예외 없는 코드베이스에서의 패턴: 조기 반환 매크로

체이닝이 항상 최선은 아닙니다. 절차적으로 읽히는 코드가 더 명확한 경우도 많습니다. 이때 expected에서 흔히 쓰는 패턴은 “실패면 즉시 반환”입니다.

표준에 공식 매크로가 있는 건 아니지만, 팀에서 일관된 형태로 도입할 수 있습니다.

#define TRY(expr)                          \
    ({                                     \
        auto _r = (expr);                  \
        if (!_r) return std::unexpected(_r.error()); \
        *_r;                               \
    })

다만 위 매크로는 GCC/Clang 확장(구문 표현식)을 사용하므로 이식성이 떨어집니다. 이식성을 원하면 함수 템플릿이나 C++23의 람다를 활용한 헬퍼로 대체하는 편이 낫습니다.

이식 가능한 형태의 예시는 다음처럼 “블록 스코프에서 단계적으로 검사”하는 방식입니다.

std::expected<int, Error> compute() {
    auto a = step1();
    if (!a) return std::unexpected(a.error());

    auto b = step2(*a);
    if (!b) return std::unexpected(b.error());

    return *b + 1;
}

이 패턴은 장황해 보이지만, 예외 없는 환경에서 디버깅이 매우 직관적입니다.

std::optional과 무엇이 다른가

std::optional<T>는 “값이 없을 수 있음”만 표현합니다. 왜 실패했는지 알 수 없습니다. expected는 실패 이유를 강제하므로 다음이 가능합니다.

  • 실패 사유별로 다른 처리(재시도, fallback, 사용자 메시지)
  • 로깅/메트릭에서 에러 코드를 집계
  • API 계약이 분명해져 호출자가 실수로 실패를 무시하기 어려움

std::error_code와 함께 쓰기

기존 C++ 생태계에는 std::error_code가 널리 쓰입니다. expected<T, std::error_code> 조합은 호환성과 실용성이 좋습니다.

#include <expected>
#include <system_error>

std::expected<int, std::error_code> parse_int(const std::string& s) {
    try {
        size_t idx = 0;
        int v = std::stoi(s, &idx);
        if (idx != s.size()) {
            return std::unexpected(std::make_error_code(std::errc::invalid_argument));
        }
        return v;
    } catch (...) {
        // 예외를 쓰지 않기로 했다면, 이런 라이브러리 호출은 경계에서만 캡슐화
        return std::unexpected(std::make_error_code(std::errc::invalid_argument));
    }
}

위 예시는 내부적으로 예외를 쓰는 표준 함수(std::stoi)를 호출하지만, “경계에서만 예외를 캡슐화하고 외부에는 expected로 노출”하는 전략을 보여줍니다. 즉, 코드베이스 전체에서 예외를 금지하더라도 서드파티/표준 라이브러리의 예외를 완전히 피하기 어려울 때 현실적인 절충안이 됩니다.

성능 관점: 예외 vs expected

  • 예외는 “정상 경로가 빠르고, 실패 경로가 매우 비쌀 수 있음”이라는 특성이 있습니다.
  • expected는 “정상/실패 모두가 값으로 흐르며, 분기 비용이 예측 가능”합니다.

실제로는 컴파일러, 플랫폼, 실패 빈도에 따라 달라집니다. 하지만 서버 코드에서 “실패가 드물다”는 가정이 깨지는 순간(예: 외부 API 불안정, 디스크/네트워크 오류, 타임아웃 폭증) 예외 기반 설계는 급격히 복잡해질 수 있습니다.

또한 운영에서 중요한 것은 평균 성능보다 꼬리 지연인 경우가 많습니다. 예외 폭증은 로그/스택 언와인딩/할당 등으로 지연을 키울 수 있고, 이는 사용자 체감에 직결됩니다. 프론트엔드에서 긴 작업이 응답성을 망가뜨리는 문제를 다루는 Chrome INP 느림 - Long Task 분해와 scheduler 실전 글처럼, “최악 상황에서의 비용을 통제”하는 관점이 expected 선택의 근거가 되기도 합니다.

도입 체크리스트

  1. 공개 API(모듈 경계)부터 expected로 바꿔 실패를 타입으로 드러내기
  2. 에러 타입 E를 먼저 표준화하기(코드, 카테고리, 재시도 가능 여부)
  3. value() 남용 금지, if (!r) 또는 조합 함수로 흐름을 명확히 하기
  4. 예외를 쓰는 라이브러리는 “경계에서 캡슐화”해서 내부로 새지 않게 하기
  5. 로깅/메트릭에서 에러 코드를 집계 가능하도록 설계하기

마무리

C++23 std::expected는 예외를 완전히 대체하는 만능 도구라기보다, “실패가 정상적으로 발생하는 도메인”에서 코드를 더 예측 가능하고 테스트 가능하게 만드는 강력한 선택지입니다. 특히 에러를 값으로 모델링하면, 재시도/대체 경로/관측 가능성 같은 운영 요구사항을 API 설계 단계에서 자연스럽게 녹일 수 있습니다.

다음 단계로는 팀 코드베이스에서 자주 발생하는 실패 케이스(파일 I/O, 네트워크, 파싱)를 골라 expected 기반의 공통 에러 타입과 유틸리티(transform_error, 로깅 훅, 재시도 정책)를 먼저 정리해보는 것을 권합니다.