Published on

C++23 std - -expected로 예외 없이 안전한 리소스 회수

Authors

서버 코드나 시스템 프로그래밍에서는 예외를 꺼리는 팀이 많습니다. 성능 이슈라기보다도, ABI 경계(플러그인, C API), 예외 비활성화 빌드 옵션, 예외 안전성 규약을 일관되게 지키기 어렵다는 이유가 큽니다. 그렇다고 bool 반환과 errno에만 의존하면 오류 전파가 누락되기 쉽고, 리소스 회수(파일 디스크립터, 소켓, 핸들)가 실패 경로에서 깨지기 쉽습니다.

C++23의 std::expected는 “실패를 값으로” 다루는 표준 도구입니다. 핵심은 다음 두 가지를 동시에 만족시키는 것입니다.

  • 성공 경로는 값(T)을 자연스럽게 전달한다
  • 실패 경로는 에러(E)를 강제적으로 다루게 만든다

이 글에서는 std::expected를 중심으로, 예외 없이도 안전한 리소스 획득과 회수(정확히는 RAII를 통한 자동 회수)를 구성하는 실전 패턴을 정리합니다.

std::expected가 리소스 회수에 유리한가

리소스 회수 문제는 대부분 “획득 이후 중간 단계에서 실패”할 때 터집니다.

  • 파일을 열었다
  • 버퍼를 할당했다
  • 락을 잡았다
  • 그 다음 단계에서 실패했다

예외를 쓰면 스택 언와인딩으로 RAII가 자동 실행되지만, 예외를 쓰지 않는 환경에서는 중간 실패마다 수동 close()를 호출하기 쉽고, 결국 누락됩니다.

std::expected는 실패를 값으로 전달하되, 리소스는 RAII 객체로 감싸서 “성공한 순간부터는 자동 회수”를 보장하는 구조를 만들기 좋습니다.

즉, 결론은 이 조합입니다.

  • 실패 전파: std::expected
  • 회수 보장: RAII(스마트 포인터, 핸들 래퍼)

기본 형태: expected<T, E>로 실패를 강제하기

먼저 가장 단순한 형태를 보겠습니다.

#include <expected>
#include <string>

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

std::expected<int, Error> parse_port(std::string_view s) {
  if (s.empty()) {
    return std::unexpected(Error{1, "empty"});
  }
  int v = 0;
  for (char c : s) {
    if (c < '0' || c > '9') {
      return std::unexpected(Error{2, "not a number"});
    }
    v = v * 10 + (c - '0');
  }
  if (v < 1 || v > 65535) {
    return std::unexpected(Error{3, "out of range"});
  }
  return v;
}

호출 측은 value()를 바로 꺼내 쓰기보다, 실패를 분기하도록 작성하는 편이 안전합니다.

auto port = parse_port("8080");
if (!port) {
  // port.error() 사용
  return;
}
int p = *port; // 또는 port.value()

이제 이 패턴을 “리소스 획득”으로 확장합니다.

리소스는 RAII로 감싸고, 획득 함수는 expected로 반환하기

리소스 회수의 정석은 RAII입니다. POSIX 파일 디스크립터를 예로 들면, int fd를 그대로 들고 다니지 말고 소멸자에서 close()가 호출되는 래퍼를 만들면 실패 경로가 단순해집니다.

아래 예제는 파일을 열고(open), 실패하면 에러를 반환하며, 성공하면 Fd 객체를 돌려줍니다. Fd는 이동만 허용하고 복사는 금지합니다.

#include <expected>
#include <string>
#include <utility>

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

struct SysError {
  int err;
  std::string context;

  std::string message() const {
    return context + ": " + ::strerror(err);
  }
};

class Fd {
public:
  Fd() = default;
  explicit Fd(int fd) : fd_(fd) {}

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

  Fd(Fd&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
  Fd& operator=(Fd&& other) noexcept {
    if (this != &other) {
      reset();
      fd_ = std::exchange(other.fd_, -1);
    }
    return *this;
  }

  ~Fd() { reset(); }

  int get() const { return fd_; }
  explicit operator bool() const { return fd_ >= 0; }

  void reset() {
    if (fd_ >= 0) {
      ::close(fd_);
      fd_ = -1;
    }
  }

private:
  int fd_ = -1;
};

std::expected<Fd, SysError> open_readonly(const char* path) {
  int fd = ::open(path, O_RDONLY);
  if (fd < 0) {
    return std::unexpected(SysError{errno, "open"});
  }
  return Fd{fd};
}

이제부터는 중간에 어떤 단계에서 실패하더라도, 이미 획득한 Fd는 스코프를 벗어나며 자동으로 닫힙니다. 예외가 없어도 동일합니다.

“여러 리소스”를 안전하게 획득하기: 단계별 expected + RAII

현실에서는 한 번에 하나만 열지 않습니다. 예를 들어

  • 설정 파일을 연다
  • 내용을 읽는다
  • 로그 파일을 연다
  • 락을 잡는다

같은 시나리오가 흔합니다. expected로 단계별로 실패를 전파하면, 실패 시점에서 필요한 정보(컨텍스트)를 남기면서도 회수는 RAII에 맡길 수 있습니다.

#include <expected>
#include <string>
#include <vector>

std::expected<std::string, SysError> read_all(const Fd& fd) {
  std::string out;
  char buf[4096];
  for (;;) {
    ssize_t n = ::read(fd.get(), buf, sizeof(buf));
    if (n == 0) break;
    if (n < 0) {
      return std::unexpected(SysError{errno, "read"});
    }
    out.append(buf, buf + n);
  }
  return out;
}

std::expected<void, SysError> init_from_config(const char* config_path,
                                               const char* log_path) {
  auto cfg_fd = open_readonly(config_path);
  if (!cfg_fd) return std::unexpected(cfg_fd.error());

  auto cfg_text = read_all(*cfg_fd);
  if (!cfg_text) return std::unexpected(cfg_text.error());

  auto log_fd = open_readonly(log_path);
  if (!log_fd) return std::unexpected(log_fd.error());

  // 여기서부터 cfg_fd, log_fd는 모두 RAII로 보호됨
  // cfg_text도 값으로 보관됨

  return {};
}

init_from_config는 실패하면 SysError를 반환하고, 성공하면 void 성공을 의미하는 빈 expected를 반환합니다.

이 구조의 장점은 “실패 처리 코드가 눈에 보인다”는 점입니다. 예외 기반 코드에서는 실패 분기가 숨겨져 있어, 리뷰 시 누락이 생기기도 합니다.

에러 타입 설계: 코드, 컨텍스트, 원인 연결

expected를 쓰면 에러 타입 E 설계가 매우 중요해집니다. 최소한 아래는 권장합니다.

  • 기계가 읽을 수 있는 코드(예: errno, enum)
  • 사람이 읽을 수 있는 컨텍스트 문자열
  • 필요하면 원인 체이닝(어느 단계에서 발생했는지)

간단히 “원인 체이닝”을 흉내 내면 다음처럼 만들 수 있습니다.

#include <memory>
#include <string>

struct AppError {
  enum class Kind { Sys, Parse, InvalidState } kind;
  int sys_errno = 0;
  std::string context;
  std::shared_ptr<AppError> cause;
};

inline AppError with_context(AppError e, std::string ctx) {
  e.context = std::move(ctx);
  return e;
}

inline AppError chain(AppError top, AppError cause) {
  top.cause = std::make_shared<AppError>(std::move(cause));
  return top;
}

실전에서는 로그/관측(Observability)과 연결됩니다. 장애를 빠르게 줄이려면 “어디서 실패했는지”가 중요합니다. 이런 관점은 예외 없는 런타임 문제를 추적할 때 특히 빛을 봅니다. 예를 들어 서비스 운영에서 원인 추적이 중요하다는 점은 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 글의 맥락과도 맞닿아 있습니다.

조합(체이닝) 패턴: and_then, transform, or_else

std::expected는 성공/실패 분기를 함수형 스타일로 조합할 수 있는 멤버 함수를 제공합니다.

  • and_then(f): 성공 값이 있으면 f(T)를 호출해 다음 expected로 이어감
  • transform(f): 성공 값에 f(T)를 적용해 값을 변환
  • or_else(f): 실패 시 f(E)로 에러를 변환하거나 복구

이를 사용하면 “단계별 if 체크”를 줄이고, 성공 흐름을 더 읽기 좋게 만들 수 있습니다.

#include <expected>
#include <string>

std::expected<std::string, SysError> load_config_text(const char* path) {
  return open_readonly(path)
    .and_then([](Fd fd) {
      // fd는 값으로 이동되지만, 람다 스코프에서 RAII 유지
      return read_all(fd);
    })
    .or_else([](SysError e) {
      e.context = "load_config_text: " + e.context;
      return std::unexpected(e);
    });
}

여기서 중요한 디테일은 람다 인자 타입입니다.

  • and_then의 람다 인자는 보통 T를 값으로 받습니다(이동)
  • RAII 리소스는 이동 가능해야 합니다

즉, expected를 적극적으로 조합하려면 “리소스 래퍼는 move-only”로 설계하는 편이 자연스럽습니다.

리소스 회수의 또 다른 축: 스코프 가드와 expected

RAII 래퍼를 만들기 어려운 상황(기존 API, 락/상태 롤백 등)에서는 스코프 가드를 사용하기도 합니다. C++23 표준에 scope_exit가 들어오지는 않았지만, 간단한 스코프 가드는 직접 만들 수 있습니다.

#include <utility>

template <class F>
class ScopeExit {
public:
  explicit ScopeExit(F f) : f_(std::move(f)) {}
  ScopeExit(const ScopeExit&) = delete;
  ScopeExit& operator=(const ScopeExit&) = delete;
  ScopeExit(ScopeExit&& other) noexcept : f_(std::move(other.f_)), active_(other.active_) {
    other.active_ = false;
  }
  ~ScopeExit() { if (active_) f_(); }
  void release() { active_ = false; }

private:
  F f_;
  bool active_ = true;
};

template <class F>
ScopeExit<F> scope_exit(F f) { return ScopeExit<F>(std::move(f)); }

이제 expected 기반의 함수에서도 예외 없이 “중간 상태 롤백”을 안전하게 보장할 수 있습니다.

#include <expected>

std::expected<void, SysError> do_two_phase_update() {
  bool flag = false;
  auto rollback = scope_exit([&] {
    if (flag) {
      // 롤백 로직
      flag = false;
    }
  });

  // 1단계 성공
  flag = true;

  // 2단계 실패 가정
  return std::unexpected(SysError{1, "phase2 failed"});

  // 성공 시 롤백 비활성화
  // rollback.release();
  // return {};
}

이 패턴은 “리소스”가 파일/소켓 같은 OS 핸들뿐 아니라, 메모리 캐시 상태나 전역 레지스트리 변경 같은 논리적 리소스에도 적용됩니다.

예외 없는 코드에서 흔한 함정과 체크리스트

std::expected를 도입해도 아래 함정은 여전히 남습니다.

value() 남발

value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외를 금지하는 코드베이스라면 value() 대신 if (!x) 분기나 *x 사용을 규약으로 두는 것이 안전합니다.

에러를 너무 문자열로만 표현

문자열은 편하지만 분류가 어렵습니다. 최소한 enum 코드나 errno 같은 숫자 코드를 함께 들고 가면, 모니터링/집계가 쉬워집니다. 운영 관점의 “원인별 집계”는 장애 대응 시간을 줄입니다. 이런 운영 이슈는 예를 들어 GCP Cloud Run 503·콜드스타트 7분 지연 해결 가이드 같은 글에서 다루는 병목 추적과도 결이 같습니다.

expected에 “리소스 생 포인터”를 넣기

expected<T, E>TFILE*int fd 같은 생 리소스를 넣으면, 호출자가 해제를 잊기 쉽습니다. T는 “자동 회수되는 타입”이어야 합니다. 즉 unique_ptr 커스텀 deleter, 핸들 래퍼 등을 사용하세요.

실전 예제: unique_ptr + 커스텀 deleter로 C 라이브러리 핸들 감싸기

C 라이브러리 핸들은 보통 create()destroy() 쌍으로 제공됩니다. 이를 unique_ptr로 감싸면 RAII가 즉시 적용됩니다.

#include <expected>
#include <memory>
#include <string>

struct LibHandle;
extern "C" {
  LibHandle* lib_create(int* out_err);
  void lib_destroy(LibHandle*);
}

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

struct LibDeleter {
  void operator()(LibHandle* p) const noexcept {
    if (p) lib_destroy(p);
  }
};

using LibPtr = std::unique_ptr<LibHandle, LibDeleter>;

std::expected<LibPtr, LibError> make_handle() {
  int err = 0;
  LibHandle* raw = lib_create(&err);
  if (!raw) {
    return std::unexpected(LibError{err, "lib_create"});
  }
  return LibPtr(raw);
}

이제 LibPtr가 스코프를 벗어나면 항상 lib_destroy()가 호출됩니다. 실패는 expected로 전파됩니다.

마이그레이션 전략: 기존 코드에 expected를 점진 도입하기

대규모 코드베이스에서 한 번에 바꾸기 어렵다면 다음 순서가 현실적입니다.

  1. 리소스 래퍼부터 도입한다(핸들, fd, mutex 등)
  2. “가장 바깥 API”부터 expected를 반환하도록 바꾼다
  3. 내부는 기존 스타일을 유지하되, 경계에서 expected로 변환한다
  4. 자주 실패하는 경로부터 and_then 조합으로 정리한다

이 방식은 예외를 끄고도 안정성을 올리며, 리팩터링 리스크를 줄입니다.

정리

  • std::expected는 실패를 값으로 만들고, 호출자가 실패 처리를 누락하기 어렵게 합니다.
  • 리소스 회수는 expected가 아니라 RAII가 담당해야 합니다. expected는 “획득 실패”와 “중간 실패”를 깔끔하게 전파하는 역할입니다.
  • 리소스 타입은 move-only로 설계하면 and_then 조합이 자연스럽습니다.
  • 에러 타입은 코드와 컨텍스트를 함께 들고 가고, 필요하면 원인 체이닝을 고려하세요.

예외 없이도 충분히 안전하고 읽기 좋은 리소스 관리 코드는 만들 수 있습니다. C++23에서 std::expected는 그 구성을 표준으로 끌어올린 핵심 도구입니다.