Published on

C++23 std - -expected로 예외 없애기 - 소유권·에러전파

Authors

서버·에이전트·CLI 같은 장기 실행 C++ 프로그램에서 예외는 종종 운영 리스크가 됩니다. 경계에서 누락된 catch 하나로 프로세스가 죽거나, 예외 안전성(특히 소멸자/이동/부분 초기화) 때문에 코드가 과도하게 방어적으로 변합니다. 반대로 모든 API가 boolout 파라미터로 실패를 표현하면, 호출자는 에러를 무시하기 쉬워지고 에러 정보도 빈약해집니다.

C++23의 std::expected는 이 두 세계 사이에서 균형을 잡습니다. 성공 값(T) 또는 실패 값(E)을 타입 시스템으로 강제하고, 호출자가 실패를 처리하거나 전파하도록 자연스럽게 유도합니다. 이 글에서는 std::expected를 중심으로 예외 없는 설계, 소유권 모델링, 에러 전파 패턴을 실전 관점에서 정리합니다.

운영 환경에서 “예상치 못한 실패 경로”는 결국 장애로 이어집니다. 예를 들어 TLS 핸드셰이크에서 특정 조건만 실패하는 케이스처럼, 실패를 값으로 남겨야 원인 추적이 빨라집니다. 관련 디버깅 관점은 EKS에서 TLS 1.3만 실패할 때 - OpenSSL·ALPN도 참고할 만합니다.

std::expected 한 문장 정의

std::expected<T, E>는 다음을 표현합니다.

  • 성공: T 값을 보유
  • 실패: E(에러) 값을 보유

즉 반환 타입 자체가 “성공/실패”의 합타입(sum type)이며, 호출자는 if (res) 또는 res.has_value()로 분기합니다.

예외 기반 함수가 T를 반환하고 실패 시 throw한다면, expected 기반 함수는 expected<T, E>를 반환하고 실패 시 unexpected(E)를 반환합니다.

왜 예외 대신 expected인가

예외를 완전히 배제해야 하는지는 프로젝트 성격에 따라 다르지만, 다음 상황에서는 expected가 특히 유리합니다.

  • 에러가 “정상적인 분기”인 경우: 파일 없음, 네트워크 타임아웃, 파싱 실패 등
  • 경계가 많은 코드: RPC/HTTP/DB/파일 I/O가 얽힌 서비스 코드
  • 예외 전파 경로가 불명확한 코드베이스: 라이브러리 혼용, ABI 경계, 플러그인 구조
  • 성능/예측 가능성: 예외 테이블/언와인딩 비용 자체보다도 “어디서 터질지 모름”이 문제

반대로 “프로그래밍 오류(불변식 위반)”는 expected로 감싸기보다 assert, 계약(가능하면), 혹은 종료가 더 적절합니다. 즉,

  • 예상 가능한 실패expected
  • 버그는 빠르게 터뜨리기

이 기준이 깔끔한 코드 경계를 만들어 줍니다.

에러 타입 E 설계: 문자열만 넣지 말 것

Estd::string으로 두면 구현은 쉽지만, 호출자가 분기하기 어렵고(문자열 비교), 로깅/관측 외에 활용도가 떨어집니다. 실전에서는 다음 중 하나가 일반적입니다.

  • std::error_code + 컨텍스트 문자열
  • 프로젝트 전용 enum class + 메시지/원인 체인
  • 외부 라이브러리와의 호환을 위해 std::error_code 중심

예시: std::error_code 기반 에러

아래는 파일 읽기 API를 expected로 바꾼 예시입니다.

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

struct ReadError {
  std::error_code ec;
  std::string context; // 어떤 파일/어떤 단계에서 실패했는지
};

std::expected<std::string, ReadError> read_all_text(const std::string& path) {
  std::ifstream in(path, std::ios::binary);
  if (!in) {
    return std::unexpected(ReadError{std::make_error_code(std::errc::no_such_file_or_directory),
                                     "open failed: " + path});
  }

  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(ReadError{std::make_error_code(std::errc::io_error),
                                     "read failed: " + path});
  }

  return data;
}

포인트는 E가 “사람이 읽을 정보(context)”와 “기계가 분기할 정보(ec)”를 동시에 담는 것입니다.

소유권(Ownership)과 expected: 리소스는 성공 케이스에만 담아라

예외 없는 설계에서 가장 흔한 함정은 “실패했는데 리소스가 반쯤 잡혀 있는 상태”입니다. expected는 이를 구조적으로 정리하는 데 도움이 됩니다.

  • 성공 시에만 T가 생성되어 리소스를 소유
  • 실패 시에는 E만 존재하므로 리소스 누수 가능성이 낮아짐

예시: 파일 핸들을 소유하는 타입 반환

핸들을 RAII로 감싼 File을 만들고, 열기에 실패하면 unexpected로 반환합니다.

#include <expected>
#include <cstdio>
#include <string>
#include <system_error>

class File {
public:
  explicit File(std::FILE* f) : f_(f) {}
  File(const File&) = delete;
  File& operator=(const File&) = delete;
  File(File&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
  File& operator=(File&& other) noexcept {
    if (this != &other) {
      close();
      f_ = other.f_;
      other.f_ = nullptr;
    }
    return *this;
  }
  ~File() { close(); }

  std::FILE* get() const { return f_; }

private:
  void close() {
    if (f_) std::fclose(f_);
    f_ = nullptr;
  }

  std::FILE* f_ = nullptr;
};

struct OpenError {
  std::error_code ec;
  std::string path;
};

std::expected<File, OpenError> open_file(const std::string& path, const char* mode) {
  std::FILE* f = std::fopen(path.c_str(), mode);
  if (!f) {
    // errno 기반이라면 std::error_code로 감싸는 전략을 취할 수 있음
    return std::unexpected(OpenError{std::make_error_code(std::errc::io_error), path});
  }
  return File{f};
}

이 패턴의 장점은 “성공했을 때만 소유권이 생긴다”는 점이 타입으로 고정된다는 것입니다.

에러 전파: and_then, transform, or_else로 파이프라인 만들기

std::expected는 단순히 if (!res) return unexpected(...)만을 위한 도구가 아닙니다. 함수형 파이프라인처럼 단계별 변환을 만들 수 있습니다.

  • transform(f): 성공 값 TU로 변환 (실패는 그대로)
  • and_then(f): 성공 값 T를 받아 expected<U, E>를 반환하는 다음 단계로 연결
  • or_else(f): 실패 값 E를 처리/치환

예시: 읽기 → 파싱 → 검증

#include <expected>
#include <string>
#include <system_error>

struct Error {
  std::error_code ec;
  std::string msg;
};

struct Config {
  int port;
};

std::expected<std::string, Error> read_text(const std::string& path);
std::expected<Config, Error> parse_config(const std::string& text);
std::expected<Config, Error> validate_config(Config cfg);

std::expected<Config, Error> load_config(const std::string& path) {
  return read_text(path)
    .and_then(parse_config)
    .and_then(validate_config)
    .or_else([](Error e) {
      // 여기서 공통 로깅/메시지 보강도 가능
      e.msg = "load_config failed: " + e.msg;
      return std::unexpected(e);
    });
}

이렇게 하면 중간 단계에서 실패가 발생해도 자연스럽게 최종 호출자까지 전파됩니다. 예외를 쓰지 않아도 “한 줄로 이어지는” 에러 전파가 가능해집니다.

다른 에러 타입으로의 매핑: 경계에서만 변환하라

실무에서는 모듈마다 에러 타입이 다를 수 있습니다.

  • 코어 모듈: ErrorCode enum
  • I/O 모듈: std::error_code
  • API 레이어: HTTP 상태 코드 + 바디

이때 모든 계층에서 모든 정보를 다 알 필요는 없습니다. 경계에서만 매핑하고, 내부는 단일한 에러 모델을 유지하는 편이 유지보수에 유리합니다.

예시: 내부 에러를 HTTP 응답으로 매핑

#include <expected>
#include <string>

enum class CoreErr {
  NotFound,
  InvalidInput,
  Io,
};

struct CoreError {
  CoreErr code;
  std::string detail;
};

struct HttpResponse {
  int status;
  std::string body;
};

HttpResponse to_http(CoreError e) {
  switch (e.code) {
    case CoreErr::NotFound:      return {404, "not found: " + e.detail};
    case CoreErr::InvalidInput:  return {400, "bad request: " + e.detail};
    case CoreErr::Io:            return {500, "io error: " + e.detail};
  }
  return {500, "unknown error"};
}

std::expected<std::string, CoreError> handle_domain();

HttpResponse handler() {
  auto r = handle_domain();
  if (!r) return to_http(r.error());
  return {200, *r};
}

이 방식은 장애 대응에서도 유리합니다. 예를 들어 서버 액션에서 500이 났을 때 원인 분리를 위해 경계에서 에러를 구조화하는 접근은 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결 같은 글의 관점과도 맞닿아 있습니다(언어는 달라도 “경계에서 에러를 번역”하는 것이 핵심).

expected를 쓸 때의 흔한 실수

1) 모든 실패를 expected로 감싸기

불변식 위반까지 expected로 만들면 호출자 코드가 “처리해야 할 실패”로 오염됩니다.

  • 입력 검증 실패, 네트워크 실패, 파일 없음: expected
  • nullptr 역참조 같은 버그, 컨테이너 인덱스 범위 오류: 개발 중 터져야 함

2) E에 너무 많은 것을 넣기

에러 객체가 거대해지면 복사/이동 비용이 증가하고, API 경계가 무거워집니다. 기본은 가볍게 두고, 필요하면 std::string 같은 가변 필드는 “컨텍스트” 정도만 담으세요.

3) 성공 값에 “실패를 나타내는 상태”를 남기기

예를 들어 T 내부에 valid 플래그를 두면 expected의 장점이 사라집니다. 실패는 unexpected로만 표현하는 것이 좋습니다.

예외와 expected의 공존 전략

레거시/외부 라이브러리는 예외를 던질 수 있습니다. 이때 전면 금지보다 “경계에서만 예외를 잡고 expected로 변환”하는 전략이 현실적입니다.

예시: 예외를 expected로 감싸는 어댑터

#include <expected>
#include <string>
#include <exception>

struct Error {
  std::string msg;
};

std::string legacy_call(); // 실패 시 throw

std::expected<std::string, Error> safe_call() {
  try {
    return legacy_call();
  } catch (const std::exception& e) {
    return std::unexpected(Error{std::string("exception: ") + e.what()});
  } catch (...) {
    return std::unexpected(Error{"unknown exception"});
  }
}

이렇게 하면 코어 로직은 예외 없는 세계를 유지하면서도, 외부 의존성과는 안전하게 접합할 수 있습니다.

테스트 관점: 실패 케이스가 “값”이 되면 단위 테스트가 쉬워진다

예외 기반 테스트는 보통 “던져야 한다”를 검증합니다. expected는 실패가 값이므로, 다음이 쉬워집니다.

  • 에러 코드가 정확한지
  • 컨텍스트 메시지가 포함되는지
  • 특정 단계에서 실패가 전파되는지

이는 운영에서의 장애 분석에도 직결됩니다. 예를 들어 503이나 콜드스타트 지연처럼 “실패가 일시적”일 때, 에러를 구조화해두면 재시도/서킷브레이커 정책을 정교하게 만들 수 있습니다. 관련 운영 관점은 Cloud Run 503·콜드스타트 7분 지연 해결 가이드도 함께 보면 좋습니다.

정리: std::expected로 얻는 것

  • 실패가 타입에 포함되어 무시하기 어려워짐
  • 성공 케이스에만 리소스를 담아 소유권/수명 관리가 단순해짐
  • and_then/transform/or_else에러 전파가 선언적이 됨
  • 모듈 경계에서 에러를 번역해 운영/관측/테스트가 쉬워짐

C++23의 std::expected는 “예외를 없애자”라는 구호보다, 실패를 명시적으로 다루는 설계 습관을 코드에 강제한다는 점에서 가치가 큽니다. I/O와 네트워크가 많은 현대 C++ 코드에서, 예외 없는 코어를 만들고 경계에서만 변환하는 접근은 디버깅 가능성과 안정성을 동시에 끌어올립니다.