- Published on
C++23 std - -expected로 예외 없는 에러 처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트/임베디드 등 다양한 C++ 코드베이스에서 예외는 종종 금지되거나(컴파일 옵션, ABI, 성능/예측 가능성) 운영 환경에서 디버깅 비용을 폭발시킵니다. 그렇다고 모든 함수가 bool과 출력 파라미터로 돌아가면 호출부는 금세 지저분해지고, 실패 이유가 소실되며, 에러 처리 누락도 늘어납니다.
C++23의 std::expected는 “성공 값 또는 에러”를 타입으로 모델링합니다. 핵심은 실패가 정상적인 제어 흐름으로 표현되며, 호출부에서 강제로 다루게 만들 수 있다는 점입니다. 이번 글에서는 std::expected의 기본 사용법부터, 조합 연산(and_then, transform, or_else)을 이용한 예외 없는 파이프라인 설계까지 실전 관점으로 정리합니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 둘 중 하나를 담습니다.
- 성공:
T값 - 실패:
E에러
즉, 반환 타입만 봐도 “이 함수는 실패할 수 있다”가 드러납니다. 또한 실패는 throw가 아니라 값으로 전달되므로, 예외가 금지된 환경에서도 풍부한 에러 정보를 유지할 수 있습니다.
컴파일러/표준 라이브러리 지원: C++23 및
libstdc++/libc++최신 버전이 필요합니다. 환경이 아직 C++23을 온전히 지원하지 않는다면, 유사한 개념으로tl::expected같은 라이브러리를 고려할 수 있습니다(이 글은 표준std::expected기준).
왜 예외 대신 expected인가
예외 기반 에러 처리는 장점도 많지만, 다음과 같은 현실적인 문제가 있습니다.
- 예측 가능성: 핫패스에서 예외 경로는 프로파일링/테스트가 어렵고, “터지면 느리다”가 운영 리스크가 됩니다.
- 정책/플랫폼 제약: 일부 게임/임베디드/커널 유사 환경은 예외를 아예 끄기도 합니다.
- 경계면 비용: 모듈 경계, C API 경계, 스레드 경계에서 예외 전파는 복잡해집니다.
- 누락 위험: 예외는 호출부 시그니처에 실패 가능성이 드러나지 않아, 에러 처리 정책이 팀마다 달라지기 쉽습니다.
std::expected는 실패를 타입으로 드러내고, 호출부가 has_value() 혹은 조합 연산을 통해 실패를 명시적으로 처리하게 만듭니다.
기본 사용법: 성공/실패 만들기
가장 단순한 패턴은 “성공이면 값, 실패면 에러”를 반환하는 것입니다.
#include <expected>
#include <string>
#include <system_error>
using std::expected;
expected<int, std::error_code> parse_port(const std::string& s) {
try {
int v = std::stoi(s);
if (v < 1 || v > 65535) {
return std::unexpected(std::make_error_code(std::errc::result_out_of_range));
}
return v;
} catch (...) {
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
}
}
여기서 포인트는 다음입니다.
- 실패를 만들 때는
std::unexpected(err)를 사용합니다. - 에러 타입
E는std::error_code, 커스텀 enum, 구조체 등 무엇이든 가능합니다.
호출부는 이렇게 작성합니다.
#include <iostream>
int main() {
auto port = parse_port("8080");
if (!port) {
std::cerr << "parse failed: " << port.error().message() << "\n";
return 1;
}
std::cout << "port=" << *port << "\n";
}
if (!port)는has_value()체크입니다.- 성공 값은
*port또는port.value()로 얻습니다. - 실패 값은
port.error()로 얻습니다.
에러 타입 설계: std::error_code vs 커스텀
std::error_code를 쓰면 좋은 경우
- OS/시스템 호출과 밀접한 코드
- 로그/모니터링에서 표준 메시지가 유용한 경우
- 여러 라이브러리와 에러를 합칠 때
커스텀 에러 구조체가 좋은 경우
- 도메인 정보(예: 어떤 필드가 잘못됐는지, 재시도 가능 여부, HTTP 상태 등)가 필요할 때
- 운영에서 “원인”을 더 구조적으로 남기고 싶을 때
예를 들어 재시도 가능 여부를 에러에 포함시키면, 호출부 정책을 단순화할 수 있습니다. API 레이트리밋 같은 상황은 대표적으로 “재시도 설계”가 중요합니다. 이런 관점은 [Gemini API 429 쿼터·레이트리밋 재시도 설계](https://beautifulsoup.dev/blog/gemini-api-429-quota-rate-limit-retry-design) 글의 접근과 유사하게, 실패를 분류하고 정책을 분리하는 데 도움이 됩니다.
#include <expected>
#include <string>
struct AppError {
enum class Code {
InvalidInput,
NotFound,
RateLimited,
IoError
} code;
std::string message;
bool retryable{false};
};
template <class T>
using AppExpected = std::expected<T, AppError>;
value_or로 기본값 제공하기(주의점 포함)
value_or는 실패 시 기본값을 제공해 호출부를 간단히 합니다.
int port = parse_port("not-a-number").value_or(80);
다만 이 패턴은 에러를 삼켜도 되는 경우에만 쓰는 게 좋습니다. 기본값으로 계속 진행하면 장애가 늦게 폭발하거나, 디버깅이 어려워질 수 있습니다. 운영 장애에서 “원인을 숨기는 기본값”은 종종 더 큰 비용을 만듭니다.
조합 연산으로 에러 흐름을 선언적으로 만들기
std::expected의 진짜 가치는 여러 단계의 작업을 “중간 실패 시 즉시 중단”으로 자연스럽게 연결하는 데 있습니다.
transform(f): 성공 값T를f(T)로 변환(실패면 그대로 전달)and_then(f): 성공 값T를 받아expected<U, E>를 반환하는f를 연결or_else(f): 실패E를 받아 복구하거나 다른 실패로 매핑
예시: 설정 읽기 → 파싱 → 검증
#include <expected>
#include <string>
#include <unordered_map>
using Config = std::unordered_map<std::string, std::string>;
struct Err {
std::string msg;
};
template <class T>
using Exp = std::expected<T, Err>;
Exp<std::string> get_cfg(const Config& c, const std::string& key) {
auto it = c.find(key);
if (it == c.end()) return std::unexpected(Err{"missing key: " + key});
return it->second;
}
Exp<int> parse_int(const std::string& s) {
try {
size_t idx = 0;
int v = std::stoi(s, &idx);
if (idx != s.size()) return std::unexpected(Err{"not an int: " + s});
return v;
} catch (...) {
return std::unexpected(Err{"not an int: " + s});
}
}
Exp<int> in_range(int v, int lo, int hi) {
if (v < lo || v > hi) return std::unexpected(Err{"out of range"});
return v;
}
Exp<int> load_port(const Config& c) {
return get_cfg(c, "port")
.and_then(parse_int)
.and_then([](int v) { return in_range(v, 1, 65535); });
}
이 구조의 장점:
- 각 함수는 단일 책임(읽기/파싱/검증)을 가집니다.
- 실패 시점에서 자동으로 체인이 끊기고, 최초 실패가 위로 전달됩니다.
- 호출부는
load_port하나만 처리하면 됩니다.
or_else로 에러에 정책을 붙이기
실패를 로깅하거나, 특정 실패만 복구하고 싶을 때 or_else가 유용합니다.
#include <iostream>
Exp<int> load_port_with_default(const Config& c) {
return load_port(c).or_else([](const Err& e) -> Exp<int> {
std::cerr << "load_port failed: " << e.msg << "\n";
// 정책: 설정이 없거나 잘못되면 8080으로 복구
return 8080;
});
}
여기서도 마찬가지로 “복구가 타당한 실패”만 선별하는 것이 중요합니다. 무조건 기본값으로 복구하면 장애 원인이 흐려집니다.
transform_error로 에러 타입 변환하기
레이어가 다른 모듈을 연결할 때, 내부 에러를 외부 에러로 매핑해야 합니다. 이때 transform_error가 깔끔합니다.
#include <expected>
#include <string>
struct DbErr { int code; std::string msg; };
struct ApiErr { int http; std::string msg; };
template <class T>
using DbExp = std::expected<T, DbErr>;
template <class T>
using ApiExp = std::expected<T, ApiErr>;
ApiErr map_db_to_api(const DbErr& e) {
if (e.code == 1205) return ApiErr{503, "db timeout"};
return ApiErr{500, "db error: " + e.msg};
}
ApiExp<std::string> handler(DbExp<std::string> dbResult) {
return dbResult.transform_error(map_db_to_api);
}
이 패턴은 운영에서 특히 중요합니다. 예를 들어 DB 데드락/타임아웃을 관측하고 원인 쿼리를 추적해야 할 때는 실패를 “의미 있는 형태”로 끌어올려야 합니다. 관련 주제로는 [MySQL InnoDB 데드락 로그로 원인 쿼리 추적](https://beautifulsoup.dev/blog/mysql-innodb-deadlock-log-trace-root-cause-query) 같은 접근이 결국 같은 결을 가집니다. 즉, 실패를 숨기지 말고 구조화해 추적 가능하게 만드는 것입니다.
예외 없는 코드에서 흔히 놓치는 포인트
1) 에러 전파 누락을 줄이는 방법
expected를 반환하면 호출부가 강제로 체크하도록 유도할 수 있습니다. 추가로 다음을 권장합니다.
- 반환값 무시를 막기 위해
[[nodiscard]]를 붙이기
[[nodiscard]] std::expected<int, Err> do_work();
2) 에러 메시지는 “사람용”, 코드는 “정책용”
운영 정책(재시도, 폴백, 알람)은 문자열 매칭이 아니라 코드/필드로 결정해야 합니다. 문자열은 로깅과 디버깅을 위한 보조 수단으로 두는 편이 안전합니다.
3) 성능 관점: 값 타입을 가볍게
std::expected는 값을 담기 때문에, T가 큰 타입이면 이동/복사 비용이 커질 수 있습니다.
- 큰 객체는
std::shared_ptr/std::unique_ptr또는 뷰 타입으로 감싸기 - 에러 타입
E도 과도하게 비대해지지 않게 설계(필요하면 메시지는 지연 생성)
std::expected로 “재시도 가능한 실패” 모델링하기
네트워크/외부 API/IO는 실패가 일상입니다. 예외 없이도 재시도 정책을 깔끔히 분리할 수 있습니다.
#include <expected>
#include <string>
struct NetError {
enum class Kind { Timeout, RateLimit, Dns, Unknown } kind;
std::string msg;
};
template <class T>
using NetExp = std::expected<T, NetError>;
bool retryable(const NetError& e) {
return e.kind == NetError::Kind::Timeout || e.kind == NetError::Kind::RateLimit;
}
NetExp<std::string> fetch();
NetExp<std::string> fetch_with_policy(int max_attempts) {
for (int i = 0; i < max_attempts; ++i) {
auto r = fetch();
if (r) return r;
if (!retryable(r.error())) return r;
// backoff는 호출부 정책으로 구현(슬립/지수 백오프 등)
}
return std::unexpected(NetError{NetError::Kind::Timeout, "exhausted retries"});
}
실전에서는 지수 백오프, 큐잉, 토큰 버짓 같은 정책이 결합됩니다. 이 주제는 [OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기](https://beautifulsoup.dev/blog/openai-api-429-rate-limit-exceeded-exponential-backoff-queueing-token-budget-python-retry-design)에서 다룬 것처럼 “실패를 분류하고, 재시도 비용을 관리”하는 방향으로 확장할 수 있습니다.
언제 expected가 과하고, 언제 필수인가
- 과한 경우: 로컬 계산에서 실패 가능성이 사실상 없고, 실패 시 프로그램 종료가 합리적인 경우(예: 불변식 위반)
- 잘 맞는 경우: IO, 파싱, 외부 의존성, 사용자 입력, 환경 설정, 네트워크처럼 “실패가 정상”인 경로
예외를 완전히 배제할 필요는 없습니다. 하지만 팀/프로덕트 정책상 예외를 최소화해야 한다면 expected를 경계면(입력/IO/외부 호출)에 적극 도입하고, 내부 로직은 성공 값 중심으로 유지하는 방식이 유지보수에 유리합니다.
정리
C++23 std::expected는 예외 없이도 다음을 가능하게 합니다.
- 실패 가능성을 타입으로 명시
- 에러 정보를 보존한 채 전파
and_then/transform/or_else로 에러 흐름을 선언적으로 구성- 레이어 간 에러 매핑(
transform_error)으로 경계면을 깔끔하게 유지
결국 중요한 것은 “예외를 쓰느냐 마느냐”보다, 실패를 누락 없이, 정책적으로, 추적 가능하게 다루는 것입니다. std::expected는 그 목표를 C++ 표준 라이브러리 차원에서 지원하는 강력한 도구입니다.