Published on

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

Authors

예외 기반 에러처리는 C++에서 강력하지만, 모든 코드베이스에 잘 맞는 해답은 아닙니다. 저지연 시스템, 게임/엔진, 임베디드, “예외 금지” 규칙이 있는 조직, 혹은 예외 경로에서의 비용과 제어 흐름을 더 명시적으로 만들고 싶은 팀에서는 예외를 끄거나 제한적으로만 쓰는 경우가 많습니다.

C++23의 std::expected는 이런 상황에서 “성공 값”과 “오류 값”을 타입으로 명확히 표현하고, 호출자에게 오류 처리를 강제할 수 있는 표준 도구입니다. 여기에 C++의 핵심 철학인 RAII(Resource Acquisition Is Initialization)를 결합하면, 예외 없이도 깔끔하고 안전한 에러처리 흐름을 만들 수 있습니다.

이 글에서는 std::expected를 단순 소개하는 수준을 넘어, 실제 코드에서 RAII와 결합해 예외 없는 에러처리를 어떻게 구조화하는지, 어떤 함정이 있는지, 그리고 팀 규칙으로 어떻게 굳히면 좋은지까지 정리합니다.

관련해서 CI에서 컴파일러/표준 라이브러리 매트릭스를 돌려 호환성을 확보하는 것도 중요합니다. 대규모 C++ 프로젝트라면 GitHub Actions 매트릭스 전략도 함께 참고해보세요: GitHub Actions 병렬·매트릭스로 CI 50% 단축

std::expected가 해결하는 문제

예외 대신 반환값으로 오류를 표현할 때의 고질적인 문제

예외를 쓰지 않으면 보통 다음 중 하나로 가게 됩니다.

  • “특수 값”으로 실패 표현: 예를 들어 -1, nullptr, 빈 문자열 등
  • bool 반환 + out-parameter: bool read(File& f, Data* out)
  • std::optional로 성공/실패만 표현: 실패 이유는 별도로 로깅하거나 전역 상태로

이 방식들은 공통적으로 다음 문제가 생깁니다.

  • 실패 이유가 타입에 녹아있지 않아 호출자가 놓치기 쉽다
  • out-parameter는 호출부가 지저분해지고, 부분 초기화/정리 문제가 생긴다
  • 오류 전파가 장황해지고, 중간 단계에서 컨텍스트를 잃기 쉽다

std::expected는 “성공 값 T 또는 오류 값 E”를 한 타입으로 묶어, 실패 이유를 타입으로 강제합니다.

std::expected의 기본 형태

  • 성공: expectedT 값을 가진다
  • 실패: expectedE 값을 가진다

핵심 API는 다음 정도만 기억해도 충분합니다.

  • has_value() 또는 operator bool()
  • value() / error()
  • value_or(...)
  • and_then(...), transform(...), or_else(...), transform_error(...) (단계적 조합)

에러 타입 E 설계: 문자열보다 “구조화된 오류”

E를 단순히 std::string으로 두면 편해 보이지만, 운영에서 결국 문제가 됩니다.

  • 분류/집계가 어렵다(알람, 통계, 리트라이 정책)
  • 호출자가 분기 처리하기 어렵다
  • 국제화/로깅 포맷이 섞인다

권장 패턴은 “오류 코드 + 컨텍스트”입니다.

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

enum class AppErrc {
  Io,
  Parse,
  InvalidArg,
  NotFound,
  Permission,
};

struct AppError {
  AppErrc code;
  std::string message;      // 사람용 컨텍스트
  std::error_code sys_ec{}; // 필요 시 OS/라이브러리 오류 연결
};

template <class T>
using Expected = std::expected<T, AppError>;
  • 분기 처리는 code
  • 디버깅/관측성은 message
  • 시스템 콜 실패는 sys_ec로 연결

특히 파일/소켓/스레드 같은 시스템 리소스는 errno나 플랫폼별 에러를 그대로 문자열로 박제하기보다 std::error_code를 같이 보관하면 후처리가 쉬워집니다.

RAII와 결합: “실패해도 누수 없는” 흐름 만들기

예외를 끄면 많은 사람들이 오해하는 지점이 있습니다.

  • “예외가 없으니 RAII가 덜 중요하다”가 아니라
  • “예외가 없더라도 조기 반환이 많아지므로 RAII가 더 중요하다”가 맞습니다.

std::expected 기반 코드는 return unexpected(...)로 빠져나가는 경로가 많아집니다. 이때 매번 close()/free()를 직접 호출하면 누수가 발생하기 쉽습니다. RAII는 이 조기 반환 경로에서 자동 정리를 보장합니다.

예시 1: 파일 핸들을 RAII로 감싸고 expected로 열기

아래는 POSIX open/close를 예로 든 간단한 패턴입니다. (Windows라면 HANDLE에 맞게 동일한 구조로 만들면 됩니다.)

#include <expected>
#include <string>
#include <system_error>
#include <fcntl.h>
#include <unistd.h>

struct UniqueFd {
  int fd{-1};

  UniqueFd() = default;
  explicit UniqueFd(int f) : fd(f) {}

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

  UniqueFd(UniqueFd&& other) noexcept : fd(other.fd) {
    other.fd = -1;
  }
  UniqueFd& operator=(UniqueFd&& other) noexcept {
    if (this != &other) {
      reset();
      fd = other.fd;
      other.fd = -1;
    }
    return *this;
  }

  ~UniqueFd() { reset(); }

  void reset() noexcept {
    if (fd != -1) {
      ::close(fd);
      fd = -1;
    }
  }

  int get() const noexcept { return fd; }
  explicit operator bool() const noexcept { return fd != -1; }
};

enum class AppErrc { Io, InvalidArg };

struct AppError {
  AppErrc code;
  std::string message;
  std::error_code sys_ec{};
};

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

Expected<UniqueFd> open_readonly(const std::string& path) {
  if (path.empty()) {
    return std::unexpected(AppError{AppErrc::InvalidArg, "empty path"});
  }

  int fd = ::open(path.c_str(), O_RDONLY);
  if (fd == -1) {
    return std::unexpected(AppError{
      AppErrc::Io,
      "open failed: " + path,
      std::error_code(errno, std::generic_category())
    });
  }

  return UniqueFd{fd};
}

포인트:

  • 성공 시 UniqueFd를 값으로 반환합니다. 이동 가능하므로 비용이 크지 않습니다.
  • 실패 시 std::unexpected(AppError{...})로 실패를 명시합니다.
  • 호출자는 Expected<UniqueFd>를 강제로 처리해야 합니다.

예시 2: 여러 단계에서 실패 가능한 로직을 “조기 반환 + RAII”로

expected의 장점은 “실패하면 즉시 반환”을 자연스럽게 만들면서도, RAII로 정리가 자동이라는 점입니다.

#include <vector>
#include <cstdint>
#include <unistd.h>

Expected<std::vector<std::uint8_t>> read_all(int fd) {
  std::vector<std::uint8_t> out;
  std::uint8_t buf[4096];

  while (true) {
    ssize_t n = ::read(fd, buf, sizeof(buf));
    if (n == 0) break;
    if (n < 0) {
      return std::unexpected(AppError{
        AppErrc::Io,
        "read failed",
        std::error_code(errno, std::generic_category())
      });
    }
    out.insert(out.end(), buf, buf + n);
  }

  return out;
}

Expected<std::vector<std::uint8_t>> load_file(const std::string& path) {
  auto fd = open_readonly(path);
  if (!fd) return std::unexpected(fd.error());

  // fd는 UniqueFd이므로 여기서 어떤 조기 반환이 발생해도 close 보장
  return read_all(fd->get());
}

여기서 load_fileopen 실패든 read 실패든, 어떤 경로로 빠져도 UniqueFd 소멸자가 close를 호출합니다.

and_then/transform로 파이프라인 구성하기

조기 반환 스타일이 가장 직관적이지만, 함수 조합이 많은 경우 expected의 모나딕(파이프라인) API가 깔끔합니다.

  • and_then: 성공 값이 있을 때 다음 Expected를 반환하는 함수를 연결
  • transform: 성공 값을 다른 값으로 매핑(오류는 그대로)
  • or_else: 실패 시 오류를 변환하거나 복구 로직 수행
#include <expected>
#include <string>
#include <vector>

Expected<std::string> decode_utf8(std::vector<std::uint8_t> bytes) {
  // 예시: 실제 구현에서는 검증/변환 수행
  return std::string(bytes.begin(), bytes.end());
}

Expected<std::string> load_text(const std::string& path) {
  return load_file(path)
    .and_then([](auto bytes) { return decode_utf8(std::move(bytes)); })
    .transform([](std::string s) {
      // 후처리 예: BOM 제거, 개행 정규화 등
      return s;
    })
    .or_else([](AppError e) {
      // 오류에 컨텍스트 추가
      e.message = "load_text: " + e.message;
      return std::unexpected(e);
    });
}

이 스타일은 “중간에 실패하면 자동으로 아래 단계가 스킵된다”는 점에서 비즈니스 로직 파이프라인에 잘 맞습니다.

예외 없는 코드에서의 “실패 원자성”과 RAII

예외를 사용하지 않아도, 실패 시 상태가 중간까지 변경되는 문제는 그대로 존재합니다. 특히 다음 상황에서 버그가 자주 납니다.

  • 함수가 여러 리소스를 순서대로 획득하다가 중간 실패
  • 일부만 초기화된 객체가 외부로 노출
  • 실패했는데도 전역/싱글톤 상태가 변경됨

해결책은 크게 두 가지입니다.

  1. 리소스는 RAII 객체로 즉시 감싸기
  2. 커밋(상태 반영)은 마지막에 한 번만 수행하기

예를 들어 “임시 파일에 쓰고 rename으로 커밋” 같은 패턴은 예외 유무와 상관없이 강력합니다. expected는 이 커밋 단계의 실패도 타입으로 강제해주므로, 호출자가 놓치기 어렵습니다.

std::expected 도입 시 팀 규칙(실전 체크리스트)

1) 경계에서만 예외를 허용할지 결정

프로젝트에 따라 전략이 갈립니다.

  • 코어 라이브러리: 예외 금지, expected 사용
  • 어플리케이션 경계(UI, main, RPC 핸들러): 최상단에서만 예외 처리/로깅

혹은 완전 예외 금지로 가되, 서드파티가 던지는 예외를 경계에서 catchexpected로 변환하는 “어댑터 레이어”를 두는 방식도 흔합니다.

2) E는 반드시 구조화

  • 최소: 오류 코드(enum) + 메시지
  • 권장: std::error_code 또는 원인 체인(원인 오류를 포인터/값으로 보관)

3) value()를 무분별하게 쓰지 않기

value()는 값이 없으면(실패면) 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 사실상 금지에 가깝습니다.

대신 다음을 사용하세요.

  • if (!exp) return unexpected(exp.error());
  • and_then/transform 체이닝
  • value_or (단, 기본값이 논리적으로 타당할 때만)

4) 오류 전파는 “복사 최소화”를 의식

AppError에 큰 문자열을 담으면 전파 비용이 커질 수 있습니다.

  • 메시지는 필요할 때만 추가(or_else에서 컨텍스트를 덧붙이기)
  • 큰 문자열 대신 짧은 코드/키를 담고, 로깅에서 확장
  • 또는 std::string을 이동하도록 람다에서 AppError e를 값으로 받아 수정 후 반환(위 예시처럼)

기존 코드와의 접점: optional, error_code, C API

optional과의 역할 분담

  • 실패 이유가 중요하지 않거나, 단순히 “있음/없음”이면 optional
  • 실패 이유로 분기/로깅/리트라이가 필요하면 expected

예: 캐시 조회는 optional이 자연스럽고, 디스크/네트워크 I/O는 expected가 자연스럽습니다.

std::error_code 기반 API와의 결합

표준 라이브러리와 일부 플랫폼 API는 std::error_code를 이미 사용합니다. 이 경우 Estd::error_code를 포함하면 변환 비용이 거의 없고, 호출자가 친숙하게 다룰 수 있습니다.

C API 래핑

C API는 보통 int 리턴 + errno 또는 out-parameter로 오류를 전달합니다. 이때 “C API 호출 직후”에 expected로 변환하고, 이후 레이어는 expected만 쓰게 만들면 코드베이스가 훨씬 단정해집니다.

빌드/호환성 메모: 표준 라이브러리 지원 확인

std::expected는 C++23 기능이지만, 컴파일러와 표준 라이브러리 조합에 따라 지원 시점이 다를 수 있습니다. 실무에서는 다음을 권합니다.

  • CI에서 -std=c++23를 켠 매트릭스 빌드 구성
  • libstdc++/libc++ 버전별 테스트
  • 필요하면 폴리필(tl::expected 등)로 브릿지하다가 표준으로 전환

이 과정에서 GitHub Actions 매트릭스는 특히 효과적입니다: GitHub Actions 병렬·매트릭스로 CI 50% 단축

정리: expected는 “명시적 실패”, RAII는 “자동 정리”

  • std::expected는 실패를 타입으로 드러내고, 호출자가 처리하도록 강제합니다.
  • 예외를 쓰지 않을수록 조기 반환 경로가 늘어나며, 이때 RAII가 누수/정리 버그를 막는 핵심 안전장치가 됩니다.
  • 좋은 설계는 E를 구조화하고, 경계에서 변환하며, 내부 로직은 and_then 또는 조기 반환 패턴으로 단순화하는 것입니다.

다음 단계로는 “오류 컨텍스트 체이닝(원인 보존)”, “로그 정책(오류 생성 지점 vs 소비 지점)”, “리트라이 가능 오류 분류”까지 확장하면 운영 품질이 크게 올라갑니다.