Published on

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

Authors

예외 기반 에러 처리는 강력하지만, 시스템/인프라 코드나 성능 민감한 라이브러리, 또는 예외를 금지한 코드베이스에서는 오히려 부담이 됩니다. 대표적으로

  • 예외 전파 경로가 길어지며 제어 흐름이 불투명해짐
  • ABI/컴파일 옵션(예: -fno-exceptions) 제약
  • 실패가 “정상적인 결과의 한 종류”인 경우(네트워크, 파일 I/O, 파싱 등) 예외가 과도함

같은 문제가 생깁니다. C++23의 std::expected는 “성공 값 또는 실패 값”을 타입으로 명시해, 예외 없이도 읽기 쉬운 에러 처리를 가능하게 합니다.

이 글에서는 std::expected를 실무에서 어떻게 설계하고 조합하는지, 그리고 기존 방식(에러 코드, std::optional, 예외)과 비교해 어떤 장점이 있는지 코드로 정리합니다.

std::expected 한 줄 정의

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

  • 성공: T
  • 실패: E 에러

즉, 함수 시그니처가 “성공/실패 가능”을 명확히 드러냅니다.

#include <expected>
#include <string>

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

이 시그니처만 봐도 호출자는 “정상 값은 int, 실패하면 문자열 에러”라는 사실을 강제적으로 다루게 됩니다.

std::optional로는 부족한가

std::optional<T>는 성공 여부만 표현합니다. 실패 이유가 빠집니다.

#include <optional>
#include <string_view>

std::optional<int> parse_int_opt(std::string_view s);

이 경우 실패 원인을 남기려면 전역 상태(errno), 아웃 파라미터, 로깅 의존 등으로 흘러가기가 쉽습니다. 반면 std::expected는 실패 이유를 타입으로 운반합니다.

기본 사용법: 생성, 검사, 값/에러 접근

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

enum class ParseErr {
  Empty,
  Invalid,
  OutOfRange
};

std::expected<int, ParseErr> parse_int(std::string_view s) {
  if (s.empty()) return std::unexpected(ParseErr::Empty);

  int value = 0;
  auto first = s.data();
  auto last  = s.data() + s.size();

  auto [ptr, ec] = std::from_chars(first, last, value);
  if (ec == std::errc::invalid_argument) return std::unexpected(ParseErr::Invalid);
  if (ec == std::errc::result_out_of_range) return std::unexpected(ParseErr::OutOfRange);
  if (ptr != last) return std::unexpected(ParseErr::Invalid);

  return value;
}

std::string to_string(ParseErr e) {
  switch (e) {
    case ParseErr::Empty: return "empty";
    case ParseErr::Invalid: return "invalid";
    case ParseErr::OutOfRange: return "out_of_range";
  }
  return "unknown";
}

호출부는 다음처럼 명시적으로 처리합니다.

auto r = parse_int("42");
if (!r) {
  // r.error()로 실패 원인 접근
  std::printf("parse failed: %s\n", to_string(r.error()).c_str());
  return;
}

// 성공 값
int x = *r;            // 또는 r.value()
std::printf("x=%d\n", x);
  • if (r) 또는 if (!r)로 성공/실패 분기
  • 성공 값: *r, r.value()
  • 실패 값: r.error()
  • 실패 생성: std::unexpected(err)

에러 타입 설계: 문자열 vs enum vs 구조체

실무에서 가장 많이 고민하는 지점이 E 타입입니다.

1) enum class 에러 코드

장점: 가볍고 비교가 쉬움. 단점: 메시지/컨텍스트가 부족.

  • 라이브러리 내부 로직 실패(파싱, 검증, 상태 전이)에 적합

2) std::string 메시지

장점: 바로 로깅 가능. 단점: 할당 비용, 분류/정책 처리 어려움.

  • CLI 도구, 단발성 유틸리티에 적합

3) 구조체(코드 + 컨텍스트)

가장 실무 친화적입니다.

#include <string>
#include <system_error>

enum class Errc {
  Io,
  Parse,
  InvalidState,
  Timeout,
  Permission
};

struct Error {
  Errc code;
  std::string message;          // 사람이 읽는 메시지
  std::error_code sys{};        // 필요 시 OS/라이브러리 에러
};

inline Error make_error(Errc c, std::string msg, std::error_code sys = {}) {
  return Error{c, std::move(msg), sys};
}

이렇게 하면

  • 정책 분기: code로 처리
  • 로깅: message
  • 원인 추적: sys (예: EACCES, ETIMEDOUT)

을 동시에 잡을 수 있습니다.

조합 가능한 에러 처리: “단계별 실패”를 깔끔하게

예외 없는 코드에서 가장 지저분해지는 패턴은 “중간 단계에서 실패하면 즉시 반환”입니다. std::expected는 이를 표준화합니다.

예를 들어 설정 문자열을 읽고, 파싱하고, 검증하는 흐름을 생각해봅시다.

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

struct Config {
  int port;
};

std::expected<std::string, Error> read_text_file(std::string_view path);
std::expected<int, Error> parse_port(std::string_view s);
std::expected<Config, Error> validate_config(int port);

std::expected<Config, Error> load_config(std::string_view path) {
  auto text = read_text_file(path);
  if (!text) return std::unexpected(text.error());

  auto port = parse_port(*text);
  if (!port) return std::unexpected(port.error());

  auto cfg = validate_config(*port);
  if (!cfg) return std::unexpected(cfg.error());

  return *cfg;
}

여기까지는 “깔끔하지만 반복이 많다”는 느낌이 있을 수 있습니다. 그래서 다음 섹션의 변환/체이닝이 중요합니다.

transform, and_then, or_else로 체이닝하기

C++23의 std::expected는 함수형 스타일의 조합 메서드를 제공합니다.

  • transform(f): 성공 값 Tf(T)로 변환. 실패는 그대로 전달.
  • and_then(f): 성공 값에 대해 f(T)를 호출하는데, f는 또 다른 std::expected를 반환(연쇄 가능).
  • or_else(f): 실패 시 f(E)를 호출해 복구/변환.

load_config를 체이닝으로 바꾸면 다음처럼 됩니다.

std::expected<Config, Error> load_config(std::string_view path) {
  return read_text_file(path)
    .and_then([](const std::string& text) {
      return parse_port(text);
    })
    .and_then([](int port) {
      return validate_config(port);
    });
}

중간 if (!r) return ...가 사라지고, “성공 경로”가 직선으로 읽힙니다.

실패를 로깅만 하고 그대로 전파: or_else

#include <cstdio>

std::expected<Config, Error> load_config_logged(std::string_view path) {
  return load_config(path)
    .or_else([](const Error& e) -> std::expected<Config, Error> {
      std::printf("load_config failed: code=%d msg=%s\n",
                  static_cast<int>(e.code), e.message.c_str());
      return std::unexpected(e);
    });
}

실패를 삼키지 않고 관측만 하는 패턴은 운영 환경에서 특히 유용합니다. 네트워크/외부 API처럼 실패가 잦은 영역은 재시도, 백오프 같은 정책과 결합되기 때문입니다. 이런 관점은 OpenAI 429·rate_limit 재시도·백오프 설계 가이드의 “실패를 값으로 다루고 정책으로 감싸는” 접근과도 통합니다.

에러 타입 변환: 하위 계층 에러를 상위 계층 의미로 매핑

실무에서는 계층마다 에러 의미가 달라집니다.

  • 파일 읽기 계층: Permission, NotFound, Io
  • 설정 로드 계층: ConfigMissing, ConfigInvalid

이때 하위 에러를 그대로 노출하면 상위 계층이 불필요한 결합을 갖게 됩니다. or_else를 이용해 매핑할 수 있습니다.

enum class ConfigErrc {
  Missing,
  Invalid,
  Io
};

struct ConfigError {
  ConfigErrc code;
  std::string message;
};

std::expected<std::string, Error> read_text_file(std::string_view path);

std::expected<std::string, ConfigError> read_config_text(std::string_view path) {
  return read_text_file(path)
    .or_else([&](const Error& e) -> std::expected<std::string, ConfigError> {
      if (e.code == Errc::Permission) {
        return std::unexpected(ConfigError{ConfigErrc::Io, "permission denied"});
      }
      if (e.code == Errc::Io) {
        return std::unexpected(ConfigError{ConfigErrc::Io, "io error"});
      }
      return std::unexpected(ConfigError{ConfigErrc::Missing, "config missing"});
    });
}

핵심은 “에러를 숨기는 게 아니라, 의미를 정제해서 노출”한다는 점입니다.

예외와의 공존 전략: 경계에서만 변환

현실적으로 모든 코드가 예외를 금지하지는 않습니다. 추천하는 전략은 다음입니다.

  • 내부 코어(라이브러리/핵심 로직): std::expected
  • 애플리케이션 경계(UI, RPC 핸들러, main): 예외로 변환하거나 에러 응답으로 매핑

예를 들어 경계에서만 예외를 던지고 싶다면:

#include <stdexcept>

template <class T, class E>
T value_or_throw(std::expected<T, E> r) {
  if (!r) throw std::runtime_error("operation failed");
  return *r;
}

반대로 예외 기반 API를 expected로 감싸서 코어로 들여오는 것도 가능합니다.

#include <expected>
#include <string>

std::expected<int, std::string> safe_div(int a, int b) {
  try {
    if (b == 0) throw std::runtime_error("divide by zero");
    return a / b;
  } catch (const std::exception& ex) {
    return std::unexpected(std::string(ex.what()));
  }
}

이렇게 “경계에서 변환”하면, 내부는 예외 없는 직선 흐름을 유지할 수 있습니다.

std::error_code와 함께 쓰기

OS/네트워크/파일 I/O는 이미 std::error_code 중심으로 설계된 API가 많습니다. Estd::error_code로 두는 것도 좋은 선택입니다.

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

std::expected<void, std::error_code> do_io() {
  // 예시: 실패했다고 가정
  return std::unexpected(std::make_error_code(std::errc::permission_denied));
}

void run() {
  auto r = do_io();
  if (!r) {
    std::printf("io failed: %s\n", r.error().message().c_str());
  }
}

다만 std::error_code는 “분류/정책”에는 강하지만 “도메인 컨텍스트(어느 파일, 어떤 파라미터)”가 부족할 수 있으니, 앞서 소개한 구조체 에러에 std::error_code를 포함하는 방식이 실무에서 더 자주 쓰입니다.

재시도/백오프 같은 정책을 expected로 감싸기

expected의 강점은 실패가 값이기 때문에, 정책을 함수로 감싸 조합하기 쉽다는 점입니다.

#include <expected>
#include <chrono>
#include <thread>

template <class F>
auto retry_n(int n, F&& f) {
  using R = decltype(f());
  static_assert(std::is_same_v<R, R>, "");

  R last = f();
  for (int i = 0; i < n && !last; ++i) {
    std::this_thread::sleep_for(std::chrono::milliseconds(50 * (i + 1)));
    last = f();
  }
  return last;
}

사용 예:

auto r = retry_n(3, [&] {
  return read_text_file("/etc/app.conf");
});
if (!r) {
  // 최종 실패 처리
}

이 패턴은 네트워크 ETIMEDOUT, ECONNRESET 같은 오류를 다룰 때 특히 유효합니다. 장애 대응 관점에서는 Node.js fetch ECONNRESET·ETIMEDOUT 해결법처럼 “실패를 분류하고 재시도/타임아웃 정책을 분리”하는 접근이 언어를 가리지 않고 반복됩니다.

자주 하는 실수와 주의점

1) value() 남발로 사실상 예외처럼 쓰기

r.value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 *r를 무조건 쓰라는 뜻이 아니라, 반드시 if (!r)로 분기하거나 and_then 체이닝으로 강제하는 편이 낫습니다.

2) 에러를 문자열로만 두고 정책 처리를 포기

문자열은 로깅에는 좋지만, 재시도 가능/불가능, 사용자 오류/시스템 오류 같은 분류가 어려워집니다. 최소한 enum class 코드와 메시지를 함께 두는 방식을 권장합니다.

3) “모든 함수가 expected”가 되며 시그니처가 무거워지는 문제

해결책은 경계 설정입니다.

  • 실패가 가능한 함수(외부 입력, I/O, 파싱, 상태 전이): expected
  • 실패가 논리적으로 불가능한 순수 계산: 일반 반환

그리고 계층 경계에서만 에러 타입을 변환해, 상위로 올라갈수록 단순한 도메인 에러만 보이게 하세요.

마이그레이션 가이드: 기존 코드에서 단계적으로 도입하기

  1. 예외를 던지는 함수 앞에 얇은 래퍼를 만들어 expected로 변환
  2. 새로 작성하는 모듈부터 expected 기반으로 설계
  3. 상위 계층에서 and_then 체이닝으로 성공 경로를 정리
  4. 에러 타입을 enum에서 구조체로 확장(메시지/컨텍스트/시스템 에러 추가)

운영 환경에서 중요한 것은 “실패를 숨기지 않고, 분류 가능하게 만들고, 정책(재시도/백오프/서킷브레이커 등)으로 감싸는 것”입니다. std::expected는 이를 C++ 표준 라이브러리 레벨에서 지원해, 팀 규칙으로도 강제하기 쉬운 기반을 제공합니다.

정리

  • std::expected는 성공 값 T 또는 실패 값 E를 담아, 예외 없이도 명확한 에러 처리를 가능하게 합니다.
  • and_then, transform, or_else를 활용하면 단계적 실패 로직을 깔끔하게 체이닝할 수 있습니다.
  • 실무에서는 E를 구조체로 설계해 코드(정책)와 메시지(관측), 시스템 에러(원인)를 함께 들고 가는 방식이 가장 유연합니다.
  • 예외와는 “경계에서만 변환”하는 공존 전략이 마이그레이션과 유지보수에 유리합니다.

C++23을 도입할 수 있는 환경이라면, 예외 없는 코드베이스에서도 읽기 쉽고 조합 가능한 에러 처리를 위해 std::expected를 우선순위 높게 검토해볼 만합니다.