Published on

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

Authors

예외 기반 에러 처리는 편리하지만, 시스템/임베디드/게임 서버처럼 예외를 꺼두거나 예외 비용과 예측 불가능성이 부담인 환경에서는 다른 선택지가 필요합니다. C++23의 std::expected는 “성공 값 또는 에러”를 타입으로 표현해, 실패를 숨기지 않으면서도 제어 흐름을 과도하게 꼬지 않는 균형점을 제공합니다.

이 글에서는 std::expected자원 관리(RAII) 와 결합해, 파일/소켓/FD/핸들 같은 자원을 예외 없이도 안전하게 다루는 실전 패턴을 다룹니다. 특히 “생성 단계에서 실패할 수 있는 RAII 타입”을 어떻게 설계하고, 호출자에게 어떻게 전파할지에 초점을 맞춥니다.

std::expected인가: 실패를 타입으로 고정하기

전통적으로 예외를 쓰지 않는 코드는 다음 중 하나로 기웁니다.

  • 반환값으로 bool 또는 int(errno) + out-parameter
  • 실패 시 nullptr 반환
  • std::optional로 “없음” 표현

하지만 자원 관리에서는 “왜 실패했는지”가 중요합니다. std::optional은 이유를 담기 어렵고, errno/out-parameter는 호출 규약이 느슨해 실수하기 쉽습니다.

std::expected<T, E>는 다음을 보장합니다.

  • 성공 시 T를, 실패 시 E반드시 가진다
  • 호출자는 has_value() 또는 if (exp)로 분기하도록 유도된다
  • 에러 타입 E를 도메인에 맞게 설계할 수 있다

즉, 실패 경로를 코드 리뷰에서 놓치기 어려워지고, 자원 획득 실패를 “정상적인 분기”로 다룰 수 있습니다.

핵심 원칙: RAII는 유지하고, “생성”만 expected로 감싼다

C++에서 자원 관리는 RAII가 정답입니다.

  • 생성자에서 자원을 획득
  • 소멸자에서 자원을 해제

문제는 생성자가 실패할 수 있다는 점입니다. 예외를 쓰면 생성자에서 던지면 되지만, 예외를 금지하면 생성자에서 실패를 표현하기가 어렵습니다.

이때 많이 쓰는 패턴이:

  • 생성자는 private
  • static create(...) 팩토리가 std::expected<RAIIType, Error>를 반환

이렇게 하면 “자원 획득 실패”는 팩토리에서 다루고, 생성된 객체는 언제나 유효한 상태를 보장할 수 있습니다.

예제 1: 파일 디스크립터를 안전하게 감싸기

POSIX open은 실패할 수 있고, 성공해도 close를 반드시 호출해야 합니다. 이를 RAII로 감싸고, 생성은 expected로 처리해 보겠습니다.

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

struct File {
  int fd = -1;

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

  File(File&& other) noexcept : fd(other.fd) { other.fd = -1; }
  File& operator=(File&& other) noexcept {
    if (this != &other) {
      reset();
      fd = other.fd;
      other.fd = -1;
    }
    return *this;
  }

  ~File() { reset(); }

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

private:
  explicit File(int fd_) : fd(fd_) {}

public:
  static std::expected<File, std::error_code> open_readonly(const char* path) {
    int fd = ::open(path, O_RDONLY);
    if (fd == -1) {
      return std::unexpected(std::error_code(errno, std::generic_category()));
    }
    return File(fd);
  }
};

사용 측은 실패를 강제적으로 처리하게 됩니다.

#include <cstdio>

std::expected<std::string, std::error_code> read_first_byte(const char* path) {
  auto f = File::open_readonly(path);
  if (!f) {
    return std::unexpected(f.error());
  }

  char c;
  ssize_t n = ::read(f->fd, &c, 1);
  if (n != 1) {
    return std::unexpected(std::error_code(errno, std::generic_category()));
  }

  return std::string(1, c);
}

int main() {
  auto r = read_first_byte("/tmp/nope");
  if (!r) {
    std::fprintf(stderr, "error: %s\n", r.error().message().c_str());
    return 1;
  }
  std::printf("%s\n", r->c_str());
}

포인트는 다음과 같습니다.

  • File 객체가 만들어졌다면 fd는 유효하거나(정상) move 이후 비어있음(정상)만 존재
  • close 누락이 구조적으로 불가능
  • 실패는 std::error_code로 구체화되어 로그/리트라이/분기 처리가 쉬움

예제 2: “여러 자원”을 단계적으로 획득할 때의 롤백

실제 코드는 파일 하나로 끝나지 않습니다. 예를 들어:

  1. 설정 파일 열기
  2. lock 파일 열기
  3. 임시 파일 생성

중간에 실패하면 앞에서 잡은 자원은 자동 해제되어야 합니다. RAII가 있으니 그냥 지역 변수로 쌓으면 됩니다. expected는 실패 전파만 담당합니다.

#include <expected>
#include <string>
#include <system_error>

struct AppResources {
  File config;
  File lock;

  static std::expected<AppResources, std::error_code> create(const char* cfg, const char* lockPath) {
    auto c = File::open_readonly(cfg);
    if (!c) return std::unexpected(c.error());

    auto l = File::open_readonly(lockPath);
    if (!l) return std::unexpected(l.error());

    // 여기까지 오면 둘 다 확보됨
    return AppResources{std::move(*c), std::move(*l)};
  }
};

중간에 lock 획득이 실패하면 config는 스코프 종료로 자동 해제됩니다. 예외 없이도 롤백이 자연스럽게 됩니다.

에러 타입 설계: error_code vs 커스텀 enum

std::expected의 진짜 장점은 E를 “우리 도메인에 맞게” 설계할 수 있다는 점입니다.

  • OS API 래핑 중심: std::error_code가 적합
  • 비즈니스 규칙 에러: enum + 메시지(또는 에러 구조체)가 적합

예를 들어 파서/설정 로더라면 다음처럼 만들 수 있습니다.

#include <expected>
#include <string>

enum class ConfigErr {
  NotFound,
  PermissionDenied,
  InvalidFormat,
  MissingKey,
};

struct ConfigError {
  ConfigErr code;
  std::string detail;
};

template <class T>
using Exp = std::expected<T, ConfigError>;

이 방식은 호출자에게 “무엇을 처리해야 하는지”를 더 명확히 강제합니다. 운영에서 장애를 추적할 때도 에러 코드가 정규화되어 로그 분석이 쉬워집니다. 회귀 원인을 빠르게 찾는 습관은 git bisect 자동화와도 결이 같습니다. 관련해서는 git bisect run으로 회귀 커밋 10분 추적 자동화도 함께 참고할 만합니다.

expected 전파 패턴: “조기 반환”을 표준화하기

expected를 쓰면 코드가 if (!x) return unexpected(...) 형태로 반복됩니다. 팀 스타일로는 충분히 괜찮지만, 더 정리하고 싶다면 헬퍼를 두는 방법이 있습니다.

C++23에는 표준 TRY 매크로가 없으므로, 최소한의 로컬 매크로를 쓰는 팀도 많습니다.

#define TRY_EXP(name, expr)            \
  auto name##_tmp = (expr);            \
  if (!name##_tmp)                     \
    return std::unexpected(name##_tmp.error()); \
  auto name = std::move(*name##_tmp)

사용 예:

std::expected<AppResources, std::error_code> init(const char* cfg, const char* lockPath) {
  TRY_EXP(config, File::open_readonly(cfg));
  TRY_EXP(lock, File::open_readonly(lockPath));
  return AppResources{std::move(config), std::move(lock)};
}

주의할 점은 매크로 도입 시:

  • 스코프/이름 충돌을 피하도록 접미 변수 사용
  • return 타입이 동일해야 함

매크로가 싫다면, 반복을 받아들이고 “명시성”을 택하는 것도 좋은 선택입니다.

자원관리에서 자주 하는 실수와 expected의 역할

1) 실패를 무시한 채 진행

예외가 없으면 실패가 조용히 누락되기 쉽습니다. expected는 타입 단계에서 실패를 강제합니다.

  • 반환값을 버리면 경고가 안 뜰 수 있으니, [[nodiscard]]를 붙이는 것을 권장합니다.
struct [[nodiscard]] OpenResult {
  std::expected<File, std::error_code> value;
};

다만 실무에서는 타입을 감싸기보다, 함수 자체에 [[nodiscard]]를 붙이는 편이 간단합니다.

[[nodiscard]] std::expected<File, std::error_code> open_readonly(const char* path);

2) 에러 변환 과정에서 정보 유실

errno를 문자열로 바꿔서 던져버리면, 호출자는 분기할 수 없습니다. std::error_code를 유지하고, 로깅 시점에만 메시지화하는 것이 좋습니다.

3) 소멸자에서 실패를 처리하려고 함

소멸자는 실패를 반환할 수 없습니다. 소멸자에서 실패 가능한 작업을 하면 결국 로그만 남기거나 terminate로 이어집니다.

  • 닫기/해제는 “실패해도 어쩔 수 없는” 작업만 소멸자에서 수행
  • 실패가 중요한 close라면 close() 같은 명시적 멤버를 두고, 호출자가 expected<void, E>로 처리
struct Socket {
  int fd = -1;
  ~Socket() { if (fd != -1) ::close(fd); }

  std::expected<void, std::error_code> close_strict() {
    if (fd == -1) return {};
    if (::close(fd) == -1) {
      return std::unexpected(std::error_code(errno, std::generic_category()));
    }
    fd = -1;
    return {};
  }
};

std::expected와 성능/가독성 균형

  • 예외를 끈 빌드에서도 실패 전달이 깔끔함
  • 분기 비용은 명시적이며, 성공 경로에서도 expected 객체가 한 번 생성됨
  • 에러 타입 E가 무겁다면(큰 문자열 등) 이동/복사 비용이 생길 수 있으니, 가능한 한 E는 작게 유지하고 상세 메시지는 필요 시 생성하는 전략이 좋습니다

실제 운영 환경에서는 “성능 문제를 감으로 추측하지 말고 관측하라”가 원칙입니다. 예를 들어 서버가 느려졌을 때 DB 쿼리 병목을 pg_stat_statements로 추적하듯, C++ 코드도 프로파일러와 트레이싱으로 병목을 확인해야 합니다. 관측/추적 관점은 PostgreSQL 쿼리 폭주? pg_stat_statements로 병목 추적 글의 접근법과도 통합니다.

실전 적용 체크리스트

  1. 자원 타입은 RAII로: 소멸자에서 해제, move-only 권장
  2. 생성 실패는 팩토리로: static createstd::expected 반환
  3. 에러 타입을 표준화: OS 래핑은 std::error_code, 도메인은 커스텀 구조체
  4. 전파 규칙 통일: 조기 반환 패턴(또는 팀 매크로)을 합의
  5. 소멸자에서 실패 금지: 실패가 중요하면 명시적 close_strict() 같은 API 제공

마무리

std::expected는 예외를 대체하는 “또 다른 만능 에러 처리”라기보다, 실패를 타입으로 고정해 실수를 줄이는 도구에 가깝습니다. 여기에 RAII를 결합하면, 자원 누수/정리 누락 같은 고질적인 버그를 구조적으로 제거하면서도, 예외가 없는 환경에서 충분히 읽기 좋은 코드를 만들 수 있습니다.

다음 단계로는:

  • 프로젝트 전반의 에러 타입을 어떻게 계층화할지
  • expected 기반 API에서 로깅/메트릭을 어디에 둘지
  • 테스트에서 실패 케이스를 어떻게 쉽게 만들지

를 정리해두면, 팀 단위로도 일관된 “예외 없는 안전한 자원관리” 스타일을 구축할 수 있습니다.