- Published on
C++23 std - -expected로 오류처리와 RAII 누수 차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/시스템 코드에서 오류 처리는 늘 두 가지 목표 사이에서 줄타기를 합니다. 첫째는 오류를 빠짐없이 전파하는 것, 둘째는 리소스 누수 없이 정리하는 것입니다. 전통적으로 C++은 예외(throw) 또는 반환 코드(int errno)로 이 문제를 풀어왔지만, 예외는 ABI/성능/정책 이슈로 금지되는 환경이 많고, 반환 코드는 호출자 체인에서 누락되기 쉽습니다.
C++23의 std::expected는 “성공 값 또는 오류”를 타입으로 표현해, 오류 전파를 강제하면서도 RAII와 자연스럽게 결합할 수 있게 해줍니다. 이 글에서는 std::expected를 실전 스타일로 사용하는 방법과, RAII로 누수를 차단하는 조합 패턴을 코드로 정리합니다.
운영 환경에서 장애를 줄이는 핵심은 결국 “실패를 정상 경로로 모델링”하고 “정리 코드를 구조로 강제”하는 것입니다. 이 관점은 인프라/플랫폼 트러블슈팅에서도 동일합니다. 예를 들어 권한 문제를 원인-결과로 분해해 재현 가능한 형태로 만드는 과정은 S3 AccessDenied 403 - 버킷 정책과 SSE-KMS 권한 같은 글과도 맥이 닿습니다.
std::expected가 해결하는 것
std::expected<T, E>는 다음 중 하나를 담습니다.
- 성공:
T값 - 실패:
E오류
핵심은 실패가 타입 시스템에 포함된다는 점입니다. 즉, 함수 시그니처만 봐도 실패 가능성이 드러나고, 호출자는 값을 꺼내기 전에 성공 여부를 확인하도록 유도됩니다.
예외/반환 코드와 비교
- 예외 기반
- 장점: 성공 경로가 깔끔함
- 단점: 예외 금지 정책, 예외 안전성 레벨(기본/강한/무예외) 설계 부담, 핫패스 비용/가시성 논쟁
- 반환 코드 기반
- 장점: 단순, C API와 호환
- 단점: 중간에서 체크 누락, 오류 정보 빈약, 리소스 정리 누락과 결합되기 쉬움
std::expected- 장점: 체크를 강제하는 타입, 오류 정보 구조화, 조기 반환 패턴이 명확, RAII와 궁합 좋음
- 단점: 체이닝 유틸이 아직 파편화(표준에
and_then/transform이 없는 구현도 많음), 템플릿 타입이 길어질 수 있음
기본 사용법: 성공/실패를 명시적으로 다루기
아래는 파일을 읽어 문자열로 반환하는 함수 예시입니다. 실패 시에는 std::error_code를 담아 반환합니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
std::expected<std::string, std::error_code>
read_all_text(const std::string& path) {
std::ifstream ifs(path, std::ios::binary);
if (!ifs) {
return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
}
std::string data;
ifs.seekg(0, std::ios::end);
data.resize(static_cast<size_t>(ifs.tellg()));
ifs.seekg(0, std::ios::beg);
if (!ifs.read(data.data(), static_cast<std::streamsize>(data.size()))) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return data;
}
void example() {
auto r = read_all_text("/tmp/a.txt");
if (!r) {
// r.error()로 오류를 강제 처리
const std::error_code ec = r.error();
return;
}
const std::string& text = *r;
(void)text;
}
포인트는 다음과 같습니다.
- 실패는
return std::unexpected(ec)로 반환 - 호출자는
if (!r)로 성공 여부 확인 후*r접근 - “체크 누락”이 코드 리뷰에서 즉시 드러남
RAII와 결합: 조기 반환이 많아질수록 더 안전해진다
std::expected를 도입하면 조기 반환(early return)이 많아지는 경향이 있습니다. 이때 RAII가 없다면 누수가 늘 수 있지만, C++은 원래 RAII가 강점이므로 오히려 궁합이 좋습니다.
예시: C 핸들 자원을 RAII로 감싸기
예를 들어 OS 핸들/소켓/DB 커넥션처럼 “획득 후 반드시 해제”해야 하는 자원이 있다고 합시다. 아래는 단순화를 위해 FILE*를 사용하되, 패턴은 모든 핸들에 동일합니다.
#include <expected>
#include <cstdio>
#include <memory>
#include <system_error>
struct file_closer {
void operator()(std::FILE* f) const noexcept {
if (f) std::fclose(f);
}
};
using file_ptr = std::unique_ptr<std::FILE, file_closer>;
std::expected<file_ptr, std::error_code>
open_file(const char* path, const char* mode) {
std::FILE* f = std::fopen(path, mode);
if (!f) {
return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
}
return file_ptr{f};
}
std::expected<size_t, std::error_code>
write_bytes(std::FILE* f, const void* data, size_t n) {
const size_t w = std::fwrite(data, 1, n, f);
if (w != n) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return w;
}
std::expected<void, std::error_code>
save_config(const char* path, const std::string& payload) {
auto f = open_file(path, "wb");
if (!f) return std::unexpected(f.error());
auto w = write_bytes(f->get(), payload.data(), payload.size());
if (!w) return std::unexpected(w.error());
// 여기서 어떤 이유로든 return 하더라도 file_ptr이 fclose를 보장
return {};
}
여기서 누수 차단의 핵심은 다음입니다.
open_file의 성공 값이file_ptr(RAII) 자체- 이후 단계에서 실패로 조기 반환해도 소멸자가 정리를 수행
- “정리 코드”가 비즈니스 로직과 분리됨
expected<void, E>로 단계형 작업을 깔끔하게 구성
성공 시 반환할 값이 없고 “성공/실패만” 필요하다면 std::expected<void, E>가 유용합니다. 위의 save_config처럼 파이프라인을 구성할 때 특히 좋습니다.
다만 expected<void, E>는 return {};가 성공을 의미한다는 점이 익숙하지 않을 수 있어, 팀 컨벤션으로 return std::expected<void, E>{};처럼 명시적으로 쓰기도 합니다.
오류 타입 설계: std::error_code vs 커스텀 에러
std::error_code를 선택하면 좋은 경우
- OS/표준 라이브러리 친화적
- 로깅/전달 시 문자열화가 쉬움
- “오류 도메인”을
error_category로 확장 가능
커스텀 에러 구조체를 선택하면 좋은 경우
- 도메인 컨텍스트가 중요할 때(예: 어떤 설정 키가 잘못됐는지)
- 재시도 가능 여부, 사용자 메시지, 원인 체인(cause)을 담고 싶을 때
예시:
#include <expected>
#include <string>
#include <system_error>
struct app_error {
std::error_code code;
std::string message;
bool retryable{false};
};
std::expected<int, app_error> parse_port(const std::string& s) {
try {
int p = std::stoi(s);
if (p < 1 || p > 65535) {
return std::unexpected(app_error{
std::make_error_code(std::errc::invalid_argument),
"port out of range",
false
});
}
return p;
} catch (...) {
return std::unexpected(app_error{
std::make_error_code(std::errc::invalid_argument),
"invalid port string",
false
});
}
}
예외를 금지한 코드베이스라면 std::stoi 대신 std::from_chars를 쓰는 편이 더 일관됩니다.
expected 전파 패턴: 보일러플레이트 줄이기
expected는 명시적이지만, 단계가 많아지면 if (!r) return unexpected(r.error());가 반복됩니다. 이를 줄이는 방법은 크게 두 가지입니다.
- C++23에 포함된
std::expected만으로는 체이닝 연산이 부족할 수 있어, 팀 내 헬퍼를 만든다 - 컴파일러 확장이나 서드파티(TL expected 등)를 쓴다
여기서는 표준만으로도 충분히 많이 쓰는 “전파 헬퍼”를 예로 듭니다.
#include <expected>
template <class T, class E>
std::expected<T, E> propagate(std::expected<T, E>&& r) {
return std::move(r);
}
template <class T, class E>
T value_or_return(std::expected<T, E>&& r, std::expected<void, E>& out_err) {
if (!r) {
out_err = std::unexpected(r.error());
return T{}; // 사용 시 주의: 호출부에서 out_err 검사 필요
}
return std::move(*r);
}
위처럼 억지로 만들기보다는, 보통은 “짧은 전파 함수”를 도메인별로 만들거나, C++26 이후 표준화 논의가 진행 중인 모나딕 연산(and_then 등)을 기다리는 팀도 많습니다. 실무에서는 가독성과 일관성이 더 중요하므로, 팀 컨벤션에 맞춰 최소한으로만 추상화하는 것을 권합니다.
부분 초기화와 누수: expected는 구조를, RAII는 정리를 담당
누수는 단순히 delete를 안 해서 생기지 않습니다. 실무에서 더 흔한 패턴은 다음입니다.
- A 자원 획득
- B 자원 획득
- C 단계에서 실패
- A/B 정리 코드가 누락되거나, 예외/반환 경로에서 건너뜀
expected는 “실패 경로”를 명시화해서 C 단계의 실패를 숨기지 않게 만들고, RAII는 A/B의 정리를 자동화합니다. 둘을 결합하면 실패가 많아질수록 코드가 더 안전해지는 역설적인 구조가 됩니다.
이게 중요한 이유는, 장애의 상당수가 “예상치 못한 조합의 실패”에서 나오기 때문입니다. 예컨대 권한 문제로 API가 403을 내고, 그걸 처리하지 못해 재시도 폭주나 리소스 고갈로 이어지는 식입니다. 이런 실패 시나리오를 타입과 구조로 고정해두면, 운영 중 변수가 줄어듭니다. 비슷한 맥락의 접근으로 진단 루틴을 구조화하는 글로는 K8s CrashLoopBackOff 원인 12가지 10분 진단도 참고할 만합니다.
실전 예제: “자원 획득 + 검증 + 커밋” 트랜잭션 스타일
아래는 “파일 열기 → 임시 파일에 쓰기 → 원자적 교체” 같은 작업을 단순화한 예시입니다. 핵심은 커밋 이전에 실패하면 자동으로 롤백(임시 파일 삭제)되게 만드는 것입니다.
#include <expected>
#include <filesystem>
#include <fstream>
#include <system_error>
namespace fs = std::filesystem;
struct temp_file {
fs::path path;
bool committed{false};
~temp_file() noexcept {
if (!committed) {
std::error_code ec;
fs::remove(path, ec);
}
}
};
std::expected<temp_file, std::error_code>
create_temp_next_to(const fs::path& target) {
std::error_code ec;
fs::path tmp = target;
tmp += ".tmp";
// 이미 존재하면 충돌로 처리(정교하게 하려면 랜덤 suffix)
if (fs::exists(tmp, ec)) {
return std::unexpected(std::make_error_code(std::errc::file_exists));
}
std::ofstream ofs(tmp, std::ios::binary);
if (!ofs) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return temp_file{tmp, false};
}
std::expected<void, std::error_code>
atomic_write(const fs::path& target, const std::string& payload) {
auto tmp = create_temp_next_to(target);
if (!tmp) return std::unexpected(tmp.error());
{
std::ofstream ofs(tmp->path, std::ios::binary | std::ios::trunc);
if (!ofs) return std::unexpected(std::make_error_code(std::errc::io_error));
ofs.write(payload.data(), static_cast<std::streamsize>(payload.size()));
if (!ofs) return std::unexpected(std::make_error_code(std::errc::io_error));
}
std::error_code ec;
fs::rename(tmp->path, target, ec);
if (ec) return std::unexpected(ec);
tmp->committed = true; // 여기까지 오면 임시 파일 삭제 금지
return {};
}
이 패턴의 장점:
- 중간 실패 시 임시 파일이 자동 정리됨(누수 차단)
- 성공 시에만
committed = true - 오류는
expected로 호출자에게 강제 전파
expected를 도입할 때 자주 하는 실수
1) 오류를 문자열로만 반환
std::expected<T, std::string>는 빠르게 시작하기엔 좋지만, 시간이 지나면 분기/매칭/로깅에서 구조화가 안 되어 유지보수가 어려워집니다. 최소한 다음 중 하나를 권합니다.
std::error_codeenum class+ 부가 정보 구조체- 도메인 에러 타입(위의
app_error같은 형태)
2) 성공 값에 “실패를 나타내는 센티넬”을 섞음
예: expected<int, E>를 반환하면서 성공 값 -1을 실패처럼 취급하는 코드는 expected의 이점을 스스로 버리는 것입니다. 실패는 반드시 unexpected로만 표현해야 호출자 실수가 줄어듭니다.
3) RAII 없이 expected만으로 안전해졌다고 착각
expected는 오류 전파를 안전하게 만들지만, 자원 정리는 여전히 RAII의 몫입니다. 특히 C API 핸들, 락, 임시 파일, 트랜잭션 같은 자원은 “성공/실패와 무관하게 항상 정리”되어야 하므로, 소멸자 기반으로 설계해야 합니다.
마무리: “실패를 타입으로, 정리를 구조로”
C++23의 std::expected는 예외를 쓰지 않더라도 오류를 풍부하게 전달하고, 호출자가 실패를 무시하기 어렵게 만들어줍니다. 여기에 RAII를 결합하면 조기 반환이 많아져도 누수가 생기지 않는 구조를 만들 수 있습니다.
정리하면 다음 체크리스트로 가져가면 좋습니다.
- 실패 가능 함수는
std::expected<T, E>로 모델링 - 자원은 성공 값으로 반환하되 RAII 타입으로 감싸기
- “커밋 이전 실패는 자동 롤백”을 소멸자로 구현
- 오류 타입은 문자열이 아니라 구조화된 타입으로
운영에서의 실패는 늘 “예외 케이스”처럼 보이지만, 코드에서는 예외가 아니라 정상적인 분기로 취급되어야 합니다. std::expected는 그 방향으로 C++을 한 걸음 더 밀어주는 도구입니다.
추가로, 장애를 줄이는 관점에서 재현 가능한 실패 경로를 설계하는 방식은 배포 파이프라인/권한/런타임 진단에서도 동일하게 적용됩니다. 예를 들어 권한 오류를 타입과 단계로 분해해 원인을 좁히는 접근은 GitHub Actions OIDC에서 AWS AssumeRoleAccessDenied 해결 같은 사례에서도 그대로 유효합니다.