- Published on
C++23 std - -expected로 예외 없이 에러전파·자원관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/시스템 코드나 라이브러리 코드에서 예외를 꺼리는 이유는 꽤 현실적입니다. ABI 경계에서 예외가 새면 치명적이고, 예외 안전성(특히 strong guarantee)을 모든 경로에서 만족시키기 어렵고, 실패가 “정상적인 제어 흐름”인 경우(파일 없음, 네트워크 타임아웃 등)에는 예외가 오히려 과한 경우가 많습니다.
C++23의 std::expected는 이런 상황에서 “성공 값 또는 에러”를 타입으로 표현해 호출자에게 강제하고, 실패를 값으로 전파하면서도 코드 가독성을 유지할 수 있게 해줍니다. 이 글에서는 std::expected의 핵심 사용법, 에러 타입 설계, 전파 패턴, 그리고 RAII 기반 자원관리와 결합하는 방법을 실전 관점에서 정리합니다.
관련해서 운영 환경에서 실패를 추적하는 습관도 중요합니다. 예를 들어 서비스가 계속 재시작되는 상황에서 원인을 좁히는 과정은 에러 전파 설계와 맞닿아 있습니다. 필요하면 systemd 서비스가 계속 재시작될 때 원인 추적법도 함께 참고해보세요.
std::expected란 무엇인가
std::expected<T, E>는 다음 둘 중 하나를 담습니다.
- 성공:
T값 - 실패:
E에러 값
예외와 달리 실패가 타입에 드러나므로, 호출자는 성공/실패 처리를 컴파일 타임에 강제받습니다(무시하면 경고/리뷰에서 바로 드러남). 또한 실패를 “값”으로 다루기 때문에 로깅, 변환, 합성(composition)이 쉬워집니다.
기본 사용 예
#include <expected>
#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");
}
}
int main() {
auto r = parse_int("123x");
if (!r) {
// r.error()는 std::string
return 1;
}
int v = *r; // 또는 r.value()
(void)v;
}
여기서는 예외를 내부에서만 사용했지만(파싱 구현 편의), 외부 API는 예외 없이 실패를 표현합니다. 예외를 완전히 배제하려면 std::from_chars를 쓰는 편이 더 적절합니다.
에러 타입 설계: 문자열만으로는 부족하다
처음에는 E = std::string이 편하지만, 규모가 커지면 다음 문제가 생깁니다.
- 에러 분류가 어려움(문자열 파싱으로 분기)
- 로깅/메트릭 태깅이 불안정(문구 변경이 곧 스키마 변경)
- 원인 체이닝(cause)과 컨텍스트(파일 경로, errno 등) 전달이 번거로움
실전에서는 “기계가 처리 가능한 코드 + 사람이 읽을 메시지 + 원인 정보”를 함께 담는 에러 타입이 유용합니다.
#include <string>
#include <system_error>
enum class Errc {
io,
parse,
not_found,
permission,
timeout,
invariant_violation,
};
struct Error {
Errc code;
std::string message;
std::error_code sys; // errno 계열을 담고 싶을 때
};
이렇게 해두면 호출자는 code로 분기하고, 로그에는 message와 sys를 함께 남길 수 있습니다.
전파 패턴 1: 조기 반환(early return)
std::expected의 가장 직관적인 전파는 “실패면 즉시 반환”입니다.
#include <expected>
#include <string>
using ExpInt = std::expected<int, Error>;
ExpInt read_config_value();
ExpInt compute(int x);
ExpInt pipeline() {
auto a = read_config_value();
if (!a) return std::unexpected(a.error());
auto b = compute(*a);
if (!b) return std::unexpected(b.error());
return *b;
}
단점은 반복이 많아질 수 있다는 점입니다. 이후에 and_then, transform 같은 조합 함수를 쓰면 더 깔끔해집니다.
전파 패턴 2: and_then/transform로 합성
transform: 성공 값T를U로 매핑and_then: 성공 값T를 받아 또 다른expected<U, E>를 반환(체이닝)
#include <expected>
std::expected<int, Error> read_config_value();
std::expected<int, Error> compute(int);
std::expected<int, Error> pipeline2() {
return read_config_value()
.and_then([](int v) { return compute(v); })
.transform([](int v) { return v * 2; });
}
이 방식은 “성공 경로”가 위에서 아래로 자연스럽게 읽힙니다. 실패는 자동으로 스킵되어 마지막까지 전파됩니다.
전파 패턴 3: 에러 변환(transform_error)로 경계 정리
모듈 경계에서 에러 타입을 통일하고 싶을 때가 많습니다. 예를 들어 하위 레이어는 std::error_code를 쓰고, 상위 레이어는 도메인 에러 Error를 쓰게 만들 수 있습니다.
#include <expected>
#include <system_error>
std::expected<std::string, std::error_code> read_file_raw();
std::expected<std::string, Error> read_file() {
return read_file_raw().transform_error([](std::error_code ec) {
Error e{Errc::io, "read_file failed", ec};
return e;
});
}
이렇게 하면 하위 구현의 디테일은 감추고, 상위에서는 일관된 에러 모델로 처리할 수 있습니다.
value() vs operator* vs value_or
value()는 실패 시 예외를 던질 수 있습니다(구현에 따라bad_expected_access). 예외를 금기하는 코드베이스라면 사용을 피하는 편이 안전합니다.operator*와operator->는 전제조건이 “성공 상태”이므로, 반드시if (exp)로 가드한 뒤 사용합니다.value_or(default)는 기본값으로 폴백할 때 유용하지만, 실패를 삼키는 코드가 되기 쉬우니 로깅/메트릭이 필요한 경우 주의합니다.
자원관리: expected는 RAII를 대체하지 않는다
중요한 포인트는 std::expected가 자원관리를 해주는 도구가 아니라는 점입니다. 자원은 여전히 RAII로 관리해야 합니다.
- 파일 디스크립터:
unique_ptr+ 커스텀 deleter 또는 전용 래퍼 - 뮤텍스:
std::lock_guard/std::unique_lock - 메모리:
std::unique_ptr/std::vector
expected는 “실패 경로가 늘어나는 상황”에서 RAII의 장점을 더 크게 만들어 줍니다. 실패해도 스코프를 벗어나면 자원이 자동 해제되기 때문입니다.
예시: 파일 디스크립터를 RAII로 감싸고 expected로 반환
#include <expected>
#include <string>
#include <system_error>
#include <unistd.h>
#include <fcntl.h>
struct UniqueFd {
int fd = -1;
UniqueFd() = default;
explicit UniqueFd(int f) : fd(f) {}
UniqueFd(const UniqueFd&) = delete;
UniqueFd& operator=(const UniqueFd&) = delete;
UniqueFd(UniqueFd&& other) noexcept : fd(other.fd) { other.fd = -1; }
UniqueFd& operator=(UniqueFd&& other) noexcept {
if (this != &other) {
reset();
fd = other.fd;
other.fd = -1;
}
return *this;
}
~UniqueFd() { reset(); }
void reset() {
if (fd != -1) {
::close(fd);
fd = -1;
}
}
int get() const { return fd; }
explicit operator bool() const { return fd != -1; }
};
std::expected<UniqueFd, Error> open_readonly(const char* path) {
int fd = ::open(path, O_RDONLY);
if (fd == -1) {
Error e{Errc::io, "open failed", std::error_code(errno, std::generic_category())};
return std::unexpected(e);
}
return UniqueFd{fd};
}
이제 호출자는 다음처럼 안전하게 사용합니다.
#include <expected>
#include <string>
std::expected<std::string, Error> read_first_byte(const char* path) {
auto fd = open_readonly(path);
if (!fd) return std::unexpected(fd.error());
char c;
ssize_t n = ::read(fd->get(), &c, 1);
if (n != 1) {
Error e{Errc::io, "read failed", std::error_code(errno, std::generic_category())};
return std::unexpected(e);
}
return std::string(1, c);
}
실패가 어디서 발생하든 UniqueFd는 스코프 종료 시 자동으로 close됩니다. 예외가 없어도 동일한 안전성을 확보할 수 있습니다.
실패 경로에서 컨텍스트를 “덧씌우기”
실전 디버깅에서 가장 중요한 건 “어디서 무엇을 하다 실패했는지”입니다. 하위 함수의 에러를 상위에서 그대로 던져주면(또는 그대로 반환하면) 컨텍스트가 부족해집니다.
transform_error로 상위 컨텍스트를 추가하는 패턴이 유용합니다.
std::expected<std::string, Error> load_user_profile(std::string_view user_id);
std::expected<void, Error> handle_request(std::string_view user_id) {
return load_user_profile(user_id)
.transform_error([&](Error e) {
e.message = "handle_request: " + std::string(user_id) + ": " + e.message;
return e;
})
.transform([](auto&&) {
return;
});
}
이 방식은 운영 환경에서 로그만 보고도 원인 지점을 빠르게 좁히는 데 도움이 됩니다. 장애 상황에서의 원인 추적은 결국 “에러가 어떤 경로로 전파되며 어떤 정보를 잃었는가”의 문제이기도 합니다. 쿠버네티스 환경에서 반복 크래시를 로그로 좁히는 접근은 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기처럼 다른 분야에서도 동일하게 적용됩니다.
expected<void, E>로 절차적 API 표현
성공 시 반환할 값이 없을 때는 std::expected<void, E>가 깔끔합니다.
#include <expected>
std::expected<void, Error> write_all(UniqueFd& fd, std::string_view data);
std::expected<void, Error> save_config(const char* path, std::string_view data) {
auto fd = open_readonly(path);
if (!fd) return std::unexpected(fd.error());
// 예시상 readonly지만, 패턴만 보자
return write_all(*fd, data);
}
이때도 조기 반환/체이닝 모두 동일하게 적용됩니다.
예외를 완전히 배제하려면: 경계에서의 규칙 정하기
std::expected를 도입할 때 가장 흔한 실패는 “어떤 함수는 예외, 어떤 함수는 expected”가 뒤섞여 호출자가 혼란을 겪는 상황입니다. 다음 중 하나로 규칙을 정하는 편이 좋습니다.
- 애플리케이션 내부는 예외 허용, 모듈/외부 경계(API, ABI, 스레드 엔트리)에서
expected로 변환 - 저수준 계층(IO, 시스템콜, 드라이버, 임베디드)은 예외 금지, 상위도 일관되게
expected
특히 스레드 엔트리 함수에서 예외가 밖으로 새면 std::terminate로 이어질 수 있으니, 경계에서 expected로 흡수하고 로깅하는 전략이 유효합니다.
테스트 관점: 실패가 타입이 되면 테스트가 쉬워진다
예외 기반 테스트는 throw 타입과 메시지에 의존하기 쉽습니다. expected는 실패가 값이라서, 다음처럼 명확하게 검사할 수 있습니다.
#include <cassert>
void test_parse() {
auto r = parse_int("x");
assert(!r);
// 문자열 에러라면 메시지 비교, 구조화 에러라면 code 비교
}
구조화된 Error를 쓰면 assert(r.error().code == Errc::parse); 같은 형태로 안정적인 테스트가 가능합니다.
마무리: expected는 “명시적 실패”와 “RAII”를 연결한다
정리하면 std::expected의 핵심 가치는 다음입니다.
- 실패가 타입으로 드러나 호출자가 반드시 처리하게 됨
- 실패를 값으로 다루며 체이닝/변환/컨텍스트 추가가 쉬움
- 예외 없이도 실패 경로를 깔끔하게 전파 가능
- RAII와 결합하면 실패 경로가 늘어도 자원 누수 없이 설계 가능
운영 환경에서의 장애 대응은 결국 “실패를 얼마나 잘 모델링하고, 얼마나 많은 컨텍스트를 안전하게 전달했는가”로 귀결됩니다. std::expected는 그 모델링을 C++ 표준 라이브러리 차원에서 지원해주는 도구이고, RAII는 그 모델이 어떤 경로로 흘러가도 자원이 안전하게 정리되도록 받쳐주는 기반입니다.