- Published on
C++23 std - -expected로 예외 없는 오류 처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트/임베디드 등 다양한 C++ 코드베이스에서 “예외를 쓸 것인가?”는 늘 논쟁거리입니다. 예외는 성공 경로를 깔끔하게 유지하지만, 런타임 비용(특히 스택 언와인딩), ABI/컴파일 옵션(-fno-exceptions) 제약, 에러 흐름이 호출 그래프 밖으로 튀는 디버깅 난이도 같은 이유로 금지되는 환경도 많습니다.
C++23의 std::expected는 이런 상황에서 성공 값 또는 실패(에러) 값을 명시적으로 반환할 수 있게 해주는 표준 도구입니다. 흔히 쓰던 bool+out parameter, std::optional(에러 정보 손실), std::variant(의도 전달이 약함)보다 의미가 분명하고, std::error_code만으로 부족했던 도메인별 에러 표현도 강화할 수 있습니다.
이 글에서는 std::expected의 핵심 API, 에러 타입 설계, 전파 패턴, 예외/에러코드 대비 장단점, 그리고 팀 코드에 도입할 때의 실전 팁을 다룹니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 중 하나의 상태를 가집니다.
- 성공: 값
T를 보유 - 실패: 에러
E를 보유
즉, 함수 시그니처만 봐도 “이 함수는 실패할 수 있으며, 실패 시 E를 준다”가 명확해집니다.
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(std::string_view s);
위 시그니처는 다음을 강제합니다.
- 호출자는 성공/실패를 확인해야 한다(무시하기 어렵다)
- 실패 이유를 문자열로 받을 수 있다
기본 사용법: 성공/실패 만들기
성공 반환
#include <expected>
std::expected<int, int> ok_value() {
return 42; // T로 암시적 변환되어 성공 상태
}
실패 반환: std::unexpected
실패는 std::unexpected<E>로 감쌉니다.
#include <expected>
std::expected<int, int> fail_value() {
return std::unexpected(404); // E를 담아 실패 상태
}
확인 및 접근
#include <expected>
#include <iostream>
#include <string>
std::expected<int, std::string> parse_int(std::string_view s) {
try {
size_t pos = 0;
int v = std::stoi(std::string(s), &pos);
if (pos != s.size()) {
return std::unexpected("trailing characters");
}
return v;
} catch (...) {
return std::unexpected("invalid integer");
}
}
void use() {
auto r = parse_int("123x");
if (!r) {
std::cerr << "parse failed: " << r.error() << "\n";
return;
}
std::cout << "value: " << *r << "\n"; // 또는 r.value()
}
if (r)또는r.has_value()로 성공 여부 확인- 성공 값:
*r,r.value() - 에러 값:
r.error()
주의: r.value()는 실패 상태에서 호출하면 std::bad_expected_access를 던질 수 있습니다(예외 비활성 환경이면 사용을 피하고 *r도 성공 확인 후에만 접근하세요).
왜 std::optional이 아니라 std::expected인가
std::optional<T>는 실패를 표현할 수 있지만 왜 실패했는지를 잃습니다.
- 파일 읽기 실패: 권한 문제인가, 파일이 없는가, 인코딩 문제인가?
- 네트워크 실패: 타임아웃인가, DNS인가, TLS인가?
운영에서 “원인”은 복구와 직결됩니다. 예를 들어 장애 분석에서 원인 로그를 빨리 좁히는 건 매우 중요합니다. 인프라/운영 문제를 추적하는 글들(예: EKS CrashLoopBackOff 원인 10분만에 찾기, MySQL InnoDB 데드락 추적 - deadlock.log 읽기)이 강조하는 것도 결국 “진단 가능한 에러 정보”입니다. 애플리케이션 코드에서도 같은 원칙이 적용됩니다.
std::expected는 에러를 타입으로 모델링해 진단 가능성을 기본값으로 만듭니다.
에러 타입 E 설계 전략
E는 아무 타입이나 될 수 있지만, 실전에서는 다음 중 하나가 자주 쓰입니다.
1) std::error_code 기반
표준/플랫폼 에러와 결합이 쉽고, 값이 작아 효율적입니다.
#include <expected>
#include <system_error>
std::expected<void, std::error_code> write_file(/*...*/);
다만 도메인별 상세 정보(예: 어느 필드가 잘못됐는지)를 담기엔 부족할 수 있습니다.
2) enum + 메시지(또는 컨텍스트) 구조체
실무에서 가장 균형이 좋습니다.
#include <expected>
#include <string>
enum class ParseErrc {
Empty,
InvalidChar,
Overflow
};
struct ParseError {
ParseErrc code;
size_t position = 0;
std::string message;
};
std::expected<int, ParseError> parse_int2(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError{ParseErrc::Empty, 0, "empty input"});
}
long long acc = 0;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') {
return std::unexpected(ParseError{ParseErrc::InvalidChar, i, "non-digit"});
}
acc = acc * 10 + (c - '0');
if (acc > 2147483647LL) {
return std::unexpected(ParseError{ParseErrc::Overflow, i, "overflow"});
}
}
return static_cast<int>(acc);
}
이 방식의 장점은 다음과 같습니다.
- 로깅/모니터링에 필요한 컨텍스트를 구조적으로 전달
- 호출자는
code로 분기하고,message는 관측성(로그) 용도로 사용
3) std::string만 쓰기(최소 설계)
가장 쉽지만, 호출 측에서 분기하기 어렵고 국제화/머신 리더블 처리에 불리합니다. “초기 도입” 단계에서는 가능하나, 규모가 커지면 enum 기반으로 옮기는 것을 권장합니다.
오류 전파 패턴: and_then, transform, or_else
std::expected의 진짜 강점은 “실패를 자연스럽게 전파”하는 조합 함수들입니다.
transform(f): 성공 값T를U로 변환(실패면 그대로)and_then(f): 성공이면 다음expected를 반환하는 함수를 연결or_else(f): 실패일 때 에러를 변환하거나 복구
예시로, 문자열을 읽고 숫자로 파싱한 뒤, 범위를 검증하는 파이프라인을 만들어보겠습니다.
#include <expected>
#include <string>
#include <string_view>
struct Err {
std::string msg;
};
std::expected<std::string, Err> read_text();
std::expected<int, Err> parse_num(std::string_view);
std::expected<int, Err> validate_range(int v) {
if (v < 0 || v > 100) {
return std::unexpected(Err{"out of range"});
}
return v;
}
std::expected<int, Err> load_config_value() {
return read_text()
.and_then([](const std::string& s) { return parse_num(s); })
.and_then([](int v) { return validate_range(v); });
}
위 코드는 예외 없이도 “첫 실패”가 자동으로 전파됩니다. 중간에 if (!r) return r;를 반복하지 않아도 되어, 성공 경로가 읽기 쉬워집니다.
expected를 반환하는 함수 설계 팁
1) 반환 타입은 최상위 API에서부터 정하라
하위 함수들이 제각각 다른 에러 타입을 반환하면 조합이 어려워집니다. 모듈 단위로 E를 통일하거나, 변환 규칙을 명확히 두세요.
- 예: 파서 모듈은
ParseError - 예: IO 모듈은
IoError - 상위 서비스 레이어는
AppError로 래핑
2) 에러 변환은 or_else로 한 곳에서
#include <expected>
#include <string>
struct IoError { std::string msg; };
struct AppError { std::string msg; };
std::expected<int, IoError> read_number_from_disk();
std::expected<int, AppError> load_number() {
return read_number_from_disk().or_else([](const IoError& e) {
return std::unexpected(AppError{"disk read failed: " + e.msg});
});
}
이렇게 하면 하위 모듈의 에러 표현이 상위로 새는 것을 막고, 로깅/메시지 정책도 계층별로 유지할 수 있습니다.
3) T가 void인 경우도 유용하다
성공 시 값이 필요 없고 “성공/실패”만 필요하면 std::expected<void, E>가 깔끔합니다.
#include <expected>
#include <system_error>
std::expected<void, std::error_code> ensure_dir_exists(/*...*/);
예외 vs std::expected: 트레이드오프를 현실적으로 보기
std::expected가 특히 좋은 경우
- 예외가 금지된 환경(
-fno-exceptions) 또는 ABI 제약 - 실패가 빈번할 수 있는 경로(파싱, 검증, 사용자 입력)
- 호출자가 실패를 “정상 흐름”으로 다뤄야 하는 API
- 에러를 구조적으로 전달하고 싶은 경우(에러 코드, 위치, 원인 체인)
예외가 여전히 유리한 경우
- 실패가 “진짜 예외적”이고 상위에서 한 번에 처리하는 구조
- 오류 전파가 매우 깊고, 모든 레벨에서 반환 타입을 바꾸기 어려운 레거시
- 광범위한 라이브러리들이 예외 기반으로 설계된 경우
현실적인 결론은 “둘 중 하나만”이 아니라, 모듈 경계에서 정책을 정하고 변환하는 것입니다.
- 내부는
expected - 외부 API는 예외(혹은 그 반대)
이때 변환 지점이 명확해야 운영/디버깅이 쉬워집니다. CI에서 문제를 빨리 좁히는 것처럼(예: GitHub Actions 캐시 안 먹을 때 키·경로·권한 7단계), 에러도 “어디서 어떤 정책으로 바뀌는지”가 추적 가능해야 합니다.
실전 예제: 파일에서 정수 읽기(예외 없이)
아래는 파일을 읽고 정수로 파싱하는 예제입니다. 표준 스트림은 예외를 켤 수도 있지만, 여기서는 예외 없이 상태를 검사하는 방식으로 작성합니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
enum class ReadIntErrc {
OpenFailed,
ReadFailed,
ParseFailed
};
struct ReadIntError {
ReadIntErrc code;
std::string path;
std::string detail;
};
std::expected<int, ReadIntError> read_int_file(const std::string& path) {
std::ifstream in(path);
if (!in.is_open()) {
return std::unexpected(ReadIntError{ReadIntErrc::OpenFailed, path, "cannot open"});
}
std::string s;
if (!(in >> s)) {
return std::unexpected(ReadIntError{ReadIntErrc::ReadFailed, path, "cannot read token"});
}
// 간단 파싱(부호/공백 등은 필요에 맞게 확장)
long long acc = 0;
for (char c : s) {
if (c < '0' || c > '9') {
return std::unexpected(ReadIntError{ReadIntErrc::ParseFailed, path, "non-digit"});
}
acc = acc * 10 + (c - '0');
if (acc > 2147483647LL) {
return std::unexpected(ReadIntError{ReadIntErrc::ParseFailed, path, "overflow"});
}
}
return static_cast<int>(acc);
}
호출 측은 다음처럼 명확하게 처리합니다.
#include <iostream>
void run(const std::string& path) {
auto r = read_int_file(path);
if (!r) {
const auto& e = r.error();
std::cerr << "failed: path=" << e.path << " detail=" << e.detail << "\n";
return;
}
std::cout << "value=" << *r << "\n";
}
여기서 중요한 점은 “실패가 값으로 모델링”되니, 로깅/리트라이/대체 경로 선택 같은 정책을 호출자가 자연스럽게 구현할 수 있다는 것입니다.
도입 체크리스트(팀/레거시 관점)
- 모듈별 에러 타입 정책을 먼저 정하세요.
E가 난립하면 조합이 어려워집니다. - 에러 메시지는 “사람용”과 “분기용”을 분리하세요. 분기는 enum, 메시지는 로그.
- 성공 경로가 중요한 API는
transform/and_then조합으로 가독성을 확보하세요. - 경계(예: 라이브러리 API, RPC 핸들러)에서만 변환하세요. 내부는 일관되게.
- 테스트에서 실패 케이스를 먼저 작성하세요.
expected는 실패 케이스 테스트가 훨씬 쉬워집니다.
마무리
std::expected는 C++에서 “예외 없는 오류 처리”를 단순히 가능하게 하는 수준을 넘어, 실패를 타입 시스템 안으로 끌어들여 API의 의도를 명확히 하고, 관측성과 디버깅 경험을 개선하는 도구입니다.
예외를 완전히 대체할지 여부는 프로젝트 성격에 따라 다르지만, 최소한 **실패가 흔한 경로(파싱/검증/IO 경계)**부터 std::expected로 정리하면 코드 품질과 운영 안정성 모두에서 체감 효과가 큽니다.