Published on

C++23 std - -expected로 예외 없이 오류전파+자원관리

Authors

서버나 CLI 툴처럼 실패가 “정상적인 흐름”에 포함되는 프로그램에서는 예외 기반 에러 처리가 오히려 복잡도를 키우는 경우가 많습니다. 특히 다음 요구가 동시에 있을 때 그렇습니다.

  • 실패를 호출자에게 명시적으로 전달하고 싶다
  • 실패 경로에서도 파일 핸들, 소켓, 락 같은 자원을 확실히 정리해야 한다
  • 테스트에서 실패 케이스를 으로 다루고 싶다
  • -fno-exceptions 같은 빌드 옵션을 쓰거나(임베디드/저지연), 예외 비용을 피하고 싶다

C++23의 std::expected는 “성공 값 또는 오류 값”을 담는 표준 타입으로, 예외 없이도 오류 전파를 구조화할 수 있게 해줍니다. 그리고 C++의 강점인 RAII와 결합하면, 실패가 발생해도 자원 정리는 자동으로 되고 오류는 깔끔하게 상위로 전파됩니다.

아래에서는 std::expected의 핵심 사용법, 오류 타입 설계, 체이닝 패턴, 그리고 RAII 자원관리와 결합하는 실전 예제를 다룹니다.

std::expected 한 줄 정의

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

  • 성공: T
  • 실패: E 오류

예외 대신 “실패를 값으로 반환”하는 것이 핵심입니다. 호출자는 반환값을 보고 분기합니다.

#include <expected>
#include <string>

std::expected<int, std::string> parse_int(std::string_view s);

auto r = parse_int("123");
if (!r) {
  // r.error() : std::string
} else {
  // *r 또는 r.value() : int
}

이 모델의 장점은 함수 시그니처만 봐도 “실패할 수 있음”이 드러난다는 점입니다.

예외 없는 오류전파에서 중요한 것: 오류 타입 설계

실무에서 E를 무엇으로 할지가 품질을 좌우합니다.

  • 단순 메시지면 std::string도 가능
  • 분기 처리가 필요하면 enum class 또는 구조체 추천
  • OS 에러를 다루면 std::error_code가 강력

추천 1) std::error_code 기반

#include <expected>
#include <system_error>

using result_void = std::expected<void, std::error_code>;
using result_str  = std::expected<std::string, std::error_code>;

std::error_code는 카테고리와 값을 포함하므로 로깅과 분기가 쉽고, errno나 플랫폼 에러를 자연스럽게 담을 수 있습니다.

추천 2) 도메인 오류 구조체

#include <expected>
#include <string>

enum class errc {
  invalid_input,
  not_found,
  io_error,
};

struct error {
  errc code;
  std::string message;
};

template <class T>
using expected_t = std::expected<T, error>;

이렇게 하면 호출자가 code로 분기하고, message는 관측(로그/디버깅)에 사용하도록 역할을 분리할 수 있습니다.

기본 사용 패턴: “반환 즉시 전파”

예외 없는 스타일에서 가장 흔한 패턴은 다음입니다.

  1. 하위 함수 호출
  2. 실패면 즉시 반환
  3. 성공이면 값 사용
#include <expected>
#include <string>

std::expected<int, std::string> parse_port(std::string_view s) {
  if (s.empty()) return std::unexpected("empty");

  int port = 0;
  for (char c : s) {
    if (c < '0' || c > '9') return std::unexpected("not a number");
    port = port * 10 + (c - '0');
  }

  if (port < 1 || port > 65535) return std::unexpected("out of range");
  return port;
}

std::expected<void, std::string> start_server(std::string_view port_str) {
  auto port_r = parse_port(port_str);
  if (!port_r) return std::unexpected(port_r.error());

  int port = *port_r;
  // ... bind/listen ...
  return {};
}

이 방식은 명확하지만, 단계가 많아지면 if (!r) return ...가 반복됩니다. 그래서 체이닝과 헬퍼가 필요해집니다.

체이닝: and_then, transform, or_else

std::expected는 모나딕 연산을 제공합니다.

  • and_then(f) : 성공일 때만 다음 함수를 실행, 실패면 그대로 전파
  • transform(f) : 성공 값에 함수 적용(값만 바꿈)
  • or_else(f) : 실패일 때만 오류를 변환하거나 로깅

예제: 설정 읽기 → 파싱 → 검증

#include <expected>
#include <string>
#include <string_view>

struct config {
  std::string host;
  int port;
};

using exp_str = std::expected<std::string, std::string>;
using exp_cfg = std::expected<config, std::string>;

exp_str read_file_text(const std::string& path);
std::expected<int, std::string> parse_int(std::string_view s);

exp_cfg load_config(const std::string& path) {
  return read_file_text(path)
    .and_then([](const std::string& text) -> exp_cfg {
      // 매우 단순한 예: "host=...\nport=..." 형태라고 가정
      auto host_pos = text.find("host=");
      auto port_pos = text.find("port=");
      if (host_pos == std::string::npos || port_pos == std::string::npos)
        return std::unexpected("missing keys");

      auto host_end = text.find('\n', host_pos);
      std::string host = text.substr(host_pos + 5, host_end - (host_pos + 5));

      auto port_end = text.find('\n', port_pos);
      auto port_str = text.substr(port_pos + 5, port_end - (port_pos + 5));

      return parse_int(port_str)
        .and_then([&](int port) -> exp_cfg {
          if (port < 1 || port > 65535) return std::unexpected("bad port");
          return config{host, port};
        });
    })
    .or_else([](const std::string& e) -> exp_cfg {
      // 에러 메시지에 컨텍스트를 추가
      return std::unexpected(std::string("load_config failed: ") + e);
    });
}

이 스타일의 장점은 “성공 흐름”이 위에서 아래로 자연스럽게 읽힌다는 점입니다.

RAII와 결합: 실패해도 자원은 자동 정리

예외를 쓰지 않더라도 RAII는 여전히 가장 강력한 자원관리 도구입니다. std::expected는 “실패를 값으로 반환”할 뿐, 스코프를 벗어날 때 소멸자가 호출되는 규칙은 동일합니다.

핵심은 다음입니다.

  • 자원 획득은 객체 생성으로
  • 자원 해제는 소멸자로
  • 실패는 std::unexpected로 반환

예제 1) FILE을 RAII로 감싸고 expected로 전파

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

class file_handle {
public:
  explicit file_handle(std::FILE* f) : f_(f) {}
  file_handle(const file_handle&) = delete;
  file_handle& operator=(const file_handle&) = delete;

  file_handle(file_handle&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
  file_handle& operator=(file_handle&& other) noexcept {
    if (this != &other) {
      close();
      f_ = other.f_;
      other.f_ = nullptr;
    }
    return *this;
  }

  ~file_handle() { close(); }

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

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

  std::FILE* f_{};
};

std::expected<file_handle, std::error_code> open_file(const std::string& path) {
  std::FILE* f = std::fopen(path.c_str(), "rb");
  if (!f) {
    // errno를 error_code로 래핑
    return std::unexpected(std::error_code(errno, std::generic_category()));
  }
  return file_handle(f);
}

std::expected<std::string, std::error_code> read_all(const std::string& path) {
  auto fh = open_file(path);
  if (!fh) return std::unexpected(fh.error());

  std::string out;
  char buf[4096];
  while (true) {
    auto n = std::fread(buf, 1, sizeof(buf), fh->get());
    if (n > 0) out.append(buf, buf + n);
    if (n < sizeof(buf)) {
      if (std::ferror(fh->get())) {
        return std::unexpected(std::error_code(errno, std::generic_category()));
      }
      break;
    }
  }
  return out;
}

여기서 중요한 점은 read_all에서 중간에 실패해도 file_handle의 소멸자가 호출되어 파일이 닫힌다는 것입니다. 예외가 없어도 자원 정리는 자동입니다.

예제 2) 락/뮤텍스와 expected

락은 RAII가 특히 빛납니다. 실패 경로가 많아도 std::lock_guardstd::unique_lock이 알아서 해제합니다.

#include <expected>
#include <mutex>
#include <string>

std::mutex g_mu;
int g_counter = 0;

std::expected<int, std::string> increment_if_positive(int delta) {
  std::lock_guard<std::mutex> lk(g_mu);
  if (delta <= 0) return std::unexpected("delta must be positive");
  g_counter += delta;
  return g_counter;
}

expected로 “오류 컨텍스트”를 쌓는 법

실무 디버깅에서 중요한 건 “어디서 왜 실패했는지”입니다. 예외의 스택 트레이스를 포기하는 대신, 오류 값에 컨텍스트를 덧붙이는 패턴을 가져가야 합니다.

패턴: or_else로 메시지 래핑

#include <expected>
#include <string>

template <class T>
using exp = std::expected<T, std::string>;

exp<int> step1();
exp<int> step2(int);

exp<int> pipeline() {
  return step1()
    .and_then([](int v) { return step2(v); })
    .or_else([](const std::string& e) -> exp<int> {
      return std::unexpected(std::string("pipeline failed: ") + e);
    });
}

더 발전시키면 오류 타입을 구조화해서 context 스택을 벡터로 들고 다니는 방식도 가능합니다.

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

레거시 코드가 이미 예외를 던지고 있다면, 한 번에 갈아엎기보다 “경계 계층”부터 바꾸는 것이 안전합니다.

  1. 외부 I/O 계층(파일/네트워크/DB)에서 예외를 잡아 expected로 변환
  2. 비즈니스 로직은 expected만 사용
  3. 최상위 main 또는 요청 핸들러에서 오류를 로깅하고 종료/응답

예외를 expected로 변환하는 래퍼

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

template <class F>
auto to_expected(F&& f)
  -> std::expected<decltype(f()), std::string>
{
  try {
    return f();
  } catch (const std::exception& e) {
    return std::unexpected(std::string("exception: ") + e.what());
  } catch (...) {
    return std::unexpected("unknown exception");
  }
}

// 사용 예
// auto r = to_expected([&] { return legacy_may_throw(); });

이렇게 하면 내부는 점진적으로 expected 스타일로 옮기고, 레거시 예외는 경계에서만 처리할 수 있습니다.

흔한 함정과 실무 팁

1) value()는 최후의 수단

r.value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 *r를 쓰기 전에 반드시 if (!r)로 가드하거나, and_then 체이닝으로 구조화하세요.

2) 오류 타입 E는 가급적 가볍게

오류가 자주 발생하는 경로라면 std::string 복사가 부담일 수 있습니다. 다음을 고려하세요.

  • std::error_code + 별도 로그
  • enum class + 필요 시에만 문자열 변환
  • 메시지가 필요하면 std::string을 이동(move) 중심으로 구성

3) “재시도” 같은 정책은 상위에서

expected는 실패를 표현할 뿐, 재시도 정책까지 강제하지 않습니다. 재시도는 호출자(상위 레벨)가 결정하는 것이 일반적으로 더 낫습니다. 이 관점은 API 호출에서 429 같은 오류를 다룰 때도 동일합니다. 재시도/큐잉 패턴 자체는 다른 글인 OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지에서 정리했지만, 핵심은 “실패를 값으로 올리고 상위에서 정책을 적용”하는 구조가 유지보수에 유리하다는 점입니다.

종합 예제: 자원 획득 + 단계별 처리 + 컨텍스트 누적

아래는 파일을 읽고, 숫자를 파싱하고, 결과를 검증하는 흐름을 expected로 구성한 예시입니다.

#include <expected>
#include <string>
#include <string_view>
#include <system_error>
#include <charconv>

using exp_str = std::expected<std::string, std::error_code>;
using exp_int = std::expected<int, std::error_code>;

exp_str read_all(const std::string& path); // 앞서 구현했다고 가정

exp_int parse_int_ec(std::string_view s) {
  int v = 0;
  auto first = s.data();
  auto last  = s.data() + s.size();
  auto [ptr, ec] = std::from_chars(first, last, v);
  if (ec != std::errc{} || ptr != last) {
    return std::unexpected(std::make_error_code(std::errc::invalid_argument));
  }
  return v;
}

std::expected<int, std::string> load_threshold(const std::string& path) {
  return read_all(path)
    .transform([](std::string text) {
      // 공백 제거 같은 전처리(간단히 끝 개행 제거)
      while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) text.pop_back();
      return text;
    })
    .and_then([](const std::string& s) -> std::expected<int, std::string> {
      auto r = parse_int_ec(s);
      if (!r) return std::unexpected("parse failed: invalid number");
      return *r;
    })
    .and_then([](int v) -> std::expected<int, std::string> {
      if (v < 0 || v > 100) return std::unexpected("range failed: expected 0..100");
      return v;
    })
    .or_else([&](const std::error_code& ec) -> std::expected<int, std::string> {
      return std::unexpected(std::string("io failed: ") + ec.message());
    });
}

포인트는 다음입니다.

  • I/O 계층은 std::error_code로 “기계 친화적” 오류를 반환
  • 상위 비즈니스 함수는 사용자 친화적 메시지로 변환
  • 전처리/검증을 transformand_then으로 단계화
  • 실패해도 RAII로 자원은 정리된다는 전제하에 로직은 오류 전파에 집중

마무리

std::expected는 예외를 완전히 대체한다기보다, “실패가 흔한 경로”에서 제어 흐름을 더 명시적으로 만들어주는 도구입니다. 특히 RAII와 결합하면 다음을 동시에 얻습니다.

  • 실패를 값으로 다루는 명시적 API
  • 호출자 중심의 정책 결정(재시도/대체 경로/로그)
  • 스코프 기반 자원 정리로 누수와 교착 위험 감소

실무에서는 E를 어떻게 설계할지, 그리고 or_else로 컨텍스트를 어떻게 쌓을지가 핵심입니다. 작은 유틸부터 expected를 도입해 경계 계층에서 변환하고, 내부 로직을 체이닝 스타일로 정리하면 예외 없는 코드베이스에서도 읽기 쉬운 오류전파와 안정적인 자원관리를 동시에 달성할 수 있습니다.