- Published on
C++23 std - -expected로 예외 없이 에러처리+회수
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/시스템 코드에서 예외를 꺼리는 팀은 꽤 많습니다. 성능 이슈(특히 예외 테이블/언와인딩), 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 기반 코드가 예외 기반만큼(혹은 그 이상으로) 읽기 쉬워집니다.
“리소스 + 값”을 함께 반환하는 설계
실전에서는 “열린 핸들 + 파싱된 메타데이터”처럼 여러 결과를 함께 반환하고 싶습니다. 이때 expected의 T에 구조체를 넣으면 됩니다.
#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를 같이 쓰면 에러 처리 품질과 리소스 안정성을 동시에 끌어올릴 수 있습니다.