- Published on
C++23 std - -expected로 예외 없이 에러 처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트/임베디드 등 다양한 C++ 코드베이스에서 예외 기반 에러 처리는 늘 논쟁거리입니다. 예외를 켜면 편하지만 ABI/성능/코드 규약 이슈가 생기고, 끄면 실패 전파가 지저분해지기 쉽습니다. C++23의 std::expected는 이 간극을 메우는 표준 도구입니다. 성공 값 또는 실패 값을 명시적으로 담아 반환하고, 호출자는 실패를 강제적으로 처리하게 만들 수 있습니다.
이 글에서는 std::expected의 핵심 사용법부터, 에러 타입 설계, 함수 합성(체이닝), 변환 유틸, 그리고 예외/std::optional/에러 코드와의 트레이드오프까지 실전 관점으로 정리합니다.
참고로, 이미 비슷한 주제로 정리된 글이 있다면 함께 보셔도 좋습니다: C++23 std - -expected로 예외 없는 에러처리
std::expected란 무엇인가
std::expected<T, E>는 다음 중 하나를 담습니다.
- 성공:
T값 - 실패:
E에러 값
핵심은 실패가 “예외로 던져지는 이벤트”가 아니라 “반환값의 일부”라는 점입니다. 따라서 호출자는 반드시 결과를 분기 처리하거나, 의도적으로 상위로 전달해야 합니다.
대표 API는 아래와 같습니다.
has_value()또는operator bool()로 성공 여부 확인value()로 성공 값 접근(실패 상태에서 호출하면 예외가 날 수 있으니 주의)error()로 실패 값 접근std::unexpected(e)로 실패 생성
기본 예제: 파일 읽기를 expected로 표현
예외 없이 파일 읽기 실패를 전달한다고 가정해 보겠습니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
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 content((std::istreambuf_iterator<char>(ifs)),
std::istreambuf_iterator<char>());
if (!ifs.good() && !ifs.eof()) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return content;
}
int main() {
auto r = read_all_text("/tmp/input.txt");
if (!r) {
// 실패를 값으로 처리
const std::error_code ec = r.error();
// 로깅/메트릭/상위 전파 등
return 1;
}
const std::string& text = r.value();
(void)text;
}
여기서 중요한 습관은 value()를 바로 호출하지 않고, 먼저 if (!r) 또는 if (r.has_value())로 분기하는 것입니다.
std::optional과의 차이: 실패 이유가 필요하다
std::optional<T>는 성공/실패(값 있음/없음)만 표현합니다. 실패 이유가 필요 없거나, 실패가 정상 흐름인 경우에는 optional이 더 단순합니다.
반면 expected는 실패 이유가 “업무적으로 의미”가 있거나, 호출자가 원인별 처리를 해야 하는 경우에 적합합니다.
optional: 파싱이 실패하면 그냥 비어 있음expected: 파싱 실패 원인(포맷 오류, 범위 초과, 인코딩 문제 등)을 구분
에러 타입 E를 어떻게 설계할까
E는 단순 정수 코드부터 풍부한 구조체까지 가능합니다. 실무에서 중요한 기준은 아래 3가지입니다.
- 로깅/관측 가능성: 문제가 났을 때 원인 파악이 가능한가
- 전파 비용: 에러 객체가 너무 무거워 복사 비용이 커지지 않는가
- 호출자 처리 용이성: 호출자가 분기 처리하기 쉬운가
패턴 1: std::error_code 사용
표준 라이브러리와 잘 맞고, 비용이 가볍습니다.
using result_t = std::expected<int, std::error_code>;
단점은 도메인 특화 정보(예: 어떤 필드가 잘못됐는지)를 담기 어렵다는 점입니다.
패턴 2: 도메인 에러 구조체
실패 원인에 추가 컨텍스트를 담고 싶다면 구조체가 좋습니다.
#include <expected>
#include <string>
enum class parse_errc {
empty,
invalid_char,
overflow
};
struct parse_error {
parse_errc code;
std::size_t pos{}; // 문제가 발생한 위치
std::string detail; // 디버깅용 메시지
};
std::expected<int, parse_error> parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(parse_error{parse_errc::empty, 0, "empty input"});
}
long long sign = 1;
std::size_t i = 0;
if (s[0] == '-') { sign = -1; i = 1; }
long long v = 0;
for (; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') {
return std::unexpected(parse_error{parse_errc::invalid_char, i, "non-digit"});
}
v = v * 10 + (c - '0');
if (v > 2147483647LL + (sign < 0)) {
return std::unexpected(parse_error{parse_errc::overflow, i, "out of int range"});
}
}
return static_cast<int>(v * sign);
}
이 방식은 관측성이 뛰어나지만, 에러 객체가 커질수록 전파 비용이 커질 수 있습니다. 실무에서는 detail을 항상 채우기보다, 디버그 빌드에서만 넣거나, 짧은 고정 문자열로 제한하는 식의 규율이 필요합니다.
실패 전파를 깔끔하게: early return 패턴
expected는 “실패하면 즉시 반환” 패턴과 궁합이 좋습니다.
#include <expected>
#include <string>
struct err { int code; };
std::expected<int, err> step1();
std::expected<std::string, err> step2(int);
std::expected<void, err> step3(const std::string&);
std::expected<void, err> pipeline() {
auto a = step1();
if (!a) return std::unexpected(a.error());
auto b = step2(*a);
if (!b) return std::unexpected(b.error());
auto c = step3(*b);
if (!c) return std::unexpected(c.error());
return {};
}
여기서 *a는 a.value()와 유사하지만, 역시 성공 상태에서만 써야 합니다.
체이닝: and_then, transform, or_else 활용
C++23 std::expected는 함수형 스타일의 합성을 지원합니다.
and_then(f): 성공 값이 있으면f(T)를 호출하고, 그 결과expected를 반환transform(f): 성공 값이 있으면f(T)로T를 다른 타입으로 변환or_else(f): 실패면f(E)를 호출해 복구하거나 다른 실패로 매핑
예를 들어 파싱 후 검증 후 문자열 생성 같은 흐름을 합성할 수 있습니다.
#include <expected>
#include <string>
#include <string_view>
struct err { std::string msg; };
std::expected<int, err> parse_port(std::string_view s);
std::expected<int, err> validate_port(int p) {
if (p < 1 || p > 65535) {
return std::unexpected(err{"port out of range"});
}
return p;
}
std::expected<std::string, err> make_endpoint(int port) {
return std::string("127.0.0.1:") + std::to_string(port);
}
std::expected<std::string, err> build_endpoint(std::string_view port_text) {
return parse_port(port_text)
.and_then(validate_port)
.and_then(make_endpoint);
}
이 패턴의 장점은 중간 단계에서 실패하면 자동으로 실패가 전파되고, 성공 경로만 선언적으로 읽힌다는 점입니다.
에러 변환: 레이어별로 에러 타입을 바꾸기
실무에서는 레이어마다 에러 표현이 달라집니다.
- 인프라 레이어:
std::error_code - 도메인 레이어:
domain_error - API 레이어:
api_error(HTTP 상태/에러 코드)
이때 or_else 또는 수동 매핑으로 에러 타입을 변환합니다.
#include <expected>
#include <system_error>
#include <string>
struct api_error {
int http_status;
std::string code;
};
api_error map_ec(std::error_code ec) {
if (ec == std::errc::no_such_file_or_directory) {
return {404, "NOT_FOUND"};
}
return {500, "INTERNAL"};
}
std::expected<std::string, std::error_code> read_all_text_ec(const std::string& path);
std::expected<std::string, api_error> read_all_text_api(const std::string& path) {
auto r = read_all_text_ec(path);
if (!r) return std::unexpected(map_ec(r.error()));
return *r;
}
이렇게 하면 하위 레이어의 디테일을 상위 레이어에 누수시키지 않으면서도, 실패를 값으로 안전하게 다룰 수 있습니다.
expected<void, E>: 성공 값이 필요 없을 때
성공 시 반환할 값이 없다면 std::expected<void, E>가 깔끔합니다.
#include <expected>
#include <system_error>
std::expected<void, std::error_code> write_config();
std::expected<void, std::error_code> init() {
auto r = write_config();
if (!r) return std::unexpected(r.error());
return {};
}
return {};는 성공을 의미합니다.
예외와의 공존: 경계에서만 변환하기
기존 코드가 예외를 사용한다면 한 번에 전부 바꾸기 어렵습니다. 현실적인 전략은 “경계”에서만 변환하는 것입니다.
- 내부 코어 로직:
expected로 실패를 값으로 모델링 - 외부 프레임워크 경계: 필요 시 예외로 변환(또는 반대로)
예를 들어 외부 API가 예외를 요구한다면:
#include <expected>
#include <stdexcept>
#include <string>
struct err { std::string msg; };
std::expected<int, err> compute();
int compute_or_throw() {
auto r = compute();
if (!r) {
throw std::runtime_error(r.error().msg);
}
return *r;
}
이렇게 하면 예외는 “최외곽”으로 격리되고, 내부는 테스트와 추론이 쉬운 함수형 스타일을 유지할 수 있습니다.
성능/코드 품질 관점에서의 체크리스트
expected는 예외를 완전히 대체한다기보다, 실패를 더 명시적으로 만들고 제어 흐름을 단순화하는 도구입니다. 다만 도입 시 아래를 점검해야 합니다.
- 에러 객체 크기:
E가 커지면 반환/복사 비용이 커질 수 있습니다. 가능하면 작은 값 타입으로 유지하세요. - 성공 경로의 비용: 성공이 대부분인 함수에서 실패 정보까지 항상 구성하지 않도록 설계하세요.
- 로그/메트릭 위치: 실패를 반환만 하고 아무도 기록하지 않으면 장애 분석이 어려워집니다. 운영 환경에서 재시작 루프나 장애 원인 추적이 중요한 것처럼, 실패를 어디서 관측할지 규칙을 정해야 합니다. 이런 관점은 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘 같은 글의 접근과도 닿아 있습니다.
- 호출자 강제성:
expected는 호출자가 분기를 하도록 유도하지만,value()를 무심코 호출하면 런타임 문제가 될 수 있습니다. 팀 규칙으로 “value()직접 호출 금지, 반드시 분기 후 접근” 같은 룰을 두는 것도 방법입니다.
마이그레이션 전략: 작은 단위로, 경계부터
기존에 bool 반환 + out parameter, 혹은 int 에러 코드로 구성된 API를 expected로 바꾸는 순서는 보통 다음이 안전합니다.
- 가장 자주 실패하는 경로부터
expected로 감싼 래퍼 함수를 추가 - 신규 기능은
expected우선으로 작성 - 호출부에서 실패 처리 패턴을 표준화(로깅, 변환, 상위 전파)
- 경계에서만 예외/에러 코드로 변환해 레거시와 공존
이 방식은 대규모 리팩터링 없이도 점진적으로 실패 처리를 개선할 수 있습니다.
정리
std::expected는 C++에서 “예외 없이도” 실패를 풍부하게 표현하고, 호출자에게 실패 처리를 강제하며, 함수 합성까지 가능한 표준 도구입니다. 특히 다음 상황에서 효과가 큽니다.
- 예외를 금지하거나 제한하는 코드베이스
- 실패가 정상 흐름으로 자주 발생하는 로직(파싱, 검증, I/O 경계)
- 에러 원인을 호출자에게 명확히 전달해야 하는 API
도입의 핵심은 E를 과하게 무겁게 만들지 않기, 레이어별 에러 변환 규칙 세우기, 그리고 어디서 관측할지(로그/메트릭) 명확히 하는 것입니다. 이런 규칙만 잡히면 expected는 에러 처리를 “불편한 의무”에서 “읽히는 코드”로 바꿔줍니다.