- Published on
C++23 std - -expected로 예외 없는 오류·메모리 안전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·임베디드·게임 엔진 같은 C++ 코드베이스에서는 예외를 꺼두거나(-fno-exceptions) 경계(ABI, FFI)에서 예외 전파를 막는 경우가 많습니다. 하지만 예외를 쓰지 않는다고 해서 오류 처리가 쉬워지는 건 아닙니다. 반환값으로 bool/int를 돌려주고, 별도의 out-parameter로 결과를 채우는 방식은 호출부에서 실수하기 쉽고, 중간 단계에서 리소스 정리가 누락되면 메모리/핸들 누수로 이어집니다.
C++23의 std::expected는 성공 값 또는 오류 값 중 하나를 담는 표준 타입으로, 예외 없이도 오류를 “타입 시스템”으로 끌어올립니다. 이 글에서는 std::expected를 이용해 예외 없는 오류 처리를 일관되게 만들고, RAII와 결합해 메모리/리소스 안전성을 높이는 실전 패턴을 다룹니다.
std::expected가 해결하는 문제
1) 오류 경로를 숨기지 않는다
예외는 호출부 코드가 깔끔해 보이지만, 실제로는 “어디서 던질지”가 분산되어 있고, 경계(스레드, C API, 플러그인, 네트워크 루프)에서 예외가 새어 나오면 종료로 이어질 수 있습니다. 반대로 반환 코드 방식은 호출부가 오류를 무시해도 컴파일러가 막기 어렵습니다.
std::expected<T, E>는 함수 시그니처에 실패 가능성을 명시합니다.
- 성공:
T - 실패:
E(에러 코드, 에러 enum, 커스텀 에러 구조체 등)
2) “부분 초기화”와 리소스 누수를 줄인다
예외 없는 코드에서 흔한 패턴은 다음과 같습니다.
- 1단계에서 리소스 A 획득
- 2단계에서 실패
- A 해제 누락
std::expected는 RAII 객체(예: std::unique_ptr, std::vector, 파일 핸들 래퍼)를 성공 값으로 담고, 실패 시에는 오류만 반환하도록 구성하기 좋습니다. 즉, 성공 경로에서만 완성된 객체를 만들고 반환하게 유도합니다.
3) 오류 전파가 단순해진다
예외가 없다면 “중간에서 실패하면 즉시 반환” 패턴이 늘어납니다. std::expected는 이 패턴을 표준화하고, transform, and_then, or_else 같은 조합 연산(모나딕 스타일)로 체이닝을 가능하게 합니다.
기본 사용법: 성공/실패 생성과 검사
아래 예시는 문자열을 정수로 파싱하되, 실패하면 에러 코드를 반환합니다.
#include <expected>
#include <string>
#include <charconv>
#include <system_error>
enum class ParseErr {
Empty,
Invalid,
OutOfRange
};
std::expected<int, ParseErr> parse_int(std::string_view s) {
if (s.empty()) return std::unexpected(ParseErr::Empty);
int value{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec == std::errc::invalid_argument)
return std::unexpected(ParseErr::Invalid);
if (ec == std::errc::result_out_of_range)
return std::unexpected(ParseErr::OutOfRange);
return value;
}
void use() {
auto r = parse_int("123");
if (!r) {
// r.error()로 원인 확인
return;
}
int x = *r; // 또는 r.value()
}
핵심 포인트는 다음과 같습니다.
- 실패 생성은
std::unexpected(E) - 성공은
T를 그대로 반환 - 검사:
if (result)또는result.has_value() - 성공 값:
*result,result.value() - 오류 값:
result.error()
“예외 없는” 오류 설계: 에러 타입을 어떻게 잡을까
E를 무엇으로 둘지는 팀의 에러 정책을 좌우합니다.
옵션 A: std::errc/std::error_code 기반
표준/플랫폼 코드와 잘 맞습니다. 파일 I/O, 소켓, OS 호출 래핑에 유리합니다.
- 장점: 범용, 로깅/표준 메시지 매핑 쉬움
- 단점: 도메인 특화 정보(예: 어떤 필드가 문제인지) 담기 어려움
옵션 B: 도메인 enum + 컨텍스트 구조체
서비스/도메인 로직에서는 “어떤 입력이 잘못됐는지” 같은 정보를 넣는 편이 디버깅에 좋습니다.
#include <expected>
#include <string>
enum class UserErrc {
NotFound,
InvalidEmail,
DbUnavailable
};
struct UserError {
UserErrc code;
std::string message; // 필요 시
};
using UserResult = std::expected<int /*user_id*/, UserError>;
여기서 message를 무조건 문자열로 두면 할당이 발생할 수 있습니다. 성능/할당 제약이 크면 std::string_view(수명 관리 주의) 또는 고정 버퍼, 혹은 code만 두고 메시지는 로깅 계층에서 매핑하는 방식이 낫습니다.
RAII와 결합해 리소스 안전성 높이기
std::expected는 “성공 시 완성된 리소스 소유 객체를 반환”하는 스타일과 궁합이 좋습니다.
예시: 파일 열기 래퍼를 expected로 반환
#include <expected>
#include <cstdio>
#include <system_error>
class File {
public:
explicit File(std::FILE* f) : f_(f) {}
File(File&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
File& operator=(File&& other) noexcept {
if (this != &other) {
close();
f_ = other.f_;
other.f_ = nullptr;
}
return *this;
}
~File() { close(); }
File(const File&) = delete;
File& operator=(const File&) = delete;
std::FILE* get() const { return f_; }
private:
void close() {
if (f_) std::fclose(f_);
f_ = nullptr;
}
std::FILE* f_{};
};
std::expected<File, std::error_code> open_file(const char* path, const char* mode) {
if (auto* f = std::fopen(path, mode)) {
return File{f};
}
return std::unexpected(std::error_code(errno, std::generic_category()));
}
std::expected<std::string, std::error_code> read_all(const char* path) {
auto f = open_file(path, "rb");
if (!f) return std::unexpected(f.error());
// 여기서부터는 File이 RAII로 fclose 보장
std::string out;
// ... 실제 읽기 로직 생략
return out;
}
이 구조의 장점:
open_file실패 시에는File이 아예 생성되지 않음read_all중간 실패가 생겨도File소멸자가 정리- 예외가 없어도 “자원 정리 누락” 위험이 크게 줄어듦
조합 연산으로 오류 전파를 정리하기
std::expected는 다음 같은 연산을 제공합니다(구현/표준 라이브러리 버전에 따라 지원 범위는 확인 필요).
and_then: 성공 값이 있을 때만 다음 함수를 실행(다음도expected반환)transform: 성공 값을 변환(다음은 일반 값 반환)or_else: 실패일 때만 복구/대체 로직 실행
예시: 파싱 → 검증 → 변환 체이닝
#include <expected>
#include <string_view>
enum class Err { Parse, Negative };
std::expected<int, Err> parse(std::string_view s);
std::expected<int, Err> ensure_non_negative(int x) {
if (x < 0) return std::unexpected(Err::Negative);
return x;
}
std::expected<int, Err> parse_and_validate(std::string_view s) {
return parse(s)
.and_then(ensure_non_negative)
.transform([](int x) { return x * 2; });
}
이 스타일은 if (!r) return unexpected(...)가 연속되는 코드를 줄이고, 성공 경로를 더 읽기 쉽게 만듭니다.
호출부 패턴: “반드시 처리”를 강제하는 습관
std::expected가 있어도 호출부에서 무시하면 의미가 없습니다. 다음을 권장합니다.
- 반환값을 반드시 변수에 받기
- 실패면 즉시 리턴하거나, 로깅 후 명시적으로 처리
- 성공 값은
*r또는r.value()로 꺼내기
또한 컴파일러 경고를 활용하려면 [[nodiscard]]를 적극 사용하세요.
#include <expected>
enum class Err { Fail };
[[nodiscard]] std::expected<int, Err> do_work();
이렇게 하면 호출부에서 do_work();만 호출하고 결과를 버릴 때 경고가 나올 수 있습니다(컴파일러/플래그에 따라 다름).
std::expected와 예외의 역할 분담
현실적인 결론은 “예외를 완전히 없애자”가 아니라, 경계와 정책을 분리하는 것입니다.
- 라이브러리 내부:
std::expected로 실패를 값으로 다루고, 호출부가 흐름 제어를 명시적으로 하게 만든다. - 애플리케이션 최상단(예:
main): 정말 복구 불가능한 상황만 예외/종료로 처리하거나, 모든expected실패를 로깅하고 종료 코드를 결정한다.
특히 스레드 엔트리, 콜백 기반 프레임워크, C API 경계에서는 예외 전파가 치명적일 수 있으니 std::expected가 더 안전한 선택이 됩니다.
메모리 안전 관점에서의 체크리스트
std::expected 자체가 메모리 안전을 “보장”하는 도구는 아닙니다. 하지만 다음 규율을 만들기 좋습니다.
- 성공 값은 소유권이 명확한 타입으로 반환(
std::unique_ptr, 값 타입, RAII 핸들) - 실패 시에는 부분 생성된 객체를 외부로 노출하지 않기
- 오류 타입
E는 가급적 가볍고 복사 비용이 예측 가능하게 설계 - 에러 메시지 문자열을 남발해 할당 폭탄을 만들지 않기
std::string_view를 오류에 담을 경우 수명 규칙을 문서화(임시 문자열 참조 금지)
이런 규율은 데이터베이스 데드락처럼 “실패가 정상 흐름”인 영역에서 특히 중요합니다. 실패를 예외로 던져버리면 재시도/백오프 같은 정책이 호출부에 흩어지기 쉽습니다. 재시도 패턴을 정리하는 관점은 MySQL 8 Deadlock 1213 원인추적·재시도 패턴 글의 접근과도 통합니다.
실전 팁: 로그/관측과 함께 쓰기
std::expected는 “실패가 값”이기 때문에, 실패를 모아서 로깅하거나 메트릭으로 올리기 좋습니다. 운영에서 중요한 건 “실패했는지”뿐 아니라 “어떤 코드로 얼마나 자주 실패하는지”입니다. 원인 추적을 체계화하는 사고방식은 AWS IAM AccessDenied 403, 정책 시뮬레이터로 추적 같은 글에서 다루는 관측/추적 루틴과 유사합니다.
간단한 패턴은 다음과 같습니다.
#include <expected>
#include <string>
#include <iostream>
enum class Err { Network, Timeout };
std::string to_string(Err e) {
switch (e) {
case Err::Network: return "network";
case Err::Timeout: return "timeout";
}
return "unknown";
}
template <class T>
T value_or_log(std::expected<T, Err> r, T fallback) {
if (!r) {
std::cerr << "operation failed: " << to_string(r.error()) << "\n";
return fallback;
}
return *r;
}
마이그레이션 전략: 반환 코드에서 expected로
기존 코드가 다음처럼 되어 있다면:
bool foo(out T& result, out Err& err)int foo(out T* result)+errno
다음 순서로 바꾸는 것이 안전합니다.
- 내부 구현은 그대로 두고, 얇은 어댑터로
std::expectedAPI를 추가 - 신규 호출부부터
expected를 사용 - 기존 API는 점진적으로 제거
예시:
#include <expected>
enum class Err { Fail };
bool legacy_make(int& out, Err& err);
std::expected<int, Err> make() {
int v{};
Err e{};
if (!legacy_make(v, e)) return std::unexpected(e);
return v;
}
이 방식은 대규모 리팩터링 없이도 호출부의 안전성을 빠르게 올릴 수 있습니다.
정리
std::expected는 C++23에서 표준화된 “성공 또는 실패” 반환 타입으로, 예외 없는 오류 처리의 일관성을 높입니다.- RAII 타입과 함께 쓰면 리소스 누수와 부분 초기화 노출을 줄여 사실상의 메모리/리소스 안전성을 강화합니다.
[[nodiscard]], 가벼운 오류 타입 설계, 조합 연산(and_then,transform)을 활용하면 호출부의 실수를 더 줄일 수 있습니다.- 운영 관점에서는 실패를 값으로 다루는 덕분에 로깅/메트릭/재시도 정책을 구조화하기 좋습니다.
예외를 완전히 배제하든, 경계에서만 차단하든, 중요한 건 “실패가 정상 흐름일 수 있다”는 사실을 코드에 드러내는 것입니다. std::expected는 그 목적에 맞춘 C++23의 가장 실용적인 도구 중 하나입니다.