Published on

C++23 std - -expected로 예외 없이 자원누수 막기

Authors

서버나 시스템 프로그래밍에서 "예외를 쓰지 않는다"는 선택은 흔합니다. ABI 경계(플러그인, C API), 성능/코드사이즈, 예외 비활성화 빌드(-fno-exceptions) 같은 이유로 오류를 반환값으로 다루곤 하죠.

문제는 그 순간부터 자원 관리가 급격히 어려워진다는 점입니다. 파일 디스크립터, FILE*, 소켓, malloc 버퍼, DB 핸들처럼 "획득 후 반드시 해제"가 필요한 자원은, 오류 분기마다 close/free를 직접 호출하다가 한 군데만 빠져도 누수가 납니다.

C++의 정답은 여전히 RAII지만, RAII 객체를 만드는 과정 자체가 실패할 수 있다는 점이 곤란합니다. C++23의 std::expected는 이 지점을 깔끔하게 메워줍니다. 이 글에서는 std::expected로 실패 가능한 자원 획득을 표현하고, 예외 없이도 누수 없는 코드를 만드는 패턴을 실전 관점에서 정리합니다.

관련해서 "실패를 값으로 다룬다"는 철학은 Rust에서 더 강하게 드러납니다. 빌림/소유권이 다른 언어지만, 오류 전파를 값으로 모델링한다는 관점은 참고할 만합니다. 필요하면 Rust E0502/E0499 빌림 충돌 6가지 패턴도 함께 읽어보면 좋습니다.

std::expected가 자원 누수에 강한가

핵심은 두 가지입니다.

  1. 실패를 "반환"하므로 제어 흐름이 명시적이다
  2. 성공 경로에서 RAII 객체를 반환해 소유권을 이동시킨다

즉, "자원 획득" 함수는 성공 시 소유권을 가진 RAII 래퍼를 반환하고, 실패 시 에러를 반환합니다. 호출자는 if (!r) 같은 분기로 즉시 처리하거나, 상위로 그대로 전파합니다. 이때 RAII 객체는 스코프를 벗어나면 자동 해제되므로, 중간에 어떤 분기에서 빠져나가도 누수 가능성이 줄어듭니다.

std::expected는 대략 다음 형태입니다.

  • 성공: std::expected<T, E> 안에 T
  • 실패: std::expected<T, E> 안에 E

여기서 T를 "자원을 소유하는 타입"으로 만들면 됩니다.

준비: 에러 타입 설계

에러를 단순 문자열로 할 수도 있지만, 운영에서 디버깅 가능한 형태가 좋습니다.

  • 에러 코드(열거형)
  • 메시지(옵션)
  • errno 같은 OS 에러(옵션)
#include <expected>
#include <string>
#include <cerrno>

enum class Errc {
  OpenFailed,
  ReadFailed,
  WriteFailed,
  InvalidInput,
};

struct Error {
  Errc code;
  int sys_errno = 0;
  std::string message;
};

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

이렇게 해두면 모든 함수 시그니처가 Result<T>로 통일됩니다.

실패 가능한 자원 획득을 RAII로 감싸기

예제로 POSIX 파일 디스크립터를 다뤄보겠습니다. open이 실패할 수 있으니 RAII 래퍼를 만들고, 생성은 static 팩토리로 제공합니다.

주의: 본문에 부등호 문자가 노출되면 MDX 빌드가 깨질 수 있으니, 제네릭/템플릿 표기 등은 코드 블록 안에서만 사용합니다.

#include <expected>
#include <string>
#include <unistd.h>
#include <fcntl.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) {
      reset();
      fd_ = other.fd_;
      other.fd_ = -1;
    }
    return *this;
  }

  ~UniqueFd() { reset(); }

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

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

private:
  int fd_ = -1;
};

Result<UniqueFd> open_readonly(const std::string& path) {
  int fd = ::open(path.c_str(), O_RDONLY);
  if (fd < 0) {
    return std::unexpected(Error{
      .code = Errc::OpenFailed,
      .sys_errno = errno,
      .message = "open failed: " + path,
    });
  }
  return UniqueFd{fd};
}

포인트는 다음입니다.

  • UniqueFd는 소유권을 가진 RAII 타입
  • 실패는 std::unexpected(Error{...})
  • 성공은 UniqueFd 반환(이동)

이제 호출자가 어떤 경로로 빠져나가든 파일은 자동으로 닫힙니다.

expected로 단계별 작업 조합하기

자원 획득 후 읽기 같은 후속 작업도 실패할 수 있습니다. 흔한 실수는 실패 시점마다 수동으로 close를 호출하는 것인데, RAII가 있으면 그럴 필요가 없습니다.

#include <vector>
#include <sys/stat.h>

Result<std::vector<char>> read_all(const std::string& path) {
  auto fd_res = open_readonly(path);
  if (!fd_res) return std::unexpected(fd_res.error());

  UniqueFd fd = std::move(*fd_res);

  struct stat st {};
  if (::fstat(fd.get(), &st) != 0) {
    return std::unexpected(Error{
      .code = Errc::ReadFailed,
      .sys_errno = errno,
      .message = "fstat failed",
    });
  }

  std::vector<char> buf(static_cast<size_t>(st.st_size));
  ssize_t n = ::read(fd.get(), buf.data(), buf.size());
  if (n < 0) {
    return std::unexpected(Error{
      .code = Errc::ReadFailed,
      .sys_errno = errno,
      .message = "read failed",
    });
  }

  buf.resize(static_cast<size_t>(n));
  return buf;
}

여기서 fstatread가 실패해도 fd는 스코프 종료 시 자동으로 닫힙니다. 즉, "예외가 없어서" 오히려 더 명시적인 제어 흐름을 갖고도 누수는 방지됩니다.

조합을 더 깔끔하게: and_then, transform

C++23 std::expected에는 조합용 멤버 함수가 있습니다.

  • transform: 성공 값 TU로 변환
  • and_then: 성공 값으로 다음 expected를 이어붙임
  • or_else: 실패를 다른 실패로 매핑하거나 로깅

표준 라이브러리 구현/버전에 따라 사용 가능 여부가 다를 수 있으니, 컴파일러/라이브러리 버전을 확인하세요.

아래는 and_then 스타일로 "열기 → 읽기"를 분리해 조합하는 예입니다.

Result<std::vector<char>> read_all2(const std::string& path) {
  return open_readonly(path).and_then([](UniqueFd fd) -> Result<std::vector<char>> {
    std::vector<char> buf(4096);
    ssize_t n = ::read(fd.get(), buf.data(), buf.size());
    if (n < 0) {
      return std::unexpected(Error{
        .code = Errc::ReadFailed,
        .sys_errno = errno,
        .message = "read failed",
      });
    }
    buf.resize(static_cast<size_t>(n));
    return buf;
  });
}

람다 인자로 UniqueFd가 값으로 들어오므로(이동), 람다 블록을 벗어날 때 자동으로 닫힙니다. 중간에 실패 반환을 해도 동일합니다.

실전에서 자주 터지는 누수 포인트와 expected 패턴

1) 부분 초기화 후 실패

예: 소켓 생성 후 옵션 설정 중 실패하면 소켓을 닫아야 합니다. RAII로 소켓을 감싼 뒤, 각 단계는 Result<void>로 반환하면 됩니다.

class UniqueSocket {
public:
  explicit UniqueSocket(int fd) : fd_(fd) {}
  UniqueSocket(UniqueSocket&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
  UniqueSocket& operator=(UniqueSocket&& o) noexcept {
    if (this != &o) { reset(); fd_ = o.fd_; o.fd_ = -1; }
    return *this;
  }
  ~UniqueSocket() { reset(); }
  int get() const { return fd_; }
  void reset() { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } }
private:
  int fd_ = -1;
};

Result<void> set_nonblocking(int fd) {
  int flags = ::fcntl(fd, F_GETFL, 0);
  if (flags < 0) {
    return std::unexpected(Error{.code = Errc::InvalidInput, .sys_errno = errno, .message = "F_GETFL failed"});
  }
  if (::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
    return std::unexpected(Error{.code = Errc::InvalidInput, .sys_errno = errno, .message = "F_SETFL failed"});
  }
  return {};
}

Result<UniqueSocket> make_socket_nonblocking() {
  int fd = ::socket(AF_INET, SOCK_STREAM, 0);
  if (fd < 0) {
    return std::unexpected(Error{.code = Errc::OpenFailed, .sys_errno = errno, .message = "socket failed"});
  }
  UniqueSocket s(fd);

  auto r = set_nonblocking(s.get());
  if (!r) return std::unexpected(r.error());

  return s; // 이동
}

부분 초기화든 중간 실패든 소켓 누수는 원천 차단됩니다.

2) C API와 혼합될 때

C API는 보통 nullptr/음수로 실패를 표현합니다. 이때 "바로 RAII로 감싸서" 반환을 expected로 올리면 됩니다.

예: FILE*unique_ptr에 커스텀 deleter로 감싸기.

#include <cstdio>
#include <memory>

struct FileCloser {
  void operator()(std::FILE* f) const {
    if (f) std::fclose(f);
  }
};

using UniqueFile = std::unique_ptr<std::FILE, FileCloser>;

Result<UniqueFile> fopen_read(const std::string& path) {
  std::FILE* f = std::fopen(path.c_str(), "rb");
  if (!f) {
    return std::unexpected(Error{
      .code = Errc::OpenFailed,
      .sys_errno = errno,
      .message = "fopen failed: " + path,
    });
  }
  return UniqueFile(f);
}

이 패턴은 라이브러리 핸들(sqlite3*, EVP_PKEY*, CURL*)에도 그대로 적용됩니다.

팀 규칙으로 굳히기: 함수 경계에서의 원칙

std::expected를 도입해도, 팀 코드가 섞이면 누수/중복 해제가 다시 생깁니다. 다음 원칙을 권합니다.

  1. "자원 획득" 함수는 RAII 타입을 반환한다
  2. 실패는 std::unexpected(Error)로만 표현한다
  3. RAII 타입은 복사 금지, 이동만 허용한다
  4. raw 핸들은 가능한 한 외부로 노출하지 않는다(get()은 최후 수단)
  5. 상위로 전파할 때는 에러 컨텍스트를 덧붙인다

컨텍스트 덧붙이기는 or_else로도 가능하고, 단순히 에러 메시지를 감싸는 헬퍼를 둬도 됩니다.

Error with_context(Error e, const std::string& ctx) {
  if (!e.message.empty()) e.message = ctx + ": " + e.message;
  else e.message = ctx;
  return e;
}

template <class T>
Result<T> add_context(Result<T> r, const std::string& ctx) {
  if (!r) return std::unexpected(with_context(r.error(), ctx));
  return r;
}

Result<std::vector<char>> read_all3(const std::string& path) {
  return add_context(read_all(path), "read_all3(" + path + ")");
}

운영에서 장애를 줄이는 건 "원인 파악 속도"인 경우가 많습니다. 이 컨텍스트 누적은 체감이 큽니다. 장애 분석/진단 관점은 K8s CrashLoopBackOff 진단 - OOMKilled·Probe 같은 글의 접근과도 통합니다. 현상보다 "경계에서 어떤 정보가 사라지는가"를 관리하는 게 핵심입니다.

std::expected를 써도 RAII가 깨지는 경우

expected가 만능은 아닙니다. 아래 상황은 여전히 조심해야 합니다.

  • RAII 객체 내부에서 또 다른 자원을 다루는데 이동/소멸 규칙이 틀린 경우(이중 close)
  • get()으로 raw 핸들을 꺼내 외부에 넘긴 뒤, 외부가 소유권을 가져가 버리는 경우
  • 콜백 기반 API에 raw 포인터를 넘기고, 콜백이 호출되기 전 스코프가 끝나는 경우(수명 문제)

이런 경우엔 소유권 이전 규칙을 명확히 하거나, "소유권 이전"을 표현하는 별도 타입(예: release() 제공)을 설계해야 합니다.

빌드/도구체인 체크: C++23 expected 사용 조건

  • 컴파일러: GCC, Clang, MSVC의 C++23 모드에서 지원이 진행 중이며, 실제로는 표준 라이브러리 구현 버전이 더 중요합니다.
  • 설정: -std=c++23 또는 MSVC /std:c++latest
  • 헤더: #include <expected>

만약 표준 라이브러리에서 and_then/transform 지원이 불완전하다면, 우선은 if (!r) return std::unexpected(r.error()); 스타일로 시작해도 충분히 가치가 있습니다.

CI에서 도구체인/캐시가 꼬이면 표준 라이브러리 버전이 달라져 재현이 어려울 수 있습니다. 그런 경우엔 GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅처럼 "환경을 고정"하는 접근이 도움이 됩니다.

정리: 예외 없는 코드에서 누수를 막는 가장 현실적인 방법

  • 예외를 쓰지 않는다면, 실패는 반드시 반환값으로 전파해야 합니다.
  • 반환값 기반 오류 처리에서 자원 누수를 막는 핵심은 "성공 값으로 RAII 소유권을 반환"하는 것입니다.
  • C++23 std::expected는 성공/실패를 하나의 타입으로 묶어, 분기와 전파를 표준화합니다.
  • 결과적으로 코드가 길어지기보다, 오히려 "실패 경로"가 정리되고 리뷰 포인트가 명확해집니다.

다음 단계로는

  • 프로젝트 공통 Error 정책(코드, 컨텍스트, 로깅)
  • 자원별 RAII 래퍼 템플릿화(예: UniqueHandle with deleter)
  • expected 조합 함수(and_then, transform, or_else) 기반 파이프라인

까지 확장하면, 예외 없이도 충분히 견고한 시스템 코드를 만들 수 있습니다.