Published on

C++23 std - -expected로 예외 없이 오류전파·RAII

Authors

서버·에이전트·게임엔진처럼 지연시간과 안정성이 중요한 C++ 코드베이스에서는 예외를 전면 금지하거나 제한적으로만 허용하는 경우가 많습니다. 하지만 예외를 끄면(-fno-exceptions) 곧바로 마주치는 문제가 있습니다.

  • 깊은 호출 스택에서 오류를 "빠르게" 전파하기가 번거롭다
  • 리소스 정리(파일/소켓/락/메모리)를 매번 수동으로 처리하다가 누수가 난다
  • 오류 코드와 로그가 제각각이라 디버깅이 힘들다

C++23의 std::expected는 이 간극을 메우는 표준 도구입니다. 반환값에 "성공 값" 또는 "오류"를 함께 담아 호출자가 강제적으로 처리하게 만들고, RAII와 결합하면 예외 없이도 "안전한 조기 반환"을 깔끔하게 구현할 수 있습니다.

아래에서는 std::expected의 핵심 사용법, 오류 타입 설계, 전파 패턴, 그리고 RAII와 함께 쓰는 실전 구성을 예제로 정리합니다.

std::expected가 해결하는 것

std::expected는 다음 형태의 타입입니다.

  • 성공 시 T 값을 가진다
  • 실패 시 E 오류 값을 가진다

즉, 함수 시그니처만 봐도 "이 함수는 실패할 수 있다"가 드러납니다.

#include <expected>
#include <string>

enum class Errc {
  invalid_input,
  io_error,
};

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

예외 기반 코드에서 throw가 하던 역할을, 이제는 return std::unexpected(err)가 대신합니다.

기본 사용법: 생성, 검사, 접근

#include <expected>
#include <charconv>
#include <string_view>

enum class Errc { invalid_input };

std::expected<int, Errc> parse_int(std::string_view s) {
  int v = 0;
  auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
  if (ec != std::errc{} || ptr != s.data() + s.size()) {
    return std::unexpected(Errc::invalid_input);
  }
  return v;
}

void use() {
  auto r = parse_int("123");
  if (!r) {
    // r.error()로 오류 접근
    return;
  }
  // r.value() 또는 *r 로 값 접근
  int x = *r;
}

포인트는 두 가지입니다.

  • if (!r)로 실패 여부를 명시적으로 확인
  • 실패 시 r.error()로 오류를 구조적으로 꺼냄

오류 타입 설계: enum만으로는 부족하다

단순한 enum class는 빠르고 가볍지만, 운영 환경에서는 대개 다음 정보가 필요합니다.

  • 어떤 단계에서 실패했는지(컨텍스트)
  • OS 오류 코드(예: errno), 라이브러리 오류 코드
  • 디버깅을 위한 메시지

한 가지 실전적인 절충안은 "작은 오류 구조체"입니다.

#include <string>
#include <system_error>

enum class Errc {
  invalid_input,
  open_failed,
  read_failed,
};

struct Error {
  Errc code;
  int sys_errno = 0;      // 필요할 때만 세팅
  std::string context;    // 디버깅용
};

이렇게 하면 std::expected<T, Error>로 오류를 풍부하게 전달할 수 있습니다. (성능이 민감하면 std::string 대신 std::string_view + 별도 저장소, 또는 고정 버퍼를 고려하세요.)

예외 없이 "조기 반환" 전파 패턴

std::expected의 장점은 "실패 시 즉시 반환"을 일관되게 만들 수 있다는 점입니다.

패턴 1: 직접 전파

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

std::expected<int, Error> load_config_version(std::string_view path) {
  auto text = read_file(path);
  if (!text) return std::unexpected(text.error());

  auto v = parse_int(*text);
  if (!v) return std::unexpected(Error{Errc::invalid_input, 0, "version"});

  return *v;
}

이 방식은 명시적이라 좋지만, 반복이 늘어납니다.

패턴 2: and_then / transform로 파이프라인화

std::expected는 모나딕 연산을 제공합니다.

  • transform(f)는 성공 값에 f(T)를 적용
  • and_then(f)는 성공 값에 f(T)를 적용하되, f가 다시 expected를 반환할 때 연결
  • or_else(f)는 실패 시 오류를 변환하거나 로깅
std::expected<std::string, Error> read_file(std::string_view path);
std::expected<int, Error> parse_version(std::string_view text);

std::expected<int, Error> load_config_version(std::string_view path) {
  return read_file(path)
    .and_then([](const std::string& s) {
      return parse_version(s);
    })
    .or_else([](const Error& e) -> std::expected<int, Error> {
      // 로깅/메트릭 후 그대로 전파
      return std::unexpected(e);
    });
}

파이프라인은 "성공 경로"를 직선으로 읽게 만들어 주고, 실패는 자동으로 우회합니다.

RAII와 결합: 예외가 없어도 리소스는 자동 정리된다

예외를 끄면 흔히 오해하는 부분이 있습니다. "예외가 없으면 RAII가 의미가 줄어든다"는 생각인데, 실제로는 반대입니다.

  • 예외가 있든 없든, 스코프를 벗어나면 소멸자가 호출됩니다.
  • std::expected는 실패 시 조기 return을 자주 쓰게 만듭니다.
  • 조기 반환이 많을수록 RAII가 더 중요해집니다.

파일 디스크립터 RAII 예제

POSIX 파일 디스크립터는 반드시 close 해야 합니다. 예외 없이 작성하면 return 경로마다 close를 넣기 쉽고, 누락이 생깁니다. RAII로 감싸면 해결됩니다.

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

struct Fd {
  int fd = -1;
  Fd() = default;
  explicit Fd(int f) : fd(f) {}
  Fd(const Fd&) = delete;
  Fd& operator=(const Fd&) = delete;
  Fd(Fd&& other) noexcept : fd(other.fd) { other.fd = -1; }
  Fd& operator=(Fd&& other) noexcept {
    if (this != &other) {
      if (fd != -1) ::close(fd);
      fd = other.fd;
      other.fd = -1;
    }
    return *this;
  }
  ~Fd() { if (fd != -1) ::close(fd); }
};

enum class Errc { open_failed, read_failed };
struct Error { Errc code; int sys_errno; std::string context; };

std::expected<Fd, Error> open_ro(const char* path) {
  int f = ::open(path, O_RDONLY);
  if (f == -1) {
    return std::unexpected(Error{Errc::open_failed, errno, "open"});
  }
  return Fd{f};
}

std::expected<std::string, Error> read_all(const char* path) {
  auto fd = open_ro(path);
  if (!fd) return std::unexpected(fd.error());

  std::string out;
  char buf[4096];
  while (true) {
    ssize_t n = ::read(fd->fd, buf, sizeof(buf));
    if (n == 0) break;
    if (n < 0) {
      return std::unexpected(Error{Errc::read_failed, errno, "read"});
    }
    out.append(buf, buf + n);
  }
  // 여기서 조기 반환이든 정상 반환이든, fd는 소멸하며 close 된다.
  return out;
}

핵심은 read_all 어디에서 return 하더라도 Fd 소멸자가 실행된다는 점입니다. 예외가 없어도 RAII는 동일하게 강력합니다.

"오류 전파 + 락" 같이 섞인 코드에서의 장점

멀티스레드 코드에서 락을 잡고 중간에 실패하면, 락 해제를 놓치기 쉽습니다. 하지만 std::scoped_lock 같은 RAII 락과 expected는 궁합이 좋습니다.

#include <expected>
#include <mutex>
#include <unordered_map>
#include <string>

struct Error { int code; std::string context; };

class Cache {
  std::mutex mu_;
  std::unordered_map<std::string, std::string> m_;

public:
  std::expected<std::string, Error> get(std::string key) {
    std::scoped_lock lock(mu_);
    auto it = m_.find(key);
    if (it == m_.end()) {
      return std::unexpected(Error{404, "cache_miss"});
    }
    return it->second;
  }
};

조기 반환이 많아져도 락 해제는 자동입니다.

호출자 측 처리 전략: "반드시 처리"를 코드로 강제하기

예외는 호출자가 try/catch를 빼먹어도 컴파일이 됩니다. 반면 expected는 호출자가 값을 쓰려면 실패 여부를 확인해야 하는 흐름을 자연스럽게 유도합니다.

다만 value()를 무심코 호출하면 실패 시 예외(std::bad_expected_access)가 발생할 수 있습니다. 예외를 금지한다면 다음 원칙을 추천합니다.

  • 프로젝트 규칙으로 value() 사용 금지(리뷰/린트)
  • if (!r) return ...; 또는 and_then 체이닝으로만 접근

expected를 "경계"에 두고, 내부는 단순하게

모든 함수가 std::expected를 반환하면 타입이 전염되는 문제가 생길 수 있습니다. 실전에서는 다음처럼 레이어링이 깔끔합니다.

  • 외부 I/O, 파싱, 네트워크 등 실패가 흔한 경계에서 expected 사용
  • 내부 순수 로직은 실패가 불가능하도록 설계하고 일반 반환

예를 들어 설정 파일을 읽고 파싱하는 경계는 expected로, 그 결과를 받아 처리하는 비즈니스 로직은 일반 함수로 유지합니다.

std::expected와 기존 방식 비교

std::optional 대비

  • optional은 실패 이유를 담지 못합니다
  • expected는 실패 이유를 1급 값으로 전달합니다

오류 코드(int) 대비

  • 오류 코드만 반환하면 성공 값 전달을 위해 out-parameter가 늘어납니다
  • expected는 성공 값과 오류를 한 타입에 담아 호출자 경험을 개선합니다

현업 팁: 오류를 "관측 가능"하게 만들기

오류 전파는 결국 관측(로그/메트릭/트레이싱)과 이어집니다. expected는 오류가 값이므로, 경계에서 일관된 로깅을 넣기 좋습니다.

  • or_else에서 공통 로깅
  • Errorcontext를 누적(단, 과도한 문자열 누적은 비용이 될 수 있음)

운영 장애를 추적할 때도 "어디서 실패했는지"가 중요합니다. 이런 관점은 인프라 트러블슈팅 글에서 말하는 재현·추적 습관과 유사합니다. 예를 들어 네트워크 경로를 단계적으로 좁혀가는 방식은 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법 같은 글의 접근과 닮아 있습니다.

마이그레이션 전략: 예외 코드에서 expected

기존 예외 기반 코드를 한 번에 바꾸기 어렵다면, 다음 순서를 추천합니다.

  1. I/O 경계부터 expected로 바꾸기(파일, 네트워크, DB)
  2. 예외를 던지던 함수를 expected로 감싸는 어댑터 작성
  3. 상위 레이어에서 and_then으로 성공 경로를 정리
  4. 마지막에 예외를 끄거나(가능하면) 제한적으로만 남기기

CI에서 컴파일 옵션과 규칙을 점진적으로 강화하는 것도 도움이 됩니다. 대규모 변경을 안전하게 굴리는 방식은 GitHub Actions 병렬·매트릭스로 CI 50% 단축처럼 파이프라인을 잘 설계해두면 훨씬 수월합니다.

정리

C++23 std::expected는 "예외 없이도" 다음을 가능하게 합니다.

  • 실패 가능한 함수의 시그니처를 명확히 표현
  • 오류를 구조적 데이터로 전달해 디버깅/관측성을 개선
  • 조기 반환 기반의 깔끔한 오류 전파
  • RAII와 결합해 리소스 누수 없이 안전한 코드 유지

예외를 없애는 목적이 성능이든, ABI/플랫폼 제약이든, 운영 안정성이든 간에 std::expected는 C++ 표준 라이브러리 차원에서 제공되는 가장 실용적인 대안 중 하나입니다. 특히 조기 반환이 많은 코드일수록 RAII와 함께 쓸 때 효과가 극대화됩니다.