Published on

C++23 std - -expected로 예외 없이 에러처리+회수

Authors

서버/시스템 코드에서 예외를 꺼리는 팀은 꽤 많습니다. 성능 이슈(특히 예외 테이블/언와인딩), ABI 경계(플러그인, C API), 실시간 제약, 혹은 단순히 “예외가 어디서 터질지 모르겠다”는 운영 불안 때문입니다. 그렇다고 bool/에러코드만으로 흐름 제어를 하면, 실패 경로가 늘어날수록 코드가 급격히 지저분해지고(일명 에러 처리 보일러플레이트), 리소스 회수 누락이 생기기 쉽습니다.

C++23의 std::expected는 “값 또는 에러”를 타입 시스템으로 표현해, 예외 없이도 명시적이고 조합 가능한 오류 전파를 가능하게 합니다. 그리고 C++의 강력한 무기인 RAII를 결합하면, 실패하더라도 리소스 회수는 자동으로 보장됩니다.

이 글에서는 std::expected의 핵심 사용법과, 파일/소켓/메모리 같은 리소스를 다루는 실전 패턴을 예제로 정리합니다. (운영 관점의 “실패를 설계”하는 접근은 OpenAI Responses API 503 멈춤 - 재시도·폴백 설계 글의 사고방식과도 통합니다.)

std::expected인가

기존 방식의 한계

  • 예외: 호출자 코드가 깔끔해지는 대신, 실패 경로가 런타임에 숨어들고(특히 경계가 많을수록), 예외를 금지한 코드베이스에서는 사용 자체가 불가합니다.
  • 에러코드/std::optional: 성공/실패는 표현 가능하지만, 실패 이유가 약해지거나(단순 nullopt), 호출자에서 매번 에러 확인/전파를 반복하게 됩니다.

std::expected<T, E>는 성공 시 T, 실패 시 E를 담습니다. 실패 이유를 타입으로 강제하면서도, 호출자는 예외 없이 “값이 있으면 진행, 없으면 에러 전파”를 구조적으로 작성할 수 있습니다.

std::expected가 주는 설계 이점

  • 에러가 값으로 다뤄져 제어 흐름이 명시적
  • 함수 시그니처만 봐도 실패 가능성이 드러남
  • E에 도메인 에러(코드, 메시지, 컨텍스트)를 담아 관측 가능성을 개선
  • RAII와 결합 시 실패 경로에서도 리소스 회수 자동화

기본 사용법: 값 꺼내기와 에러 전파

아래는 파일을 열고 내용을 읽는 예시입니다. 실패하면 std::error_code를 반환합니다.

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

std::expected<std::string, std::error_code>
read_all_text(const std::string& path) {
    std::ifstream ifs(path, std::ios::binary);
    if (!ifs) {
        return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
    }

    std::string data((std::istreambuf_iterator<char>(ifs)),
                     std::istreambuf_iterator<char>());

    if (ifs.bad()) {
        return std::unexpected(std::make_error_code(std::errc::io_error));
    }

    return data;
}

std::expected<size_t, std::error_code>
count_lines(const std::string& path) {
    auto text = read_all_text(path);
    if (!text) {
        return std::unexpected(text.error());
    }

    size_t lines = 0;
    for (char c : *text) {
        if (c == '\n') ++lines;
    }
    return lines;
}

포인트는 2가지입니다.

  • 실패는 std::unexpected(e)로 만든다.
  • 호출자는 if (!exp)로 실패를 감지하고 exp.error()를 전파한다.

여기까지만 해도 bool/에러코드보다 낫지만, “전파 보일러플레이트”는 여전히 남아 있습니다. 이 문제는 아래에서 패턴으로 줄입니다.

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

std::error_code를 쓰면 좋은 경우

  • OS/표준 라이브러리 에러와 자연스럽게 맞물릴 때
  • 로깅/모니터링에서 숫자 코드가 유용할 때
  • C API 경계에서 매핑이 쉬울 때

커스텀 에러가 필요한 경우

  • 도메인 규칙 위반(예: 설정 파일 스키마 오류)
  • 실패 원인에 컨텍스트(파일 경로, 요청 ID, 단계)를 붙이고 싶을 때

예시로 “어느 단계에서 실패했는지”를 담는 에러를 만들어봅니다.

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

enum class stage {
    open,
    read,
    parse
};

struct app_error {
    stage where;
    std::error_code ec;
    std::string context;
};

using exp_string = std::expected<std::string, app_error>;

static app_error make_err(stage s, std::errc c, std::string ctx) {
    return app_error{ s, std::make_error_code(c), std::move(ctx) };
}

이렇게 하면 운영 로그에서 “실패 위치 + OS 에러 + 컨텍스트”를 함께 남길 수 있어, 재현이 어려운 문제(특히 배포 후)에서 진단 속도가 크게 좋아집니다.

핵심: 예외 없이도 리소스 회수는 RAII로 끝낸다

std::expected는 에러 전파를 담당하고, 리소스 회수는 RAII가 담당합니다. 이 둘을 섞으면 “실패 경로에서 누수/미회수”를 상당히 줄일 수 있습니다.

예시 1) unique_ptr + 커스텀 deleter로 C 리소스 감싸기

C 라이브러리 핸들을 반환하는 API를 감싸는 전형적인 상황입니다.

#include <expected>
#include <memory>
#include <system_error>

extern "C" {
    struct c_handle;
    c_handle* c_open(const char* path);
    void c_close(c_handle* h);
}

struct handle_deleter {
    void operator()(c_handle* h) const noexcept {
        if (h) c_close(h);
    }
};

using handle_ptr = std::unique_ptr<c_handle, handle_deleter>;

std::expected<handle_ptr, std::error_code>
open_handle(const char* path) {
    if (auto* raw = c_open(path)) {
        return handle_ptr(raw);
    }
    return std::unexpected(std::make_error_code(std::errc::io_error));
}

std::expected<int, std::error_code>
use_handle(const char* path) {
    auto h = open_handle(path);
    if (!h) return std::unexpected(h.error());

    // 여기서부터는 *h가 유효한 핸들
    // 실패로 조기 반환하더라도 handle_ptr의 deleter가 자동 호출됨

    return 0;
}

이 패턴의 장점은 명확합니다.

  • 조기 반환이 많아져도 누수 위험이 낮음
  • 함수가 실패할 수 있다는 사실이 expected에 남음
  • C 자원도 C++ 자원처럼 다룰 수 있음

예시 2) std::scope_exit로 “부분 성공 후 롤백” 만들기

C++23에는 std::scope_exit가 표준에 들어왔고(헤더 scope), 예외를 쓰지 않더라도 스코프 종료 시 정리 작업을 강제할 수 있습니다. 특히 “중간 단계까지 성공했는데 다음 단계에서 실패”하는 경우 유용합니다.

#include <expected>
#include <scope>
#include <system_error>

struct tmp_file {
    int fd;
};

std::expected<tmp_file, std::error_code> create_tmp();
std::expected<void, std::error_code> write_tmp(const tmp_file&);
std::expected<void, std::error_code> commit_tmp(const tmp_file&);
void remove_tmp(const tmp_file&) noexcept;

std::expected<void, std::error_code> build_artifact() {
    auto t = create_tmp();
    if (!t) return std::unexpected(t.error());

    auto rollback = std::scope_exit([&] { remove_tmp(*t); });

    if (auto w = write_tmp(*t); !w) return std::unexpected(w.error());
    if (auto c = commit_tmp(*t); !c) return std::unexpected(c.error());

    // 성공했으면 롤백 해제(임시파일 제거를 하지 않도록)
    rollback.release();
    return {};
}

이 코드는 예외가 없어도 트랜잭션 같은 “보상(rollback)”을 간단히 표현합니다. 분산 트랜잭션에서 보상 개념이 중요하듯(MSA SAGA 보상 트랜잭션 중복 실행 방지법), 로컬 리소스에서도 같은 사고방식이 강력합니다.

전파 보일러플레이트 줄이기: 작은 헬퍼로 TRY 만들기

표준에는 아직 Rust의 ? 같은 문법이 없습니다. 하지만 매번

  • auto x = f(); if (!x) return unexpected(x.error()); 를 쓰는 건 피곤합니다.

매크로를 싫어하는 팀도 많지만, “에러 전파”만큼은 매크로가 실용적인 경우가 많습니다.

#define TRY_ASSIGN(lhs, expr)                \
    auto _tmp_##lhs = (expr);                \
    if (!_tmp_##lhs)                         \
        return std::unexpected(_tmp_##lhs.error()); \
    lhs = std::move(*_tmp_##lhs)

#define TRY(expr)                            \
    do {                                     \
        auto _tmp = (expr);                  \
        if (!_tmp)                           \
            return std::unexpected(_tmp.error()); \
    } while (0)

사용 예:

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

std::expected<int, std::error_code> parse_int(std::string_view);
std::expected<std::string, std::error_code> read_all_text(const std::string&);

std::expected<int, std::error_code> read_and_parse(const std::string& path) {
    std::string text;
    TRY_ASSIGN(text, read_all_text(path));

    int value;
    TRY_ASSIGN(value, parse_int(text));

    return value;
}

이렇게 하면 expected 기반 코드가 예외 기반만큼(혹은 그 이상으로) 읽기 쉬워집니다.

“리소스 + 값”을 함께 반환하는 설계

실전에서는 “열린 핸들 + 파싱된 메타데이터”처럼 여러 결과를 함께 반환하고 싶습니다. 이때 expectedT에 구조체를 넣으면 됩니다.

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

struct connection;
struct conn_deleter { void operator()(connection*) const noexcept; };
using conn_ptr = std::unique_ptr<connection, conn_deleter>;

struct session {
    conn_ptr conn;
    std::string peer;
};

std::expected<session, std::error_code> open_session();

std::expected<void, std::error_code> run() {
    auto s = open_session();
    if (!s) return std::unexpected(s.error());

    // s->conn은 RAII로 자동 close
    // s->peer 같은 부가정보도 함께 전달

    return {};
}

이 방식은 “리소스 소유권이 어디에 있나”를 코드로 고정시켜, 운영 중 발생하는 누수/이중 해제 같은 사고를 줄입니다. 고루틴 누수 진단처럼(Go 고루틴 누수 5분 진단 - pprof·채널닫기) 리소스는 결국 관측 가능한 장애로 이어지기 때문에, 소유권을 타입으로 잠그는 건 큰 가치가 있습니다.

예외를 완전히 배제할 때의 주의점

소멸자에서 실패를 표현하지 말 것

RAII의 소멸자는 noexcept가 기본 기대치입니다. 소멸 중 실패(예: close 실패)를 “반환”할 수 없으니, 다음 중 하나로 정책을 정하세요.

  • close 실패는 무시(대부분의 close는 실패해도 회복 불가)
  • 로깅만 한다
  • 명시적 flush/commit 함수를 두고, 그 함수는 expected로 실패를 반환한다

에러 값의 크기/복사 비용

E에 큰 문자열을 넣으면 전파 비용이 커질 수 있습니다.

  • std::error_code + 필요 시 별도 로깅
  • 혹은 작은 에러 구조체(코드, enum, 짧은 컨텍스트)로 유지

ABI 경계에서의 전략

  • 라이브러리 내부는 expected로 작성
  • 외부 C API에는 expected를 노출하지 말고, 에러코드/아웃 파라미터로 변환

정리: expected는 “에러를 값으로”, RAII는 “회수를 자동으로”

  • std::expected는 예외 없이도 실패를 타입으로 강제해, 호출자에게 “반드시 처리하라”는 압력을 줍니다.
  • 리소스 회수는 unique_ptr 커스텀 deleter, RAII 래퍼 타입, std::scope_exit로 구조화하면 됩니다.
  • 전파 보일러플레이트는 TRY/TRY_ASSIGN 같은 작은 헬퍼로 크게 줄일 수 있습니다.

결국 목표는 “실패해도 안전한 코드”입니다. 예외를 쓰지 않더라도, std::expected와 RAII를 같이 쓰면 에러 처리 품질과 리소스 안정성을 동시에 끌어올릴 수 있습니다.