- Published on
C++23 std - -expected로 예외 없이 안전한 리턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 라이브러리 코드에서 예외(throw)는 편리하지만, 비용(스택 언와인딩), ABI 경계(플러그인·FFI), 예외 비활성화 빌드(-fno-exceptions) 같은 현실적인 제약 때문에 종종 "예외 없는" 설계를 요구받습니다. 그렇다고 bool + out param 조합이나 nullptr 리턴 같은 관용구로 돌아가면, 호출부가 에러를 무시하기 쉽고(누락), 반환 타입이 모호해지며, 자원 정리가 분기마다 흩어져 누수 위험이 커집니다.
C++23의 std::expected는 "성공 값" 또는 "에러 값"을 타입으로 강제하는 방식으로 이 문제를 정면으로 해결합니다. 특히 RAII와 결합하면 예외 없이도 누수 없는 리턴을 만들 수 있고, 호출부에서 에러 처리를 강제하거나 최소한 명시적으로 만들 수 있습니다.
std::expected 한 줄 정의
std::expected<T, E>는 두 상태 중 하나를 가집니다.
- 성공:
T값을 보유 - 실패:
E에러 값을 보유
핵심은 에러도 값이라는 점입니다. 따라서 예외처럼 "어딘가로 튀어 올라가는" 제어 흐름이 아니라, 함수 시그니처로 에러 가능성을 공개하고 호출부가 이를 처리하도록 압박합니다.
참고:
std::expected는 C++23 표준입니다. 구현체에 따라#include <expected>지원이 필요합니다.
왜 "누수 없는 리턴"에 강한가
예외 기반 코드에서 누수는 보통 "예외가 던져지는 경로"에서 정리가 누락될 때 생깁니다. 하지만 현대 C++에서는 RAII가 있기 때문에 예외가 있든 없든 누수는 줄일 수 있습니다. 문제는 다음과 같습니다.
- 예외를 끄면 RAII가 있어도 "에러 반환" 분기가 늘어나고
- 분기마다
close()·free()·delete같은 정리 코드가 반복되며 - 중간 리턴이 많아져 사람 손으로 관리하는 정리가 다시 등장
std::expected는 실패를 값으로 올리되, 성공 경로에서는 정상적인 값 반환처럼 보이게 만들고, 실패 경로에서는 unexpected(E)로 명확히 반환하게 해줍니다. 여기에 RAII 타입(std::unique_ptr, std::vector, 파일 핸들 래퍼 등)을 반환값으로 쓰면, 실패 시에도 중간에 생성된 자원은 스코프 종료로 안전하게 정리됩니다.
기본 예제: 파일 읽기에서 예외 제거
아래 예제는 파일을 읽어 문자열로 반환하되, 실패 시 에러 코드를 값으로 반환합니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
enum class ReadFileErrc {
open_failed,
read_failed
};
struct ReadFileError {
ReadFileErrc code;
std::string path;
std::string message;
};
std::expected<std::string, ReadFileError>
read_all_text(const std::string& path) {
std::ifstream in(path, std::ios::binary);
if (!in.is_open()) {
return std::unexpected(ReadFileError{
ReadFileErrc::open_failed,
path,
"failed to open"
});
}
std::string data;
in.seekg(0, std::ios::end);
data.resize(static_cast<size_t>(in.tellg()));
in.seekg(0, std::ios::beg);
if (!in.read(data.data(), static_cast<std::streamsize>(data.size()))) {
return std::unexpected(ReadFileError{
ReadFileErrc::read_failed,
path,
"failed to read"
});
}
return data;
}
호출부는 성공·실패를 분기해야 합니다.
auto r = read_all_text("config.json");
if (!r) {
// r.error()로 에러 접근
const auto& e = r.error();
// 로깅/전파/대체값 선택
return;
}
// 성공 시 r.value() 또는 *r
auto text = std::move(*r);
여기서 "누수 없는" 포인트는 std::string과 std::ifstream이 모두 RAII라는 점입니다. 실패 시점에 어떤 분기로 빠져도 스코프가 끝나면 안전하게 정리됩니다.
예외대체에서 중요한 설계: E 타입을 어떻게 잡을까
E는 단순히 int나 std::error_code로도 충분하지만, 실무에서는 다음 중 하나가 자주 쓰입니다.
std::error_code: OS/라이브러리 에러를 표준화해서 전달- 도메인 에러 enum + 컨텍스트:
enum으로 분류하고, 경로/ID/입력값 등 컨텍스트를 함께 std::variant로 에러 합치기: 하위 호출의 서로 다른 에러 타입을 합성
std::error_code를 쓰는 패턴
#include <expected>
#include <system_error>
std::expected<int, std::error_code> parse_port(std::string_view s) {
int port = 0;
// 단순 예시
for (char c : s) {
if (c < '0' || c > '9') {
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
}
port = port * 10 + (c - '0');
}
if (port < 1 || port > 65535) {
return std::unexpected(std::make_error_code(std::errc::result_out_of_range));
}
return port;
}
에러를 문자열로 만들고 싶다면 호출부에서 ec.message()를 쓰면 됩니다.
에러 전파를 "깔끔하게" 만드는 방법
expected가 도입되면 다음 문제가 바로 등장합니다.
- 하위 함수가
expected를 반환할 때, 상위 함수에서if (!r) return unexpected(r.error());를 반복하게 됨
이를 줄이는 대표적인 방식은 두 가지입니다.
- 작은 함수로 쪼개고, 각 함수에서 즉시 반환
- 조합 함수(
and_then,transform,or_else)를 활용
표준 std::expected에는 모나딕 연산이 포함되어 있습니다(구현체 지원 수준은 확인 필요). 아래는 개념적으로 가장 많이 쓰는 형태입니다.
transform: 성공 값 변환
#include <expected>
#include <string>
std::expected<int, std::string> to_int(std::string_view s);
auto r = to_int("42").transform([](int v) {
return v * 2;
});
// r는 expected<int, string>
and_then: 다음 expected 체이닝
#include <expected>
#include <string>
std::expected<int, std::string> to_int(std::string_view s);
std::expected<int, std::string> positive_only(int v);
auto r = to_int("10").and_then(positive_only);
or_else: 실패 시 복구/대체
#include <expected>
#include <string>
std::expected<int, std::string> to_int(std::string_view s);
auto r = to_int("not-a-number").or_else([](const std::string& e) {
// 로깅 후 기본값 제공
return std::expected<int, std::string>(0);
});
이 조합 스타일의 장점은 "실패는 자동으로 전파"되고, 성공 경로만 람다로 기술해 호출부 보일러플레이트를 줄인다는 점입니다.
"누락 없는" 에러 처리 만들기: must-use 결과 강제
expected를 반환해도 호출부가 무시해버리면 의미가 퇴색합니다.
read_all_text("x");호출하고 결과를 버려도 컴파일이 될 수 있음
이를 막기 위해 C++에서는 [[nodiscard]]를 붙이는 패턴이 매우 중요합니다.
#include <expected>
#include <string>
struct Error { std::string msg; };
[[nodiscard]]
std::expected<std::string, Error> load_config(std::string_view path);
이렇게 하면 결과를 사용하지 않을 때 컴파일러 경고(또는 설정에 따라 에러)를 유도할 수 있어 "에러 처리 누락"을 크게 줄입니다.
자원 핸들을 expected로 안전하게 반환하기
예외 없는 코드에서 누수가 생기는 대표 상황은 "핸들 생성 후 중간 실패"입니다. RAII 래퍼를 만들어 expected로 반환하면 깔끔합니다.
아래는 POSIX 파일 디스크립터를 RAII로 감싼 뒤 expected로 반환하는 예시입니다.
#include <expected>
#include <system_error>
#include <unistd.h>
#include <fcntl.h>
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_; }
void reset() {
if (fd_ != -1) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_ = -1;
};
[[nodiscard]]
std::expected<UniqueFd, std::error_code>
open_readonly(const char* path) {
int fd = ::open(path, O_RDONLY);
if (fd == -1) {
return std::unexpected(std::error_code(errno, std::generic_category()));
}
return UniqueFd(fd);
}
이제 호출부는 UniqueFd를 값으로 받고, 성공한 순간부터는 어떤 경로로 빠져도 소멸자가 close()를 보장합니다. 즉, 예외가 없더라도 "반환 이후의 누수"를 구조적으로 차단합니다.
expected와 예외의 역할 분담
std::expected가 예외를 완전히 대체해야 하는 것은 아닙니다. 실무적으로는 다음 기준이 유용합니다.
- 예상 가능한 실패(파일 없음, 입력 오류, 네트워크 타임아웃):
expected - 프로그래밍 오류(불변식 위반, 논리 버그):
assert, 계약(가능하면), 또는 예외 - 정말 드문 치명적 상황: 예외가 더 간결할 수 있음
특히 라이브러리 API는 "호출자가 복구 가능한 실패"를 expected로 표현하면, 호출자에게 선택권(재시도, fallback, 사용자 메시지)을 명확히 제공합니다.
성능 관점: 스택 언와인딩 없는 실패 경로
예외는 정상 경로가 빠르고 실패 경로가 상대적으로 비싸다는 특성이 있습니다(스택 언와인딩, 테이블 기반 런타임 비용 등). expected는 실패도 값으로 반환하므로 실패 경로가 예측 가능하고, 프로파일링과 튜닝이 단순해집니다.
다만 expected도 무조건 "더 빠르다"는 보장은 없습니다.
E가 크면 복사/이동 비용이 증가- 체이닝을 과도하게 쓰면 인라이닝/최적화 상황에 따라 코드가 커질 수 있음
그래서 E는 보통 작게(에러 코드 + 최소 컨텍스트) 유지하고, 큰 문자열 메시지는 로깅 계층에서 생성하는 식으로 설계하는 편이 좋습니다.
실전 팁: 에러 컨텍스트는 "지금" 붙이고 "나중"에 포맷
에러 메시지를 즉시 문자열로 만들면 비용이 커지고, 로케일/포맷 정책도 섞입니다. 대신 아래처럼 구조화된 에러를 반환하고, 최상위 경계(예: 요청 핸들러, CLI main)에서만 포맷팅하세요.
#include <expected>
#include <string>
enum class DbErrc { connection_failed, query_failed };
struct DbError {
DbErrc code;
std::string query;
int vendor_code;
};
std::string format_error(const DbError& e) {
// 최상위에서만 문자열 생성
switch (e.code) {
case DbErrc::connection_failed: return "db connection failed";
case DbErrc::query_failed: return "db query failed: " + e.query;
}
return "unknown";
}
이 방식은 운영 환경에서 로깅/관측성을 강화할 때도 유리합니다. 메모리 압박이나 누수 진단 관점에서는, 에러 문자열을 남발하는 것 자체가 메모리 사용량을 키울 수 있으니 주의가 필요합니다. 관련해서는 Linux OOM Killer 로그 추적과 메모리 누수 진단도 함께 참고해볼 만합니다.
expected 도입 체크리스트
1) 반환 타입부터 바꾸지 말고 "경계"부터 바꾸기
가장 효과적인 시작점은 다음 경계입니다.
- 파일/네트워크/DB 등 I/O 계층
- 외부 라이브러리 래핑 계층
- 스레드/태스크 실행 계층
경계에서 expected로 실패를 값으로 만들면, 상위 계층이 예외 정책과 무관하게 동작하기 쉬워집니다.
2) [[nodiscard]]는 기본값으로
에러를 "반드시 보게" 만드는 장치가 됩니다. 팀 컨벤션으로 강제하는 편이 좋습니다.
3) T는 RAII 타입을 선호
- 포인터 대신
std::unique_ptr - raw handle 대신
UniqueFd같은 래퍼 - 버퍼는
std::vector/std::string
이 조합이 "누수 없는 리턴"을 사실상 자동화합니다.
4) 에러 합성 전략을 미리 정하기
하위 계층 에러를 상위 계층에서 어떻게 표현할지 정하지 않으면, E 타입이 난립합니다.
- 단일
std::error_code로 통일 - 도메인별
enum+ 컨텍스트 - 필요 시
std::variant로 합치기
마무리
C++23 std::expected는 예외를 쓰지 않는 환경에서도 "실패를 타입으로 표현"해 API를 정직하게 만들고, [[nodiscard]]와 RAII를 결합해 에러 처리 누락과 자원 누수 가능성을 구조적으로 줄이는 도구입니다.
예외대체의 핵심은 단순히 throw를 없애는 것이 아니라, 실패를 호출부가 자연스럽게 다루도록 만드는 설계입니다. expected는 그 설계를 표준 라이브러리 차원에서 제공한다는 점에서, 이제는 외부 라이브러리(예: tl::expected)에 의존하지 않고도 팀 차원의 일관된 에러 처리 모델을 구축할 수 있게 해줍니다.
운영 환경에서 성능·안정성 문제를 줄이는 접근은 언어를 가리지 않고 비슷한 결을 가집니다. 예를 들어 캐시·재시도·실패 전파를 설계할 때도 "실패를 명시적으로 다루는 구조"가 중요합니다. 웹 계층의 실패 전파·중복 호출 방지 관점은 Next.js App Router 서버액션 중복호출 막는 6가지에서 아이디어를 얻을 수도 있습니다.