Published on

C++23 std - -expected로 예외 없이 안전한 자원회수

Authors

서버/클라이언트 C++ 코드에서 예외를 끄는(-fno-exceptions) 팀도 많고, 예외를 켜더라도 경계(FFI, 스레드, 코루틴, 콜백)에서 예외 전파가 금지되는 구간은 늘 존재합니다. 이때 흔히 bool 리턴 + errno 같은 전역 상태, 혹은 std::optional로 실패만 표현하는 방식이 섞이면서 다음 문제가 반복됩니다.

  • 실패 원인(에러 코드/메시지/컨텍스트)이 유실됨
  • 호출자가 체크를 빼먹어도 컴파일러가 도와주지 못함
  • 실패 경로가 복잡해지며 자원 회수(파일/소켓/락/메모리)가 누락됨

C++23의 std::expected는 “성공 값 또는 실패 값”을 타입으로 강제해, 예외 없이도 명시적인 에러 전파RAII 기반 자원 회수를 함께 가져갈 수 있게 해줍니다.

아래에서는 std::expected를 자원 관리 코드에 적용하는 방법을, 실수하기 쉬운 포인트(중복 해제, 부분 초기화, 에러 컨텍스트 보존) 중심으로 정리합니다.

std::expected가 자원 회수에 유리한가

자원 회수는 결국 “실패 경로에서 반드시 정리되는가”가 핵심입니다. 예외를 쓰면 스택 언와인딩이 자동으로 정리를 도와주지만, 예외를 쓰지 않으면 다음 중 하나로 흘러가기 쉽습니다.

  • goto cleanup/break로 수동 정리
  • 중첩 if로 성공 경로를 감싸기
  • 반환 직전마다 정리 코드를 반복

std::expected를 쓰면 실패를 return unexpected(err)로 즉시 반환하고, 이미 획득한 자원은 RAII 객체가 스코프 종료 시 정리합니다. 즉, 에러 전파는 expected, 정리는 RAII로 역할이 분리됩니다.

또한 expected는 실패를 값으로 가지므로, 에러에 필요한 컨텍스트(경로, errno, 원인 문자열, 재시도 가능 여부 등)를 구조체로 풍부하게 담을 수 있습니다.

기본 형태: 성공 값과 에러 타입 설계

가장 먼저 할 일은 “우리 도메인에 맞는 에러 타입”을 정하는 것입니다. 문자열만 들고 다니면 편하지만, 운영에서 원인 분류가 어렵습니다. 최소한 아래 정도는 추천합니다.

  • 에러 코드(열거형)
  • OS 에러(errno 또는 플랫폼별 코드)
  • 메시지
  • 컨텍스트(파일 경로, 요청 ID 등)
#include <expected>
#include <string>
#include <system_error>

enum class Errc {
  kIo,
  kInvalidArg,
  kNotFound,
  kPermission,
  kUnknown,
};

struct Error {
  Errc code{};
  std::error_code sys;   // errno 기반이라면 std::generic_category()
  std::string message;
  std::string context;
};

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

inline std::unexpected<Error> Fail(Errc c, std::string msg, std::string ctx = {},
                                  std::error_code sys = {}) {
  return std::unexpected(Error{c, sys, std::move(msg), std::move(ctx)});
}

여기서 중요한 점은, 에러 타입이 “던져질 예외”가 아니라 “값”이므로 복사/이동 비용을 고려해야 합니다. 메시지가 크면 std::string 대신 std::string_view를 쓰고 수명 관리를 해야 하는데, 실전에서는 오히려 버그를 부르기 쉬워서 보통은 std::string이 안전합니다.

RAII로 자원 회수: 파일 디스크립터 예제

자원 회수의 기본은 RAII입니다. 핸들을 소유하는 타입을 만들고 소멸자에서 닫습니다.

#include <unistd.h>

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

  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) return *this;
    Reset();
    fd_ = other.fd_;
    other.fd_ = -1;
    return *this;
  }

  ~UniqueFd() { Reset(); }

  int Get() const { return fd_; }
  int Release() { int t = fd_; fd_ = -1; return t; }
  void Reset(int new_fd = -1) {
    if (fd_ != -1) ::close(fd_);
    fd_ = new_fd;
  }

  explicit operator bool() const { return fd_ != -1; }

 private:
  int fd_ = -1;
};

이제 “열기 함수”는 Expected<UniqueFd>를 반환하면 됩니다.

#include <fcntl.h>
#include <cerrno>

Expected<UniqueFd> OpenReadOnly(const char* path) {
  int fd = ::open(path, O_RDONLY);
  if (fd == -1) {
    return Fail(Errc::kIo,
                "open failed",
                std::string("path=") + path,
                std::error_code(errno, std::generic_category()));
  }
  return UniqueFd(fd);
}

핵심 효과는 다음입니다.

  • 성공 시: UniqueFd가 소유권을 가져가며 자동으로 닫힘
  • 실패 시: Expected가 에러를 보존하고 즉시 반환
  • 호출자는 if (!res)로 강제적으로 분기하도록 유도됨

부분 초기화/다단계 획득: “획득 즉시 소유” 원칙

자원 누수는 대개 “여러 자원을 순서대로 얻다가 중간 실패”에서 발생합니다. 해결 원칙은 단순합니다.

  • 자원을 얻는 즉시 RAII 객체에 담는다
  • 다음 단계가 실패하면 스코프 종료로 자동 정리된다

예: 설정 파일을 열고, 버퍼를 만들고, 락을 잡는 흐름.

#include <mutex>
#include <vector>

struct Locked {
  std::unique_lock<std::mutex> lock;
};

Expected<std::vector<char>> ReadAll(const char* path, std::mutex& m) {
  auto fdExp = OpenReadOnly(path);
  if (!fdExp) return std::unexpected(fdExp.error());
  UniqueFd fd = std::move(*fdExp);

  Locked g{std::unique_lock<std::mutex>(m)}; // 여기서부터 락은 RAII

  std::vector<char> buf;
  buf.resize(4096);

  // ... read 루프 (실패 시 Fail로 반환)
  // 실패하면 g/ fd / buf는 자동 정리

  return buf;
}

std::expected 자체가 자원을 정리해주지는 않습니다. 정리는 RAII가 담당합니다. 다만 expected는 “실패를 즉시 반환”하는 경로를 매우 싸고 명확하게 만들어, RAII가 제대로 작동하도록 코드 구조를 단순화합니다.

에러 전파를 깔끔하게: and_then, transform 패턴

C++23의 std::expected는 모나딕 연산을 제공합니다. 구현/표준 라이브러리 버전에 따라 제공 범위가 다를 수 있지만(특히 transform_error 등), 가능한 환경이라면 다음 패턴이 유용합니다.

  • and_then: 성공 값이 있을 때만 다음 함수를 호출, 실패면 그대로 전파
  • transform: 성공 값을 다른 성공 값으로 변환

예: 파일을 열고 내용을 파싱하는 흐름을 중첩 if 없이 작성.

Expected<std::string> ReadText(UniqueFd fd);
Expected<int> ParsePort(const std::string& s);

Expected<int> LoadPort(const char* path) {
  return OpenReadOnly(path)
    .and_then([](UniqueFd fd) { return ReadText(std::move(fd)); })
    .and_then([](const std::string& txt) { return ParsePort(txt); });
}

이 방식은 실패가 생기면 그 즉시 에러가 “값으로” 흘러가므로, 예외 없이도 파이프라인을 만들 수 있습니다.

만약 표준 라이브러리에서 해당 멤버가 아직 충분히 지원되지 않는다면, 동일한 효과를 내는 헬퍼 함수를 직접 작성하는 것도 실전적인 선택입니다.

자원 회수와 expected를 섞을 때 흔한 함정

1) expected<T>에서 T가 또 다른 소유권을 가진 경우

예를 들어 Expected<std::unique_ptr<Foo>>는 괜찮습니다. 다만 “성공 값 꺼내기”에서 실수하면 소유권이 꼬입니다.

  • *expT&를 반환하므로, 이동하려면 std::move(*exp)가 필요
  • 성공 값을 지역 변수로 이동해 소유권을 명확히 하는 습관이 안전
auto fooExp = MakeFoo();
if (!fooExp) return std::unexpected(fooExp.error());
std::unique_ptr<Foo> foo = std::move(*fooExp);

2) 에러를 “복사”하다가 컨텍스트가 누락되는 경우

실패를 전파할 때 return std::unexpected(fooExp.error());처럼 에러를 그대로 넘기는 것은 좋습니다. 하지만 상위 레이어에서 컨텍스트를 추가해야 할 때가 많습니다.

Expected<Data> LoadData(const char* path) {
  auto fdExp = OpenReadOnly(path);
  if (!fdExp) {
    Error e = fdExp.error();
    e.context += " | while LoadData";
    return std::unexpected(std::move(e));
  }
  // ...
}

이처럼 “에러 누적” 규칙을 팀 내에서 정해두면, 운영에서 트러블슈팅 속도가 크게 올라갑니다.

3) 소멸자에서 실패를 보고하고 싶은 욕심

자원 해제(close, unlock, free)는 소멸자에서 조용히 수행하는 게 기본입니다. 소멸자에서 에러를 expected로 반환할 수 없고, 예외를 던지면 더 위험합니다.

  • 해제 실패를 반드시 처리해야 한다면, 소멸자 대신 명시적 Close()를 제공하고 호출자가 Expected<void>로 처리
  • 소멸자는 최후의 안전망으로만 사용
Expected<void> CloseChecked(UniqueFd& fd) {
  if (!fd) return {};
  if (::close(fd.Release()) != 0) {
    return Fail(Errc::kIo, "close failed", {},
                std::error_code(errno, std::generic_category()));
  }
  return {};
}

Expected<void>로 “실패 가능하지만 값은 없는” 작업 표현

자원 회수나 커밋/플러시처럼 결과 값이 의미 없고 성공/실패만 중요한 API는 Expected<void>가 깔끔합니다.

Expected<void> FlushToDisk(UniqueFd& fd) {
  if (!fd) return Fail(Errc::kInvalidArg, "invalid fd");
  // 예: ::fsync(fd.Get())
  return {};
}

호출자는 다음처럼 쓰게 됩니다.

auto ok = FlushToDisk(fd);
if (!ok) {
  // ok.error() 로 로깅/리트라이/상위 전파
}

예외 기반 RAII와의 관계: expected는 대체재가 아니라 조합재

정리하면 역할이 다릅니다.

  • RAII: 자원 회수의 메커니즘(스코프 기반 정리)
  • std::expected: 실패를 표현/전파하는 데이터 모델

예외를 쓰지 않는 코드베이스에서는 expected가 사실상 “표준화된 에러 전파”가 되고, 예외를 쓰는 코드베이스에서도 경계면(스레드 엔트리, C API, 콜백, 게임 루프 등)에서는 expected가 더 적합한 경우가 많습니다.

특히 “락/컨텍스트/자원 누수”는 언어를 막론하고 운영 장애로 직결됩니다. 파이썬에서도 컨텍스트 매니저로 누수와 데드락을 막듯이, C++에서는 RAII가 그 자리를 차지하고 expected가 실패 전파를 정돈해줍니다. 관련 사고 패턴은 Python async contextmanager로 누수·락 해결 글의 관점과도 연결됩니다.

실전 가이드: 팀 규칙으로 정하면 좋은 것들

  1. 공개 API는 Expected<T>를 기본으로
  • 라이브러리/모듈 경계에서 예외를 섞으면 호출자가 처리 정책을 세우기 어렵습니다.
  1. 에러 타입을 통일하고 컨텍스트 누적 규칙을 정하기
  • 에러 코드(분류)와 시스템 에러(원인)를 분리하면 관측성이 좋아집니다.
  1. 자원은 “획득 즉시 소유”
  • 핸들/포인터를 날것으로 들고 다니는 시간을 0에 가깝게 줄이세요.
  1. 명시적 정리가 필요한 자원은 CloseChecked() 같은 API로 분리
  • 소멸자에서 실패를 처리하려 하지 말고, 체크 가능한 정리 함수를 제공하세요.
  1. 성능 핫패스에서는 에러 생성 비용을 고려
  • 성공이 대부분인 경로라면 에러 객체 생성은 실패 시에만 일어나므로 대체로 문제 없지만, 실패가 잦은 경로에서는 문자열 생성/할당이 부담이 될 수 있습니다.

운영 환경에서 “실패는 반드시 일어난다”는 전제를 두고, 실패를 값으로 다루며 자원 회수를 구조적으로 강제하는 것이 장기적으로 가장 싸게 먹힙니다. 장애 대응에서의 보상/복구 관점은 분산 시스템의 보상 트랜잭션과도 닮아 있는데, 개념적으로는 MSA 트랜잭션 Saga 패턴 보상로직 설계 가이드에서 말하는 “실패를 전제로 한 설계”와 같은 결입니다.

마무리

C++23 std::expected는 예외를 완전히 대체하려는 기능이라기보다, 예외 없이도 실패를 타입으로 강제하고 에러 컨텍스트를 보존하며 RAII가 빛나도록 코드 구조를 단순화하는 도구입니다.

  • 정리(자원 회수)는 RAII로
  • 실패 전파는 std::expected
  • 경계면에서는 예외보다 expected가 더 안전한 선택인 경우가 많음

이 조합을 팀의 기본 패턴으로 굳히면, 누수/데드락/중복 해제 같은 “재현 어려운 버그”가 눈에 띄게 줄고, 실패 원인도 훨씬 빨리 추적할 수 있습니다.