- Published on
C++23 std - -expected로 예외 없는 에러 처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·클라이언트·임베디드 등 다양한 C++ 코드베이스에서 “예외를 켜둘 것인가”는 늘 논쟁거리입니다. 예외는 강력하지만, 제어 흐름이 비가시적이고(호출 스택을 뛰어넘음), 성능/바이너리 크기/ABI 정책, 그리고 팀의 코딩 규칙과 충돌하는 경우가 많습니다. 특히 라이브러리 경계(플러그인, FFI, 게임 엔진, 저지연 시스템)에서는 예외가 금지되거나, 최소한 “경계 밖으로 던지지 말라”는 룰이 흔합니다.
C++23의 std::expected는 이런 상황에서 “반환값으로 성공/실패를 함께 전달”하는 표준 도구입니다. 단순히 bool + out-parameter 조합을 대체하는 수준이 아니라, 에러를 타입으로 모델링하고 호출자가 반드시 확인하도록 유도하며, 체이닝/전파를 깔끔하게 만드는 것이 핵심입니다.
아래에서는 std::expected의 개념, 설계 요령, 실전 패턴(에러 타입 정의, 전파, 변환, 로깅 경계)까지 한 번에 정리합니다.
왜 std::expected인가
전통적인 예외 기반 설계의 장점은 “정상 경로 코드가 깔끔하다”는 점입니다. 반면 단점도 명확합니다.
- 예외가 어디서 던져지는지 코드만 보고 파악하기 어렵습니다.
- 예외 비활성화 빌드(
-fno-exceptions) 환경에서는 사용 자체가 불가능합니다. - 저지연/실시간 시스템에서 예외 unwind 비용과 예측 가능성 문제가 발생할 수 있습니다.
- 라이브러리 경계에서 예외가 새어 나오면 치명적입니다.
std::expected는 다음을 제공합니다.
- 반환 타입에 성공 값과 실패 값을 함께 담습니다.
- 호출자가
has_value()또는if (exp)같은 형태로 성공 여부를 확인하게 됩니다. - 실패값(에러)을 구조화할 수 있습니다(에러 코드, 메시지, 원인, 컨텍스트 등).
- 예외 없이도 “조기 반환” 스타일로 전파가 가능합니다.
운영 관점에서 “실패를 데이터로 취급”하는 태도는 분산 시스템/배치 작업에서도 중요합니다. 예를 들어 중복 발행/중복 처리 방지처럼 실패/재시도를 설계 단계에서 다루는 글인 Kafka 중복발행·중복처리 막는 Outbox+Idempotency에서도 결국 핵심은 실패를 숨기지 않고 다루는 것입니다. C++에서도 같은 철학이 적용됩니다.
기본 사용법: 값 또는 에러
std::expected<T, E>는 “성공하면 T를, 실패하면 E를 담는 컨테이너”입니다.
T는 성공 값 타입E는 에러 타입
간단한 예로 파일에서 정수를 읽는 함수를 만들어 보겠습니다.
#include <expected>
#include <fstream>
#include <string>
enum class ReadError {
OpenFailed,
ParseFailed
};
std::expected<int, ReadError> read_int_from_file(const std::string& path) {
std::ifstream in(path);
if (!in.is_open()) {
return std::unexpected(ReadError::OpenFailed);
}
int value{};
if (!(in >> value)) {
return std::unexpected(ReadError::ParseFailed);
}
return value;
}
int main() {
auto r = read_int_from_file("/tmp/value.txt");
if (!r) {
// r.error()로 에러 접근
return 1;
}
int v = *r; // 또는 r.value()
(void)v;
}
포인트는 두 가지입니다.
- 실패 시
std::unexpected(E)로 감싸서 반환합니다. - 호출자는
if (!r)또는r.has_value()로 반드시 확인하는 흐름이 자연스럽습니다.
에러 타입 설계: 코드만 둘 것인가, 컨텍스트까지 담을 것인가
E를 enum class로 두면 가볍고 비교가 쉬우며, 성능도 좋습니다. 하지만 운영에서 디버깅 가능한 정보가 부족해질 수 있습니다.
실전에서는 다음 중 하나가 자주 쓰입니다.
enum class+ 별도 로깅(컨텍스트는 로그에만)- 구조체 에러 타입(코드 + 메시지 + 원인 + 경로 등)
std::error_code기반(플랫폼/라이브러리 연동)
구조체 기반 예시입니다.
#include <expected>
#include <string>
enum class Errc {
Io,
InvalidInput,
NotFound
};
struct Error {
Errc code;
std::string message;
};
std::expected<int, Error> parse_port(std::string_view s) {
if (s.empty()) {
return std::unexpected(Error{Errc::InvalidInput, "port is empty"});
}
int port = 0;
for (char c : s) {
if (c < '0' || c > '9') {
return std::unexpected(Error{Errc::InvalidInput, "port must be numeric"});
}
port = port * 10 + (c - '0');
}
if (port < 1 || port > 65535) {
return std::unexpected(Error{Errc::InvalidInput, "port out of range"});
}
return port;
}
운영 장애를 추적할 때는 “에러 코드”와 “상황 설명”이 같이 있어야 재현이 빨라집니다. 특히 마이크로서비스 환경에서 타임아웃/데드라인 초과 같은 문제를 분석할 때도 동일합니다. 네트워크/서버 오류를 다루는 감각은 gRPC 마이크로서비스 503·데드라인 초과 디버깅 같은 글에서 강조되는 부분과 결이 같습니다.
전파 패턴 1: 조기 반환(가장 단순하고 강력)
std::expected를 쓰면 “실패면 즉시 반환” 패턴이 깔끔해집니다.
#include <expected>
#include <string>
struct Error {
int code;
std::string message;
};
std::expected<std::string, Error> read_text(const std::string& path);
std::expected<int, Error> parse_int(const std::string& s);
std::expected<int, Error> read_and_parse_int(const std::string& path) {
auto text = read_text(path);
if (!text) {
return std::unexpected(text.error());
}
auto value = parse_int(*text);
if (!value) {
return std::unexpected(value.error());
}
return *value;
}
이 패턴은 예외의 “전파”와 유사하지만, 제어 흐름이 명시적이고 디버깅이 쉽습니다.
value()는 주의해서 사용
*exp는 값이 없으면 UB가 아니라 정의된 동작으로 예외를 던질 수 있습니다(구현에 따라std::bad_expected_access).- 예외 없는 환경이라면
value()/operator*를 “값이 확실할 때만” 쓰는 규칙이 필요합니다.
즉, 팀 규칙으로 “항상 if (!r) return unexpected(...) 이후에만 *r 사용” 같은 컨벤션을 두는 것이 좋습니다.
전파 패턴 2: transform과 and_then로 체이닝
std::expected는 함수형 스타일의 헬퍼를 제공합니다. 구현체/표준 라이브러리 버전에 따라 사용 가능 범위가 다를 수 있으니, 사용하는 컴파일러와 표준 라이브러리의 C++23 지원 상태를 확인하세요.
개념적으로는 다음과 같습니다.
transform(f): 성공 값T를f(T)로 변환(실패는 그대로 전파)and_then(f): 성공 값T를 받아expected<U, E>를 반환하는f를 체이닝or_else(f): 실패 값E를 받아 복구하거나 다른 에러로 매핑
예시(아이디어 중심):
#include <expected>
#include <string>
struct Error { int code; std::string message; };
std::expected<std::string, Error> read_text(const std::string& path);
std::expected<int, Error> parse_int(const std::string& s);
std::expected<int, Error> pipeline(const std::string& path) {
return read_text(path)
.and_then([](const std::string& s) { return parse_int(s); })
.transform([](int x) { return x * 2; });
}
이 방식은 단계가 많아질수록 가독성이 좋아집니다. 다만 팀 내 C++ 숙련도에 따라 “명시적 조기 반환”이 더 읽기 쉬운 경우도 많으니 상황에 맞게 선택하세요.
에러 매핑: 라이브러리 에러를 도메인 에러로 바꾸기
실전에서는 “하위 레이어의 에러 타입”을 “상위 레이어의 에러 타입”으로 변환해야 합니다.
예를 들어 파일 I/O 레이어는 IoError를, 도메인 레이어는 AppError를 쓰도록 분리할 수 있습니다.
#include <expected>
#include <string>
enum class IoErrc { OpenFailed, ReadFailed };
struct IoError { IoErrc code; std::string path; };
enum class AppErrc { ConfigMissing, ConfigInvalid };
struct AppError { AppErrc code; std::string message; };
std::expected<std::string, IoError> read_file(const std::string& path);
std::expected<std::string, AppError> read_config(const std::string& path) {
auto r = read_file(path);
if (!r) {
// 하위 에러를 상위 의미로 재해석
return std::unexpected(AppError{
AppErrc::ConfigMissing,
"config read failed: " + r.error().path
});
}
return *r;
}
이렇게 하면 상위 레이어는 하위 레이어의 세부 에러에 의존하지 않고(결합도 감소), 사용자에게 의미 있는 실패를 제공할 수 있습니다.
std::optional과의 차이: 실패 이유가 필요하면 expected
std::optional<T>: 값이 있거나 없다std::expected<T, E>: 값이 있거나, 왜 없는지(E)가 있다
설정 파일이 “없을 수도 있음”이 정상 시나리오라면 optional이 맞을 수 있습니다. 하지만 “없으면 장애”라면 expected로 실패 이유를 올리는 편이 운영/디버깅에 유리합니다.
예외와의 공존 전략: 경계에서만 변환
이미 예외를 쓰는 서드파티 라이브러리를 사용한다면, 내부에서는 예외를 쓰되 “프로젝트 경계”에서 expected로 변환하는 전략이 현실적입니다.
- 라이브러리 호출부에서
try/catch로 예외를 잡는다 - 도메인 에러 타입으로 매핑해
std::unexpected로 반환한다 - 경계 밖으로 예외가 새지 않게 한다
#include <expected>
#include <string>
#include <exception>
struct Error { std::string message; };
std::expected<int, Error> safe_call() {
try {
// 예외를 던질 수 있는 코드
return 42;
} catch (const std::exception& e) {
return std::unexpected(Error{std::string("exception: ") + e.what()});
} catch (...) {
return std::unexpected(Error{"unknown exception"});
}
}
이 패턴은 “예외를 완전히 금지”하기보다, 예외가 시스템 전체로 전염되는 것을 막는 실용적인 절충안입니다.
성능/메모리 관점 체크리스트
std::expected 자체는 “값 또는 에러를 저장하는 객체”이므로, T와 E의 크기에 영향을 받습니다.
E에 큰std::string을 넣으면 실패 경로에서 할당이 발생할 수 있습니다.- 저지연 코드에서는
E를 작은enum class로 두고, 상세 메시지는 로깅에서 구성하는 방식이 유리할 수 있습니다. - 반대로 운영 편의성이 더 중요하면, 컨텍스트를
E에 포함시키는 것이 장애 대응 시간을 줄입니다.
여기서 중요한 건 “일관된 정책”입니다. 예를 들어 장애 분석에서 로그가 핵심인 경우, 크래시/재시작 루프를 추적하는 방법론(로그, 프로브, 리소스)을 다룬 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅처럼, C++ 서비스도 실패 정보를 어디에 남길지(반환값 vs 로그) 기준을 정해두는 편이 좋습니다.
실전 예제: 설정 로드 + 검증 + 사용
아래 예시는 expected를 이용해 구성 파일을 읽고, 파싱하고, 검증하는 흐름을 “예외 없이” 구성한 예입니다.
#include <expected>
#include <fstream>
#include <string>
#include <string_view>
enum class Errc {
Io,
Parse,
Validation
};
struct Error {
Errc code;
std::string message;
};
struct Config {
std::string host;
int port;
};
std::expected<std::string, Error> read_all(const std::string& path) {
std::ifstream in(path);
if (!in.is_open()) {
return std::unexpected(Error{Errc::Io, "cannot open: " + path});
}
std::string s((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return s;
}
std::expected<int, Error> parse_port(std::string_view s) {
if (s.empty()) return std::unexpected(Error{Errc::Parse, "port empty"});
int port = 0;
for (char c : s) {
if (c < '0' || c > '9') return std::unexpected(Error{Errc::Parse, "port not numeric"});
port = port * 10 + (c - '0');
}
return port;
}
std::expected<Config, Error> parse_config(std::string_view text) {
// 데모용: "host=...\nport=..." 형태만 처리
std::string host;
std::string port_str;
for (size_t i = 0; i < text.size();) {
auto end = text.find('\n', i);
if (end == std::string_view::npos) end = text.size();
auto line = text.substr(i, end - i);
if (line.rfind("host=", 0) == 0) host = std::string(line.substr(5));
if (line.rfind("port=", 0) == 0) port_str = std::string(line.substr(5));
i = end + 1;
}
if (host.empty()) return std::unexpected(Error{Errc::Parse, "missing host"});
auto port = parse_port(port_str);
if (!port) return std::unexpected(port.error());
Config cfg{host, *port};
if (cfg.port < 1 || cfg.port > 65535) {
return std::unexpected(Error{Errc::Validation, "port out of range"});
}
return cfg;
}
std::expected<Config, Error> load_config(const std::string& path) {
auto text = read_all(path);
if (!text) return std::unexpected(text.error());
auto cfg = parse_config(*text);
if (!cfg) return std::unexpected(cfg.error());
return *cfg;
}
이 구조의 장점은 다음과 같습니다.
- I/O 실패, 파싱 실패, 검증 실패가 모두 다른 코드/메시지로 분리됩니다.
- 호출자는
load_config()결과만 보고도 실패 원인을 분기할 수 있습니다. - 예외를 금지한 환경에서도 동일한 API를 유지할 수 있습니다.
팀 적용 가이드: 규칙을 정하면 생산성이 올라간다
std::expected를 도입할 때는 스타일을 통일해야 “장점”이 살아납니다.
- 실패는
std::unexpected(...)로만 반환한다(중구난방 방지) E는 프로젝트 공통 에러 타입(또는 계층별 에러 타입)으로 표준화한다- 성공 값 접근은 “성공 체크 이후에만” 한다는 규칙을 둔다
- 경계(스레드 시작점, RPC 핸들러, main 루프)에서 에러를 로깅/메트릭으로 수집한다
예외는 때로 편하지만, 운영 가능한 시스템을 만들려면 실패를 “흐름의 일부”로 다뤄야 합니다. std::expected는 그 설계를 표준 라이브러리 수준에서 지원하는 도구이며, C++23부터는 더 이상 외부 라이브러리에 의존하지 않고도 일관된 에러 처리 모델을 구축할 수 있습니다.
마무리
- 예외를 쓰기 어려운 환경에서는
std::expected가 사실상 표준 해법입니다. expected의 진짜 가치는 “에러를 타입으로 모델링”하고 “전파/매핑 규칙을 명시”하는 데 있습니다.- 도입 시에는 에러 타입 정책과 사용 규칙을 함께 정해야 코드베이스가 깔끔해집니다.
다음 단계로는 std::error_code와의 결합, expected 기반의 Result 타입 별칭, 모듈 경계에서의 에러 매핑 규약(에러 코드 표준화)까지 확장해 보면 좋습니다.