Published on

C++23 std - -expected로 예외 없이 안전하게 자원관리

Authors

서버/인프라 코드든 라이브러리 코드든, C++에서 예외(throw)를 꺼리는 팀은 꽤 많습니다. 이유는 명확합니다. 예외는 제어 흐름을 비가시적으로 만들고, ABI/컴파일 옵션(예: -fno-exceptions) 제약을 만들며, 실시간/저지연 시스템에서는 비용 예측이 어렵다고 느끼기 쉽습니다. 그렇다고 전통적인 bool 반환 + out 파라미터로 돌아가면 오류 전파가 장황해지고, 누락된 체크가 곧 장애로 이어집니다.

C++23의 std::expected는 이 간극을 메우는 표준 도구입니다. 성공 값 또는 오류 값을 동일한 타입 컨테이너로 전달하고, 호출자는 실패를 강제적으로 다루게 됩니다. 그리고 RAII를 결합하면 예외 없이도 자원 누수/부분 초기화 문제를 강하게 줄일 수 있습니다.

이 글에서는 std::expected로 예외를 제거하면서도 안전한 자원관리(파일/소켓/FD/핸들, 트랜잭션성 작업)를 구성하는 실전 패턴을 다룹니다.

std::expected 핵심 개념

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

  • 성공: T
  • 실패: E 오류

주요 API는 아래와 같습니다.

  • has_value() / operator bool() : 성공 여부
  • value() : 성공 값(실패 시 UB가 아니라 예외를 던질 수 있으니 주의, 팀 정책에 따라 value() 사용을 제한하는 경우도 많습니다)
  • error() : 오류 값
  • value_or(default) : 기본값 제공
  • and_then, transform, or_else, transform_error : 함수형 체이닝

예외를 쓰지 않는 코드베이스라면 특히 중요한 점은, value()가 내부적으로 std::bad_expected_access를 던질 수 있다는 것입니다. 따라서 예외 비활성 환경에서는 value() 대신 if (!exp) return unexpected(exp.error()); 같은 형태로 다루는 습관이 좋습니다.

std::expected가 “자원관리”와 잘 맞나

자원관리 문제는 보통 두 가지에서 터집니다.

  1. 부분 초기화: A는 열었는데 B에서 실패해서 A를 닫지 못함
  2. 오류 전파 누락: 실패를 무시하고 다음 단계 진행

RAII는 1을 해결하는 정석입니다. 자원을 소유하는 타입이 소멸자에서 정리하면, 중간에 실패하더라도 스코프를 벗어나는 순간 자동으로 정리됩니다.

std::expected는 2를 해결합니다. 실패가 반환 타입에 “박혀” 있으니, 호출자는 성공/실패 분기를 반드시 작성하게 되고(또는 체이닝에서 자동으로 중단), 실패를 값으로 전달할 수 있습니다.

결론적으로 RAII + expected는 예외 없이도 안전한 자원관리 파이프라인을 구성하기에 가장 자연스러운 조합입니다.

에러 타입 설계: 문자열보다 “구조화된 오류”

Estd::string을 쓰면 시작은 쉽지만, 운영/관측성 측면에서 금방 한계가 옵니다. 추천은 다음처럼 구조화된 오류 타입입니다.

  • 오류 코드(enum)
  • 시스템 에러(std::error_code) 보관
  • 메시지(필요 시)
  • 컨텍스트(경로, endpoint 등)
#include <expected>
#include <string>
#include <system_error>

enum class Errc {
  OpenFailed,
  ReadFailed,
  ParseFailed,
  Timeout,
};

struct Error {
  Errc code;
  std::error_code sys;   // errno 기반이면 여기에 담기
  std::string context;   // 파일 경로/호스트 등
  std::string message;   // 사람이 읽을 메시지
};

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

이렇게 해두면 상위 레이어에서 code로 분기하고, 로깅에는 syscontext를 함께 출력해 원인 추적이 쉬워집니다.

RAII 핸들 래퍼 + expected로 안전하게 열기

POSIX FD, 윈도우 HANDLE, 소켓 등은 “자원 소유 타입”으로 감싸는 순간 안전성이 크게 올라갑니다.

아래는 간단한 FD RAII 예시입니다.

#include <expected>
#include <cerrno>
#include <cstring>
#include <system_error>
#include <string>

#include <fcntl.h>
#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) {
      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;
};

struct Error {
  int code; // 예시 단순화
  std::error_code sys;
  std::string context;
  std::string message;
};

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

Expected<UniqueFd> open_readonly(std::string path) {
  int fd = ::open(path.c_str(), O_RDONLY);
  if (fd == -1) {
    return std::unexpected(Error{
      .code = 1,
      .sys = std::error_code(errno, std::generic_category()),
      .context = path,
      .message = "open failed"
    });
  }
  return UniqueFd(fd);
}

포인트는 UniqueFd가 자원을 소유하므로, 이후 로직에서 실패가 발생해도 자동으로 close()가 호출된다는 점입니다. expected는 “열기 실패”를 값으로 전달합니다.

단계적 초기화: 여러 자원을 묶을 때의 패턴

실제 시스템은 파일 하나만 열지 않습니다. 예를 들어

  • 설정 파일 열기
  • 읽기
  • 파싱
  • 소켓 연결
  • 핸드셰이크

처럼 여러 단계가 있고, 중간 실패 시 이미 확보한 자원을 정리해야 합니다.

RAII + expected에서는 “각 자원은 소유 타입으로 즉시 감싸고, 실패는 즉시 반환”이 가장 단순하며 안전합니다.

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

struct Error {
  std::string message;
};

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

Expected<std::string> read_all(const UniqueFd& fd);
Expected<int> parse_port(const std::string& text);
Expected<int> connect_tcp(std::string host, int port); // 예시: 소켓 fd 반환

Expected<int> init_client(std::string config_path) {
  auto fd_exp = open_readonly(config_path);
  if (!fd_exp) return std::unexpected(fd_exp.error());
  UniqueFd cfg_fd = std::move(*fd_exp);

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

  auto port_exp = parse_port(*text_exp);
  if (!port_exp) return std::unexpected(port_exp.error());

  auto sock_exp = connect_tcp("127.0.0.1", *port_exp);
  if (!sock_exp) return std::unexpected(sock_exp.error());

  return *sock_exp;
}

여기서 read_all이나 parse_port에서 실패하면 함수는 즉시 반환되고, cfg_fd는 스코프 종료로 자동 정리됩니다.

and_then/transform로 오류 전파를 더 짧게

위 코드는 안전하지만 반복이 많습니다. C++23 std::expected는 체이닝을 제공합니다.

  • and_then(f) : 성공 값이면 f(T)를 호출해 다음 expected로 이어감
  • transform(f) : 성공 값이면 U f(T)로 바꿔 expected<U,E> 반환
  • or_else(f) : 실패면 f(E) 실행

예시는 다음처럼 바꿀 수 있습니다.

Expected<int> init_client_chain(std::string config_path) {
  return open_readonly(config_path)
    .and_then([](UniqueFd fd) {
      return read_all(fd);
    })
    .and_then([](std::string text) {
      return parse_port(text);
    })
    .and_then([](int port) {
      return connect_tcp("127.0.0.1", port);
    });
}

주의할 점은 람다의 파라미터 전달 방식입니다.

  • UniqueFd 같은 move-only 타입은 값으로 받아 move 흐름을 타게 하거나
  • 참조로 받되 생명주기를 명확히 해야 합니다.

또한 체이닝은 디버깅 시 스택 트레이스가 단순하지 않을 수 있어, 팀 스타일에 따라 “핫패스는 체이닝, 복잡한 곳은 명시적 if-return”처럼 혼용하기도 합니다.

부분 실패를 “롤백 가능한 작업”으로 모델링하기

자원관리는 파일/소켓 같은 OS 자원에만 국한되지 않습니다. 예를 들어

  • 임시 파일 생성 후 최종 파일로 rename
  • DB 트랜잭션 시작 후 커밋/롤백
  • 설정 적용 중 일부만 반영됨

이런 경우는 RAII로 “스코프 가드”를 두는 게 매우 강력합니다. 예외가 없더라도, 중간 return unexpected(...)가 발생하면 가드 소멸자가 롤백을 수행합니다.

#include <utility>

class ScopeGuard {
public:
  explicit ScopeGuard(std::function<void()> f) : f_(std::move(f)) {}
  ~ScopeGuard() { if (active_) f_(); }
  void dismiss() { active_ = false; }
private:
  std::function<void()> f_;
  bool active_ = true;
};

Expected<void> update_config_atomically(std::string path, std::string new_text) {
  // 예시: tmp 파일 쓰고 rename 하는 흐름
  auto fd_exp = open_readonly(path + ".tmp");
  if (!fd_exp) return std::unexpected(fd_exp.error());
  UniqueFd tmp_fd = std::move(*fd_exp);

  ScopeGuard cleanup([&]() {
    // 실패 시 tmp 정리(여기서는 예시)
    // 실제로는 unlink 등을 수행
  });

  // write_all(tmp_fd, new_text) 같은 단계가 있다고 가정
  // 실패하면 unexpected 반환, cleanup이 실행됨

  // rename 성공 시 cleanup 해제
  cleanup.dismiss();
  return {};
}

이 패턴은 장애 대응에서 특히 유용합니다. “중간 실패”가 발생해도 시스템이 불완전한 상태로 남지 않게 만들 수 있습니다.

예외 제거가 운영 안정성으로 이어지는 지점

예외를 제거한다고 자동으로 안정해지는 것은 아닙니다. 하지만 expected는 실패를 명시화하여 다음을 개선합니다.

  • 실패 경로가 타입에 드러나므로 코드리뷰에서 놓치기 어려움
  • 에러 컨텍스트를 구조화해 로깅/메트릭에 일관되게 반영 가능
  • 재시도/백오프/서킷브레이커 같은 정책을 상위 레이어에서 강제하기 쉬움

특히 네트워크/외부 API 호출은 “실패가 정상”인 영역이라, 실패를 값으로 다루는 설계가 잘 맞습니다. 재시도/백오프 패턴은 다른 글의 접근도 참고할 만합니다.

실전 팁: 팀 규칙으로 굳히면 좋은 것들

value() 사용 금지 또는 제한

예외 비활성(-fno-exceptions) 환경에서 value() 호출은 정책 위반이 될 수 있습니다. 팀 차원에서

  • if (!exp) return unexpected(exp.error());
  • 또는 체이닝(and_then) 사용

으로 통일하면 좋습니다.

오류 타입에 std::error_code를 포함

POSIX errno, 소켓 오류, 파일 I/O 오류는 결국 OS 오류 코드로 수렴합니다. 문자열만 남기면 원인 추적이 어려워집니다. 구조화된 오류에 std::error_code를 넣어두면 운영에서 강력합니다.

“자원은 즉시 소유 타입으로”

int fd를 오래 들고 다니지 말고, 얻는 즉시 UniqueFd 같은 소유 타입으로 감싸세요. 이 한 줄이 누수/이중 close/예외적 경로 누락을 대폭 줄입니다.

경계 레이어에서만 로그

expected는 오류를 운반하는 도구입니다. 저수준 함수마다 로그를 찍기 시작하면 중복 로그가 쌓입니다. 보통은

  • 저수준: Error{code, sys, context}만 채워 반환
  • 상위(핸들러/작업 단위): 한 번만 로깅 + 메트릭

처럼 레이어링하는 것이 좋습니다.

마무리

C++23 std::expected는 예외를 쓰지 않는 코드베이스에서 “실패를 값으로 다루는” 표준 해법을 제공합니다. 여기에 RAII를 결합하면

  • 부분 초기화/중간 실패에도 자원이 자동 정리되고
  • 오류 전파가 타입으로 강제되며
  • 운영 환경에서 원인 추적 가능한 구조화 오류를 일관되게 전달

할 수 있습니다.

정리하면, 예외를 없애는 목적이 단순히 throw를 금지하는 데 그치면 오히려 코드가 장황해질 수 있습니다. 하지만 std::expected를 중심으로 오류 모델을 설계하고, 자원을 소유 타입으로 감싸며, 롤백 가드까지 더하면 예외 없이도 충분히 견고한 자원관리와 오류 처리를 구축할 수 있습니다.