- Published on
C++23 std - -expected로 예외 없이 오류전파+자원관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI 툴처럼 실패가 “정상적인 흐름”에 포함되는 프로그램에서는 예외 기반 에러 처리가 오히려 복잡도를 키우는 경우가 많습니다. 특히 다음 요구가 동시에 있을 때 그렇습니다.
- 실패를 호출자에게 명시적으로 전달하고 싶다
- 실패 경로에서도 파일 핸들, 소켓, 락 같은 자원을 확실히 정리해야 한다
- 테스트에서 실패 케이스를 값으로 다루고 싶다
-fno-exceptions같은 빌드 옵션을 쓰거나(임베디드/저지연), 예외 비용을 피하고 싶다
C++23의 std::expected는 “성공 값 또는 오류 값”을 담는 표준 타입으로, 예외 없이도 오류 전파를 구조화할 수 있게 해줍니다. 그리고 C++의 강점인 RAII와 결합하면, 실패가 발생해도 자원 정리는 자동으로 되고 오류는 깔끔하게 상위로 전파됩니다.
아래에서는 std::expected의 핵심 사용법, 오류 타입 설계, 체이닝 패턴, 그리고 RAII 자원관리와 결합하는 실전 예제를 다룹니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 둘 중 하나를 담습니다.
- 성공:
T값 - 실패:
E오류
예외 대신 “실패를 값으로 반환”하는 것이 핵심입니다. 호출자는 반환값을 보고 분기합니다.
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(std::string_view s);
auto r = parse_int("123");
if (!r) {
// r.error() : std::string
} else {
// *r 또는 r.value() : int
}
이 모델의 장점은 함수 시그니처만 봐도 “실패할 수 있음”이 드러난다는 점입니다.
예외 없는 오류전파에서 중요한 것: 오류 타입 설계
실무에서 E를 무엇으로 할지가 품질을 좌우합니다.
- 단순 메시지면
std::string도 가능 - 분기 처리가 필요하면
enum class또는 구조체 추천 - OS 에러를 다루면
std::error_code가 강력
추천 1) std::error_code 기반
#include <expected>
#include <system_error>
using result_void = std::expected<void, std::error_code>;
using result_str = std::expected<std::string, std::error_code>;
std::error_code는 카테고리와 값을 포함하므로 로깅과 분기가 쉽고, errno나 플랫폼 에러를 자연스럽게 담을 수 있습니다.
추천 2) 도메인 오류 구조체
#include <expected>
#include <string>
enum class errc {
invalid_input,
not_found,
io_error,
};
struct error {
errc code;
std::string message;
};
template <class T>
using expected_t = std::expected<T, error>;
이렇게 하면 호출자가 code로 분기하고, message는 관측(로그/디버깅)에 사용하도록 역할을 분리할 수 있습니다.
기본 사용 패턴: “반환 즉시 전파”
예외 없는 스타일에서 가장 흔한 패턴은 다음입니다.
- 하위 함수 호출
- 실패면 즉시 반환
- 성공이면 값 사용
#include <expected>
#include <string>
std::expected<int, std::string> parse_port(std::string_view s) {
if (s.empty()) return std::unexpected("empty");
int port = 0;
for (char c : s) {
if (c < '0' || c > '9') return std::unexpected("not a number");
port = port * 10 + (c - '0');
}
if (port < 1 || port > 65535) return std::unexpected("out of range");
return port;
}
std::expected<void, std::string> start_server(std::string_view port_str) {
auto port_r = parse_port(port_str);
if (!port_r) return std::unexpected(port_r.error());
int port = *port_r;
// ... bind/listen ...
return {};
}
이 방식은 명확하지만, 단계가 많아지면 if (!r) return ...가 반복됩니다. 그래서 체이닝과 헬퍼가 필요해집니다.
체이닝: and_then, transform, or_else
std::expected는 모나딕 연산을 제공합니다.
and_then(f): 성공일 때만 다음 함수를 실행, 실패면 그대로 전파transform(f): 성공 값에 함수 적용(값만 바꿈)or_else(f): 실패일 때만 오류를 변환하거나 로깅
예제: 설정 읽기 → 파싱 → 검증
#include <expected>
#include <string>
#include <string_view>
struct config {
std::string host;
int port;
};
using exp_str = std::expected<std::string, std::string>;
using exp_cfg = std::expected<config, std::string>;
exp_str read_file_text(const std::string& path);
std::expected<int, std::string> parse_int(std::string_view s);
exp_cfg load_config(const std::string& path) {
return read_file_text(path)
.and_then([](const std::string& text) -> exp_cfg {
// 매우 단순한 예: "host=...\nport=..." 형태라고 가정
auto host_pos = text.find("host=");
auto port_pos = text.find("port=");
if (host_pos == std::string::npos || port_pos == std::string::npos)
return std::unexpected("missing keys");
auto host_end = text.find('\n', host_pos);
std::string host = text.substr(host_pos + 5, host_end - (host_pos + 5));
auto port_end = text.find('\n', port_pos);
auto port_str = text.substr(port_pos + 5, port_end - (port_pos + 5));
return parse_int(port_str)
.and_then([&](int port) -> exp_cfg {
if (port < 1 || port > 65535) return std::unexpected("bad port");
return config{host, port};
});
})
.or_else([](const std::string& e) -> exp_cfg {
// 에러 메시지에 컨텍스트를 추가
return std::unexpected(std::string("load_config failed: ") + e);
});
}
이 스타일의 장점은 “성공 흐름”이 위에서 아래로 자연스럽게 읽힌다는 점입니다.
RAII와 결합: 실패해도 자원은 자동 정리
예외를 쓰지 않더라도 RAII는 여전히 가장 강력한 자원관리 도구입니다. std::expected는 “실패를 값으로 반환”할 뿐, 스코프를 벗어날 때 소멸자가 호출되는 규칙은 동일합니다.
핵심은 다음입니다.
- 자원 획득은 객체 생성으로
- 자원 해제는 소멸자로
- 실패는
std::unexpected로 반환
예제 1) FILE을 RAII로 감싸고 expected로 전파
#include <expected>
#include <cstdio>
#include <string>
#include <system_error>
class file_handle {
public:
explicit file_handle(std::FILE* f) : f_(f) {}
file_handle(const file_handle&) = delete;
file_handle& operator=(const file_handle&) = delete;
file_handle(file_handle&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
file_handle& operator=(file_handle&& other) noexcept {
if (this != &other) {
close();
f_ = other.f_;
other.f_ = nullptr;
}
return *this;
}
~file_handle() { close(); }
std::FILE* get() const { return f_; }
private:
void close() {
if (f_) std::fclose(f_);
f_ = nullptr;
}
std::FILE* f_{};
};
std::expected<file_handle, std::error_code> open_file(const std::string& path) {
std::FILE* f = std::fopen(path.c_str(), "rb");
if (!f) {
// errno를 error_code로 래핑
return std::unexpected(std::error_code(errno, std::generic_category()));
}
return file_handle(f);
}
std::expected<std::string, std::error_code> read_all(const std::string& path) {
auto fh = open_file(path);
if (!fh) return std::unexpected(fh.error());
std::string out;
char buf[4096];
while (true) {
auto n = std::fread(buf, 1, sizeof(buf), fh->get());
if (n > 0) out.append(buf, buf + n);
if (n < sizeof(buf)) {
if (std::ferror(fh->get())) {
return std::unexpected(std::error_code(errno, std::generic_category()));
}
break;
}
}
return out;
}
여기서 중요한 점은 read_all에서 중간에 실패해도 file_handle의 소멸자가 호출되어 파일이 닫힌다는 것입니다. 예외가 없어도 자원 정리는 자동입니다.
예제 2) 락/뮤텍스와 expected
락은 RAII가 특히 빛납니다. 실패 경로가 많아도 std::lock_guard나 std::unique_lock이 알아서 해제합니다.
#include <expected>
#include <mutex>
#include <string>
std::mutex g_mu;
int g_counter = 0;
std::expected<int, std::string> increment_if_positive(int delta) {
std::lock_guard<std::mutex> lk(g_mu);
if (delta <= 0) return std::unexpected("delta must be positive");
g_counter += delta;
return g_counter;
}
expected로 “오류 컨텍스트”를 쌓는 법
실무 디버깅에서 중요한 건 “어디서 왜 실패했는지”입니다. 예외의 스택 트레이스를 포기하는 대신, 오류 값에 컨텍스트를 덧붙이는 패턴을 가져가야 합니다.
패턴: or_else로 메시지 래핑
#include <expected>
#include <string>
template <class T>
using exp = std::expected<T, std::string>;
exp<int> step1();
exp<int> step2(int);
exp<int> pipeline() {
return step1()
.and_then([](int v) { return step2(v); })
.or_else([](const std::string& e) -> exp<int> {
return std::unexpected(std::string("pipeline failed: ") + e);
});
}
더 발전시키면 오류 타입을 구조화해서 context 스택을 벡터로 들고 다니는 방식도 가능합니다.
예외 기반 코드에서 expected로 마이그레이션 전략
레거시 코드가 이미 예외를 던지고 있다면, 한 번에 갈아엎기보다 “경계 계층”부터 바꾸는 것이 안전합니다.
- 외부 I/O 계층(파일/네트워크/DB)에서 예외를 잡아
expected로 변환 - 비즈니스 로직은
expected만 사용 - 최상위
main또는 요청 핸들러에서 오류를 로깅하고 종료/응답
예외를 expected로 변환하는 래퍼
#include <expected>
#include <string>
#include <exception>
template <class F>
auto to_expected(F&& f)
-> std::expected<decltype(f()), std::string>
{
try {
return f();
} catch (const std::exception& e) {
return std::unexpected(std::string("exception: ") + e.what());
} catch (...) {
return std::unexpected("unknown exception");
}
}
// 사용 예
// auto r = to_expected([&] { return legacy_may_throw(); });
이렇게 하면 내부는 점진적으로 expected 스타일로 옮기고, 레거시 예외는 경계에서만 처리할 수 있습니다.
흔한 함정과 실무 팁
1) value()는 최후의 수단
r.value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 *r를 쓰기 전에 반드시 if (!r)로 가드하거나, and_then 체이닝으로 구조화하세요.
2) 오류 타입 E는 가급적 가볍게
오류가 자주 발생하는 경로라면 std::string 복사가 부담일 수 있습니다. 다음을 고려하세요.
std::error_code+ 별도 로그enum class+ 필요 시에만 문자열 변환- 메시지가 필요하면
std::string을 이동(move) 중심으로 구성
3) “재시도” 같은 정책은 상위에서
expected는 실패를 표현할 뿐, 재시도 정책까지 강제하지 않습니다. 재시도는 호출자(상위 레벨)가 결정하는 것이 일반적으로 더 낫습니다. 이 관점은 API 호출에서 429 같은 오류를 다룰 때도 동일합니다. 재시도/큐잉 패턴 자체는 다른 글인 OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지에서 정리했지만, 핵심은 “실패를 값으로 올리고 상위에서 정책을 적용”하는 구조가 유지보수에 유리하다는 점입니다.
종합 예제: 자원 획득 + 단계별 처리 + 컨텍스트 누적
아래는 파일을 읽고, 숫자를 파싱하고, 결과를 검증하는 흐름을 expected로 구성한 예시입니다.
#include <expected>
#include <string>
#include <string_view>
#include <system_error>
#include <charconv>
using exp_str = std::expected<std::string, std::error_code>;
using exp_int = std::expected<int, std::error_code>;
exp_str read_all(const std::string& path); // 앞서 구현했다고 가정
exp_int parse_int_ec(std::string_view s) {
int v = 0;
auto first = s.data();
auto last = s.data() + s.size();
auto [ptr, ec] = std::from_chars(first, last, v);
if (ec != std::errc{} || ptr != last) {
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
}
return v;
}
std::expected<int, std::string> load_threshold(const std::string& path) {
return read_all(path)
.transform([](std::string text) {
// 공백 제거 같은 전처리(간단히 끝 개행 제거)
while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) text.pop_back();
return text;
})
.and_then([](const std::string& s) -> std::expected<int, std::string> {
auto r = parse_int_ec(s);
if (!r) return std::unexpected("parse failed: invalid number");
return *r;
})
.and_then([](int v) -> std::expected<int, std::string> {
if (v < 0 || v > 100) return std::unexpected("range failed: expected 0..100");
return v;
})
.or_else([&](const std::error_code& ec) -> std::expected<int, std::string> {
return std::unexpected(std::string("io failed: ") + ec.message());
});
}
포인트는 다음입니다.
- I/O 계층은
std::error_code로 “기계 친화적” 오류를 반환 - 상위 비즈니스 함수는 사용자 친화적 메시지로 변환
- 전처리/검증을
transform과and_then으로 단계화 - 실패해도 RAII로 자원은 정리된다는 전제하에 로직은 오류 전파에 집중
마무리
std::expected는 예외를 완전히 대체한다기보다, “실패가 흔한 경로”에서 제어 흐름을 더 명시적으로 만들어주는 도구입니다. 특히 RAII와 결합하면 다음을 동시에 얻습니다.
- 실패를 값으로 다루는 명시적 API
- 호출자 중심의 정책 결정(재시도/대체 경로/로그)
- 스코프 기반 자원 정리로 누수와 교착 위험 감소
실무에서는 E를 어떻게 설계할지, 그리고 or_else로 컨텍스트를 어떻게 쌓을지가 핵심입니다. 작은 유틸부터 expected를 도입해 경계 계층에서 변환하고, 내부 로직을 체이닝 스타일로 정리하면 예외 없는 코드베이스에서도 읽기 쉬운 오류전파와 안정적인 자원관리를 동시에 달성할 수 있습니다.