Published on

C++23 std - -expected로 예외 제거·자원누수 막기

Authors

서버/에이전트/CLI 같이 실패가 자주 발생하는 코드에서 예외는 강력하지만, 팀 규칙(예외 금지), 성능/바이너리 크기, 경계(FFI, 코루틴, 스레드)에서의 복잡도 때문에 오히려 유지보수를 어렵게 만들기도 합니다. C++23의 std::expected는 “성공 값 또는 오류 값”을 타입으로 표현해, 예외 없이도 읽기 좋은 오류 전파를 가능하게 합니다. 특히 RAII와 결합하면 “실패 시 누수”와 “부분 초기화(half-constructed) 상태”를 구조적으로 줄일 수 있습니다.

이 글에서는 std::expected의 핵심 사용법과, 예외 제거가 실제로 자원 누수를 줄이는 이유, 그리고 실무에서 바로 쓰는 조합 패턴(파일/소켓/DB 핸들 등)을 코드로 정리합니다.

std::expected가 해결하는 문제: 예외 흐름의 암묵성

예외 기반 코드는 호출부가 실패 경로를 놓치기 쉽습니다. 또한 다음 상황에서 예외는 비용이 커질 수 있습니다.

  • 예외 비활성화 빌드(-fno-exceptions)를 요구하는 환경
  • 라이브러리 경계를 넘는 예외 전파(ABI/FFI) 금지
  • 예외 안전성(특히 강한 보장) 설계가 부담
  • 실패가 “정상적인 분기”인 도메인(파싱, 네트워크 재시도, 캐시 미스 등)

std::expected는 실패를 “반드시 처리해야 하는 반환값”으로 바꿔, 호출부가 실패를 의식적으로 다루게 합니다.

기본 형태: expected<T, E>

  • 성공: T
  • 실패: E

E는 보통 에러 코드/도메인 에러 타입을 둡니다. 문자열만으로 처리하면 분류가 흐려지므로, 최소한 enum class와 메시지(선택)를 같이 두는 형태가 실무에서 안정적입니다.

#include <expected>
#include <string>

enum class Errc {
  io_error,
  parse_error,
  not_found,
};

struct Error {
  Errc code;
  std::string message;
};

using std::expected;
using std::unexpected;

expected<int, Error> parse_int(std::string_view s) {
  int v = 0;
  // 단순 예시
  for (char c : s) {
    if (c < '0' || c > '9') {
      return unexpected(Error{Errc::parse_error, "non-digit"});
    }
    v = v * 10 + (c - '0');
  }
  return v;
}

int main() {
  auto r = parse_int("12a");
  if (!r) {
    // r.error()로 오류 접근
    return 1;
  }
  int value = *r; // 또는 r.value()
}

핵심은 “실패가 반환 타입에 녹아 있다”는 점입니다. 호출자는 if (!r) 같은 패턴으로 명시적으로 분기합니다.

예외 제거가 자원 누수를 줄이는 이유

예외 자체가 누수를 만든다기보다, 예외 경로를 고려하지 않은 코드가 누수를 만듭니다. 특히 다음이 흔합니다.

  • new/malloc 후 중간 단계에서 예외 발생
  • 여러 자원을 순차 획득하다가 중간 실패
  • return이 많아지며 정리 코드가 누락

std::expected로 바꿔도 자원 관리는 여전히 RAII가 핵심입니다. 다만 expected는 “실패가 일반적인 반환”이므로, 자원 획득과 실패 반환을 더 촘촘하게 결합하기 쉬워집니다.

안티패턴: 수동 정리 + 조기 반환

#include <cstdio>
#include <expected>

struct Error { int code; };
using std::expected;
using std::unexpected;

expected<long, Error> read_first_line_len(const char* path) {
  std::FILE* f = std::fopen(path, "r");
  if (!f) return unexpected(Error{1});

  char buf[256];
  if (!std::fgets(buf, sizeof(buf), f)) {
    // fclose 누락 위험
    return unexpected(Error{2});
  }

  std::fclose(f);
  return std::char_traits<char>::length(buf);
}

예외가 없더라도, 조기 반환이 많아지면 fclose 누락이 생기기 쉽습니다.

개선: RAII 래퍼 + expected

#include <cstdio>
#include <expected>
#include <utility>

struct Error { int code; };
using std::expected;
using std::unexpected;

struct File {
  std::FILE* f{nullptr};
  explicit File(std::FILE* p) : f(p) {}
  File(File&& other) noexcept : f(std::exchange(other.f, nullptr)) {}
  File& operator=(File&& other) noexcept {
    if (this != &other) {
      if (f) std::fclose(f);
      f = std::exchange(other.f, nullptr);
    }
    return *this;
  }
  ~File() { if (f) std::fclose(f); }
  File(const File&) = delete;
  File& operator=(const File&) = delete;
};

expected<File, Error> open_file(const char* path) {
  if (auto* f = std::fopen(path, "r")) return File{f};
  return unexpected(Error{1});
}

expected<long, Error> read_first_line_len(const char* path) {
  auto file = open_file(path);
  if (!file) return unexpected(file.error());

  char buf[256];
  if (!std::fgets(buf, sizeof(buf), file->f)) {
    return unexpected(Error{2});
  }

  return std::char_traits<char>::length(buf);
}

이제 실패 경로가 몇 개든 File 소멸자가 정리를 보장합니다. expected는 “실패를 값으로” 전달하고, RAII는 “정리를 타입으로” 보장합니다.

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

여러 자원을 순서대로 열어야 하는 경우(설정 파일 읽기, 소켓 연결, 핸드셰이크 등)에는 “각 단계가 expected를 반환”하도록 쪼개면, 실패 지점이 명확하고 누수 가능성이 줄어듭니다.

#include <expected>
#include <string>

enum class Errc { open_failed, read_failed, parse_failed };
struct Error { Errc code; std::string message; };

using std::expected;
using std::unexpected;

struct Config { int port; };

expected<std::string, Error> read_text_file(std::string path);
expected<Config, Error> parse_config(std::string_view text);

expected<Config, Error> load_config(std::string path) {
  auto text = read_text_file(std::move(path));
  if (!text) return unexpected(text.error());

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

  return *cfg;
}

이 패턴의 장점은 테스트가 쉬워지고(각 단계 단위 테스트), 실패가 “정상 흐름”으로 모델링된다는 점입니다.

실전 패턴 2: transform/and_then로 보일러플레이트 줄이기

C++23 std::expected는 모나딕 연산을 제공합니다.

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

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

#include <expected>
#include <string>

struct Error { std::string message; };
using std::expected;
using std::unexpected;

expected<std::string, Error> read_text_file(std::string path);
expected<int, Error> parse_port(std::string_view);

expected<int, Error> load_port(std::string path) {
  return read_text_file(std::move(path))
    .and_then([](const std::string& txt) {
      return parse_port(txt);
    })
    .or_else([](const Error& e) {
      // 여기서 로깅/메시지 보강 가능
      return expected<int, Error>(unexpected(Error{"load_port: " + e.message}));
    });
}

if (!r) return unexpected(r.error()); 반복이 줄어들고 파이프라인이 선명해집니다.

실전 패턴 3: 오류 타입을 “도메인 친화적으로” 설계하기

expected<T, E>에서 E는 단순 문자열보다 다음을 담는 것이 좋습니다.

  • 분기 가능한 코드(enum class)
  • 사람이 읽을 메시지(옵션)
  • 원인 체인(옵션)
  • 외부 시스템 에러(errno, OS 에러 코드) 보존
#include <expected>
#include <string>

enum class Errc { io, invalid_input, timeout };

struct Error {
  Errc code;
  int sys_code;          // 예: errno, GetLastError 등
  std::string message;   // 사람이 읽을 메시지
};

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

이렇게 해두면 호출부가 switch (err.code)로 복구 전략(재시도/폴백/즉시 실패)을 명확히 구현할 수 있습니다. 이런 “복구 전략 분기”는 대규모 시스템에서 중복 호출/중복 처리 같은 문제를 줄이는 데도 도움이 됩니다. 분산 환경에서의 중복 처리 관점은 MSA 트랜잭션 Saga 보상 실패·중복처리 해결도 함께 참고할 만합니다.

실전 패턴 4: 경계(FFI, 스레드, 코루틴)에서 예외 대신 expected

예외는 경계를 넘을 때 비용이 커집니다.

  • C API로 노출하는 라이브러리: 예외 전파 금지
  • 스레드 entry: 예외가 스레드 밖으로 전파되면 std::terminate
  • 네트워크/IO 루프: 실패가 빈번한데 예외는 과한 경우

이때 내부는 예외를 쓰더라도 경계에서 expected로 변환하는 전략이 유효합니다.

#include <expected>
#include <string>
#include <exception>

struct Error { std::string message; };
using std::expected;
using std::unexpected;

expected<int, Error> boundary_call() noexcept {
  try {
    // 내부 구현은 예외를 써도 됨
    return 42;
  } catch (const std::exception& e) {
    return unexpected(Error{e.what()});
  } catch (...) {
    return unexpected(Error{"unknown error"});
  }
}

이렇게 하면 외부 호출자는 예외 정책과 무관하게 동일한 방식으로 실패를 처리할 수 있습니다.

자원 누수를 더 줄이는 조합: expected + 커스텀 RAII 핸들

파일 외에도 소켓, FD, mmap, OpenSSL 객체 등은 “반드시 해제해야 하는 핸들”입니다. 표준 라이브러리의 std::unique_ptr에 커스텀 deleter를 붙여 RAII를 만들고, 생성 함수를 expected로 감싸면 가장 실용적입니다.

#include <expected>
#include <memory>

struct Error { int code; };
using std::expected;
using std::unexpected;

struct CHandle;
extern "C" {
  CHandle* ch_create();
  void ch_destroy(CHandle*);
}

struct HandleDeleter {
  void operator()(CHandle* p) const noexcept { ch_destroy(p); }
};

using HandlePtr = std::unique_ptr<CHandle, HandleDeleter>;

expected<HandlePtr, Error> make_handle() {
  if (auto* p = ch_create()) return HandlePtr{p};
  return unexpected(Error{1});
}

expected<int, Error> use_handle() {
  auto h = make_handle();
  if (!h) return unexpected(h.error());

  // *h 는 unique_ptr
  // 성공/실패 어떤 경로든 자동 해제
  return 0;
}

핸들 해제 누락은 대부분 “소유권이 불명확”해서 생깁니다. RAII로 소유권을 타입에 고정하고, expected로 실패를 값으로 고정하면, 누수 가능성이 구조적으로 낮아집니다.

예외를 완전히 없애야 할까?

반드시 그렇지는 않습니다. 실무적으로는 다음 기준이 합리적입니다.

  • 라이브러리 내부: 예외를 써도 되지만, 경계에서 expected로 변환
  • 애플리케이션 레이어: 실패가 빈번하고 복구가 중요한 부분은 expected
  • 프로그래밍 오류(불변식 위반): 예외보다 assert/std::terminate가 더 명확할 때도 많음

즉, expected는 “예외의 대체재”라기보다 “실패를 타입으로 모델링하는 도구”에 가깝습니다.

팀에 도입할 때의 체크리스트

  1. Result 별칭 통일: using Result = std::expected<T, Error> 형태로 도메인 표준화
  2. 에러 설계 합의: 코드(enum class)와 메시지, 시스템 에러 보존 여부
  3. RAII 우선: expected만으로 누수는 막지 못함. 핸들/FD는 RAII로 감싸기
  4. 로깅 정책: or_else에서 로깅할지, 상위에서 한 번만 로깅할지(중복 로깅 방지)
  5. 성능/가독성 균형: 파이프라인이 과도하게 길어지면 if (!r)가 더 읽기 쉬울 수 있음

대규모 코드베이스에서 “복구 가능한 실패”를 값으로 전파하는 습관은, 다른 언어 생태계에서 메모리 폭증/중복 호출 같은 문제를 줄이기 위해 패턴을 정리하는 것과 결이 같습니다. 예를 들어 데이터 처리에서 실패/부하를 구조적으로 줄이는 접근은 Java Stream groupBy 메모리 폭증 막는 법처럼 원인을 타입/구조로 드러내는 방식과 통합니다.

마무리

C++23 std::expected는 예외를 “금지”하는 도구가 아니라, 실패를 명시적으로 모델링해 호출부의 의도를 선명하게 만들고, RAII와 결합해 자원 해제 누락 가능성을 낮추는 도구입니다.

  • 실패가 잦고 복구가 중요한 코드: expected가 특히 잘 맞음
  • 자원 누수 방지의 핵심: 여전히 RAII이지만, expected가 실패 경로를 단순화
  • 경계에서의 안정성: 예외 대신 expected로 실패를 고정하면 ABI/스레드/FFI에서 안전

다음 단계로는, 프로젝트에 맞는 Error 타입(코드 체계, errno 보존, 원인 체인)을 정하고, 자원 핸들을 RAII로 감싼 뒤, 생성/획득 함수부터 expected로 바꿔나가면 가장 효과가 큽니다.