- Published on
C++23 std - -expected로 예외 없는 에러처리+RAII
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외 기반 에러처리는 C++에서 강력하지만, 모든 코드베이스에 잘 맞는 해답은 아닙니다. 저지연 시스템, 게임/엔진, 임베디드, “예외 금지” 규칙이 있는 조직, 혹은 예외 경로에서의 비용과 제어 흐름을 더 명시적으로 만들고 싶은 팀에서는 예외를 끄거나 제한적으로만 쓰는 경우가 많습니다.
C++23의 std::expected는 이런 상황에서 “성공 값”과 “오류 값”을 타입으로 명확히 표현하고, 호출자에게 오류 처리를 강제할 수 있는 표준 도구입니다. 여기에 C++의 핵심 철학인 RAII(Resource Acquisition Is Initialization)를 결합하면, 예외 없이도 깔끔하고 안전한 에러처리 흐름을 만들 수 있습니다.
이 글에서는 std::expected를 단순 소개하는 수준을 넘어, 실제 코드에서 RAII와 결합해 예외 없는 에러처리를 어떻게 구조화하는지, 어떤 함정이 있는지, 그리고 팀 규칙으로 어떻게 굳히면 좋은지까지 정리합니다.
관련해서 CI에서 컴파일러/표준 라이브러리 매트릭스를 돌려 호환성을 확보하는 것도 중요합니다. 대규모 C++ 프로젝트라면 GitHub Actions 매트릭스 전략도 함께 참고해보세요: GitHub Actions 병렬·매트릭스로 CI 50% 단축
std::expected가 해결하는 문제
예외 대신 반환값으로 오류를 표현할 때의 고질적인 문제
예외를 쓰지 않으면 보통 다음 중 하나로 가게 됩니다.
- “특수 값”으로 실패 표현: 예를 들어
-1,nullptr, 빈 문자열 등 bool반환 + out-parameter:bool read(File& f, Data* out)std::optional로 성공/실패만 표현: 실패 이유는 별도로 로깅하거나 전역 상태로
이 방식들은 공통적으로 다음 문제가 생깁니다.
- 실패 이유가 타입에 녹아있지 않아 호출자가 놓치기 쉽다
- out-parameter는 호출부가 지저분해지고, 부분 초기화/정리 문제가 생긴다
- 오류 전파가 장황해지고, 중간 단계에서 컨텍스트를 잃기 쉽다
std::expected는 “성공 값 T 또는 오류 값 E”를 한 타입으로 묶어, 실패 이유를 타입으로 강제합니다.
std::expected의 기본 형태
- 성공:
expected가T값을 가진다 - 실패:
expected가E값을 가진다
핵심 API는 다음 정도만 기억해도 충분합니다.
has_value()또는operator bool()value()/error()value_or(...)and_then(...),transform(...),or_else(...),transform_error(...)(단계적 조합)
에러 타입 E 설계: 문자열보다 “구조화된 오류”
E를 단순히 std::string으로 두면 편해 보이지만, 운영에서 결국 문제가 됩니다.
- 분류/집계가 어렵다(알람, 통계, 리트라이 정책)
- 호출자가 분기 처리하기 어렵다
- 국제화/로깅 포맷이 섞인다
권장 패턴은 “오류 코드 + 컨텍스트”입니다.
#include <expected>
#include <string>
#include <system_error>
enum class AppErrc {
Io,
Parse,
InvalidArg,
NotFound,
Permission,
};
struct AppError {
AppErrc code;
std::string message; // 사람용 컨텍스트
std::error_code sys_ec{}; // 필요 시 OS/라이브러리 오류 연결
};
template <class T>
using Expected = std::expected<T, AppError>;
- 분기 처리는
code로 - 디버깅/관측성은
message로 - 시스템 콜 실패는
sys_ec로 연결
특히 파일/소켓/스레드 같은 시스템 리소스는 errno나 플랫폼별 에러를 그대로 문자열로 박제하기보다 std::error_code를 같이 보관하면 후처리가 쉬워집니다.
RAII와 결합: “실패해도 누수 없는” 흐름 만들기
예외를 끄면 많은 사람들이 오해하는 지점이 있습니다.
- “예외가 없으니 RAII가 덜 중요하다”가 아니라
- “예외가 없더라도 조기 반환이 많아지므로 RAII가 더 중요하다”가 맞습니다.
std::expected 기반 코드는 return unexpected(...)로 빠져나가는 경로가 많아집니다. 이때 매번 close()/free()를 직접 호출하면 누수가 발생하기 쉽습니다. RAII는 이 조기 반환 경로에서 자동 정리를 보장합니다.
예시 1: 파일 핸들을 RAII로 감싸고 expected로 열기
아래는 POSIX open/close를 예로 든 간단한 패턴입니다. (Windows라면 HANDLE에 맞게 동일한 구조로 만들면 됩니다.)
#include <expected>
#include <string>
#include <system_error>
#include <fcntl.h>
#include <unistd.h>
struct UniqueFd {
int fd{-1};
UniqueFd() = default;
explicit UniqueFd(int f) : fd(f) {}
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(); }
void reset() noexcept {
if (fd != -1) {
::close(fd);
fd = -1;
}
}
int get() const noexcept { return fd; }
explicit operator bool() const noexcept { return fd != -1; }
};
enum class AppErrc { Io, InvalidArg };
struct AppError {
AppErrc code;
std::string message;
std::error_code sys_ec{};
};
template <class T>
using Expected = std::expected<T, AppError>;
Expected<UniqueFd> open_readonly(const std::string& path) {
if (path.empty()) {
return std::unexpected(AppError{AppErrc::InvalidArg, "empty path"});
}
int fd = ::open(path.c_str(), O_RDONLY);
if (fd == -1) {
return std::unexpected(AppError{
AppErrc::Io,
"open failed: " + path,
std::error_code(errno, std::generic_category())
});
}
return UniqueFd{fd};
}
포인트:
- 성공 시
UniqueFd를 값으로 반환합니다. 이동 가능하므로 비용이 크지 않습니다. - 실패 시
std::unexpected(AppError{...})로 실패를 명시합니다. - 호출자는
Expected<UniqueFd>를 강제로 처리해야 합니다.
예시 2: 여러 단계에서 실패 가능한 로직을 “조기 반환 + RAII”로
expected의 장점은 “실패하면 즉시 반환”을 자연스럽게 만들면서도, RAII로 정리가 자동이라는 점입니다.
#include <vector>
#include <cstdint>
#include <unistd.h>
Expected<std::vector<std::uint8_t>> read_all(int fd) {
std::vector<std::uint8_t> out;
std::uint8_t buf[4096];
while (true) {
ssize_t n = ::read(fd, buf, sizeof(buf));
if (n == 0) break;
if (n < 0) {
return std::unexpected(AppError{
AppErrc::Io,
"read failed",
std::error_code(errno, std::generic_category())
});
}
out.insert(out.end(), buf, buf + n);
}
return out;
}
Expected<std::vector<std::uint8_t>> load_file(const std::string& path) {
auto fd = open_readonly(path);
if (!fd) return std::unexpected(fd.error());
// fd는 UniqueFd이므로 여기서 어떤 조기 반환이 발생해도 close 보장
return read_all(fd->get());
}
여기서 load_file은 open 실패든 read 실패든, 어떤 경로로 빠져도 UniqueFd 소멸자가 close를 호출합니다.
and_then/transform로 파이프라인 구성하기
조기 반환 스타일이 가장 직관적이지만, 함수 조합이 많은 경우 expected의 모나딕(파이프라인) API가 깔끔합니다.
and_then: 성공 값이 있을 때 다음Expected를 반환하는 함수를 연결transform: 성공 값을 다른 값으로 매핑(오류는 그대로)or_else: 실패 시 오류를 변환하거나 복구 로직 수행
#include <expected>
#include <string>
#include <vector>
Expected<std::string> decode_utf8(std::vector<std::uint8_t> bytes) {
// 예시: 실제 구현에서는 검증/변환 수행
return std::string(bytes.begin(), bytes.end());
}
Expected<std::string> load_text(const std::string& path) {
return load_file(path)
.and_then([](auto bytes) { return decode_utf8(std::move(bytes)); })
.transform([](std::string s) {
// 후처리 예: BOM 제거, 개행 정규화 등
return s;
})
.or_else([](AppError e) {
// 오류에 컨텍스트 추가
e.message = "load_text: " + e.message;
return std::unexpected(e);
});
}
이 스타일은 “중간에 실패하면 자동으로 아래 단계가 스킵된다”는 점에서 비즈니스 로직 파이프라인에 잘 맞습니다.
예외 없는 코드에서의 “실패 원자성”과 RAII
예외를 사용하지 않아도, 실패 시 상태가 중간까지 변경되는 문제는 그대로 존재합니다. 특히 다음 상황에서 버그가 자주 납니다.
- 함수가 여러 리소스를 순서대로 획득하다가 중간 실패
- 일부만 초기화된 객체가 외부로 노출
- 실패했는데도 전역/싱글톤 상태가 변경됨
해결책은 크게 두 가지입니다.
- 리소스는 RAII 객체로 즉시 감싸기
- 커밋(상태 반영)은 마지막에 한 번만 수행하기
예를 들어 “임시 파일에 쓰고 rename으로 커밋” 같은 패턴은 예외 유무와 상관없이 강력합니다. expected는 이 커밋 단계의 실패도 타입으로 강제해주므로, 호출자가 놓치기 어렵습니다.
std::expected 도입 시 팀 규칙(실전 체크리스트)
1) 경계에서만 예외를 허용할지 결정
프로젝트에 따라 전략이 갈립니다.
- 코어 라이브러리: 예외 금지,
expected사용 - 어플리케이션 경계(UI, main, RPC 핸들러): 최상단에서만 예외 처리/로깅
혹은 완전 예외 금지로 가되, 서드파티가 던지는 예외를 경계에서 catch 후 expected로 변환하는 “어댑터 레이어”를 두는 방식도 흔합니다.
2) E는 반드시 구조화
- 최소: 오류 코드(enum) + 메시지
- 권장:
std::error_code또는 원인 체인(원인 오류를 포인터/값으로 보관)
3) value()를 무분별하게 쓰지 않기
value()는 값이 없으면(실패면) 예외를 던질 수 있습니다(구현에 따라 bad_expected_access). 예외 없는 정책이라면 사실상 금지에 가깝습니다.
대신 다음을 사용하세요.
if (!exp) return unexpected(exp.error());and_then/transform체이닝value_or(단, 기본값이 논리적으로 타당할 때만)
4) 오류 전파는 “복사 최소화”를 의식
AppError에 큰 문자열을 담으면 전파 비용이 커질 수 있습니다.
- 메시지는 필요할 때만 추가(
or_else에서 컨텍스트를 덧붙이기) - 큰 문자열 대신 짧은 코드/키를 담고, 로깅에서 확장
- 또는
std::string을 이동하도록 람다에서AppError e를 값으로 받아 수정 후 반환(위 예시처럼)
기존 코드와의 접점: optional, error_code, C API
optional과의 역할 분담
- 실패 이유가 중요하지 않거나, 단순히 “있음/없음”이면
optional - 실패 이유로 분기/로깅/리트라이가 필요하면
expected
예: 캐시 조회는 optional이 자연스럽고, 디스크/네트워크 I/O는 expected가 자연스럽습니다.
std::error_code 기반 API와의 결합
표준 라이브러리와 일부 플랫폼 API는 std::error_code를 이미 사용합니다. 이 경우 E에 std::error_code를 포함하면 변환 비용이 거의 없고, 호출자가 친숙하게 다룰 수 있습니다.
C API 래핑
C API는 보통 int 리턴 + errno 또는 out-parameter로 오류를 전달합니다. 이때 “C API 호출 직후”에 expected로 변환하고, 이후 레이어는 expected만 쓰게 만들면 코드베이스가 훨씬 단정해집니다.
빌드/호환성 메모: 표준 라이브러리 지원 확인
std::expected는 C++23 기능이지만, 컴파일러와 표준 라이브러리 조합에 따라 지원 시점이 다를 수 있습니다. 실무에서는 다음을 권합니다.
- CI에서
-std=c++23를 켠 매트릭스 빌드 구성 libstdc++/libc++버전별 테스트- 필요하면 폴리필(
tl::expected등)로 브릿지하다가 표준으로 전환
이 과정에서 GitHub Actions 매트릭스는 특히 효과적입니다: GitHub Actions 병렬·매트릭스로 CI 50% 단축
정리: expected는 “명시적 실패”, RAII는 “자동 정리”
std::expected는 실패를 타입으로 드러내고, 호출자가 처리하도록 강제합니다.- 예외를 쓰지 않을수록 조기 반환 경로가 늘어나며, 이때 RAII가 누수/정리 버그를 막는 핵심 안전장치가 됩니다.
- 좋은 설계는
E를 구조화하고, 경계에서 변환하며, 내부 로직은and_then또는 조기 반환 패턴으로 단순화하는 것입니다.
다음 단계로는 “오류 컨텍스트 체이닝(원인 보존)”, “로그 정책(오류 생성 지점 vs 소비 지점)”, “리트라이 가능 오류 분류”까지 확장하면 운영 품질이 크게 올라갑니다.