- Published on
C++23 std - -expected로 예외 없이 오류·자원관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외 기반 오류 처리는 C++에서 강력하지만, 런타임 비용/바이너리 크기/ABI 제약, 그리고 코드 경로 추론 어려움 때문에 서버·임베디드·게임 엔진 등에서 예외를 꺼두는 경우가 많습니다. 그렇다고 bool 반환 + errno + 출력 로그에 의존하면 호출자가 오류를 놓치기 쉽고, 자원 해제 코드가 분기마다 반복되면서 버그가 생깁니다.
C++23의 std::expected는 “성공 값” 또는 “오류”를 타입으로 강제하는 도구입니다. 핵심은 단순히 예외를 대체하는 것이 아니라, RAII와 결합해 오류 전파와 자원 관리를 함께 단순화하는 데 있습니다.
참고로 std::expected 자체의 기본 개념/간단 예시는 아래 글에서도 다룹니다.
std::expected가 해결하는 문제
1) 호출자가 오류를 무시하기 어렵다
반환 타입이 std::expected<T, E>면, 성공 값 T를 쓰려면 반드시 has_value() 확인 또는 value() 호출(실패 시 bad_expected_access)을 거치게 됩니다. 즉, API 설계 단계에서부터 “실패 가능성”을 강제합니다.
2) 오류를 값으로 다룰 수 있다
오류를 enum, std::error_code, 커스텀 구조체 등으로 모델링할 수 있습니다. 예외처럼 스택을 타고 올라가지만, “던지는” 대신 “반환”하므로 코드 경로가 더 명시적입니다.
3) RAII와 자연스럽게 결합된다
expected는 단지 반환 컨테이너일 뿐이고, 자원 해제는 여전히 RAII(스마트 포인터, 핸들 래퍼, 스코프 가드)가 담당합니다. 중요한 건 실패 시점마다 수동 close()/free()를 호출하지 않아도 된다는 점입니다.
기본 패턴: 오류 타입부터 설계하기
오류 타입 E는 다음 중 하나를 추천합니다.
- 간단한 라이브러리/모듈 내부:
enum class+ 메시지 - OS/네트워크 연동:
std::error_code또는std::system_error의code() - 상위 계층에 풍부한 컨텍스트 필요: 에러 구조체(코드, 메시지, 원인, 추가 데이터)
다음은 “코드 + 메시지 + 원인 에러코드” 정도를 담는 예시입니다.
#include <expected>
#include <string>
#include <system_error>
enum class AppErrc {
InvalidArgument,
Io,
Protocol,
};
struct AppError {
AppErrc code;
std::string message;
std::error_code cause; // 필요 없으면 비워도 됨
};
template <class T>
using Result = std::expected<T, AppError>;
이렇게 해두면 함수 시그니처만 봐도 “무엇이 실패할 수 있는지”가 드러납니다.
자원관리 1: 파일 핸들을 RAII로 감싸고 expected로 생성하기
예외를 끈 코드에서 흔한 실수는 “열기 성공했는지 확인”과 “실패 시 닫기”가 분기마다 반복되는 것입니다. 해결책은 핸들 래퍼 + 팩토리 함수는 expected 입니다.
#include <expected>
#include <cstdio>
#include <string>
struct File {
std::FILE* fp = nullptr;
File() = default;
explicit File(std::FILE* f) : fp(f) {}
File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&& other) noexcept : fp(other.fp) { other.fp = nullptr; }
File& operator=(File&& other) noexcept {
if (this != &other) {
close();
fp = other.fp;
other.fp = nullptr;
}
return *this;
}
~File() { close(); }
void close() noexcept {
if (fp) {
std::fclose(fp);
fp = nullptr;
}
}
};
Result<File> open_file(const std::string& path, const char* mode) {
if (path.empty()) {
return std::unexpected(AppError{AppErrc::InvalidArgument, "empty path", {}});
}
std::FILE* f = std::fopen(path.c_str(), mode);
if (!f) {
// errno를 error_code로 옮기고 싶다면 std::error_code(errno, std::generic_category())
return std::unexpected(AppError{AppErrc::Io, "fopen failed", {}});
}
return File{f};
}
포인트는 다음과 같습니다.
File은 소멸자에서 자동fclose()open_file()은 실패하면std::unexpected(error)로 반환- 호출자는
Result<File>을 통해 성공/실패를 반드시 처리
자원관리 2: “여러 자원 획득”을 단계적으로 합성하기
실무에서는 파일 하나가 아니라 “파일 열기 → 읽기 버퍼 할당 → 파싱”처럼 단계가 이어집니다. 예외가 없으면 중간 실패 시 정리 코드가 복잡해지는데, RAII를 쓰면 정리는 자동으로 해결되고 남는 문제는 오류 전파를 깔끔하게 하는 것입니다.
C++23에는 표준 TRY 매크로가 없지만, 팀/프로젝트에서 작은 헬퍼를 두면 생산성이 크게 올라갑니다.
#define TRY(expr) \
({ \
auto _tmp = (expr); \
if (!_tmp.has_value()) return std::unexpected(_tmp.error()); \
std::move(_tmp.value()); \
})
위 매크로는 GCC/Clang의 statement expression 확장에 의존하므로, 이식성이 중요하면 매크로 대신 함수/람다 기반 패턴을 사용하세요. 이 글에서는 개념을 보여주기 위해 사용합니다.
이제 “열기 → 읽기”를 합성해봅니다.
#include <vector>
Result<std::vector<unsigned char>> read_all_bytes(const std::string& path) {
auto file = TRY(open_file(path, "rb"));
std::vector<unsigned char> buf;
std::fseek(file.fp, 0, SEEK_END);
long size = std::ftell(file.fp);
std::rewind(file.fp);
if (size < 0) {
return std::unexpected(AppError{AppErrc::Io, "ftell failed", {}});
}
buf.resize(static_cast<size_t>(size));
size_t n = std::fread(buf.data(), 1, buf.size(), file.fp);
if (n != buf.size()) {
return std::unexpected(AppError{AppErrc::Io, "fread short read", {}});
}
// file은 스코프 종료 시 자동 fclose
return buf;
}
여기서 자원 정리는 File과 std::vector가 담당합니다. 오류가 어디서 나든 “닫기/해제”는 자동입니다.
오류 전파 설계: 에러를 덧붙여 컨텍스트를 유지하기
expected의 장점은 오류를 값으로 다루는 만큼, 상위 레이어에서 “어디서 실패했는지”를 컨텍스트로 보강하기 쉽다는 것입니다.
Result<int> parse_version(const std::vector<unsigned char>& bytes) {
if (bytes.size() < 4) {
return std::unexpected(AppError{AppErrc::Protocol, "header too small", {}});
}
return static_cast<int>(bytes[0]);
}
Result<int> load_version_from_file(const std::string& path) {
auto bytesExp = read_all_bytes(path);
if (!bytesExp) {
auto err = bytesExp.error();
err.message = "read_all_bytes: " + err.message;
return std::unexpected(std::move(err));
}
auto verExp = parse_version(*bytesExp);
if (!verExp) {
auto err = verExp.error();
err.message = "parse_version: " + err.message;
return std::unexpected(std::move(err));
}
return *verExp;
}
예외였다면 스택 트레이스로 추적했을 정보를, expected에서는 “메시지/코드 누적” 같은 방식으로 모델링합니다. 로그를 남길 때도 상위에서 한 번만 남기기 쉬워집니다.
std::expected와 스코프 가드로 “부분 성공” 정리하기
RAII가 항상 완벽한 건 아닙니다. 예를 들어, 함수 중간에 “외부 시스템에 등록” 같은 작업을 하고, 이후 실패하면 “등록 해제”가 필요할 수 있습니다. 이때는 스코프 가드가 유용합니다.
#include <utility>
class ScopeExit {
bool active_ = true;
std::function<void()> fn_;
public:
explicit ScopeExit(std::function<void()> fn) : fn_(std::move(fn)) {}
~ScopeExit() { if (active_) fn_(); }
void release() noexcept { active_ = false; }
};
Result<void> register_and_do_work() {
bool registered = false;
// 예시: register_resource()가 외부 시스템에 등록한다고 가정
auto reg = [&]() -> Result<void> {
registered = true;
return {};
}();
if (!reg) return std::unexpected(reg.error());
ScopeExit rollback([&] {
if (registered) {
// unregister_resource();
registered = false;
}
});
// 중간 작업 실패 시 rollback이 자동 수행됨
bool ok = false; // do_work()
if (!ok) {
return std::unexpected(AppError{AppErrc::Io, "do_work failed", {}});
}
rollback.release();
return {};
}
이 패턴은 트랜잭션/등록/임시 파일 생성처럼 “명시적 롤백”이 필요한 작업에서 특히 유용합니다.
예외 vs expected: 선택 기준
std::expected가 만능은 아닙니다. 다음 기준으로 선택하면 실전에서 시행착오가 줄어듭니다.
- 예외를 켜기 어려운 환경(게임 콘솔, 임베디드, 일부 금융/저지연 시스템):
expected우선 - API 경계가 명확하고 실패가 빈번(파싱, 네트워크, 파일 I/O):
expected가 호출 비용/가독성 측면에서 유리 - 실패가 정말 예외적이고 상위에서 일괄 처리(프레임워크 내부, 초기화 단계): 예외가 더 간결할 수 있음
- 혼용도 가능: 내부는
expected, 외부 API는 예외로 변환(또는 반대)
중요한 건 팀 규칙입니다. 한 모듈에서 예외/expected/에러코드가 섞이면 오히려 복잡도가 증가합니다.
성능/코드 품질 관점에서의 팁
E는 너무 무겁게 만들지 마세요. 문자열을 무조건 포함하면 성공 경로에서도 코드 크기가 늘 수 있습니다. 필요하면message를 옵션으로 두거나, 상위 레이어에서만 메시지를 구성하세요.“오류는 상위에서 한 번만 로깅” 규칙을 권장합니다.
expected체인에서 하위가 로그를 남기면 중복 로그가 쌓이기 쉽습니다.Result<void>를 적극적으로 쓰세요. C++에서는std::expected<void, E>가 자연스럽고, 성공/실패만 표현하는 API를 깔끔하게 만듭니다.동시성/네트워크처럼 실패가 정상 흐름인 영역에서는
expected가 특히 잘 맞습니다. 예를 들어 gRPC에서 데드라인 초과/일시적 실패를 “예외”로 던지기보다 값으로 처리하면 재시도 정책을 조합하기 쉬워집니다. (관련해서는 Go gRPC 데드라인 초과 원인 7가지와 해결 같은 글의 운영 관점도 참고할 만합니다.)
정리: expected는 오류 처리 도구이자 설계 도구
std::expected를 도입하면 단순히 try/catch를 없애는 수준을 넘어, 다음이 함께 개선됩니다.
- 실패 가능성이 타입에 드러나 API가 더 정직해짐
- 오류 전파가 명시적이어서 테스트/리뷰가 쉬워짐
- RAII와 결합해 실패 경로에서도 자원 누수/중복 정리 코드가 줄어듦
- 컨텍스트를 보강한 오류 모델링으로 운영/디버깅 품질이 올라감
실전에서는 “핸들 래퍼(RAII) + 팩토리는 expected + 상위에서 컨텍스트 추가 + 한 번만 로깅” 조합이 가장 효과적입니다. 이를 기준으로 작은 유틸(예: TRY 매크로/함수)까지 정리해두면, 예외 없이도 충분히 읽기 좋은 C++ 코드를 만들 수 있습니다.