Published on

C++23 std - -expected로 예외없이 오류전파·누수방지

Authors

서버 사이드 C++이나 시스템 프로그래밍에서는 예외를 꺼리는 팀이 많습니다. 이유는 명확합니다. 예외는 제어 흐름을 숨기고, ABI 경계를 넘을 때 비용과 정책 문제가 생기며, 실시간 성격의 코드에서는 예측 가능한 지연을 보장하기 어렵습니다. 그렇다고 전통적인 bool 반환이나 -1 같은 에러 코드는 호출부에서 누락되기 쉽고, 중간 단계에서 리소스 정리를 깜빡하면 누수로 이어집니다.

C++23의 std::expected는 이 딜레마를 꽤 우아하게 해결합니다. 성공 값과 실패 값을 타입으로 강제하고, 호출자가 오류 처리를 “반드시” 의식하게 만들며, RAII와 결합하면 예외 없이도 누수 없는 오류 전파를 구성할 수 있습니다.

아래에서는 std::expected의 핵심 사용법, 오류 타입 설계, 전파 패턴, 그리고 누수 방지 관점에서의 실전 팁을 코드 중심으로 정리합니다.

std::expected가 해결하는 문제

1) 오류가 타입에 드러난다

std::expected<T, E>는 “성공하면 T를, 실패하면 E를 가진다”를 타입으로 표현합니다. 함수 시그니처만 봐도 실패 가능성이 명확합니다.

2) 오류 전파가 구조적으로 쉬워진다

중간 단계에서 실패를 만나면 그대로 반환하면 됩니다. 예외처럼 스택을 언와인드하지 않지만, 호출 체인에서 동일한 패턴으로 전파할 수 있습니다.

3) RAII로 누수 방지

예외를 쓰지 않더라도, 스코프 기반 정리(스마트 포인터, 락 가드 등)는 동일하게 동작합니다. expected는 “실패 시 조기 반환”을 자주 만들기 때문에, RAII가 더 중요해집니다.

기본 사용법: 성공과 실패를 명시하기

다음은 파일을 읽는 단순 예시입니다.

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

enum class ReadError {
  OpenFailed,
  ReadFailed,
};

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

  std::string data((std::istreambuf_iterator<char>(in)),
                   std::istreambuf_iterator<char>());
  if (in.bad()) {
    return std::unexpected(ReadError::ReadFailed);
  }

  return data;
}

호출부는 다음처럼 처리합니다.

auto result = read_all_text("/etc/config.json");
if (!result) {
  // result.error()로 오류 원인을 확인
  return;
}

// result.value() 또는 *result 로 성공 값 접근
std::string text = *result;

여기서 중요한 점은 std::unexpected로 실패를 만들고, 호출부에서 if (!result)로 성공 여부를 확인하는 패턴이 표준화된다는 것입니다.

오류 타입 설계: 단순 enum을 넘어 “운영 가능한” 정보로

실무에서는 “왜 실패했는지”를 로깅/메트릭/재시도 정책에 연결해야 합니다. 특히 네트워크나 외부 API 호출에서는 429나 타임아웃 같은 상황이 재시도와 백오프 전략으로 이어집니다. 이런 관점은 레이트 리밋을 다루는 글(예: OpenAI API 429·Rate limit 실전 백오프 패턴)에서도 동일하게 적용됩니다.

C++에서는 오류 타입 E에 다음을 담는 것이 실용적입니다.

  • 분류 가능한 코드(열거형)
  • 사람이 읽을 메시지(로그)
  • 원인 체인(하위 오류)
  • 재시도 가능 여부, 권장 대기 시간 등 정책 힌트

예시:

#include <expected>
#include <optional>
#include <string>

enum class Errc {
  Io,
  Parse,
  Timeout,
  RateLimited,
  Permission,
  Unknown,
};

struct Error {
  Errc code;
  std::string message;
  std::optional<int> retry_after_ms; // 레이트 리밋 같은 경우
};

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

이렇게 해두면 상위 계층에서 code만 보고도 분기할 수 있고, 메시지는 관측 가능성(Observability)을 높입니다.

전파 패턴 1: 조기 반환으로 깔끔하게 이어붙이기

expected 기반 코드는 흔히 다음 형태가 됩니다.

Result<int> parse_port(const std::string& s);
Result<void> connect_to(const std::string& host, int port);

Result<void> init_client(const std::string& host, const std::string& port_str) {
  auto port = parse_port(port_str);
  if (!port) {
    return std::unexpected(port.error());
  }

  auto conn = connect_to(host, *port);
  if (!conn) {
    return std::unexpected(conn.error());
  }

  return {};
}

이 패턴은 단순하지만 반복이 많습니다. 팀 내에서 헬퍼를 만들어 통일하는 경우가 많습니다.

헬퍼로 반복 줄이기

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

Result<void> init_client2(const std::string& host, const std::string& port_str) {
  int port = TRY(parse_port(port_str));
  TRY(connect_to(host, port));
  return {};
}

주의: 위 매크로는 GCC/Clang 확장 문법을 사용합니다. 표준 C++만으로 가려면 함수 템플릿이나 람다, 혹은 라이브러리(예: tl::expected 계열)에서 제공하는 패턴을 활용하세요. 팀 규칙에 맞춰 “표준만 쓸지”를 결정하는 게 좋습니다.

전파 패턴 2: transform / and_then로 함수형 체이닝

표준 std::expectedtransform, and_then, or_else 같은 조합 함수를 제공합니다(구현 및 지원 수준은 컴파일러/표준 라이브러리 버전에 따라 차이가 있을 수 있습니다).

예를 들어 파싱 성공 시에만 다음 단계로 이어가고, 실패면 자동 전파:

Result<int> parse_port(const std::string& s);
Result<void> connect_to(const std::string& host, int port);

Result<void> init_client3(const std::string& host, const std::string& port_str) {
  return parse_port(port_str)
      .and_then([&](int port) { return connect_to(host, port); });
}

이 스타일은 “성공 경로만” 읽으면 되기 때문에 코드 리뷰에서 의도를 파악하기 쉽습니다.

누수 방지 핵심: expected는 RAII와 함께 쓸 때 완성된다

예외를 안 쓰면 누수가 줄어드는 게 아니라, 오히려 조기 반환 지점이 많아져 누수 위험이 커질 수 있습니다. 해결책은 변함없이 RAII입니다.

예시 1: raw 포인터 대신 unique_ptr

#include <expected>
#include <memory>

struct Widget {
  void use();
};

Result<std::unique_ptr<Widget>> make_widget();

Result<void> run() {
  auto w = make_widget();
  if (!w) return std::unexpected(w.error());

  // *w 는 unique_ptr<Widget>
  (*w)->use();
  return {};
}

여기서 중간에 실패로 빠져나가도 unique_ptr는 자동 해제됩니다.

예시 2: 파일 디스크립터 같은 자원도 RAII로 감싸기

#include <expected>
#include <string>

struct Fd {
  int fd{-1};
  explicit Fd(int v) : fd(v) {}
  Fd(const Fd&) = delete;
  Fd& operator=(const Fd&) = delete;
  Fd(Fd&& other) noexcept : fd(other.fd) { other.fd = -1; }
  Fd& operator=(Fd&& other) noexcept {
    if (this != &other) {
      close();
      fd = other.fd;
      other.fd = -1;
    }
    return *this;
  }
  ~Fd() { close(); }

  void close() noexcept {
    if (fd != -1) {
      // ::close(fd);  // 실제 시스템 호출
      fd = -1;
    }
  }
};

Result<Fd> open_fd(const std::string& path);
Result<void> write_all(const Fd& fd, const std::string& data);

Result<void> save(const std::string& path, const std::string& data) {
  auto fd = open_fd(path);
  if (!fd) return std::unexpected(fd.error());

  auto w = write_all(*fd, data);
  if (!w) return std::unexpected(w.error());

  return {};
}

핵심은 “오류 전파가 어디서 일어나든” 자원 수명은 타입이 관리하게 만드는 것입니다.

expected와 예외의 경계: 섞어 쓸 때의 원칙

현실적으로는 서드파티 라이브러리가 예외를 던지기도 합니다. 이때 추천하는 방법은 “경계에서만 변환”입니다.

  • 내부 코어 로직: Result<T>로 통일
  • 외부 라이브러리 호출: try/catch로 예외를 Error로 변환
  • 최상위 API: 필요하면 다시 예외 또는 에러 코드로 변환

예시:

#include <expected>
#include <string>

Result<int> parse_int_noexcept(const std::string& s) {
  try {
    size_t pos = 0;
    int v = std::stoi(s, &pos);
    if (pos != s.size()) {
      return std::unexpected(Error{Errc::Parse, "trailing characters", std::nullopt});
    }
    return v;
  } catch (const std::exception& e) {
    return std::unexpected(Error{Errc::Parse, e.what(), std::nullopt});
  }
}

이렇게 하면 예외 정책이 다른 모듈 간에도 인터페이스를 안정적으로 유지할 수 있습니다.

운영 관점 팁: 타임아웃과 재시도도 오류 타입에 포함하라

네트워크 호출에서는 타임아웃/데드라인이 “정상적인 실패”로 자주 발생합니다. gRPC 기반 서비스라면 데드라인 초과가 연쇄적으로 발생하는 패턴을 이해하는 것이 중요합니다. 관련해서는 gRPC MSA에서 Deadline Exceeded 원인과 패턴 같은 글에서 다루는 개념을 C++ 오류 타입 설계에 그대로 적용할 수 있습니다.

예를 들어 Errorretry_after_ms 같은 필드를 넣어두면, 호출부에서 정책적으로 처리하기 쉬워집니다.

Result<void> call_remote();

Result<void> call_with_simple_backoff(int max_retry) {
  for (int i = 0; i <= max_retry; ++i) {
    auto r = call_remote();
    if (r) return {};

    if (r.error().code != Errc::RateLimited && r.error().code != Errc::Timeout) {
      return std::unexpected(r.error());
    }

    int wait_ms = r.error().retry_after_ms.value_or(100 * (i + 1));
    // sleep(wait_ms);
  }
  return std::unexpected(Error{Errc::Timeout, "retry exhausted", std::nullopt});
}

이 패턴은 외부 API의 429 대응에도 그대로 적용 가능합니다. 중요한 건 “실패를 값으로 다룬다”는 점이고, 그 값이 운영 정책을 담을 수 있어야 한다는 점입니다.

자주 하는 실수와 체크리스트

1) expected를 쓰면서도 여전히 raw 자원을 들고 다니기

expected는 오류 전파를 돕지만 자원 관리는 해주지 않습니다. new/delete, malloc/free, raw FD, raw mutex를 그대로 들고 다니면 조기 반환 경로에서 실수할 확률이 올라갑니다.

  • 포인터는 unique_ptr/shared_ptr
  • 락은 std::lock_guard/std::unique_lock
  • FD/핸들은 RAII 래퍼 타입

2) 오류 타입이 너무 빈약해서 결국 로그가 “실패” 한 줄로 끝남

최소한 다음은 포함하는 편이 좋습니다.

  • code (분류)
  • message (원인)
  • 필요 시 retry_after_ms 같은 정책 힌트

3) 호출부에서 value()를 무심코 호출

result.value()는 실패 상태면 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외를 피하는 정책이라면 성공 확인 후에만 *result 또는 result.value()를 사용하세요.

결론: std::expected는 “명시적 실패”와 “누수 없는 조기 반환”의 조합이다

C++23 std::expected는 예외를 쓰지 않는 코드베이스에서 특히 강력합니다.

  • 실패 가능성이 타입으로 드러나고
  • 전파가 일관되며
  • RAII와 결합하면 조기 반환이 많아져도 누수가 줄어듭니다

팀 차원에서는 Result<T> 별칭, 공통 Error 구조체, 전파 헬퍼(표준 범위 내에서), 그리고 “RAII 우선” 규칙을 함께 도입하면 효과가 큽니다. 그렇게 하면 예외 없이도 안정적인 오류 전파와 자원 안전성을 동시에 얻을 수 있습니다.