- Published on
C++23 std - -expected로 예외 없는 안전한 자원관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외 기반 에러 처리는 편하지만, 모든 코드베이스에서 정답은 아닙니다. 특히 다음 조건이 겹치면 예외는 비용이 커집니다.
- 저지연 시스템에서 예외 경로의 비용과 예측 불가능성이 부담일 때
- C API, OS 핸들, 네트워크 라이브러리처럼 실패가 “정상 흐름의 일부”인 영역이 많을 때
- 예외가 금지된 컴파일 옵션(예:
-fno-exceptions) 또는 코딩 규약을 따를 때 - 스레드/코루틴 경계에서 실패를 표준화된 형태로 전달하고 싶을 때
C++23의 std::expected는 “성공 값” 또는 “에러 값”을 한 타입으로 표현합니다. 핵심은 실패가 타입 시스템에 드러나기 때문에, 호출자가 실패 처리를 강제받고, 예외 없이도 안전하게 자원을 관리할 수 있다는 점입니다. 이 글에서는 std::expected를 RAII와 결합해 누수 없이 자원을 획득하고, 실패를 깔끔하게 전파하는 패턴을 실전 관점에서 정리합니다.
참고로 분산 환경에서는 실패 처리 패턴이 곧 안정성입니다. 예를 들어 gRPC에서 데드라인과 리트라이가 얽히면 “실패를 어떻게 표현하고 전파하는가”가 폭주를 막는 핵심이 됩니다. 관련해서는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 함께 읽어보면 좋습니다.
std::expected 빠른 개념 정리
std::expected<T, E>는 다음 중 하나를 담습니다.
- 성공:
T - 실패:
E
주요 API는 아래 정도만 익혀도 실무에서 충분히 씁니다.
has_value()또는operator bool()로 성공 여부 확인value()로 성공 값 접근(실패 상태에서 호출하면 정의된 방식으로 종료될 수 있으니 주의)error()로 에러 값 접근value_or(default)로 기본값 제공
중요한 설계 포인트는 E를 무엇으로 할지입니다. 보통은 다음 중 하나가 현실적입니다.
std::error_code: OS/라이브러리 에러와의 호환이 좋음- 자체
enum class Error: 도메인 에러를 컴팩트하게 표현 - 구조체
struct Error { ... }: 메시지, 원인, 컨텍스트를 함께 담음
이 글에서는 자원 관리 예제로 “에러 코드 + 추가 컨텍스트”가 담긴 구조체를 사용해보겠습니다.
예외 없이도 자원은 RAII로 관리한다
예외를 쓰지 않는다고 해서 RAII를 포기하는 게 아닙니다. 오히려 반대입니다.
- 자원 획득은 생성자/팩토리에서
- 자원 해제는 소멸자에서
- 실패는
std::expected로 반환
이 조합이 강력한 이유는 다음입니다.
- 성공한 뒤에는 평범한 RAII 객체로서 자동 해제됨
- 실패한 경우에는 애초에 “유효한 자원 객체”가 만들어지지 않음
- 중간 단계에서 실패해도, 이미 만들어진 임시 RAII 객체들이 스코프 종료로 정리됨
즉, “자원 누수 방지”는 RAII가 담당하고, “실패 전달”은 std::expected가 담당합니다.
실전 패턴 1: 파일 디스크립터를 expected로 안전하게 열기
POSIX의 open은 실패가 빈번하고, 실패 이유가 errno로 전달됩니다. 예외 없이도 다음처럼 깔끔한 API를 만들 수 있습니다.
#include <expected>
#include <string>
#include <system_error>
#include <unistd.h>
#include <fcntl.h>
struct OpenError {
std::error_code ec;
std::string path;
};
class UniqueFd {
public:
UniqueFd() = default;
explicit UniqueFd(int fd) : fd_(fd) {}
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(); }
int get() const { return fd_; }
explicit operator bool() const { return fd_ != -1; }
void reset() {
if (fd_ != -1) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_ = -1;
};
std::expected<UniqueFd, OpenError> open_readonly(const std::string& path) {
int fd = ::open(path.c_str(), O_RDONLY);
if (fd == -1) {
return std::unexpected(OpenError{
std::error_code(errno, std::generic_category()),
path
});
}
return UniqueFd(fd);
}
사용 측은 실패 처리를 강제받습니다.
#include <iostream>
int main() {
auto fd = open_readonly("/tmp/data.txt");
if (!fd) {
std::cerr << "open failed: path=" << fd.error().path
<< " ec=" << fd.error().ec.message() << "\n";
return 1;
}
// 성공 이후에는 RAII로 안전
int raw = fd->get();
(void)raw;
return 0;
}
포인트는 open_readonly가 “성공 시에만 유효한 RAII 객체”를 만들고, 실패 시에는 std::unexpected로 에러를 돌려준다는 점입니다.
실전 패턴 2: 다단계 자원 획득을 expected로 조합하기
자원 관리가 까다로운 경우는 “여러 단계를 거쳐 초기화”할 때입니다. 예를 들어
- 파일 열기
- 크기 확인
- 버퍼 할당
- 읽기
중간에 실패하면 이미 잡은 자원을 정리해야 합니다. RAII를 쓰면 정리는 자동이지만, 실패 전파가 지저분해지기 쉽습니다. std::expected를 쓰면 실패 전파가 명시적이면서도 일관됩니다.
#include <expected>
#include <vector>
#include <sys/stat.h>
struct ReadError {
std::error_code ec;
std::string what;
};
std::expected<size_t, ReadError> file_size(int fd) {
struct stat st;
if (::fstat(fd, &st) != 0) {
return std::unexpected(ReadError{
std::error_code(errno, std::generic_category()),
"fstat"
});
}
return static_cast<size_t>(st.st_size);
}
std::expected<std::vector<unsigned char>, ReadError> read_all(const std::string& path) {
auto fd = open_readonly(path);
if (!fd) {
return std::unexpected(ReadError{fd.error().ec, "open: " + fd.error().path});
}
auto sz = file_size(fd->get());
if (!sz) {
return std::unexpected(sz.error());
}
std::vector<unsigned char> buf(*sz);
size_t off = 0;
while (off < buf.size()) {
ssize_t n = ::read(fd->get(), buf.data() + off, buf.size() - off);
if (n < 0) {
return std::unexpected(ReadError{
std::error_code(errno, std::generic_category()),
"read"
});
}
if (n == 0) break;
off += static_cast<size_t>(n);
}
buf.resize(off);
return buf;
}
여기서 중요한 점은 read_all 내부에서 어떤 단계에서 실패하든, UniqueFd는 스코프를 벗어나며 자동으로 닫힌다는 것입니다. 실패 전파는 std::unexpected로만 일관되게 처리합니다.
실전 패턴 3: “에러 컨텍스트”를 누적해 디버깅 가능하게 만들기
예외를 안 쓰면 스택 트레이스가 없어서 디버깅이 불리하다고 느낄 수 있습니다. 이때는 E에 컨텍스트를 넣는 방식이 효과적입니다.
- 어떤 작업에서 실패했는지(
what) - 어떤 입력이었는지(
path,endpoint) - 원인 코드(
std::error_code)
또한 “호출자 관점에서 의미 있는 에러”로 매핑하는 레이어를 두면 운영이 쉬워집니다. 예를 들어 OS 에러를 그대로 노출하지 않고, 도메인 에러로 변환합니다.
enum class DomainErr {
NotFound,
PermissionDenied,
Io,
};
struct DomainError {
DomainErr kind;
std::string context;
std::error_code cause;
};
DomainError map_error(const ReadError& e) {
if (e.cause == std::errc::no_such_file_or_directory) {
return {DomainErr::NotFound, e.what, e.cause};
}
if (e.cause == std::errc::permission_denied) {
return {DomainErr::PermissionDenied, e.what, e.cause};
}
return {DomainErr::Io, e.what, e.cause};
}
이런 매핑은 “로그/알림에서 노이즈를 줄이고”, “재시도 가능/불가능” 같은 정책 결정에도 직접 연결됩니다. API 재시도 설계 관점은 Claude API 529 과부하·429 제한 재시도 설계에서도 유사한 원칙을 다룹니다.
std::expected와 예외의 역할 분담
예외를 완전히 배제할지, 혼용할지는 제품 성격에 따라 다릅니다. 다만 다음 원칙은 꽤 보편적으로 유효합니다.
- 라이브러리 경계에서는
std::expected가 유리- ABI/언어 경계(C, Rust, 다른 런타임)에서 예외는 위험
- 호출자에게 실패 처리를 강제할 수 있음
- “정말 비정상”에는 예외가 유리할 수 있음
- 불변식 위반, 프로그래밍 오류, 복구 불가능한 상태
정리하면, 복구 가능한 실패(파일 없음, 네트워크 타임아웃, 권한 부족)는 std::expected로 표현하고, 버그에 가까운 상태는 assert 또는 예외로 분리하는 편이 유지보수에 도움이 됩니다.
자원관리 관점에서 expected가 특히 좋은 이유
자원관리에서 흔히 겪는 문제는 “실패 시점이 다양해서 정리가 누락되는 것”입니다.
- 반환 코드 기반 C 스타일은 호출자가 매번
goto cleanup또는 중첩if로 정리 - 예외 기반은 정리는 쉬워도, 예외가 전파되는 경계를 잘못 잡으면 프로그램 정책이 꼬임
std::expected는 다음 균형점을 제공합니다.
- 정리: RAII로 자동
- 실패: 타입으로 강제
- 정책: 호출자가
if (!res)에서 결정
특히 팀 개발에서 “이 함수가 실패할 수 있는지”가 시그니처에 드러나는 것이 큽니다. 코드 리뷰에서 누락을 잡기 쉬워지고, 테스트에서도 실패 케이스를 강제로 다루게 됩니다.
팁: expected 사용 시 자주 하는 실수
value()를 무심코 호출하기
res.value()는 성공을 확신할 때만 써야 합니다. 실무에서는 아래처럼 가드 패턴을 습관화하는 편이 안전합니다.
auto data = read_all(path);
if (!data) {
// error handling
return;
}
use(*data);
에러 타입을 너무 “문자열”로만 두기
문자열은 편하지만 정책 결정을 못 합니다. 최소한 enum class 또는 std::error_code를 같이 두는 게 좋습니다.
성공 값에 “부분적으로 초기화된 객체”를 넣기
성공 타입 T는 “완전히 유효한 상태”여야 합니다. 부분 초기화가 필요하면 내부적으로 RAII를 더 잘게 쪼개거나, 빌더 패턴을 쓰되 최종 결과만 expected로 반환하는 편이 안전합니다.
마무리: 예외 없는 코드의 생산성을 올리는 조합
C++23 std::expected는 예외를 대체하는 만능 도구라기보다, “복구 가능한 실패를 명시적으로 모델링”하는 표준 도구입니다. 여기에 RAII를 결합하면 다음을 동시에 얻습니다.
- 실패는 호출자가 반드시 처리
- 자원은 스코프 기반으로 자동 정리
- 에러 컨텍스트를 구조화해 운영/디버깅 개선
실무에서 추천하는 시작점은 단순합니다.
- 파일/소켓/뮤텍스 같은 “명확한 자원”부터
UniqueXRAII 래퍼를 만든다 - 생성(획득) 함수는
std::expected<UniqueX, Error>로 반환한다 - 상위 레이어에서 도메인 에러로 매핑하고, 재시도/대체 경로 같은 정책을 둔다
이렇게 쌓아가면 예외를 쓰지 않아도 코드가 덜 복잡해지고, 실패가 더 투명해집니다.