Published on

C++23 std - -expected로 예외 없는 안전한 자원관리

Authors

예외 기반 에러 처리는 편하지만, 모든 코드베이스에서 정답은 아닙니다. 특히 다음 조건이 겹치면 예외는 비용이 커집니다.

  • 저지연 시스템에서 예외 경로의 비용과 예측 불가능성이 부담일 때
  • C API, OS 핸들, 네트워크 라이브러리처럼 실패가 “정상 흐름의 일부”인 영역이 많을 때
  • 예외가 금지된 컴파일 옵션(예: -fno-exceptions) 또는 코딩 규약을 따를 때
  • 스레드/코루틴 경계에서 실패를 표준화된 형태로 전달하고 싶을 때

C++23의 std::expected는 “성공 값” 또는 “에러 값”을 한 타입으로 표현합니다. 핵심은 실패가 타입 시스템에 드러나기 때문에, 호출자가 실패 처리를 강제받고, 예외 없이도 안전하게 자원을 관리할 수 있다는 점입니다. 이 글에서는 std::expected를 RAII와 결합해 누수 없이 자원을 획득하고, 실패를 깔끔하게 전파하는 패턴을 실전 관점에서 정리합니다.

참고로 분산 환경에서는 실패 처리 패턴이 곧 안정성입니다. 예를 들어 gRPC에서 데드라인과 리트라이가 얽히면 “실패를 어떻게 표현하고 전파하는가”가 폭주를 막는 핵심이 됩니다. 관련해서는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 함께 읽어보면 좋습니다.

std::expected 빠른 개념 정리

std::expected<T, E>는 다음 중 하나를 담습니다.

  • 성공: T
  • 실패: E

주요 API는 아래 정도만 익혀도 실무에서 충분히 씁니다.

  • has_value() 또는 operator bool()로 성공 여부 확인
  • value()로 성공 값 접근(실패 상태에서 호출하면 정의된 방식으로 종료될 수 있으니 주의)
  • error()로 에러 값 접근
  • value_or(default)로 기본값 제공

중요한 설계 포인트는 E를 무엇으로 할지입니다. 보통은 다음 중 하나가 현실적입니다.

  • std::error_code: OS/라이브러리 에러와의 호환이 좋음
  • 자체 enum class Error: 도메인 에러를 컴팩트하게 표현
  • 구조체 struct Error { ... }: 메시지, 원인, 컨텍스트를 함께 담음

이 글에서는 자원 관리 예제로 “에러 코드 + 추가 컨텍스트”가 담긴 구조체를 사용해보겠습니다.

예외 없이도 자원은 RAII로 관리한다

예외를 쓰지 않는다고 해서 RAII를 포기하는 게 아닙니다. 오히려 반대입니다.

  • 자원 획득은 생성자/팩토리에서
  • 자원 해제는 소멸자에서
  • 실패는 std::expected로 반환

이 조합이 강력한 이유는 다음입니다.

  • 성공한 뒤에는 평범한 RAII 객체로서 자동 해제됨
  • 실패한 경우에는 애초에 “유효한 자원 객체”가 만들어지지 않음
  • 중간 단계에서 실패해도, 이미 만들어진 임시 RAII 객체들이 스코프 종료로 정리됨

즉, “자원 누수 방지”는 RAII가 담당하고, “실패 전달”은 std::expected가 담당합니다.

실전 패턴 1: 파일 디스크립터를 expected로 안전하게 열기

POSIX의 open은 실패가 빈번하고, 실패 이유가 errno로 전달됩니다. 예외 없이도 다음처럼 깔끔한 API를 만들 수 있습니다.

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

struct OpenError {
  std::error_code ec;
  std::string path;
};

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_; }
  explicit operator bool() const { return fd_ != -1; }

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

private:
  int fd_ = -1;
};

std::expected<UniqueFd, OpenError> open_readonly(const std::string& path) {
  int fd = ::open(path.c_str(), O_RDONLY);
  if (fd == -1) {
    return std::unexpected(OpenError{
      std::error_code(errno, std::generic_category()),
      path
    });
  }
  return UniqueFd(fd);
}

사용 측은 실패 처리를 강제받습니다.

#include <iostream>

int main() {
  auto fd = open_readonly("/tmp/data.txt");
  if (!fd) {
    std::cerr << "open failed: path=" << fd.error().path
              << " ec=" << fd.error().ec.message() << "\n";
    return 1;
  }

  // 성공 이후에는 RAII로 안전
  int raw = fd->get();
  (void)raw;
  return 0;
}

포인트는 open_readonly가 “성공 시에만 유효한 RAII 객체”를 만들고, 실패 시에는 std::unexpected로 에러를 돌려준다는 점입니다.

실전 패턴 2: 다단계 자원 획득을 expected로 조합하기

자원 관리가 까다로운 경우는 “여러 단계를 거쳐 초기화”할 때입니다. 예를 들어

  1. 파일 열기
  2. 크기 확인
  3. 버퍼 할당
  4. 읽기

중간에 실패하면 이미 잡은 자원을 정리해야 합니다. RAII를 쓰면 정리는 자동이지만, 실패 전파가 지저분해지기 쉽습니다. std::expected를 쓰면 실패 전파가 명시적이면서도 일관됩니다.

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

struct ReadError {
  std::error_code ec;
  std::string what;
};

std::expected<size_t, ReadError> file_size(int fd) {
  struct stat st;
  if (::fstat(fd, &st) != 0) {
    return std::unexpected(ReadError{
      std::error_code(errno, std::generic_category()),
      "fstat"
    });
  }
  return static_cast<size_t>(st.st_size);
}

std::expected<std::vector<unsigned char>, ReadError> read_all(const std::string& path) {
  auto fd = open_readonly(path);
  if (!fd) {
    return std::unexpected(ReadError{fd.error().ec, "open: " + fd.error().path});
  }

  auto sz = file_size(fd->get());
  if (!sz) {
    return std::unexpected(sz.error());
  }

  std::vector<unsigned char> buf(*sz);
  size_t off = 0;
  while (off < buf.size()) {
    ssize_t n = ::read(fd->get(), buf.data() + off, buf.size() - off);
    if (n < 0) {
      return std::unexpected(ReadError{
        std::error_code(errno, std::generic_category()),
        "read"
      });
    }
    if (n == 0) break;
    off += static_cast<size_t>(n);
  }

  buf.resize(off);
  return buf;
}

여기서 중요한 점은 read_all 내부에서 어떤 단계에서 실패하든, UniqueFd는 스코프를 벗어나며 자동으로 닫힌다는 것입니다. 실패 전파는 std::unexpected로만 일관되게 처리합니다.

실전 패턴 3: “에러 컨텍스트”를 누적해 디버깅 가능하게 만들기

예외를 안 쓰면 스택 트레이스가 없어서 디버깅이 불리하다고 느낄 수 있습니다. 이때는 E에 컨텍스트를 넣는 방식이 효과적입니다.

  • 어떤 작업에서 실패했는지(what)
  • 어떤 입력이었는지(path, endpoint)
  • 원인 코드(std::error_code)

또한 “호출자 관점에서 의미 있는 에러”로 매핑하는 레이어를 두면 운영이 쉬워집니다. 예를 들어 OS 에러를 그대로 노출하지 않고, 도메인 에러로 변환합니다.

enum class DomainErr {
  NotFound,
  PermissionDenied,
  Io,
};

struct DomainError {
  DomainErr kind;
  std::string context;
  std::error_code cause;
};

DomainError map_error(const ReadError& e) {
  if (e.cause == std::errc::no_such_file_or_directory) {
    return {DomainErr::NotFound, e.what, e.cause};
  }
  if (e.cause == std::errc::permission_denied) {
    return {DomainErr::PermissionDenied, e.what, e.cause};
  }
  return {DomainErr::Io, e.what, e.cause};
}

이런 매핑은 “로그/알림에서 노이즈를 줄이고”, “재시도 가능/불가능” 같은 정책 결정에도 직접 연결됩니다. API 재시도 설계 관점은 Claude API 529 과부하·429 제한 재시도 설계에서도 유사한 원칙을 다룹니다.

std::expected와 예외의 역할 분담

예외를 완전히 배제할지, 혼용할지는 제품 성격에 따라 다릅니다. 다만 다음 원칙은 꽤 보편적으로 유효합니다.

  • 라이브러리 경계에서는 std::expected가 유리
    • ABI/언어 경계(C, Rust, 다른 런타임)에서 예외는 위험
    • 호출자에게 실패 처리를 강제할 수 있음
  • “정말 비정상”에는 예외가 유리할 수 있음
    • 불변식 위반, 프로그래밍 오류, 복구 불가능한 상태

정리하면, 복구 가능한 실패(파일 없음, 네트워크 타임아웃, 권한 부족)는 std::expected로 표현하고, 버그에 가까운 상태는 assert 또는 예외로 분리하는 편이 유지보수에 도움이 됩니다.

자원관리 관점에서 expected가 특히 좋은 이유

자원관리에서 흔히 겪는 문제는 “실패 시점이 다양해서 정리가 누락되는 것”입니다.

  • 반환 코드 기반 C 스타일은 호출자가 매번 goto cleanup 또는 중첩 if로 정리
  • 예외 기반은 정리는 쉬워도, 예외가 전파되는 경계를 잘못 잡으면 프로그램 정책이 꼬임

std::expected는 다음 균형점을 제공합니다.

  • 정리: RAII로 자동
  • 실패: 타입으로 강제
  • 정책: 호출자가 if (!res)에서 결정

특히 팀 개발에서 “이 함수가 실패할 수 있는지”가 시그니처에 드러나는 것이 큽니다. 코드 리뷰에서 누락을 잡기 쉬워지고, 테스트에서도 실패 케이스를 강제로 다루게 됩니다.

팁: expected 사용 시 자주 하는 실수

value()를 무심코 호출하기

res.value()는 성공을 확신할 때만 써야 합니다. 실무에서는 아래처럼 가드 패턴을 습관화하는 편이 안전합니다.

auto data = read_all(path);
if (!data) {
  // error handling
  return;
}
use(*data);

에러 타입을 너무 “문자열”로만 두기

문자열은 편하지만 정책 결정을 못 합니다. 최소한 enum class 또는 std::error_code를 같이 두는 게 좋습니다.

성공 값에 “부분적으로 초기화된 객체”를 넣기

성공 타입 T는 “완전히 유효한 상태”여야 합니다. 부분 초기화가 필요하면 내부적으로 RAII를 더 잘게 쪼개거나, 빌더 패턴을 쓰되 최종 결과만 expected로 반환하는 편이 안전합니다.

마무리: 예외 없는 코드의 생산성을 올리는 조합

C++23 std::expected는 예외를 대체하는 만능 도구라기보다, “복구 가능한 실패를 명시적으로 모델링”하는 표준 도구입니다. 여기에 RAII를 결합하면 다음을 동시에 얻습니다.

  • 실패는 호출자가 반드시 처리
  • 자원은 스코프 기반으로 자동 정리
  • 에러 컨텍스트를 구조화해 운영/디버깅 개선

실무에서 추천하는 시작점은 단순합니다.

  1. 파일/소켓/뮤텍스 같은 “명확한 자원”부터 UniqueX RAII 래퍼를 만든다
  2. 생성(획득) 함수는 std::expected<UniqueX, Error>로 반환한다
  3. 상위 레이어에서 도메인 에러로 매핑하고, 재시도/대체 경로 같은 정책을 둔다

이렇게 쌓아가면 예외를 쓰지 않아도 코드가 덜 복잡해지고, 실패가 더 투명해집니다.