Published on

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

Authors

예외 기반 오류 처리는 C++에서 강력하지만, 런타임 비용/바이너리 크기/ABI 제약, 그리고 코드 경로 추론 어려움 때문에 서버·임베디드·게임 엔진 등에서 예외를 꺼두는 경우가 많습니다. 그렇다고 bool 반환 + errno + 출력 로그에 의존하면 호출자가 오류를 놓치기 쉽고, 자원 해제 코드가 분기마다 반복되면서 버그가 생깁니다.

C++23의 std::expected는 “성공 값” 또는 “오류”를 타입으로 강제하는 도구입니다. 핵심은 단순히 예외를 대체하는 것이 아니라, RAII와 결합해 오류 전파와 자원 관리를 함께 단순화하는 데 있습니다.

참고로 std::expected 자체의 기본 개념/간단 예시는 아래 글에서도 다룹니다.

std::expected가 해결하는 문제

1) 호출자가 오류를 무시하기 어렵다

반환 타입이 std::expected<T, E>면, 성공 값 T를 쓰려면 반드시 has_value() 확인 또는 value() 호출(실패 시 bad_expected_access)을 거치게 됩니다. 즉, API 설계 단계에서부터 “실패 가능성”을 강제합니다.

2) 오류를 값으로 다룰 수 있다

오류를 enum, std::error_code, 커스텀 구조체 등으로 모델링할 수 있습니다. 예외처럼 스택을 타고 올라가지만, “던지는” 대신 “반환”하므로 코드 경로가 더 명시적입니다.

3) RAII와 자연스럽게 결합된다

expected는 단지 반환 컨테이너일 뿐이고, 자원 해제는 여전히 RAII(스마트 포인터, 핸들 래퍼, 스코프 가드)가 담당합니다. 중요한 건 실패 시점마다 수동 close()/free()를 호출하지 않아도 된다는 점입니다.

기본 패턴: 오류 타입부터 설계하기

오류 타입 E는 다음 중 하나를 추천합니다.

  • 간단한 라이브러리/모듈 내부: enum class + 메시지
  • OS/네트워크 연동: std::error_code 또는 std::system_errorcode()
  • 상위 계층에 풍부한 컨텍스트 필요: 에러 구조체(코드, 메시지, 원인, 추가 데이터)

다음은 “코드 + 메시지 + 원인 에러코드” 정도를 담는 예시입니다.

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

enum class AppErrc {
  InvalidArgument,
  Io,
  Protocol,
};

struct AppError {
  AppErrc code;
  std::string message;
  std::error_code cause; // 필요 없으면 비워도 됨
};

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

이렇게 해두면 함수 시그니처만 봐도 “무엇이 실패할 수 있는지”가 드러납니다.

자원관리 1: 파일 핸들을 RAII로 감싸고 expected로 생성하기

예외를 끈 코드에서 흔한 실수는 “열기 성공했는지 확인”과 “실패 시 닫기”가 분기마다 반복되는 것입니다. 해결책은 핸들 래퍼 + 팩토리 함수는 expected 입니다.

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

struct File {
  std::FILE* fp = nullptr;

  File() = default;
  explicit File(std::FILE* f) : fp(f) {}

  File(const File&) = delete;
  File& operator=(const File&) = delete;

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

  ~File() { close(); }

  void close() noexcept {
    if (fp) {
      std::fclose(fp);
      fp = nullptr;
    }
  }
};

Result<File> open_file(const std::string& path, const char* mode) {
  if (path.empty()) {
    return std::unexpected(AppError{AppErrc::InvalidArgument, "empty path", {}});
  }

  std::FILE* f = std::fopen(path.c_str(), mode);
  if (!f) {
    // errno를 error_code로 옮기고 싶다면 std::error_code(errno, std::generic_category())
    return std::unexpected(AppError{AppErrc::Io, "fopen failed", {}});
  }

  return File{f};
}

포인트는 다음과 같습니다.

  • File은 소멸자에서 자동 fclose()
  • open_file()은 실패하면 std::unexpected(error)로 반환
  • 호출자는 Result<File>을 통해 성공/실패를 반드시 처리

자원관리 2: “여러 자원 획득”을 단계적으로 합성하기

실무에서는 파일 하나가 아니라 “파일 열기 → 읽기 버퍼 할당 → 파싱”처럼 단계가 이어집니다. 예외가 없으면 중간 실패 시 정리 코드가 복잡해지는데, RAII를 쓰면 정리는 자동으로 해결되고 남는 문제는 오류 전파를 깔끔하게 하는 것입니다.

C++23에는 표준 TRY 매크로가 없지만, 팀/프로젝트에서 작은 헬퍼를 두면 생산성이 크게 올라갑니다.

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

위 매크로는 GCC/Clang의 statement expression 확장에 의존하므로, 이식성이 중요하면 매크로 대신 함수/람다 기반 패턴을 사용하세요. 이 글에서는 개념을 보여주기 위해 사용합니다.

이제 “열기 → 읽기”를 합성해봅니다.

#include <vector>

Result<std::vector<unsigned char>> read_all_bytes(const std::string& path) {
  auto file = TRY(open_file(path, "rb"));

  std::vector<unsigned char> buf;
  std::fseek(file.fp, 0, SEEK_END);
  long size = std::ftell(file.fp);
  std::rewind(file.fp);

  if (size < 0) {
    return std::unexpected(AppError{AppErrc::Io, "ftell failed", {}});
  }

  buf.resize(static_cast<size_t>(size));
  size_t n = std::fread(buf.data(), 1, buf.size(), file.fp);
  if (n != buf.size()) {
    return std::unexpected(AppError{AppErrc::Io, "fread short read", {}});
  }

  // file은 스코프 종료 시 자동 fclose
  return buf;
}

여기서 자원 정리는 Filestd::vector가 담당합니다. 오류가 어디서 나든 “닫기/해제”는 자동입니다.

오류 전파 설계: 에러를 덧붙여 컨텍스트를 유지하기

expected의 장점은 오류를 값으로 다루는 만큼, 상위 레이어에서 “어디서 실패했는지”를 컨텍스트로 보강하기 쉽다는 것입니다.

Result<int> parse_version(const std::vector<unsigned char>& bytes) {
  if (bytes.size() < 4) {
    return std::unexpected(AppError{AppErrc::Protocol, "header too small", {}});
  }
  return static_cast<int>(bytes[0]);
}

Result<int> load_version_from_file(const std::string& path) {
  auto bytesExp = read_all_bytes(path);
  if (!bytesExp) {
    auto err = bytesExp.error();
    err.message = "read_all_bytes: " + err.message;
    return std::unexpected(std::move(err));
  }

  auto verExp = parse_version(*bytesExp);
  if (!verExp) {
    auto err = verExp.error();
    err.message = "parse_version: " + err.message;
    return std::unexpected(std::move(err));
  }

  return *verExp;
}

예외였다면 스택 트레이스로 추적했을 정보를, expected에서는 “메시지/코드 누적” 같은 방식으로 모델링합니다. 로그를 남길 때도 상위에서 한 번만 남기기 쉬워집니다.

std::expected와 스코프 가드로 “부분 성공” 정리하기

RAII가 항상 완벽한 건 아닙니다. 예를 들어, 함수 중간에 “외부 시스템에 등록” 같은 작업을 하고, 이후 실패하면 “등록 해제”가 필요할 수 있습니다. 이때는 스코프 가드가 유용합니다.

#include <utility>

class ScopeExit {
  bool active_ = true;
  std::function<void()> fn_;
public:
  explicit ScopeExit(std::function<void()> fn) : fn_(std::move(fn)) {}
  ~ScopeExit() { if (active_) fn_(); }
  void release() noexcept { active_ = false; }
};

Result<void> register_and_do_work() {
  bool registered = false;

  // 예시: register_resource()가 외부 시스템에 등록한다고 가정
  auto reg = [&]() -> Result<void> {
    registered = true;
    return {};
  }();

  if (!reg) return std::unexpected(reg.error());

  ScopeExit rollback([&] {
    if (registered) {
      // unregister_resource();
      registered = false;
    }
  });

  // 중간 작업 실패 시 rollback이 자동 수행됨
  bool ok = false; // do_work()
  if (!ok) {
    return std::unexpected(AppError{AppErrc::Io, "do_work failed", {}});
  }

  rollback.release();
  return {};
}

이 패턴은 트랜잭션/등록/임시 파일 생성처럼 “명시적 롤백”이 필요한 작업에서 특히 유용합니다.

예외 vs expected: 선택 기준

std::expected가 만능은 아닙니다. 다음 기준으로 선택하면 실전에서 시행착오가 줄어듭니다.

  • 예외를 켜기 어려운 환경(게임 콘솔, 임베디드, 일부 금융/저지연 시스템): expected 우선
  • API 경계가 명확하고 실패가 빈번(파싱, 네트워크, 파일 I/O): expected가 호출 비용/가독성 측면에서 유리
  • 실패가 정말 예외적이고 상위에서 일괄 처리(프레임워크 내부, 초기화 단계): 예외가 더 간결할 수 있음
  • 혼용도 가능: 내부는 expected, 외부 API는 예외로 변환(또는 반대)

중요한 건 팀 규칙입니다. 한 모듈에서 예외/expected/에러코드가 섞이면 오히려 복잡도가 증가합니다.

성능/코드 품질 관점에서의 팁

  1. E는 너무 무겁게 만들지 마세요. 문자열을 무조건 포함하면 성공 경로에서도 코드 크기가 늘 수 있습니다. 필요하면 message를 옵션으로 두거나, 상위 레이어에서만 메시지를 구성하세요.

  2. “오류는 상위에서 한 번만 로깅” 규칙을 권장합니다. expected 체인에서 하위가 로그를 남기면 중복 로그가 쌓이기 쉽습니다.

  3. Result<void>를 적극적으로 쓰세요. C++에서는 std::expected<void, E>가 자연스럽고, 성공/실패만 표현하는 API를 깔끔하게 만듭니다.

  4. 동시성/네트워크처럼 실패가 정상 흐름인 영역에서는 expected가 특히 잘 맞습니다. 예를 들어 gRPC에서 데드라인 초과/일시적 실패를 “예외”로 던지기보다 값으로 처리하면 재시도 정책을 조합하기 쉬워집니다. (관련해서는 Go gRPC 데드라인 초과 원인 7가지와 해결 같은 글의 운영 관점도 참고할 만합니다.)

정리: expected는 오류 처리 도구이자 설계 도구

std::expected를 도입하면 단순히 try/catch를 없애는 수준을 넘어, 다음이 함께 개선됩니다.

  • 실패 가능성이 타입에 드러나 API가 더 정직해짐
  • 오류 전파가 명시적이어서 테스트/리뷰가 쉬워짐
  • RAII와 결합해 실패 경로에서도 자원 누수/중복 정리 코드가 줄어듦
  • 컨텍스트를 보강한 오류 모델링으로 운영/디버깅 품질이 올라감

실전에서는 “핸들 래퍼(RAII) + 팩토리는 expected + 상위에서 컨텍스트 추가 + 한 번만 로깅” 조합이 가장 효과적입니다. 이를 기준으로 작은 유틸(예: TRY 매크로/함수)까지 정리해두면, 예외 없이도 충분히 읽기 좋은 C++ 코드를 만들 수 있습니다.