Published on

C++23 std - -expected로 예외 제거·누수 방지

Authors

서버나 라이브러리 코드에서 예외(throw)는 강력하지만, 운영 환경에서는 종종 비용과 리스크로 돌아옵니다. 예외는 호출 스택을 건너뛰며 흐름을 바꾸기 때문에 성능 예측이 어렵고, ABI 경계(플러그인, C API, 다른 컴파일러/표준 라이브러리)에서 취급이 까다롭습니다. 특히 “실패는 정상 흐름”인 코드(파일 I/O, 네트워크, 파싱, 사용자 입력)에서는 예외가 오히려 가독성과 안정성을 떨어뜨리기도 합니다.

C++23의 std::expected는 이런 문제를 해결하기 위한 표준 도구입니다. 성공 값(T) 또는 실패 값(E)을 명시적으로 반환해, 호출자가 실패를 반드시 처리하도록 유도합니다. 또한 예외가 스택을 날려버리며 발생시키는 “부분 초기화 상태”와 “실패 경로 누수”를 줄이는 데도 도움이 됩니다(물론 RAII가 기본 전제입니다).

이 글에서는 std::expected로 예외를 제거하면서도 코드가 장황해지지 않게 만드는 패턴, 그리고 실패 경로에서 누수를 막는 설계 포인트를 정리합니다.

참고로 실패를 명시적으로 다루는 방식은 분산 트랜잭션/보상 설계와도 결이 비슷합니다. 실패 경로를 1급 시민으로 만드는 관점은 Saga 보상 트랜잭션 실패 재처리 설계 가이드에서도 통합니다.

std::expected가 해결하는 문제: 예외의 비용과 누수

1) 예외는 “제어 흐름”이 된다

예외는 에러 전달뿐 아니라 흐름을 바꿉니다. 이때 호출자는 함수 시그니처만 보고 실패 가능성을 알기 어렵고, 실패 처리가 호출 체인 어딘가로 밀려납니다. 반면 std::expected<T, E>는 반환 타입 자체가 “실패 가능”을 드러냅니다.

2) 실패 경로에서의 누수는 대부분 “부분 초기화 + 수동 해제”에서 나온다

예외가 있든 없든, 누수는 보통 다음 조합에서 발생합니다.

  • 여러 리소스를 순차로 획득
  • 중간 실패 시 이전 리소스를 수동으로 해제해야 함
  • 조기 반환/예외로 인해 해제 코드가 실행되지 않음

RAII로 대부분 해결되지만, 현실에서는 C 핸들, OS 핸들, 레거시 라이브러리, 커스텀 풀/아레나 등 “소유권이 불명확한 리소스”가 섞이면서 틈이 생깁니다. std::expected는 실패 경로를 명시적으로 만들기 때문에, 리소스 획득과 실패 반환을 한 눈에 정리하기 쉬워집니다.

기본 사용법: 성공/실패를 타입으로 강제하기

std::expected<T, E>는 다음 상태를 가질 수 있습니다.

  • 성공: T 값을 보유
  • 실패: E 값을 보유

핵심 API는 대략 다음입니다.

  • has_value() 또는 operator bool()
  • value() / error()
  • value_or(...)
  • and_then(...), transform(...), or_else(...), transform_error(...) (구현 상태는 표준이지만 라이브러리 지원은 컴파일러/표준 라이브러리에 따라 확인 필요)

예시: 파일 읽기에서 예외 제거

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

enum class ReadErr {
    open_failed,
    read_failed
};

std::expected<std::string, ReadErr> read_all_text(const std::string& path) {
    std::ifstream in(path, std::ios::binary);
    if (!in.is_open()) {
        return std::unexpected(ReadErr::open_failed);
    }

    std::string data;
    in.seekg(0, std::ios::end);
    data.resize(static_cast<size_t>(in.tellg()));
    in.seekg(0, std::ios::beg);

    if (!in.read(data.data(), static_cast<std::streamsize>(data.size()))) {
        return std::unexpected(ReadErr::read_failed);
    }

    return data;
}

std::expected<int, ReadErr> parse_int_file(const std::string& path) {
    auto text = read_all_text(path);
    if (!text) {
        return std::unexpected(text.error());
    }

    try {
        return std::stoi(text.value());
    } catch (...) {
        // 여기서는 예시 단순화를 위해 ReadErr를 재사용
        return std::unexpected(ReadErr::read_failed);
    }
}

위 코드는 아직 stoi에서 예외를 쓰지만, 포인트는 “에러 전달이 반환값으로 일관”된다는 점입니다. 파싱까지도 예외 없이 만들고 싶다면 from_chars를 쓰면 됩니다.

#include <charconv>
#include <expected>
#include <string>

enum class ParseErr { invalid, out_of_range };

std::expected<int, ParseErr> parse_int(std::string_view s) {
    int v = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec == std::errc::invalid_argument) {
        return std::unexpected(ParseErr::invalid);
    }
    if (ec == std::errc::result_out_of_range) {
        return std::unexpected(ParseErr::out_of_range);
    }
    if (ptr != s.data() + s.size()) {
        return std::unexpected(ParseErr::invalid);
    }
    return v;
}

누수 방지의 핵심: “리소스 획득은 값으로, 실패는 즉시 반환”

std::expected 자체가 누수를 막아주진 않습니다. 누수를 막는 건 RAII와 소유권 모델입니다. 다만 std::expected는 다음을 쉽게 만듭니다.

  • 리소스 획득 단계별로 실패를 즉시 반환
  • 성공 시에만 다음 단계로 진행
  • 실패 시점에 이미 생성된 RAII 객체들이 스코프 종료로 정리

예시: C 핸들을 RAII로 감싸고 expected로 조합

#include <expected>
#include <utility>

// 예시용 C API
extern "C" {
    struct CConn;
    CConn* c_open(const char* url);
    void c_close(CConn*);
    int c_send(CConn*, const void* buf, int len);
}

enum class ConnErr { open_failed, send_failed };

class Conn {
public:
    explicit Conn(CConn* h) : h_(h) {}
    Conn(const Conn&) = delete;
    Conn& operator=(const Conn&) = delete;

    Conn(Conn&& other) noexcept : h_(std::exchange(other.h_, nullptr)) {}
    Conn& operator=(Conn&& other) noexcept {
        if (this != &other) {
            reset();
            h_ = std::exchange(other.h_, nullptr);
        }
        return *this;
    }

    ~Conn() { reset(); }

    CConn* get() const { return h_; }

private:
    void reset() {
        if (h_) c_close(h_);
        h_ = nullptr;
    }

    CConn* h_ = nullptr;
};

std::expected<Conn, ConnErr> open_conn(const char* url) {
    if (auto* h = c_open(url)) {
        return Conn(h);
    }
    return std::unexpected(ConnErr::open_failed);
}

std::expected<void, ConnErr> send_all(Conn& c, const void* buf, int len) {
    int rc = c_send(c.get(), buf, len);
    if (rc < 0) {
        return std::unexpected(ConnErr::send_failed);
    }
    return {};
}

std::expected<void, ConnErr> do_work(const char* url) {
    auto c = open_conn(url);
    if (!c) return std::unexpected(c.error());

    const char msg[] = "hello";
    auto s = send_all(c.value(), msg, static_cast<int>(sizeof(msg)));
    if (!s) return std::unexpected(s.error());

    return {};
}

여기서 중요한 점은 다음입니다.

  • Conn이 소유권을 명확히 갖고 파괴 시 c_close를 호출
  • do_work는 실패 시 즉시 반환하지만, Conn은 스코프 종료로 정리
  • 예외가 없으니 ABI 경계에서도 에러 전달이 단순

에러 타입 E 설계: 문자열 남발 대신 “구조화된 실패”

std::expected<T, E>에서 E는 보통 다음 중 하나로 설계합니다.

  • enum class: 빠르고 단순, 로깅/분기 쉬움
  • std::error_code: 시스템/라이브러리 에러와 결합 쉬움
  • 커스텀 struct: 코드, 메시지, 컨텍스트(파일 경로, 오프셋 등) 포함

권장 패턴: enum + 컨텍스트 struct

#include <expected>
#include <string>

enum class AppErrc {
    io,
    parse,
    protocol
};

struct AppError {
    AppErrc code;
    std::string message;
};

template <class T>
using Exp = std::expected<T, AppError>;

Exp<int> parse_port(std::string_view s) {
    // ...
    return std::unexpected(AppError{AppErrc::parse, "invalid port"});
}

운영에서는 “에러 코드”와 “사람이 읽을 메시지”를 분리하는 편이 좋습니다. 코드로 분기하고, 메시지는 로깅/관측에 사용합니다.

조합(컴포지션): and_then로 중첩 if 줄이기

expected를 쓰면 if (!x) return unexpected(...)가 반복되기 쉽습니다. 이를 줄이기 위해 and_then/transform 계열을 활용합니다(표준 라이브러리 지원 여부 확인 필요).

#include <expected>
#include <string>

enum class Err { read, parse };

std::expected<std::string, Err> read_text(std::string path);
std::expected<int, Err> parse_int_text(const std::string& s);

std::expected<int, Err> read_and_parse(std::string path) {
    return read_text(std::move(path))
        .and_then([](const std::string& s) {
            return parse_int_text(s);
        });
}
  • and_then: 성공 값이 있을 때만 다음 함수를 호출하고, 실패면 그대로 전파
  • transform: 성공 값을 다른 타입으로 매핑
  • or_else: 실패를 다른 실패로 매핑하거나 복구

이 방식은 예외 없는 파이프라인을 만들 때 특히 유용합니다. 실패 전파가 자연스럽고, 성공 경로가 더 눈에 들어옵니다.

“예외 제거”의 현실적인 경계: 어디까지 expected로 갈 것인가

모든 예외를 0으로 만들겠다는 목표는 비용이 큽니다. 실무에서는 경계를 정하는 게 중요합니다.

  • 라이브러리/핵심 모듈: expected로 실패를 명시 (호출자에게 강제)
  • 앱 최상단(엔트리포인트): 실패를 로깅하고 적절한 종료/응답으로 변환
  • 정말 예외적인 상황(불변식 위반, 프로그래밍 오류): assert 또는 종료

특히 스레드/코루틴 환경에서는 “실패가 어디로 전파되는가”가 더 중요합니다. 예외가 스레드 경계를 넘지 못하거나, std::terminate로 이어지는 상황을 피하려면 반환 기반 에러가 안전합니다. 비동기에서 교착/실패 전파를 다루는 관점은 Rust async 데드락? Tokio Mutex·spawn 원인 7가지 같은 글에서도 힌트를 얻을 수 있습니다.

마이그레이션 전략: 기존 예외 코드에서 expected로 옮기기

레거시 C++에서 예외를 이미 쓰고 있다면, 한 번에 갈아엎기보다 “경계부터” 바꾸는 편이 안전합니다.

1) 바깥 경계에서 예외를 expected로 변환

#include <expected>
#include <string>

struct Error { std::string msg; };

template <class T>
using Exp = std::expected<T, Error>;

Exp<int> api_wrapper() {
    try {
        // 기존 예외 기반 함수
        int v = /* may throw */ 42;
        return v;
    } catch (const std::exception& e) {
        return std::unexpected(Error{e.what()});
    } catch (...) {
        return std::unexpected(Error{"unknown error"});
    }
}

이렇게 하면 호출자는 예외를 몰라도 되고, 모듈 외부로 예외가 새지 않습니다.

2) 내부 구현을 점진적으로 expected로 치환

  • 파싱: from_chars로 전환
  • I/O: std::error_code 기반 API 활용
  • C API: RAII 래퍼 도입

자주 하는 실수 5가지

  1. Estd::string만 넣고 끝내기
  • 메시지는 바뀌고 파싱하기 어렵습니다. 최소한 코드/카테고리를 두세요.
  1. value()를 무조건 호출
  • value()는 실패 시 예외를 던질 수 있습니다(구현에 따라). if (!exp) 체크를 습관화하세요.
  1. “성공 값이 없는데 성공” 같은 상태를 만들기
  • std::expected<void, E>를 적극 사용하세요. 성공은 {}로 표현됩니다.
  1. 실패를 무시하는 API 설계
  • [[nodiscard]]를 붙여 반환값 무시를 경고로 올리세요.
template <class T, class E>
[[nodiscard]] std::expected<T, E> must_handle(std::expected<T, E> v) {
    return v;
}
  1. expected를 남발해 계층이 꼬이기
  • 모듈 경계를 정하고, 내부에서는 예외/에러코드/expected 중 하나로 일관성을 유지하세요.

결론: expected는 “실패를 설계하는 도구”다

C++23 std::expected는 예외를 단순히 대체하는 문법이 아니라, 실패를 타입 시스템으로 끌어올려 설계 품질을 높이는 도구입니다.

  • 실패가 흔한 코드에서 제어 흐름이 명확해지고
  • 경계에서 예외를 차단해 ABI/운영 리스크를 줄이며
  • RAII와 결합하면 실패 경로 누수 가능성을 낮출 수 있습니다.

다음 단계로는 Estd::error_code와 연결해 시스템 에러를 일관되게 다루거나, and_then 기반 파이프라인으로 비즈니스 로직을 “성공 경로 중심”으로 정리해보는 것을 추천합니다.