Published on

C++23 std - -expected로 예외 없는 오류 처리

Authors

서버/클라이언트/임베디드 등 다양한 C++ 코드베이스에서 “예외를 쓸 것인가?”는 늘 논쟁거리입니다. 예외는 성공 경로를 깔끔하게 유지하지만, 런타임 비용(특히 스택 언와인딩), ABI/컴파일 옵션(-fno-exceptions) 제약, 에러 흐름이 호출 그래프 밖으로 튀는 디버깅 난이도 같은 이유로 금지되는 환경도 많습니다.

C++23의 std::expected는 이런 상황에서 성공 값 또는 실패(에러) 값을 명시적으로 반환할 수 있게 해주는 표준 도구입니다. 흔히 쓰던 bool+out parameter, std::optional(에러 정보 손실), std::variant(의도 전달이 약함)보다 의미가 분명하고, std::error_code만으로 부족했던 도메인별 에러 표현도 강화할 수 있습니다.

이 글에서는 std::expected의 핵심 API, 에러 타입 설계, 전파 패턴, 예외/에러코드 대비 장단점, 그리고 팀 코드에 도입할 때의 실전 팁을 다룹니다.

std::expected 한 줄 정의

std::expected<T, E>는 다음 중 하나의 상태를 가집니다.

  • 성공: 값 T를 보유
  • 실패: 에러 E를 보유

즉, 함수 시그니처만 봐도 “이 함수는 실패할 수 있으며, 실패 시 E를 준다”가 명확해집니다.

#include <expected>
#include <string>

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

위 시그니처는 다음을 강제합니다.

  • 호출자는 성공/실패를 확인해야 한다(무시하기 어렵다)
  • 실패 이유를 문자열로 받을 수 있다

기본 사용법: 성공/실패 만들기

성공 반환

#include <expected>

std::expected<int, int> ok_value() {
  return 42; // T로 암시적 변환되어 성공 상태
}

실패 반환: std::unexpected

실패는 std::unexpected<E>로 감쌉니다.

#include <expected>

std::expected<int, int> fail_value() {
  return std::unexpected(404); // E를 담아 실패 상태
}

확인 및 접근

#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> parse_int(std::string_view s) {
  try {
    size_t pos = 0;
    int v = std::stoi(std::string(s), &pos);
    if (pos != s.size()) {
      return std::unexpected("trailing characters");
    }
    return v;
  } catch (...) {
    return std::unexpected("invalid integer");
  }
}

void use() {
  auto r = parse_int("123x");

  if (!r) {
    std::cerr << "parse failed: " << r.error() << "\n";
    return;
  }

  std::cout << "value: " << *r << "\n"; // 또는 r.value()
}
  • if (r) 또는 r.has_value()로 성공 여부 확인
  • 성공 값: *r, r.value()
  • 에러 값: r.error()

주의: r.value()는 실패 상태에서 호출하면 std::bad_expected_access를 던질 수 있습니다(예외 비활성 환경이면 사용을 피하고 *r도 성공 확인 후에만 접근하세요).

std::optional이 아니라 std::expected인가

std::optional<T>는 실패를 표현할 수 있지만 왜 실패했는지를 잃습니다.

  • 파일 읽기 실패: 권한 문제인가, 파일이 없는가, 인코딩 문제인가?
  • 네트워크 실패: 타임아웃인가, DNS인가, TLS인가?

운영에서 “원인”은 복구와 직결됩니다. 예를 들어 장애 분석에서 원인 로그를 빨리 좁히는 건 매우 중요합니다. 인프라/운영 문제를 추적하는 글들(예: EKS CrashLoopBackOff 원인 10분만에 찾기, MySQL InnoDB 데드락 추적 - deadlock.log 읽기)이 강조하는 것도 결국 “진단 가능한 에러 정보”입니다. 애플리케이션 코드에서도 같은 원칙이 적용됩니다.

std::expected는 에러를 타입으로 모델링해 진단 가능성을 기본값으로 만듭니다.

에러 타입 E 설계 전략

E는 아무 타입이나 될 수 있지만, 실전에서는 다음 중 하나가 자주 쓰입니다.

1) std::error_code 기반

표준/플랫폼 에러와 결합이 쉽고, 값이 작아 효율적입니다.

#include <expected>
#include <system_error>

std::expected<void, std::error_code> write_file(/*...*/);

다만 도메인별 상세 정보(예: 어느 필드가 잘못됐는지)를 담기엔 부족할 수 있습니다.

2) enum + 메시지(또는 컨텍스트) 구조체

실무에서 가장 균형이 좋습니다.

#include <expected>
#include <string>

enum class ParseErrc {
  Empty,
  InvalidChar,
  Overflow
};

struct ParseError {
  ParseErrc code;
  size_t position = 0;
  std::string message;
};

std::expected<int, ParseError> parse_int2(std::string_view s) {
  if (s.empty()) {
    return std::unexpected(ParseError{ParseErrc::Empty, 0, "empty input"});
  }
  long long acc = 0;
  for (size_t i = 0; i < s.size(); ++i) {
    char c = s[i];
    if (c < '0' || c > '9') {
      return std::unexpected(ParseError{ParseErrc::InvalidChar, i, "non-digit"});
    }
    acc = acc * 10 + (c - '0');
    if (acc > 2147483647LL) {
      return std::unexpected(ParseError{ParseErrc::Overflow, i, "overflow"});
    }
  }
  return static_cast<int>(acc);
}

이 방식의 장점은 다음과 같습니다.

  • 로깅/모니터링에 필요한 컨텍스트를 구조적으로 전달
  • 호출자는 code로 분기하고, message는 관측성(로그) 용도로 사용

3) std::string만 쓰기(최소 설계)

가장 쉽지만, 호출 측에서 분기하기 어렵고 국제화/머신 리더블 처리에 불리합니다. “초기 도입” 단계에서는 가능하나, 규모가 커지면 enum 기반으로 옮기는 것을 권장합니다.

오류 전파 패턴: and_then, transform, or_else

std::expected의 진짜 강점은 “실패를 자연스럽게 전파”하는 조합 함수들입니다.

  • transform(f): 성공 값 TU로 변환(실패면 그대로)
  • and_then(f): 성공이면 다음 expected를 반환하는 함수를 연결
  • or_else(f): 실패일 때 에러를 변환하거나 복구

예시로, 문자열을 읽고 숫자로 파싱한 뒤, 범위를 검증하는 파이프라인을 만들어보겠습니다.

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

struct Err {
  std::string msg;
};

std::expected<std::string, Err> read_text();
std::expected<int, Err> parse_num(std::string_view);
std::expected<int, Err> validate_range(int v) {
  if (v < 0 || v > 100) {
    return std::unexpected(Err{"out of range"});
  }
  return v;
}

std::expected<int, Err> load_config_value() {
  return read_text()
    .and_then([](const std::string& s) { return parse_num(s); })
    .and_then([](int v) { return validate_range(v); });
}

위 코드는 예외 없이도 “첫 실패”가 자동으로 전파됩니다. 중간에 if (!r) return r;를 반복하지 않아도 되어, 성공 경로가 읽기 쉬워집니다.

expected를 반환하는 함수 설계 팁

1) 반환 타입은 최상위 API에서부터 정하라

하위 함수들이 제각각 다른 에러 타입을 반환하면 조합이 어려워집니다. 모듈 단위로 E를 통일하거나, 변환 규칙을 명확히 두세요.

  • 예: 파서 모듈은 ParseError
  • 예: IO 모듈은 IoError
  • 상위 서비스 레이어는 AppError로 래핑

2) 에러 변환은 or_else로 한 곳에서

#include <expected>
#include <string>

struct IoError { std::string msg; };
struct AppError { std::string msg; };

std::expected<int, IoError> read_number_from_disk();

std::expected<int, AppError> load_number() {
  return read_number_from_disk().or_else([](const IoError& e) {
    return std::unexpected(AppError{"disk read failed: " + e.msg});
  });
}

이렇게 하면 하위 모듈의 에러 표현이 상위로 새는 것을 막고, 로깅/메시지 정책도 계층별로 유지할 수 있습니다.

3) Tvoid인 경우도 유용하다

성공 시 값이 필요 없고 “성공/실패”만 필요하면 std::expected<void, E>가 깔끔합니다.

#include <expected>
#include <system_error>

std::expected<void, std::error_code> ensure_dir_exists(/*...*/);

예외 vs std::expected: 트레이드오프를 현실적으로 보기

std::expected가 특히 좋은 경우

  • 예외가 금지된 환경(-fno-exceptions) 또는 ABI 제약
  • 실패가 빈번할 수 있는 경로(파싱, 검증, 사용자 입력)
  • 호출자가 실패를 “정상 흐름”으로 다뤄야 하는 API
  • 에러를 구조적으로 전달하고 싶은 경우(에러 코드, 위치, 원인 체인)

예외가 여전히 유리한 경우

  • 실패가 “진짜 예외적”이고 상위에서 한 번에 처리하는 구조
  • 오류 전파가 매우 깊고, 모든 레벨에서 반환 타입을 바꾸기 어려운 레거시
  • 광범위한 라이브러리들이 예외 기반으로 설계된 경우

현실적인 결론은 “둘 중 하나만”이 아니라, 모듈 경계에서 정책을 정하고 변환하는 것입니다.

  • 내부는 expected
  • 외부 API는 예외(혹은 그 반대)

이때 변환 지점이 명확해야 운영/디버깅이 쉬워집니다. CI에서 문제를 빨리 좁히는 것처럼(예: GitHub Actions 캐시 안 먹을 때 키·경로·권한 7단계), 에러도 “어디서 어떤 정책으로 바뀌는지”가 추적 가능해야 합니다.

실전 예제: 파일에서 정수 읽기(예외 없이)

아래는 파일을 읽고 정수로 파싱하는 예제입니다. 표준 스트림은 예외를 켤 수도 있지만, 여기서는 예외 없이 상태를 검사하는 방식으로 작성합니다.

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

enum class ReadIntErrc {
  OpenFailed,
  ReadFailed,
  ParseFailed
};

struct ReadIntError {
  ReadIntErrc code;
  std::string path;
  std::string detail;
};

std::expected<int, ReadIntError> read_int_file(const std::string& path) {
  std::ifstream in(path);
  if (!in.is_open()) {
    return std::unexpected(ReadIntError{ReadIntErrc::OpenFailed, path, "cannot open"});
  }

  std::string s;
  if (!(in >> s)) {
    return std::unexpected(ReadIntError{ReadIntErrc::ReadFailed, path, "cannot read token"});
  }

  // 간단 파싱(부호/공백 등은 필요에 맞게 확장)
  long long acc = 0;
  for (char c : s) {
    if (c < '0' || c > '9') {
      return std::unexpected(ReadIntError{ReadIntErrc::ParseFailed, path, "non-digit"});
    }
    acc = acc * 10 + (c - '0');
    if (acc > 2147483647LL) {
      return std::unexpected(ReadIntError{ReadIntErrc::ParseFailed, path, "overflow"});
    }
  }

  return static_cast<int>(acc);
}

호출 측은 다음처럼 명확하게 처리합니다.

#include <iostream>

void run(const std::string& path) {
  auto r = read_int_file(path);
  if (!r) {
    const auto& e = r.error();
    std::cerr << "failed: path=" << e.path << " detail=" << e.detail << "\n";
    return;
  }

  std::cout << "value=" << *r << "\n";
}

여기서 중요한 점은 “실패가 값으로 모델링”되니, 로깅/리트라이/대체 경로 선택 같은 정책을 호출자가 자연스럽게 구현할 수 있다는 것입니다.

도입 체크리스트(팀/레거시 관점)

  1. 모듈별 에러 타입 정책을 먼저 정하세요. E가 난립하면 조합이 어려워집니다.
  2. 에러 메시지는 “사람용”과 “분기용”을 분리하세요. 분기는 enum, 메시지는 로그.
  3. 성공 경로가 중요한 API는 transform/and_then 조합으로 가독성을 확보하세요.
  4. 경계(예: 라이브러리 API, RPC 핸들러)에서만 변환하세요. 내부는 일관되게.
  5. 테스트에서 실패 케이스를 먼저 작성하세요. expected는 실패 케이스 테스트가 훨씬 쉬워집니다.

마무리

std::expected는 C++에서 “예외 없는 오류 처리”를 단순히 가능하게 하는 수준을 넘어, 실패를 타입 시스템 안으로 끌어들여 API의 의도를 명확히 하고, 관측성과 디버깅 경험을 개선하는 도구입니다.

예외를 완전히 대체할지 여부는 프로젝트 성격에 따라 다르지만, 최소한 **실패가 흔한 경로(파싱/검증/IO 경계)**부터 std::expected로 정리하면 코드 품질과 운영 안정성 모두에서 체감 효과가 큽니다.