Published on

C++23 std - -expected로 예외 없는 오류처리·누수 방지

Authors

예외 기반 오류처리는 강력하지만, 코드베이스가 커질수록 throw 경로가 어디로 튈지 추적이 어렵고(특히 경계 계층에서), 성능/바이너리/ABI 제약 때문에 예외를 꺼야 하는 환경도 많습니다(게임, 임베디드, 저지, 일부 서버 런타임 설정 등). C++23의 std::expected는 “실패를 값으로” 표현해 제어 흐름을 명시적으로 만들고, RAII와 결합했을 때 리소스 누수까지 구조적으로 차단하는 도구입니다.

이 글에서는 std::expected의 핵심 개념, 예외 없는 오류처리 설계 팁, 그리고 누수 방지(파일 디스크립터/메모리/핸들) 관점에서의 패턴을 실전 코드로 정리합니다.

리소스 누수는 결국 “실패 경로에서 정리가 빠지는 것”이 핵심입니다. 운영에서 Too many open files 같은 장애로 튀어나오기도 하죠. 관련해서는 리눅스 Too many open files 즉시 진단·해결도 함께 참고하면 좋습니다.

std::expected 한 줄 정의

std::expected<T, E>는 결과가 성공이면 T, 실패면 E를 담는 타입입니다.

  • 성공: expected 안에 값 T가 존재
  • 실패: expected 안에 오류 E가 존재
  • 호출자는 성공/실패를 분기 처리해야 하며, 실패를 무시하기가 더 어려워집니다

std::optional<T>가 “값이 있거나 없다”라면, std::expected<T, E>는 “값이 있거나, 왜 없는지(오류)가 있다”입니다.

왜 예외 대신 expected인가

1) 실패가 타입에 드러난다

함수 시그니처만 봐도 실패 가능성이 드러납니다.

  • 예외: 시그니처에 실패가 드러나지 않는 경우가 많음
  • expected: 반환 타입이 실패 가능성을 강제

2) 경계 계층에서의 정책이 명확해진다

예외를 쓰더라도 결국 경계에서 잡아 로그/매핑/응답 코드를 만들게 됩니다. expected는 그 “매핑”을 더 일찍, 더 명시적으로 하게 만듭니다.

3) 누수 방지에 유리한 구조를 만든다

“실패하면 즉시 반환” 패턴이 자연스럽고, RAII 객체가 스코프 종료로 정리되므로 실패 경로에서의 정리 누락을 줄입니다.

기본 사용법: 성공/실패 만들기

아래 예시는 문자열을 정수로 파싱하되, 실패 이유를 코드로 반환합니다.

#include <expected>
#include <string>
#include <charconv>

enum class ParseErr {
  Empty,
  Invalid,
  OutOfRange
};

std::expected<int, ParseErr> parse_int(std::string_view s) {
  if (s.empty()) return std::unexpected(ParseErr::Empty);

  int value = 0;
  auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);

  if (ec == std::errc::invalid_argument) {
    return std::unexpected(ParseErr::Invalid);
  }
  if (ec == std::errc::result_out_of_range) {
    return std::unexpected(ParseErr::OutOfRange);
  }
  if (ptr != s.data() + s.size()) {
    // 뒤에 쓰레기 문자가 붙은 경우
    return std::unexpected(ParseErr::Invalid);
  }
  return value;
}

int main() {
  auto r = parse_int("123");
  if (!r) {
    // r.error()로 오류 처리
    return 1;
  }
  int x = *r; // 또는 r.value()
  (void)x;
}

포인트는 std::unexpected(err)로 실패를 만들고, 호출자는 if (!r)로 실패를 확인한다는 점입니다.

오류 타입 E 설계: enum만으로는 부족할 때

실무에서는 “왜 실패했는지”를 로그/메트릭/사용자 메시지로 흘려보내야 합니다. 단순 enum은 가볍지만, 디버깅 정보가 부족합니다.

다음처럼 오류에 부가 정보를 담는 구조체를 쓰는 패턴이 흔합니다.

#include <expected>
#include <string>

enum class Errc {
  Io,
  InvalidInput,
  NotFound
};

struct Error {
  Errc code;
  std::string message;
  int sys_errno = 0; // 필요 시
};

using Result = std::expected<void, Error>;
  • code: 분기/정책 결정용
  • message: 로깅/디버깅용
  • sys_errno: errno나 OS 에러코드 매핑용

누수 방지 1: 파일 디스크립터를 RAII로 감싸고 expected로 전파

누수는 “열고 닫지 않음”에서 시작합니다. 예외를 끄고 return -1 같은 방식으로 흘리면, 중간에 빠져나가는 경로가 늘어날수록 close 누락이 생기기 쉽습니다.

아래는 POSIX 파일 디스크립터를 RAII로 감싼 뒤, 열기 함수는 expected로 반환하는 예시입니다.

#include <expected>
#include <string>
#include <utility>
#include <cerrno>

#include <fcntl.h>
#include <unistd.h>

struct Error {
  int code;            // 예: errno
  std::string message; // 로그용
};

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_(std::exchange(other.fd_, -1)) {}
  UniqueFd& operator=(UniqueFd&& other) noexcept {
    if (this != &other) {
      reset();
      fd_ = std::exchange(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, Error> open_readonly(const char* path) {
  int fd = ::open(path, O_RDONLY);
  if (fd == -1) {
    return std::unexpected(Error{errno, "open failed", errno});
  }
  return UniqueFd(fd);
}

std::expected<std::string, Error> read_all(const char* path) {
  auto fd = open_readonly(path);
  if (!fd) return std::unexpected(fd.error());

  std::string out;
  char buf[4096];
  while (true) {
    ssize_t n = ::read(fd->get(), buf, sizeof(buf));
    if (n == 0) break;
    if (n < 0) {
      return std::unexpected(Error{errno, "read failed", errno});
    }
    out.append(buf, static_cast<size_t>(n));
  }
  // 여기서 실패든 성공이든 스코프 종료 시 UniqueFd가 close
  return out;
}

핵심은 이겁니다.

  • 리소스 획득은 RAII 타입(여기서는 UniqueFd)이 담당
  • 실패 전파는 expected가 담당
  • “실패 경로에서도 자동 정리”가 구조적으로 보장

운영에서 FD 누수가 쌓이면 결국 프로세스가 EMFILE로 무너집니다. 이때 사후 진단도 중요하지만, 애초에 이런 구조로 “실패 경로 정리 누락”을 줄이는 게 더 싸게 먹힙니다.

누수 방지 2: 여러 단계 작업을 “조기 반환”으로 안전하게 연결

예외 없는 코드에서 흔히 보이는 실수는 다음입니다.

  • 단계 A 성공 후 리소스 획득
  • 단계 B 실패
  • 단계 A에서 얻은 리소스 해제가 누락

expected는 단계별로 if (!r) return unexpected(...)를 쓰게 만들고, RAII가 결합되면 누락 여지가 크게 줄어듭니다.

예시로 “설정 파일 읽기 → 파싱 → 유효성 검사”를 연결해봅니다.

#include <expected>
#include <string>
#include <string_view>

struct Error {
  std::string message;
};

struct Config {
  int port;
};

std::expected<std::string, Error> load_text(const char* path);
std::expected<Config, Error> parse_config(std::string_view text);
std::expected<void, Error> validate(const Config& cfg);

std::expected<Config, Error> load_config(const char* path) {
  auto text = load_text(path);
  if (!text) return std::unexpected(text.error());

  auto cfg = parse_config(*text);
  if (!cfg) return std::unexpected(cfg.error());

  auto ok = validate(*cfg);
  if (!ok) return std::unexpected(ok.error());

  return *cfg;
}

이 스타일은 장황해 보이지만, 실패 전파가 눈에 보이고(숨은 throw가 없음), 중간 산출물이 지역 변수로 묶이기 때문에 수명 관리가 단순해집니다.

value()는 마지막에만: 안전한 접근 규칙

expected에서 값을 꺼내는 방법은 여러 가지가 있습니다.

  • *r 또는 r->member: 성공을 이미 확인한 뒤 사용
  • r.value(): 실패 시 예외(bad_expected_access)를 던질 수 있음

예외 없는 정책을 유지하려면 value() 남발을 피하고, 성공 체크 후 역참조를 기본으로 삼는 편이 안전합니다.

경계에서의 변환: expected와 로깅/응답 매핑

expected는 내부 도메인에서는 좋지만, 결국 외부로는 다른 형태로 내보내야 합니다.

  • CLI: 종료 코드와 stderr
  • 서버: HTTP 상태 코드와 JSON
  • 라이브러리: 오류 코드/메시지

이때 중요한 건 “경계에서 한 번만 변환”하는 것입니다. 내부는 expected로 유지하고, 맨 바깥에서 정책적으로 매핑하세요.

예를 들어 Error.code를 기반으로 상태 코드를 정하는 식입니다.

또한 장애 대응 관점에서는 리소스 고갈을 빨리 감지해야 합니다. 파일/소켓/핸들 누수는 종종 운영 장애로 이어지고, 다른 형태로는 메모리 누수로도 나타납니다. 메모리 누수 관점의 실전 대응은 AutoGPT 메모리 누수? 벡터DB TTL·압축 실전 같은 글의 접근(수명/TTL/압축 등)도 사고방식에 도움이 됩니다.

expected를 도입할 때의 현실적인 가이드

1) “실패가 흔한 함수”부터 시작

  • 파싱
  • I/O
  • 네트워크
  • 외부 프로세스 호출

이런 곳은 실패가 정상 흐름에 가깝기 때문에 expected의 효용이 큽니다.

2) 반환 타입 폭발을 막기 위해 별칭을 둔다

프로젝트 공통 오류 타입을 정하고 using ResultT = std::expected<T, Error> 같은 별칭을 두면 시그니처가 안정됩니다.

#include <expected>
#include <string>

enum class Errc { Io, Parse, Invalid };
struct Error { Errc code; std::string message; };

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

using Status = std::expected<void, Error>;

3) 예외와 혼용한다면 “경계”를 정한다

  • 내부는 예외 금지, expected만 사용
  • 외부 라이브러리가 예외를 던지면 경계에서 try/catchexpected로 변환

이렇게 하면 예외가 코드베이스 전체로 퍼지는 것을 막을 수 있습니다.

마무리: expected는 오류처리 도구이자 수명관리 촉진제

std::expected는 단순히 “예외를 안 쓰는 대안”이 아니라,

  • 실패 경로를 타입으로 드러내고
  • 조기 반환을 표준 흐름으로 만들며
  • RAII 기반 정리를 자연스럽게 유도해

리소스 누수를 줄이는 구조를 만들어줍니다.

특히 파일 디스크립터/소켓/락/메모리 같은 리소스는 “정상 흐름”보다 “실패 흐름”에서 새는 경우가 훨씬 많습니다. expected로 실패를 값으로 다루고, 획득한 리소스는 RAII로 소유하게 만들면, 운영에서 터지는 고갈 장애를 설계 단계에서부터 줄일 수 있습니다.

다음 단계로는 프로젝트에 맞는 Error 모델(코드 체계, 메시지 정책, OS 에러 매핑)을 먼저 정한 뒤, I/O 계층부터 expected를 얇게 도입해보는 것을 권합니다.