Published on

C++23 std - -expected로 예외 없는 오류·메모리 안전

Authors

서버·임베디드·게임 엔진 같은 C++ 코드베이스에서는 예외를 꺼두거나(-fno-exceptions) 경계(ABI, FFI)에서 예외 전파를 막는 경우가 많습니다. 하지만 예외를 쓰지 않는다고 해서 오류 처리가 쉬워지는 건 아닙니다. 반환값으로 bool/int를 돌려주고, 별도의 out-parameter로 결과를 채우는 방식은 호출부에서 실수하기 쉽고, 중간 단계에서 리소스 정리가 누락되면 메모리/핸들 누수로 이어집니다.

C++23의 std::expected성공 값 또는 오류 값 중 하나를 담는 표준 타입으로, 예외 없이도 오류를 “타입 시스템”으로 끌어올립니다. 이 글에서는 std::expected를 이용해 예외 없는 오류 처리를 일관되게 만들고, RAII와 결합해 메모리/리소스 안전성을 높이는 실전 패턴을 다룹니다.

std::expected가 해결하는 문제

1) 오류 경로를 숨기지 않는다

예외는 호출부 코드가 깔끔해 보이지만, 실제로는 “어디서 던질지”가 분산되어 있고, 경계(스레드, C API, 플러그인, 네트워크 루프)에서 예외가 새어 나오면 종료로 이어질 수 있습니다. 반대로 반환 코드 방식은 호출부가 오류를 무시해도 컴파일러가 막기 어렵습니다.

std::expected<T, E>는 함수 시그니처에 실패 가능성을 명시합니다.

  • 성공: T
  • 실패: E (에러 코드, 에러 enum, 커스텀 에러 구조체 등)

2) “부분 초기화”와 리소스 누수를 줄인다

예외 없는 코드에서 흔한 패턴은 다음과 같습니다.

  • 1단계에서 리소스 A 획득
  • 2단계에서 실패
  • A 해제 누락

std::expected는 RAII 객체(예: std::unique_ptr, std::vector, 파일 핸들 래퍼)를 성공 값으로 담고, 실패 시에는 오류만 반환하도록 구성하기 좋습니다. 즉, 성공 경로에서만 완성된 객체를 만들고 반환하게 유도합니다.

3) 오류 전파가 단순해진다

예외가 없다면 “중간에서 실패하면 즉시 반환” 패턴이 늘어납니다. std::expected는 이 패턴을 표준화하고, transform, and_then, or_else 같은 조합 연산(모나딕 스타일)로 체이닝을 가능하게 합니다.

기본 사용법: 성공/실패 생성과 검사

아래 예시는 문자열을 정수로 파싱하되, 실패하면 에러 코드를 반환합니다.

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

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{};
  auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), 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);

  return value;
}

void use() {
  auto r = parse_int("123");
  if (!r) {
    // r.error()로 원인 확인
    return;
  }
  int x = *r; // 또는 r.value()
}

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

  • 실패 생성은 std::unexpected(E)
  • 성공은 T를 그대로 반환
  • 검사: if (result) 또는 result.has_value()
  • 성공 값: *result, result.value()
  • 오류 값: result.error()

“예외 없는” 오류 설계: 에러 타입을 어떻게 잡을까

E를 무엇으로 둘지는 팀의 에러 정책을 좌우합니다.

옵션 A: std::errc/std::error_code 기반

표준/플랫폼 코드와 잘 맞습니다. 파일 I/O, 소켓, OS 호출 래핑에 유리합니다.

  • 장점: 범용, 로깅/표준 메시지 매핑 쉬움
  • 단점: 도메인 특화 정보(예: 어떤 필드가 문제인지) 담기 어려움

옵션 B: 도메인 enum + 컨텍스트 구조체

서비스/도메인 로직에서는 “어떤 입력이 잘못됐는지” 같은 정보를 넣는 편이 디버깅에 좋습니다.

#include <expected>
#include <string>

enum class UserErrc {
  NotFound,
  InvalidEmail,
  DbUnavailable
};

struct UserError {
  UserErrc code;
  std::string message; // 필요 시
};

using UserResult = std::expected<int /*user_id*/, UserError>;

여기서 message를 무조건 문자열로 두면 할당이 발생할 수 있습니다. 성능/할당 제약이 크면 std::string_view(수명 관리 주의) 또는 고정 버퍼, 혹은 code만 두고 메시지는 로깅 계층에서 매핑하는 방식이 낫습니다.

RAII와 결합해 리소스 안전성 높이기

std::expected는 “성공 시 완성된 리소스 소유 객체를 반환”하는 스타일과 궁합이 좋습니다.

예시: 파일 열기 래퍼를 expected로 반환

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

class File {
public:
  explicit File(std::FILE* f) : f_(f) {}
  File(File&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
  File& operator=(File&& other) noexcept {
    if (this != &other) {
      close();
      f_ = other.f_;
      other.f_ = nullptr;
    }
    return *this;
  }
  ~File() { close(); }

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

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

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

  std::FILE* f_{};
};

std::expected<File, std::error_code> open_file(const char* path, const char* mode) {
  if (auto* f = std::fopen(path, mode)) {
    return File{f};
  }
  return std::unexpected(std::error_code(errno, std::generic_category()));
}

std::expected<std::string, std::error_code> read_all(const char* path) {
  auto f = open_file(path, "rb");
  if (!f) return std::unexpected(f.error());

  // 여기서부터는 File이 RAII로 fclose 보장
  std::string out;
  // ... 실제 읽기 로직 생략
  return out;
}

이 구조의 장점:

  • open_file 실패 시에는 File이 아예 생성되지 않음
  • read_all 중간 실패가 생겨도 File 소멸자가 정리
  • 예외가 없어도 “자원 정리 누락” 위험이 크게 줄어듦

조합 연산으로 오류 전파를 정리하기

std::expected는 다음 같은 연산을 제공합니다(구현/표준 라이브러리 버전에 따라 지원 범위는 확인 필요).

  • and_then: 성공 값이 있을 때만 다음 함수를 실행(다음도 expected 반환)
  • transform: 성공 값을 변환(다음은 일반 값 반환)
  • or_else: 실패일 때만 복구/대체 로직 실행

예시: 파싱 → 검증 → 변환 체이닝

#include <expected>
#include <string_view>

enum class Err { Parse, Negative };

std::expected<int, Err> parse(std::string_view s);

std::expected<int, Err> ensure_non_negative(int x) {
  if (x < 0) return std::unexpected(Err::Negative);
  return x;
}

std::expected<int, Err> parse_and_validate(std::string_view s) {
  return parse(s)
    .and_then(ensure_non_negative)
    .transform([](int x) { return x * 2; });
}

이 스타일은 if (!r) return unexpected(...)가 연속되는 코드를 줄이고, 성공 경로를 더 읽기 쉽게 만듭니다.

호출부 패턴: “반드시 처리”를 강제하는 습관

std::expected가 있어도 호출부에서 무시하면 의미가 없습니다. 다음을 권장합니다.

  1. 반환값을 반드시 변수에 받기
  2. 실패면 즉시 리턴하거나, 로깅 후 명시적으로 처리
  3. 성공 값은 *r 또는 r.value()로 꺼내기

또한 컴파일러 경고를 활용하려면 [[nodiscard]]를 적극 사용하세요.

#include <expected>

enum class Err { Fail };

[[nodiscard]] std::expected<int, Err> do_work();

이렇게 하면 호출부에서 do_work();만 호출하고 결과를 버릴 때 경고가 나올 수 있습니다(컴파일러/플래그에 따라 다름).

std::expected와 예외의 역할 분담

현실적인 결론은 “예외를 완전히 없애자”가 아니라, 경계와 정책을 분리하는 것입니다.

  • 라이브러리 내부: std::expected로 실패를 값으로 다루고, 호출부가 흐름 제어를 명시적으로 하게 만든다.
  • 애플리케이션 최상단(예: main): 정말 복구 불가능한 상황만 예외/종료로 처리하거나, 모든 expected 실패를 로깅하고 종료 코드를 결정한다.

특히 스레드 엔트리, 콜백 기반 프레임워크, C API 경계에서는 예외 전파가 치명적일 수 있으니 std::expected가 더 안전한 선택이 됩니다.

메모리 안전 관점에서의 체크리스트

std::expected 자체가 메모리 안전을 “보장”하는 도구는 아닙니다. 하지만 다음 규율을 만들기 좋습니다.

  • 성공 값은 소유권이 명확한 타입으로 반환(std::unique_ptr, 값 타입, RAII 핸들)
  • 실패 시에는 부분 생성된 객체를 외부로 노출하지 않기
  • 오류 타입 E는 가급적 가볍고 복사 비용이 예측 가능하게 설계
  • 에러 메시지 문자열을 남발해 할당 폭탄을 만들지 않기
  • std::string_view를 오류에 담을 경우 수명 규칙을 문서화(임시 문자열 참조 금지)

이런 규율은 데이터베이스 데드락처럼 “실패가 정상 흐름”인 영역에서 특히 중요합니다. 실패를 예외로 던져버리면 재시도/백오프 같은 정책이 호출부에 흩어지기 쉽습니다. 재시도 패턴을 정리하는 관점은 MySQL 8 Deadlock 1213 원인추적·재시도 패턴 글의 접근과도 통합니다.

실전 팁: 로그/관측과 함께 쓰기

std::expected는 “실패가 값”이기 때문에, 실패를 모아서 로깅하거나 메트릭으로 올리기 좋습니다. 운영에서 중요한 건 “실패했는지”뿐 아니라 “어떤 코드로 얼마나 자주 실패하는지”입니다. 원인 추적을 체계화하는 사고방식은 AWS IAM AccessDenied 403, 정책 시뮬레이터로 추적 같은 글에서 다루는 관측/추적 루틴과 유사합니다.

간단한 패턴은 다음과 같습니다.

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

enum class Err { Network, Timeout };

std::string to_string(Err e) {
  switch (e) {
    case Err::Network: return "network";
    case Err::Timeout: return "timeout";
  }
  return "unknown";
}

template <class T>
T value_or_log(std::expected<T, Err> r, T fallback) {
  if (!r) {
    std::cerr << "operation failed: " << to_string(r.error()) << "\n";
    return fallback;
  }
  return *r;
}

마이그레이션 전략: 반환 코드에서 expected

기존 코드가 다음처럼 되어 있다면:

  • bool foo(out T& result, out Err& err)
  • int foo(out T* result) + errno

다음 순서로 바꾸는 것이 안전합니다.

  1. 내부 구현은 그대로 두고, 얇은 어댑터로 std::expected API를 추가
  2. 신규 호출부부터 expected를 사용
  3. 기존 API는 점진적으로 제거

예시:

#include <expected>

enum class Err { Fail };

bool legacy_make(int& out, Err& err);

std::expected<int, Err> make() {
  int v{};
  Err e{};
  if (!legacy_make(v, e)) return std::unexpected(e);
  return v;
}

이 방식은 대규모 리팩터링 없이도 호출부의 안전성을 빠르게 올릴 수 있습니다.

정리

  • std::expected는 C++23에서 표준화된 “성공 또는 실패” 반환 타입으로, 예외 없는 오류 처리의 일관성을 높입니다.
  • RAII 타입과 함께 쓰면 리소스 누수와 부분 초기화 노출을 줄여 사실상의 메모리/리소스 안전성을 강화합니다.
  • [[nodiscard]], 가벼운 오류 타입 설계, 조합 연산(and_then, transform)을 활용하면 호출부의 실수를 더 줄일 수 있습니다.
  • 운영 관점에서는 실패를 값으로 다루는 덕분에 로깅/메트릭/재시도 정책을 구조화하기 좋습니다.

예외를 완전히 배제하든, 경계에서만 차단하든, 중요한 건 “실패가 정상 흐름일 수 있다”는 사실을 코드에 드러내는 것입니다. std::expected는 그 목적에 맞춘 C++23의 가장 실용적인 도구 중 하나입니다.