- Published on
C++23 std - -expected로 예외 없는 에러처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외 기반 에러 처리는 강력하지만, 시스템/인프라 코드나 성능 민감한 라이브러리, 또는 예외를 금지한 코드베이스에서는 오히려 부담이 됩니다. 대표적으로
- 예외 전파 경로가 길어지며 제어 흐름이 불투명해짐
- ABI/컴파일 옵션(예:
-fno-exceptions) 제약 - 실패가 “정상적인 결과의 한 종류”인 경우(네트워크, 파일 I/O, 파싱 등) 예외가 과도함
같은 문제가 생깁니다. C++23의 std::expected는 “성공 값 또는 실패 값”을 타입으로 명시해, 예외 없이도 읽기 쉬운 에러 처리를 가능하게 합니다.
이 글에서는 std::expected를 실무에서 어떻게 설계하고 조합하는지, 그리고 기존 방식(에러 코드, std::optional, 예외)과 비교해 어떤 장점이 있는지 코드로 정리합니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 중 하나를 담는 타입입니다.
- 성공:
T값 - 실패:
E에러
즉, 함수 시그니처가 “성공/실패 가능”을 명확히 드러냅니다.
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(std::string_view s);
이 시그니처만 봐도 호출자는 “정상 값은 int, 실패하면 문자열 에러”라는 사실을 강제적으로 다루게 됩니다.
왜 std::optional로는 부족한가
std::optional<T>는 성공 여부만 표현합니다. 실패 이유가 빠집니다.
#include <optional>
#include <string_view>
std::optional<int> parse_int_opt(std::string_view s);
이 경우 실패 원인을 남기려면 전역 상태(errno), 아웃 파라미터, 로깅 의존 등으로 흘러가기가 쉽습니다. 반면 std::expected는 실패 이유를 타입으로 운반합니다.
기본 사용법: 생성, 검사, 값/에러 접근
#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 first = s.data();
auto last = s.data() + s.size();
auto [ptr, ec] = std::from_chars(first, last, 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 != last) return std::unexpected(ParseErr::Invalid);
return value;
}
std::string to_string(ParseErr e) {
switch (e) {
case ParseErr::Empty: return "empty";
case ParseErr::Invalid: return "invalid";
case ParseErr::OutOfRange: return "out_of_range";
}
return "unknown";
}
호출부는 다음처럼 명시적으로 처리합니다.
auto r = parse_int("42");
if (!r) {
// r.error()로 실패 원인 접근
std::printf("parse failed: %s\n", to_string(r.error()).c_str());
return;
}
// 성공 값
int x = *r; // 또는 r.value()
std::printf("x=%d\n", x);
if (r)또는if (!r)로 성공/실패 분기- 성공 값:
*r,r.value() - 실패 값:
r.error() - 실패 생성:
std::unexpected(err)
에러 타입 설계: 문자열 vs enum vs 구조체
실무에서 가장 많이 고민하는 지점이 E 타입입니다.
1) enum class 에러 코드
장점: 가볍고 비교가 쉬움. 단점: 메시지/컨텍스트가 부족.
- 라이브러리 내부 로직 실패(파싱, 검증, 상태 전이)에 적합
2) std::string 메시지
장점: 바로 로깅 가능. 단점: 할당 비용, 분류/정책 처리 어려움.
- CLI 도구, 단발성 유틸리티에 적합
3) 구조체(코드 + 컨텍스트)
가장 실무 친화적입니다.
#include <string>
#include <system_error>
enum class Errc {
Io,
Parse,
InvalidState,
Timeout,
Permission
};
struct Error {
Errc code;
std::string message; // 사람이 읽는 메시지
std::error_code sys{}; // 필요 시 OS/라이브러리 에러
};
inline Error make_error(Errc c, std::string msg, std::error_code sys = {}) {
return Error{c, std::move(msg), sys};
}
이렇게 하면
- 정책 분기:
code로 처리 - 로깅:
message - 원인 추적:
sys(예:EACCES,ETIMEDOUT)
을 동시에 잡을 수 있습니다.
조합 가능한 에러 처리: “단계별 실패”를 깔끔하게
예외 없는 코드에서 가장 지저분해지는 패턴은 “중간 단계에서 실패하면 즉시 반환”입니다. std::expected는 이를 표준화합니다.
예를 들어 설정 문자열을 읽고, 파싱하고, 검증하는 흐름을 생각해봅시다.
#include <expected>
#include <string>
#include <string_view>
struct Config {
int port;
};
std::expected<std::string, Error> read_text_file(std::string_view path);
std::expected<int, Error> parse_port(std::string_view s);
std::expected<Config, Error> validate_config(int port);
std::expected<Config, Error> load_config(std::string_view path) {
auto text = read_text_file(path);
if (!text) return std::unexpected(text.error());
auto port = parse_port(*text);
if (!port) return std::unexpected(port.error());
auto cfg = validate_config(*port);
if (!cfg) return std::unexpected(cfg.error());
return *cfg;
}
여기까지는 “깔끔하지만 반복이 많다”는 느낌이 있을 수 있습니다. 그래서 다음 섹션의 변환/체이닝이 중요합니다.
transform, and_then, or_else로 체이닝하기
C++23의 std::expected는 함수형 스타일의 조합 메서드를 제공합니다.
transform(f): 성공 값T를f(T)로 변환. 실패는 그대로 전달.and_then(f): 성공 값에 대해f(T)를 호출하는데,f는 또 다른std::expected를 반환(연쇄 가능).or_else(f): 실패 시f(E)를 호출해 복구/변환.
위 load_config를 체이닝으로 바꾸면 다음처럼 됩니다.
std::expected<Config, Error> load_config(std::string_view path) {
return read_text_file(path)
.and_then([](const std::string& text) {
return parse_port(text);
})
.and_then([](int port) {
return validate_config(port);
});
}
중간 if (!r) return ...가 사라지고, “성공 경로”가 직선으로 읽힙니다.
실패를 로깅만 하고 그대로 전파: or_else
#include <cstdio>
std::expected<Config, Error> load_config_logged(std::string_view path) {
return load_config(path)
.or_else([](const Error& e) -> std::expected<Config, Error> {
std::printf("load_config failed: code=%d msg=%s\n",
static_cast<int>(e.code), e.message.c_str());
return std::unexpected(e);
});
}
실패를 삼키지 않고 관측만 하는 패턴은 운영 환경에서 특히 유용합니다. 네트워크/외부 API처럼 실패가 잦은 영역은 재시도, 백오프 같은 정책과 결합되기 때문입니다. 이런 관점은 OpenAI 429·rate_limit 재시도·백오프 설계 가이드의 “실패를 값으로 다루고 정책으로 감싸는” 접근과도 통합니다.
에러 타입 변환: 하위 계층 에러를 상위 계층 의미로 매핑
실무에서는 계층마다 에러 의미가 달라집니다.
- 파일 읽기 계층:
Permission,NotFound,Io - 설정 로드 계층:
ConfigMissing,ConfigInvalid
이때 하위 에러를 그대로 노출하면 상위 계층이 불필요한 결합을 갖게 됩니다. or_else를 이용해 매핑할 수 있습니다.
enum class ConfigErrc {
Missing,
Invalid,
Io
};
struct ConfigError {
ConfigErrc code;
std::string message;
};
std::expected<std::string, Error> read_text_file(std::string_view path);
std::expected<std::string, ConfigError> read_config_text(std::string_view path) {
return read_text_file(path)
.or_else([&](const Error& e) -> std::expected<std::string, ConfigError> {
if (e.code == Errc::Permission) {
return std::unexpected(ConfigError{ConfigErrc::Io, "permission denied"});
}
if (e.code == Errc::Io) {
return std::unexpected(ConfigError{ConfigErrc::Io, "io error"});
}
return std::unexpected(ConfigError{ConfigErrc::Missing, "config missing"});
});
}
핵심은 “에러를 숨기는 게 아니라, 의미를 정제해서 노출”한다는 점입니다.
예외와의 공존 전략: 경계에서만 변환
현실적으로 모든 코드가 예외를 금지하지는 않습니다. 추천하는 전략은 다음입니다.
- 내부 코어(라이브러리/핵심 로직):
std::expected - 애플리케이션 경계(UI, RPC 핸들러, main): 예외로 변환하거나 에러 응답으로 매핑
예를 들어 경계에서만 예외를 던지고 싶다면:
#include <stdexcept>
template <class T, class E>
T value_or_throw(std::expected<T, E> r) {
if (!r) throw std::runtime_error("operation failed");
return *r;
}
반대로 예외 기반 API를 expected로 감싸서 코어로 들여오는 것도 가능합니다.
#include <expected>
#include <string>
std::expected<int, std::string> safe_div(int a, int b) {
try {
if (b == 0) throw std::runtime_error("divide by zero");
return a / b;
} catch (const std::exception& ex) {
return std::unexpected(std::string(ex.what()));
}
}
이렇게 “경계에서 변환”하면, 내부는 예외 없는 직선 흐름을 유지할 수 있습니다.
std::error_code와 함께 쓰기
OS/네트워크/파일 I/O는 이미 std::error_code 중심으로 설계된 API가 많습니다. E를 std::error_code로 두는 것도 좋은 선택입니다.
#include <expected>
#include <system_error>
#include <cstdio>
std::expected<void, std::error_code> do_io() {
// 예시: 실패했다고 가정
return std::unexpected(std::make_error_code(std::errc::permission_denied));
}
void run() {
auto r = do_io();
if (!r) {
std::printf("io failed: %s\n", r.error().message().c_str());
}
}
다만 std::error_code는 “분류/정책”에는 강하지만 “도메인 컨텍스트(어느 파일, 어떤 파라미터)”가 부족할 수 있으니, 앞서 소개한 구조체 에러에 std::error_code를 포함하는 방식이 실무에서 더 자주 쓰입니다.
재시도/백오프 같은 정책을 expected로 감싸기
expected의 강점은 실패가 값이기 때문에, 정책을 함수로 감싸 조합하기 쉽다는 점입니다.
#include <expected>
#include <chrono>
#include <thread>
template <class F>
auto retry_n(int n, F&& f) {
using R = decltype(f());
static_assert(std::is_same_v<R, R>, "");
R last = f();
for (int i = 0; i < n && !last; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(50 * (i + 1)));
last = f();
}
return last;
}
사용 예:
auto r = retry_n(3, [&] {
return read_text_file("/etc/app.conf");
});
if (!r) {
// 최종 실패 처리
}
이 패턴은 네트워크 ETIMEDOUT, ECONNRESET 같은 오류를 다룰 때 특히 유효합니다. 장애 대응 관점에서는 Node.js fetch ECONNRESET·ETIMEDOUT 해결법처럼 “실패를 분류하고 재시도/타임아웃 정책을 분리”하는 접근이 언어를 가리지 않고 반복됩니다.
자주 하는 실수와 주의점
1) value() 남발로 사실상 예외처럼 쓰기
r.value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 *r를 무조건 쓰라는 뜻이 아니라, 반드시 if (!r)로 분기하거나 and_then 체이닝으로 강제하는 편이 낫습니다.
2) 에러를 문자열로만 두고 정책 처리를 포기
문자열은 로깅에는 좋지만, 재시도 가능/불가능, 사용자 오류/시스템 오류 같은 분류가 어려워집니다. 최소한 enum class 코드와 메시지를 함께 두는 방식을 권장합니다.
3) “모든 함수가 expected”가 되며 시그니처가 무거워지는 문제
해결책은 경계 설정입니다.
- 실패가 가능한 함수(외부 입력, I/O, 파싱, 상태 전이):
expected - 실패가 논리적으로 불가능한 순수 계산: 일반 반환
그리고 계층 경계에서만 에러 타입을 변환해, 상위로 올라갈수록 단순한 도메인 에러만 보이게 하세요.
마이그레이션 가이드: 기존 코드에서 단계적으로 도입하기
- 예외를 던지는 함수 앞에 얇은 래퍼를 만들어
expected로 변환 - 새로 작성하는 모듈부터
expected기반으로 설계 - 상위 계층에서
and_then체이닝으로 성공 경로를 정리 - 에러 타입을
enum에서 구조체로 확장(메시지/컨텍스트/시스템 에러 추가)
운영 환경에서 중요한 것은 “실패를 숨기지 않고, 분류 가능하게 만들고, 정책(재시도/백오프/서킷브레이커 등)으로 감싸는 것”입니다. std::expected는 이를 C++ 표준 라이브러리 레벨에서 지원해, 팀 규칙으로도 강제하기 쉬운 기반을 제공합니다.
정리
std::expected는 성공 값T또는 실패 값E를 담아, 예외 없이도 명확한 에러 처리를 가능하게 합니다.and_then,transform,or_else를 활용하면 단계적 실패 로직을 깔끔하게 체이닝할 수 있습니다.- 실무에서는
E를 구조체로 설계해 코드(정책)와 메시지(관측), 시스템 에러(원인)를 함께 들고 가는 방식이 가장 유연합니다. - 예외와는 “경계에서만 변환”하는 공존 전략이 마이그레이션과 유지보수에 유리합니다.
C++23을 도입할 수 있는 환경이라면, 예외 없는 코드베이스에서도 읽기 쉽고 조합 가능한 에러 처리를 위해 std::expected를 우선순위 높게 검토해볼 만합니다.