- Published on
C++23 std - -expected로 예외 없이 오류전파·RAII
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·에이전트·게임엔진처럼 지연시간과 안정성이 중요한 C++ 코드베이스에서는 예외를 전면 금지하거나 제한적으로만 허용하는 경우가 많습니다. 하지만 예외를 끄면(-fno-exceptions) 곧바로 마주치는 문제가 있습니다.
- 깊은 호출 스택에서 오류를 "빠르게" 전파하기가 번거롭다
- 리소스 정리(파일/소켓/락/메모리)를 매번 수동으로 처리하다가 누수가 난다
- 오류 코드와 로그가 제각각이라 디버깅이 힘들다
C++23의 std::expected는 이 간극을 메우는 표준 도구입니다. 반환값에 "성공 값" 또는 "오류"를 함께 담아 호출자가 강제적으로 처리하게 만들고, RAII와 결합하면 예외 없이도 "안전한 조기 반환"을 깔끔하게 구현할 수 있습니다.
아래에서는 std::expected의 핵심 사용법, 오류 타입 설계, 전파 패턴, 그리고 RAII와 함께 쓰는 실전 구성을 예제로 정리합니다.
std::expected가 해결하는 것
std::expected는 다음 형태의 타입입니다.
- 성공 시
T값을 가진다 - 실패 시
E오류 값을 가진다
즉, 함수 시그니처만 봐도 "이 함수는 실패할 수 있다"가 드러납니다.
#include <expected>
#include <string>
enum class Errc {
invalid_input,
io_error,
};
std::expected<int, Errc> parse_int(std::string_view s);
예외 기반 코드에서 throw가 하던 역할을, 이제는 return std::unexpected(err)가 대신합니다.
기본 사용법: 생성, 검사, 접근
#include <expected>
#include <charconv>
#include <string_view>
enum class Errc { invalid_input };
std::expected<int, Errc> parse_int(std::string_view s) {
int v = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
if (ec != std::errc{} || ptr != s.data() + s.size()) {
return std::unexpected(Errc::invalid_input);
}
return v;
}
void use() {
auto r = parse_int("123");
if (!r) {
// r.error()로 오류 접근
return;
}
// r.value() 또는 *r 로 값 접근
int x = *r;
}
포인트는 두 가지입니다.
if (!r)로 실패 여부를 명시적으로 확인- 실패 시
r.error()로 오류를 구조적으로 꺼냄
오류 타입 설계: enum만으로는 부족하다
단순한 enum class는 빠르고 가볍지만, 운영 환경에서는 대개 다음 정보가 필요합니다.
- 어떤 단계에서 실패했는지(컨텍스트)
- OS 오류 코드(예:
errno), 라이브러리 오류 코드 - 디버깅을 위한 메시지
한 가지 실전적인 절충안은 "작은 오류 구조체"입니다.
#include <string>
#include <system_error>
enum class Errc {
invalid_input,
open_failed,
read_failed,
};
struct Error {
Errc code;
int sys_errno = 0; // 필요할 때만 세팅
std::string context; // 디버깅용
};
이렇게 하면 std::expected<T, Error>로 오류를 풍부하게 전달할 수 있습니다. (성능이 민감하면 std::string 대신 std::string_view + 별도 저장소, 또는 고정 버퍼를 고려하세요.)
예외 없이 "조기 반환" 전파 패턴
std::expected의 장점은 "실패 시 즉시 반환"을 일관되게 만들 수 있다는 점입니다.
패턴 1: 직접 전파
std::expected<std::string, Error> read_file(std::string_view path);
std::expected<int, Error> load_config_version(std::string_view path) {
auto text = read_file(path);
if (!text) return std::unexpected(text.error());
auto v = parse_int(*text);
if (!v) return std::unexpected(Error{Errc::invalid_input, 0, "version"});
return *v;
}
이 방식은 명시적이라 좋지만, 반복이 늘어납니다.
패턴 2: and_then / transform로 파이프라인화
std::expected는 모나딕 연산을 제공합니다.
transform(f)는 성공 값에f(T)를 적용and_then(f)는 성공 값에f(T)를 적용하되,f가 다시expected를 반환할 때 연결or_else(f)는 실패 시 오류를 변환하거나 로깅
std::expected<std::string, Error> read_file(std::string_view path);
std::expected<int, Error> parse_version(std::string_view text);
std::expected<int, Error> load_config_version(std::string_view path) {
return read_file(path)
.and_then([](const std::string& s) {
return parse_version(s);
})
.or_else([](const Error& e) -> std::expected<int, Error> {
// 로깅/메트릭 후 그대로 전파
return std::unexpected(e);
});
}
파이프라인은 "성공 경로"를 직선으로 읽게 만들어 주고, 실패는 자동으로 우회합니다.
RAII와 결합: 예외가 없어도 리소스는 자동 정리된다
예외를 끄면 흔히 오해하는 부분이 있습니다. "예외가 없으면 RAII가 의미가 줄어든다"는 생각인데, 실제로는 반대입니다.
- 예외가 있든 없든, 스코프를 벗어나면 소멸자가 호출됩니다.
std::expected는 실패 시 조기return을 자주 쓰게 만듭니다.- 조기 반환이 많을수록 RAII가 더 중요해집니다.
파일 디스크립터 RAII 예제
POSIX 파일 디스크립터는 반드시 close 해야 합니다. 예외 없이 작성하면 return 경로마다 close를 넣기 쉽고, 누락이 생깁니다. RAII로 감싸면 해결됩니다.
#include <expected>
#include <string>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
struct Fd {
int fd = -1;
Fd() = default;
explicit Fd(int f) : fd(f) {}
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
Fd(Fd&& other) noexcept : fd(other.fd) { other.fd = -1; }
Fd& operator=(Fd&& other) noexcept {
if (this != &other) {
if (fd != -1) ::close(fd);
fd = other.fd;
other.fd = -1;
}
return *this;
}
~Fd() { if (fd != -1) ::close(fd); }
};
enum class Errc { open_failed, read_failed };
struct Error { Errc code; int sys_errno; std::string context; };
std::expected<Fd, Error> open_ro(const char* path) {
int f = ::open(path, O_RDONLY);
if (f == -1) {
return std::unexpected(Error{Errc::open_failed, errno, "open"});
}
return Fd{f};
}
std::expected<std::string, Error> read_all(const char* path) {
auto fd = open_ro(path);
if (!fd) return std::unexpected(fd.error());
std::string out;
char buf[4096];
while (true) {
ssize_t n = ::read(fd->fd, buf, sizeof(buf));
if (n == 0) break;
if (n < 0) {
return std::unexpected(Error{Errc::read_failed, errno, "read"});
}
out.append(buf, buf + n);
}
// 여기서 조기 반환이든 정상 반환이든, fd는 소멸하며 close 된다.
return out;
}
핵심은 read_all 어디에서 return 하더라도 Fd 소멸자가 실행된다는 점입니다. 예외가 없어도 RAII는 동일하게 강력합니다.
"오류 전파 + 락" 같이 섞인 코드에서의 장점
멀티스레드 코드에서 락을 잡고 중간에 실패하면, 락 해제를 놓치기 쉽습니다. 하지만 std::scoped_lock 같은 RAII 락과 expected는 궁합이 좋습니다.
#include <expected>
#include <mutex>
#include <unordered_map>
#include <string>
struct Error { int code; std::string context; };
class Cache {
std::mutex mu_;
std::unordered_map<std::string, std::string> m_;
public:
std::expected<std::string, Error> get(std::string key) {
std::scoped_lock lock(mu_);
auto it = m_.find(key);
if (it == m_.end()) {
return std::unexpected(Error{404, "cache_miss"});
}
return it->second;
}
};
조기 반환이 많아져도 락 해제는 자동입니다.
호출자 측 처리 전략: "반드시 처리"를 코드로 강제하기
예외는 호출자가 try/catch를 빼먹어도 컴파일이 됩니다. 반면 expected는 호출자가 값을 쓰려면 실패 여부를 확인해야 하는 흐름을 자연스럽게 유도합니다.
다만 value()를 무심코 호출하면 실패 시 예외(std::bad_expected_access)가 발생할 수 있습니다. 예외를 금지한다면 다음 원칙을 추천합니다.
- 프로젝트 규칙으로
value()사용 금지(리뷰/린트) if (!r) return ...;또는and_then체이닝으로만 접근
expected를 "경계"에 두고, 내부는 단순하게
모든 함수가 std::expected를 반환하면 타입이 전염되는 문제가 생길 수 있습니다. 실전에서는 다음처럼 레이어링이 깔끔합니다.
- 외부 I/O, 파싱, 네트워크 등 실패가 흔한 경계에서
expected사용 - 내부 순수 로직은 실패가 불가능하도록 설계하고 일반 반환
예를 들어 설정 파일을 읽고 파싱하는 경계는 expected로, 그 결과를 받아 처리하는 비즈니스 로직은 일반 함수로 유지합니다.
std::expected와 기존 방식 비교
std::optional 대비
optional은 실패 이유를 담지 못합니다expected는 실패 이유를 1급 값으로 전달합니다
오류 코드(int) 대비
- 오류 코드만 반환하면 성공 값 전달을 위해 out-parameter가 늘어납니다
expected는 성공 값과 오류를 한 타입에 담아 호출자 경험을 개선합니다
현업 팁: 오류를 "관측 가능"하게 만들기
오류 전파는 결국 관측(로그/메트릭/트레이싱)과 이어집니다. expected는 오류가 값이므로, 경계에서 일관된 로깅을 넣기 좋습니다.
or_else에서 공통 로깅Error에context를 누적(단, 과도한 문자열 누적은 비용이 될 수 있음)
운영 장애를 추적할 때도 "어디서 실패했는지"가 중요합니다. 이런 관점은 인프라 트러블슈팅 글에서 말하는 재현·추적 습관과 유사합니다. 예를 들어 네트워크 경로를 단계적으로 좁혀가는 방식은 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법 같은 글의 접근과 닮아 있습니다.
마이그레이션 전략: 예외 코드에서 expected로
기존 예외 기반 코드를 한 번에 바꾸기 어렵다면, 다음 순서를 추천합니다.
- I/O 경계부터
expected로 바꾸기(파일, 네트워크, DB) - 예외를 던지던 함수를
expected로 감싸는 어댑터 작성 - 상위 레이어에서
and_then으로 성공 경로를 정리 - 마지막에 예외를 끄거나(가능하면) 제한적으로만 남기기
CI에서 컴파일 옵션과 규칙을 점진적으로 강화하는 것도 도움이 됩니다. 대규모 변경을 안전하게 굴리는 방식은 GitHub Actions 병렬·매트릭스로 CI 50% 단축처럼 파이프라인을 잘 설계해두면 훨씬 수월합니다.
정리
C++23 std::expected는 "예외 없이도" 다음을 가능하게 합니다.
- 실패 가능한 함수의 시그니처를 명확히 표현
- 오류를 구조적 데이터로 전달해 디버깅/관측성을 개선
- 조기 반환 기반의 깔끔한 오류 전파
- RAII와 결합해 리소스 누수 없이 안전한 코드 유지
예외를 없애는 목적이 성능이든, ABI/플랫폼 제약이든, 운영 안정성이든 간에 std::expected는 C++ 표준 라이브러리 차원에서 제공되는 가장 실용적인 대안 중 하나입니다. 특히 조기 반환이 많은 코드일수록 RAII와 함께 쓸 때 효과가 극대화됩니다.