- Published on
C++23 std - -expected로 예외 없는 에러처리와 RAII
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: 예외를 쓰지 않는 C++에서 생기는 두 가지 문제
C++에서 예외를 끄는 환경은 생각보다 흔합니다. 게임/임베디드/저지연 서버처럼 코드 크기·성능·예측 가능성이 중요한 곳에서는 -fno-exceptions 같은 빌드 옵션을 선택하기도 하고, 조직 차원에서 예외 사용을 금지하는 경우도 있습니다. 문제는 그 다음입니다.
실패를 표현할 표준적인 타입이 없어
bool반환 +out파라미터, 혹은 에러 코드를 섞어 쓰다 보면 호출부가 지저분해집니다.실패 경로가 많아지면 자원 해제 누락이 늘고, 결국 RAII를 쓰더라도 “성공했을 때만 소유권이 생기는” 미묘한 케이스에서 실수가 발생합니다.
C++23의 std::expected는 이 두 문제를 동시에 정리할 수 있는 강력한 도구입니다. 실패를 값으로 다루되, 성공 값과 실패 값을 타입 레벨로 분리해 호출부의 의도를 명확히 만들고, RAII와 결합해 “획득은 초기화에서, 해제는 소멸에서”라는 원칙을 유지하게 해줍니다.
아래에서는 std::expected의 핵심 사용법, 에러 타입 설계, 레거시 C API 래핑, 그리고 RAII와 결합하는 실전 패턴을 코드로 정리합니다.
또한 에러 처리를 “값의 흐름”으로 설계하는 관점은 다른 언어에서도 중요합니다. 예를 들어 Rust의 빌림 범위와 수명 관리가 안전성에 어떤 영향을 주는지에 대한 감각은 C++ RAII 설계에도 도움이 됩니다. 관련해서는 Rust NLL이 허용하는 빌림 범위 이해하기도 함께 보면 좋습니다.
std::expected 한 줄 요약과 기본 형태
std::expected는 “성공 시 T, 실패 시 E”를 담는 합(sum) 타입입니다.
- 성공:
std::expected<T, E>가T값을 보유 - 실패:
E값을 보유
기본 사용 예시는 다음과 같습니다.
#include <expected>
#include <string>
enum class Error {
empty_input,
invalid_format,
};
std::expected<int, Error> parse_int(std::string_view s) {
if (s.empty()) return std::unexpected(Error::empty_input);
int sign = 1;
size_t i = 0;
if (s[0] == '-') { sign = -1; i = 1; }
int value = 0;
for (; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') return std::unexpected(Error::invalid_format);
value = value * 10 + (c - '0');
}
return sign * value;
}
int main() {
auto r = parse_int("123");
if (!r) {
// r.error()로 실패 원인 조회
return 1;
}
int v = *r; // 또는 r.value()
(void)v;
}
핵심은 호출부가 if (!r)처럼 “성공/실패 분기”를 명확히 강제받는다는 점입니다. 예외처럼 보이지 않는 흐름 제어가 아니라, 타입이 요구하는 분기입니다.
예외 없는 코드에서 expected가 특히 좋은 이유
1) 실패가 잦은 경로에서 가독성이 좋아진다
기존의 C 스타일은 보통 이런 형태입니다.
bool parse_int(std::string_view s, int& out, Error& err);
호출부는 out과 err의 수명/초기화/사용 순서를 기억해야 하고, 실수로 out을 실패 시에도 사용하는 버그가 생깁니다.
반면 std::expected<int, Error>는 성공 값이 없으면 int를 꺼낼 수 없게 설계됩니다.
2) 함수 시그니처가 문서가 된다
std::expected<T, E>는 “이 함수는 실패할 수 있고, 실패는 E로 표현한다”를 시그니처에 박아 넣습니다. 예외 기반 코드에서는 실패 가능성이 문서/주석/컨벤션에 묻히기 쉬운데, expected는 타입이 그 역할을 합니다.
3) RAII와 결합하면 실패 경로에서도 자원이 안전하다
예외를 쓰지 않아도 RAII는 유효합니다. 다만 “중간 단계에서 실패 시 이미 획득한 자원을 어떻게 정리할지”가 문제인데, expected를 사용하면 자원 획득을 성공 값으로 캡슐화해서 호출부가 실수할 여지를 줄일 수 있습니다.
에러 타입 E를 어떻게 설계할까
E는 단순 enum도 좋지만, 실전에서는 디버깅 가능한 정보가 필요합니다.
- 에러 코드(범주)
- 메시지(사람이 읽을 수 있는 설명)
- 원인(하위 API의 에러 코드,
errno등) - 컨텍스트(파일 경로, 파라미터, 오프셋 등)
다음은 가벼운 구조체 기반 예시입니다.
#include <string>
#include <system_error>
enum class Errc {
io,
parse,
invalid_argument,
};
struct Error {
Errc code;
std::string message;
std::error_code sys; // 필요할 때만 사용
};
inline Error make_io_error(std::string msg, std::error_code ec) {
return Error{Errc::io, std::move(msg), ec};
}
중요한 포인트는 E가 “값으로 이동 가능”해야 한다는 점입니다. 너무 무거운 에러 객체를 만들면 실패 경로 비용이 커질 수 있으니, 로그에 필요한 최소 정보만 담고 상세는 상위에서 보강하는 방식이 자주 쓰입니다.
RAII + expected: 자원 획득 함수를 expected로 만들기
가장 실용적인 패턴은 “자원 획득 함수가 expected<Resource, Error>를 반환”하는 것입니다. 성공 시에만 소유권이 생기고, 실패 시에는 자원이 존재하지 않으므로 해제할 것도 없습니다.
파일 디스크립터를 RAII로 감싸고 expected로 열기
POSIX open을 예로 들면, 실패 시 -1과 errno로 표현됩니다. 이를 RAII로 감싸고 expected로 반환하면 호출부가 훨씬 깔끔해집니다.
#include <expected>
#include <cerrno>
#include <cstring>
#include <system_error>
#include <unistd.h>
#include <fcntl.h>
struct Fd {
int fd = -1;
Fd() = default;
explicit Fd(int v) : fd(v) {}
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
Fd(Fd&& other) noexcept : fd(other.fd) { other.fd = -1; }
Fd& operator=(Fd&& other) noexcept {
if (this != &other) {
reset();
fd = other.fd;
other.fd = -1;
}
return *this;
}
~Fd() { reset(); }
void reset() noexcept {
if (fd != -1) {
::close(fd);
fd = -1;
}
}
int get() const noexcept { return fd; }
};
struct Error {
std::string message;
std::error_code sys;
};
std::expected<Fd, Error> open_readonly(const char* path) {
int fd = ::open(path, O_RDONLY);
if (fd == -1) {
std::error_code ec(errno, std::generic_category());
return std::unexpected(Error{
std::string("open failed: ") + path + ": " + std::strerror(errno),
ec
});
}
return Fd{fd};
}
호출부는 다음처럼 “성공일 때만 Fd를 소유”합니다.
auto fd = open_readonly("/tmp/data.bin");
if (!fd) {
// fd.error().message
return;
}
// 여기부터는 fd가 스코프를 벗어나면 자동 close
int raw = fd->get();
(void)raw;
이 조합이 강력한 이유는, 실패 경로에서 close를 호출할 일이 없고, 성공 경로에서도 조기 반환이 있더라도 소멸자가 알아서 정리해주기 때문입니다.
여러 단계 작업을 expected로 연결하기
실전 로직은 보통 “열기 → 읽기 → 파싱 → 검증 → 변환”처럼 단계가 많습니다. 예외가 없다면 단계마다 if가 늘어나는데, expected를 쓰면 패턴이 일정해지고 실수를 줄입니다.
단순한 연결: 조기 반환 패턴
std::expected<std::string, Error> read_all(Fd& fd);
std::expected<int, Error> parse_config(std::string_view);
std::expected<int, Error> load_config_value(const char* path) {
auto fd = open_readonly(path);
if (!fd) return std::unexpected(fd.error());
auto text = read_all(*fd);
if (!text) return std::unexpected(text.error());
auto value = parse_config(*text);
if (!value) return std::unexpected(value.error());
return *value;
}
이 방식은 “명시적인 분기 + 조기 반환”이라 디버깅이 쉽고, 예외가 없어도 흐름이 단순합니다.
transform와 and_then로 파이프라인 만들기
표준 std::expected에는 함수형 스타일의 헬퍼가 포함됩니다. 구현/컴파일러에 따라 지원 상태가 다를 수 있지만, C++23 표준에 있는 대표적인 것들은 transform, and_then, or_else입니다.
transform: 성공 값T를U로 변환and_then: 성공 값으로 다음expected를 이어 붙임or_else: 실패를 다른 실패로 매핑하거나 로깅
예시:
auto r = open_readonly(path)
.and_then([](Fd fd) { return read_all(fd); })
.and_then([](std::string s) { return parse_config(s); })
.or_else([](const Error& e) {
// 로깅 후 그대로 전달
return std::unexpected(e);
});
이 스타일은 “에러 전파가 자동”이라는 장점이 있지만, 람다 캡처/이동 비용과 디버깅 편의성을 고려해 팀 스타일에 맞게 선택하는 것이 좋습니다.
expected와 RAII의 경계: 소유권을 어디에 둘 것인가
expected를 쓰면서 가장 많이 하는 실수는 “자원을 성공 값으로 반환하지 않고, 밖에서 먼저 만들고 채우려는” 패턴입니다.
나쁜 예(실패 시 부분 초기화 위험):
std::expected<void, Error> init(Fd& out);
좋은 예(성공 시에만 완전한 값이 생김):
std::expected<Fd, Error> init();
즉, RAII 타입은 가능한 한 “완성된 상태”로만 외부에 노출하고, 실패 가능성이 있는 초기화는 팩토리 함수로 숨겨 expected로 감싸는 편이 안전합니다.
레거시 코드/서드파티 API를 expected로 감싸는 전략
대부분의 프로젝트는 이미 다음 중 하나를 사용하고 있습니다.
int리턴 + 음수는 실패bool리턴 +GetLastError/errno- 포인터 리턴 +
nullptr은 실패 - 콜백 기반 비동기 완료 코드
전략은 단순합니다.
- “원본 API 호출”을 가장 얇은 래퍼로 감싼다.
- 실패 조건을 한 곳에서만 해석한다.
- 성공 값은 RAII 타입으로 변환한다.
예를 들어 malloc을 RAII로 감싸면(실전에서는 std::unique_ptr를 쓰는 편이 낫지만) 이런 식입니다.
#include <expected>
#include <cstdlib>
#include <memory>
struct Error { std::string message; };
std::expected<std::unique_ptr<unsigned char[], void(*)(void*)>, Error>
alloc_bytes(size_t n) {
void* p = std::malloc(n);
if (!p) return std::unexpected(Error{"out of memory"});
return std::unique_ptr<unsigned char[], void(*)(void*)>(
static_cast<unsigned char*>(p),
&std::free
);
}
핵심은 “성공했을 때만 소유 포인터가 생성”된다는 점입니다. 호출부는 널 체크 대신 expected 체크를 하게 됩니다.
std::expected를 도입할 때의 팀 규칙 제안
1) 반환 타입 선택 기준
- 실패가 불가능하거나 논리적으로 말이 안 되면 그냥
T - 실패가 가능하면
std::expected<T, Error> - 값이 없음을 정상 상태로 표현해야 하면
std::optional<T>
optional을 에러 처리로 남용하면 “왜 없는지”가 사라져 운영/디버깅 비용이 커집니다.
2) value() 남용 금지
value()는 실패 시 예외를 던질 수 있습니다(구현에 따라 terminate 성격일 수도 있음). 예외 없는 환경이라면 특히 더 위험합니다. 호출부는 다음을 선호하세요.
if (!r) return std::unexpected(r.error());*r는 “이미 체크했다”는 팀 합의가 있을 때만
3) 에러 메시지 생성 위치 통일
에러 메시지를 매번 문자열로 만들면 비용이 큽니다. 다음 중 하나로 통일하는 것이 좋습니다.
- 에러는 코드+컨텍스트만 담고, 최종 출력 시 메시지 구성
- 혹은 성능 영향이 적은 경로에서만 메시지 구성
이런 “관측 가능성” 설계는 운영 문제를 줄이는 데 직결됩니다. 운영에서 원인 파악이 어려운 문제를 빨리 줄이는 관점은 인프라 진단 글들(예: Argo CD Sync 실패 - OutOfSync·Health Degraded 9가지)과도 결이 같습니다.
예외 기반 코드와 혼용할 때의 주의점
프로젝트에 예외가 일부 남아 있다면, 경계에서 정책을 정해야 합니다.
- “내부는 예외, 외부 API는
expected” - 또는 “내부도 전부
expected”
혼용 시 흔한 전략은 “최상위 레이어에서만 예외를 잡고 expected로 변환”입니다.
std::expected<int, Error> safe_entry() {
try {
// 예외를 던질 수 있는 레거시 호출
int v = legacy();
return v;
} catch (const std::exception& e) {
return std::unexpected(Error{e.what(), {}});
}
}
단, 예외를 완전히 끄는 빌드라면 이 패턴은 사용할 수 없으니, 레거시 호출을 점진적으로 expected 래핑으로 치환하는 방향이 필요합니다.
결론: expected는 “실패를 값으로” 만들고, RAII는 “정리를 자동으로” 만든다
C++23 std::expected는 예외 없이도 에러 처리를 구조화할 수 있게 해줍니다. 여기에 RAII를 결합하면 다음이 동시에 달성됩니다.
- 실패가 타입으로 드러나 호출부가 안전해짐
- 실패 경로가 늘어나도 자원 누수 없이 유지됨
- 레거시 C API도 얇은 래퍼로 점진적 개선 가능
도입 순서는 보통 “자원 획득 함수부터 expected로 바꾸기”가 가장 효과가 큽니다. 파일/소켓/락/핸들 같은 자원은 실패와 정리가 가장 빈번한 지점이기 때문입니다. 그 다음 파서/검증/변환 로직으로 확장하면 예외 없는 코드베이스에서도 에러 처리 품질을 크게 끌어올릴 수 있습니다.