- Published on
C++23 std - -expected로 예외 없이 안전한 소유권
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/시스템 C++ 코드에서 예외를 꺼두는 팀은 꽤 많습니다. 이유는 다양합니다. 바이너리 크기, 예측 가능한 제어 흐름, ABI/경계(플러그인, C API, 게임 엔진)에서의 안정성, 그리고 무엇보다 “실패가 정상 흐름의 일부”인 코드에서 예외가 오히려 가독성을 떨어뜨리기 때문입니다.
문제는 예외를 끄면 실패를 표현하는 방식이 급격히 빈약해진다는 점입니다. nullptr 반환, bool 반환 + out-parameter, 에러 코드를 전역/스레드 로컬에 저장… 모두 소유권(ownership)과 결합될 때 취약해집니다.
C++23의 std::expected는 이 지점을 정확히 찌릅니다. 성공 시 값(T)을, 실패 시 에러(E)를 같은 타입으로 표현하고, 호출자가 실패 처리를 “강제”받게 만들면서도, std::unique_ptr 같은 이동 전용 소유권 타입도 자연스럽게 담을 수 있습니다.
이 글에서는 std::expected를 이용해 예외 없이도 안전한 소유권 이동과 자원 관리를 구현하는 실전 패턴을 다룹니다.
std::expected가 소유권 문제를 해결하는 방식
std::expected<T, E>는 다음 의미를 가집니다.
- 성공:
T를 가진다 - 실패:
E를 가진다
여기서 중요한 포인트는 T가 이동 전용 타입이어도 된다는 점입니다. 즉, std::expected<std::unique_ptr<Foo>, Err> 같은 형태로 “성공하면 소유권을 넘긴다, 실패하면 에러를 넘긴다”를 한 번에 표현할 수 있습니다.
기존의 std::optional<std::unique_ptr<Foo>>는 실패 사유를 잃어버리고, nullptr 반환은 “실패인지, 성공인데 값이 null인지”가 섞입니다. 반면 std::expected는 실패를 값으로 모델링합니다.
기본 예제: 파일 열기와 소유권 반환
예외 없이 파일 핸들을 열고, 성공 시 소유권을 반환하는 클래식한 케이스를 보겠습니다.
#include <expected>
#include <cstdio>
#include <memory>
#include <string>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
enum class OpenErr {
NotFound,
PermissionDenied,
Unknown,
};
std::expected<FilePtr, OpenErr> open_file(const std::string& path) noexcept {
std::FILE* raw = std::fopen(path.c_str(), "rb");
if (!raw) {
// 실제로는 `errno`를 보고 매핑하는 편이 좋습니다.
return std::unexpected(OpenErr::NotFound);
}
return FilePtr{raw};
}
std::expected<std::string, OpenErr> read_all(const std::string& path) {
auto f = open_file(path);
if (!f) return std::unexpected(f.error());
std::string out;
char buf[4096];
while (true) {
auto n = std::fread(buf, 1, sizeof(buf), f->get());
if (n == 0) break;
out.append(buf, buf + n);
}
return out;
}
핵심은 다음과 같습니다.
open_file은 성공하면FilePtr을 반환해 소유권을 호출자에게 넘깁니다.- 실패하면
std::unexpected(err)로 실패를 명시합니다. - 호출자는
if (!f)로 실패를 확인하고,f.error()로 에러 원인을 얻습니다.
예외를 쓰지 않으면서도, 자원 해제는 RAII로 자동화되고, 실패 사유도 잃지 않습니다.
“안전한 소유권”을 망치는 흔한 패턴과 expected로의 교정
1) out-parameter로 unique_ptr 채우기
bool make_widget(std::unique_ptr<Widget>& out);
이 방식은 호출자가 out의 기존 소유권을 어떻게 다루는지에 따라 실수 여지가 큽니다. 또한 실패 사유가 bool에 눌려서 디버깅이 어려워집니다.
expected로 바꾸면 API 자체가 더 안전해집니다.
#include <expected>
#include <memory>
#include <string>
enum class WidgetErr { InvalidConfig, NoMemory };
std::expected<std::unique_ptr<Widget>, WidgetErr>
make_widget(const Config& cfg) noexcept;
성공 시 새 소유권을 “반환”하고, 실패 시 에러를 “반환”합니다. 소유권 전달이 함수 시그니처만 봐도 명확해집니다.
2) nullptr로 실패 표현
std::unique_ptr<T>를 반환하고 실패 시 nullptr을 돌려주는 방식은, 실패 사유를 잃습니다. 그리고 “성공이지만 값이 비어 있음”이라는 상태가 필요한 모델에서는 표현이 깨집니다.
std::expected<std::unique_ptr<T>, Err>로 바꾸면 성공/실패가 분리됩니다.
에러 타입 E 설계: error_code vs 커스텀 enum
std::expected의 E는 팀/프로젝트 성격에 따라 달라집니다.
- 라이브러리/플랫폼 친화적:
std::error_code - 도메인 에러가 명확:
enum class+ 필요한 부가 정보는 별도 구조체
예를 들어 파일 오픈 에러에 경로와 errno를 함께 담고 싶다면 다음처럼 구조체를 씁니다.
#include <expected>
#include <string>
struct OpenError {
int posix_errno;
std::string path;
};
std::expected<FilePtr, OpenError> open_file2(std::string path) noexcept {
std::FILE* raw = std::fopen(path.c_str(), "rb");
if (!raw) {
// 여기서는 예시로 -1을 넣지만 실제로는 `errno`를 복사하세요.
return std::unexpected(OpenError{ -1, std::move(path) });
}
return FilePtr{raw};
}
실패도 “값”이기 때문에 로깅과 진단이 훨씬 쉬워집니다. 운영 환경에서 문제를 빨리 좁히는 습관은 인프라 글에서도 반복해서 강조되는 주제인데, 예를 들어 원인 추적 체크리스트를 만들듯이 애플리케이션 레벨에서도 실패 정보를 구조화해 두는 게 중요합니다. 참고로 운영 진단 관점은 systemd 서비스가 계속 재시작될 때 진단 체크리스트 같은 글과 결이 같습니다.
체이닝 패턴: 실패를 전파하면서 소유권 이동하기
예외를 쓰면 throw로 위로 전파되지만, expected에서는 “리턴으로 전파”합니다. 이때 반복되는 보일러플레이트를 줄이기 위한 패턴이 필요합니다.
1) 가장 단순한 전파
auto w = make_widget(cfg);
if (!w) return std::unexpected(w.error());
use(std::move(*w));
이 패턴은 명시적이고 안전합니다. 특히 소유권 이동은 std::move(*w)로 눈에 보이게 드러납니다.
2) 작은 헬퍼로 단축
프로젝트에서 흔히 쓰는 헬퍼는 “성공이면 변환, 실패면 그대로 전파”입니다.
#include <expected>
#include <utility>
template <class T, class E, class F>
auto and_then(std::expected<T, E>&& ex, F&& f)
-> decltype(std::forward<F>(f)(std::move(*ex)))
{
using R = decltype(std::forward<F>(f)(std::move(*ex)));
if (!ex) return R{std::unexpected(ex.error())};
return std::forward<F>(f)(std::move(*ex));
}
사용 예:
auto result = and_then(open_file("/tmp/a.bin"),
[](FilePtr f) -> std::expected<std::size_t, OpenErr> {
// 소유권을 받은 f로 작업
std::fseek(f.get(), 0, SEEK_END);
auto size = static_cast<std::size_t>(std::ftell(f.get()));
return size;
});
이 방식은 예외 없는 코드에서 “파이프라인”을 만들기 좋고, 소유권이 단계마다 명확히 이동합니다.
expected와 RAII 조합: “부분 성공”을 없애기
예외 없는 코드에서 가장 위험한 상황은 다음입니다.
- 자원 A는 획득
- 자원 B 획득 실패
- A 해제 누락
expected 자체가 해제를 해주진 않지만, 성공 값에 RAII 타입을 넣어두면 “실패 시 자동 정리”가 됩니다.
예를 들어 소켓 FD를 감싸는 RAII 타입을 만들고, 연결 함수는 expected<Socket, ConnectErr>를 반환하는 식입니다.
#include <expected>
#include <unistd.h>
class Fd {
public:
explicit Fd(int fd = -1) noexcept : fd_(fd) {}
Fd(Fd&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
Fd& operator=(Fd&& other) noexcept {
if (this != &other) {
reset();
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
~Fd() { reset(); }
int get() const noexcept { return fd_; }
int release() noexcept { int t = fd_; fd_ = -1; return t; }
void reset(int fd = -1) noexcept {
if (fd_ != -1) ::close(fd_);
fd_ = fd;
}
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
private:
int fd_;
};
enum class ConnectErr { DnsFail, Timeout, Refused };
std::expected<Fd, ConnectErr> connect_tcp(const char* host, int port) noexcept;
이제 연결이 실패하면 FD는 생성되지 않거나, 생성되었더라도 스코프 종료로 정리됩니다. “부분 성공” 상태를 타입으로 차단하는 셈입니다.
경계(ABI/C API/스레드)에서의 장점
예외는 경계를 넘을 때 골치 아픕니다.
- C API로 예외가 넘어가면
std::terminate위험 - 플러그인/서드파티 바이너리와 ABI가 다르면 예외 런타임이 호환되지 않을 수 있음
- 스레드 경계에서 예외를 전파하기 어렵고, 결국
std::exception_ptr같은 우회가 필요
반면 std::expected는 단순한 값 반환이므로 경계에서 훨씬 다루기 쉽습니다. 특히 소유권도 값으로 이동하므로, “누가 해제해야 하는가”가 호출 규약에 명확히 박힙니다.
로깅/관측 가능성: 실패를 구조화하라
운영 환경에서 장애를 줄이는 핵심은 “실패를 재현 가능한 정보로 남기는 것”입니다. expected의 E를 구조화하면, 로깅에서 문자열 파싱 없이도 원인을 집계할 수 있습니다.
- 에러 enum을 메트릭 label로 집계
path,host,status같은 필드를 함께 보관- 호출 스택 대신 “실패 지점”을 코드로 남김
이런 관점은 인프라/배포에서도 동일합니다. 예를 들어 캐시가 왜 안 맞는지 원인 분류를 해두면 해결이 빨라지듯이, 애플리케이션도 에러를 분류해두면 진단이 빨라집니다. 관련해서는 GitHub Actions 캐시 안 먹힘 원인 7가지처럼 원인을 범주화하는 접근이 도움이 됩니다.
std::expected 사용 시 주의점
1) 무분별한 value() 호출 금지
ex.value()는 실패 시 예외(std::bad_expected_access)를 던질 수 있습니다. 예외 없는 정책이라면 operator bool() 체크 후 *ex 또는 ex.value() 대신 안전한 접근을 쓰세요.
auto ex = open_file("a.bin");
if (!ex) {
// handle ex.error()
return;
}
auto& f = *ex; // 안전
2) 에러 타입에 큰 객체를 넣을 때 비용
E에 큰 문자열/벡터를 넣으면 복사/이동 비용이 생깁니다. 필요하면
- 작은 enum + 별도 로그
std::string은 move를 적극 활용- 에러 메시지는 지연 생성
같은 전략을 고려하세요.
3) “정말 예외가 맞는” 상황까지 억지로 expected로 만들지 않기
프로그래밍 오류(불변식 위반, 계약 위반)는 expected로 숨기기보다 assert/종료가 더 적절할 수 있습니다. expected는 “정상적으로 발생 가능한 실패”를 모델링할 때 가장 빛납니다.
결론: 예외 없는 코드에서 소유권을 타입으로 고정하라
C++23 std::expected는 예외를 쓰지 않는 환경에서 다음을 동시에 만족시킵니다.
- 실패를 값으로 표현해 호출자가 반드시 처리하게 함
- 성공 값에 RAII/소유권 타입을 담아 누수와 이중 해제를 구조적으로 차단
- 에러를 구조화해 운영 진단과 로깅 품질을 높임
정리하면, std::expected는 “예외 없는 코드에서도 현대 C++의 소유권 모델을 끝까지 밀고 갈 수 있게 해주는 도구”입니다. 기존의 bool + out-parameter, nullptr 반환, 에러 코드 혼합 패턴을 단계적으로 expected로 치환해보면, 코드가 단단해지는 것을 체감할 수 있을 겁니다.