- Published on
C++23 std - -expected로 예외 없는 오류처리와 RAII
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI 유틸리티처럼 “실패는 흔하고, 비용은 예측 가능해야 하는” 코드베이스에서는 예외 기반 오류처리가 부담이 될 때가 많습니다. 예외는 강력하지만, 호출 스택을 타고 멀리 점프하며 제어 흐름을 바꾸고, ABI/컴파일 옵션(예: 예외 비활성화), 성능 프로파일링, 테스트 더블 구성 등에 영향을 줍니다.
C++23의 std::expected는 실패를 “값”으로 표현해, 예외 없이도 명시적이고 구성 가능한 오류 전파를 가능하게 합니다. 그리고 C++의 핵심 강점인 RAII(Resource Acquisition Is Initialization)와 결합하면, 실패 경로에서도 자원 정리를 자동화하면서 코드의 의도를 더 분명히 만들 수 있습니다.
이 글에서는 std::expected의 핵심 사용법, 에러 타입 설계, RAII와의 결합 패턴, 그리고 실전에서 자주 마주치는 함정(중첩 expected, 에러 컨텍스트, 조합/파이프라인)을 코드로 정리합니다.
왜 std::expected인가
전통적으로 C++에서 오류를 표현하는 방식은 크게 세 가지였습니다.
- 반환값으로
bool/에러코드와 out-parameter - 예외(
throw) 기반 std::optional로 성공/실패만 표현(실패 원인 없음)
std::expected는 “성공 값 T 또는 실패 값 E”를 명시적으로 담습니다.
- 성공 시:
expected<T, E>가T를 보유 - 실패 시:
expected<T, E>가E를 보유
즉, optional의 상위 호환처럼 보이지만, 실패 원인을 타입으로 강제한다는 점이 핵심입니다.
기본 사용법: 만들기, 검사, 꺼내기
아래 예시는 파일을 열어 첫 줄을 읽는 간단한 함수입니다. 실패 시 에러 문자열을 반환합니다.
#include <expected>
#include <fstream>
#include <string>
using ReadResult = std::expected<std::string, std::string>;
ReadResult read_first_line(const std::string& path) {
std::ifstream in(path);
if (!in.is_open()) {
return std::unexpected("failed to open: " + path);
}
std::string line;
if (!std::getline(in, line)) {
return std::unexpected("failed to read first line: " + path);
}
return line;
}
int main() {
auto r = read_first_line("/tmp/a.txt");
if (!r) {
// r.error()는 E를 반환
return 1;
}
// r.value()는 T를 반환 (실패면 UB가 아니라 예외를 던질 수 있음)
auto line = *r; // operator* 로 값 접근
(void)line;
}
포인트는 다음입니다.
- 실패 반환은
std::unexpected(E)로 감싼다 - 호출자는
if (!r)로 성공 여부를 검사한다 - 성공 값은
*r또는r.value()로 접근한다
프로젝트에서 예외를 사용하지 않는다면 value() 대신 *r를 선호하고, 실패 시에는 반드시 분기하도록 팀 규칙을 두는 편이 안전합니다.
RAII와의 결합: 실패 경로에서도 자원 누수 0
예외가 없더라도, 조기 반환(return)이 많아지면 자원 정리를 깜빡하기 쉽습니다. 이때 RAII가 빛납니다. std::expected로 실패를 값으로 반환하더라도, 스코프를 벗어나는 순간 소멸자가 호출되므로 자원이 안전하게 정리됩니다.
예시: FILE*을 RAII로 감싸고 expected로 실패 전파
C API는 여전히 많이 쓰입니다. 아래는 FILE*을 std::unique_ptr과 커스텀 deleter로 감싸 RAII화한 뒤, expected로 오류를 전파하는 패턴입니다.
#include <expected>
#include <cstdio>
#include <memory>
#include <string>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
using OpenFileResult = std::expected<FilePtr, std::string>;
OpenFileResult open_file(const std::string& path, const std::string& mode) {
std::FILE* raw = std::fopen(path.c_str(), mode.c_str());
if (!raw) {
return std::unexpected("fopen failed: " + path);
}
return FilePtr(raw);
}
std::expected<std::string, std::string> read_all_text(const std::string& path) {
auto f = open_file(path, "rb");
if (!f) return std::unexpected(f.error());
// 여기서부터는 RAII: 어떤 return 경로든 fclose는 보장
std::string out;
char buf[4096];
while (true) {
auto n = std::fread(buf, 1, sizeof(buf), f->get());
if (n > 0) out.append(buf, buf + n);
if (n < sizeof(buf)) {
if (std::feof(f->get())) break;
return std::unexpected("fread failed: " + path);
}
}
return out;
}
이 패턴의 장점:
open_file이 성공하면 “열린 파일 핸들”이라는 불변조건을 가진 타입(FilePtr)을 돌려줌- 이후 함수는 핸들 유효성 검사 없이 로직에 집중
- 중간에 실패해도 핸들은 자동으로 닫힘
에러 타입 설계: 문자열만으로는 부족하다
처음에는 E = std::string이 편하지만, 규모가 커지면 다음 요구가 생깁니다.
- 머신이 처리 가능한 에러 코드(분기/재시도 정책)
- 원인 체이닝(어느 단계에서 실패했는지)
- 운영 관점의 컨텍스트(파일 경로, errno, endpoint)
권장: 에러 코드 + 메시지 + 컨텍스트
#include <expected>
#include <string>
#include <system_error>
enum class Errc {
Io,
Parse,
NotFound,
Permission,
Unknown,
};
struct Error {
Errc code{Errc::Unknown};
std::string message;
std::error_code sys; // errno 기반이면 여기에
};
template <class T>
using Result = std::expected<T, Error>;
이렇게 해두면 호출자는 error().code로 정책을 결정하고, message는 로그/UX에 활용할 수 있습니다.
조합(Composability): 단계형 파이프라인 만들기
expected의 실전 가치는 “여러 단계의 실패 가능 작업을 연결”할 때 커집니다.
- 파일 읽기
- 파싱
- 검증
- 변환
- 저장
각 단계가 Result<T>를 반환하면, 호출자는 단계별로 실패를 즉시 반환하는 식으로 제어 흐름이 단순해집니다.
예시: 설정 파일 로드 파이프라인
#include <expected>
#include <string>
#include <vector>
struct Config {
int port{};
};
// 앞서 정의한 Result<T>, Error, Errc를 사용한다고 가정
Result<std::string> read_text(const std::string& path);
Result<std::vector<std::string>> split_lines(const std::string& text);
Result<Config> parse_config(const std::vector<std::string>& lines);
Result<Config> load_config(const std::string& path) {
auto text = read_text(path);
if (!text) return std::unexpected(text.error());
auto lines = split_lines(*text);
if (!lines) return std::unexpected(lines.error());
auto cfg = parse_config(*lines);
if (!cfg) return std::unexpected(cfg.error());
return *cfg;
}
여기서 핵심은 실패를 숨기지 않고, 성공 값만 다음 단계로 전달한다는 점입니다.
std::expected를 더 깔끔하게 쓰는 테크닉
1) 에러 컨텍스트 덧붙이기(랩핑)
하위 함수의 에러를 그대로 올리면 “어디서 실패했는지”가 흐려질 수 있습니다. 호출 계층에서 컨텍스트를 더해주는 래퍼가 유용합니다.
Error with_context(Error e, const std::string& ctx) {
if (!e.message.empty()) e.message = ctx + ": " + e.message;
else e.message = ctx;
return e;
}
Result<Config> load_config(const std::string& path) {
auto text = read_text(path);
if (!text) return std::unexpected(with_context(text.error(), "read_text"));
auto lines = split_lines(*text);
if (!lines) return std::unexpected(with_context(lines.error(), "split_lines"));
auto cfg = parse_config(*lines);
if (!cfg) return std::unexpected(with_context(cfg.error(), "parse_config"));
return *cfg;
}
운영에서 장애 분석할 때 이 차이는 큽니다. 비슷한 맥락으로, 장애 원인과 컨텍스트를 빠르게 좁혀가는 방법론은 동시성/리소스 누수 진단에서도 중요합니다. 예를 들어 Go 채널 데드락·고루틴 누수 5분 진단법처럼 “어디서 막혔는지”를 구조적으로 남기는 접근이 결국 유지보수성을 좌우합니다.
2) 중첩 expected 피하기
Result<Result<T>> 같은 중첩은 읽기 어렵고 실수도 늘립니다. API 설계에서 다음을 지키면 중첩을 줄일 수 있습니다.
- 성공/실패의 “단위”를 명확히 하고
- 실패는 항상
E로만 표현 - 성공 값이 또 다른 성공/실패를 내포하지 않도록 타입을 정리
예를 들어 “파싱 결과가 없을 수도 있음”은 Result<std::optional<T>>가 아니라, 정책에 따라
- 없으면 에러로 본다:
Result<T> - 없으면 정상이다:
Result<T>대신Result<std::optional<T>>를 쓰되 문서화
처럼 의도를 명확히 합니다.
3) noexcept와의 궁합
예외를 쓰지 않기로 했다면, 자주 호출되는 경계 함수(파서, 핫패스)는 noexcept를 고려할 수 있습니다. 다만 std::string 할당 등은 내부적으로 예외를 던질 수 있으므로, “완전한 noexcept”는 생각보다 어렵습니다.
현실적인 타협은 다음입니다.
- 외부로 예외를 던지지 않는다는 정책(컴파일 옵션으로 예외 비활성화 포함)
- 내부에서는 할당 실패 같은 치명 상황은 프로세스 종료로 간주
- 정상적인 실패(입력 오류, IO 오류)는
expected로 표현
예외 기반 코드에서 expected로 점진적 이행
기존에 예외 기반으로 작성된 라이브러리를 한 번에 바꾸기는 어렵습니다. 점진적으로 바꾸려면 “경계에서 변환”하는 전략이 좋습니다.
- 내부는 기존대로 예외를 사용
- 외부 API는
expected로 제공 - 경계에서
try/catch로Error로 변환
#include <expected>
#include <string>
Result<int> api_parse_int(const std::string& s) {
try {
int v = std::stoi(s);
return v;
} catch (const std::exception& e) {
return std::unexpected(Error{Errc::Parse, e.what(), {}});
}
}
이렇게 하면 호출자는 예외를 몰라도 되고, 점차 내부 구현도 expected로 옮길 수 있습니다.
운영 관점: 실패를 값으로 만들면 로그/모니터링이 쉬워진다
expected는 실패가 “반환 경로”를 타므로, 다음이 쉬워집니다.
- 실패 지점에서 구조화된 에러를 만들고
- 상위에서 컨텍스트를 누적하고
- 최상위에서 한 번만 로깅
이는 시스템 운영에서 흔한 문제인 “로그는 많은데 원인이 안 보임”을 줄여줍니다. 비슷하게 OS 레벨에서도 원인 추적이 핵심인데, 디스크가 꽉 찼는데도 용량이 안 보이는 상황처럼 관측 포인트가 어긋나면 해결이 늦어집니다. 이런 케이스는 리눅스 디스크 100%인데 용량이 안 보일 때 해결 같은 체크리스트가 도움이 됩니다. C++에서도 에러 구조화는 결국 “관측 가능성”을 올리는 작업입니다.
expected + RAII 실전 패턴 요약
권장 패턴
- 리소스 획득은 RAII 타입으로 감싼 뒤
Result<Resource>로 반환 - 비즈니스 로직은 “유효한 리소스”만 받도록 시그니처를 설계
- 실패는
Error구조체로 코드/메시지/시스템 에러를 함께 전달 - 상위 계층에서 컨텍스트를 덧붙이고 최상위에서 한 번만 로깅
피해야 할 패턴
- 실패 원인이 사라지는
bool반환 - 여기저기서 즉흥적으로 문자열만 조합한 에러
- 중첩
expected로 타입이 폭발하는 API - 성공/실패 분기 없이
value()를 남발(팀 차원의 규칙 필요)
마무리
C++23 std::expected는 “예외를 없애기 위한 기능”이라기보다, 실패를 값으로 다뤄 제어 흐름과 정책을 코드에 드러내는 도구입니다. 여기에 RAII를 결합하면, 실패 경로에서도 자원 정리가 자동으로 보장되어 안정성이 크게 올라갑니다.
정리하면 다음 한 줄로 귀결됩니다.
- 실패는
std::expected로 명시하고 - 자원은 RAII로 소유하며
- 컨텍스트는 위로 올리면서 누적한다
이 세 가지를 지키면 예외 없이도 충분히 읽기 쉬운 오류처리와, 운영에서 강한 C++ 코드를 만들 수 있습니다.