- Published on
C++23 std - -expected로 예외 없이 오류처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외(Exceptions)는 강력하지만, 런타임 비용/바이너리 크기/제어 흐름 가시성/FFI 경계(예: C API) 같은 이유로 꺼리는 팀도 많습니다. 특히 서버/임베디드/저지연 시스템에서는 “실패는 값으로 다루자”는 접근이 더 예측 가능할 때가 많죠.
C++23의 std::expected는 바로 그 지점을 표준 라이브러리 레벨에서 풀어줍니다. 성공 값 또는 오류 값을 동일한 타입으로 표현하고, 호출자가 이를 강제적으로 확인하도록 유도합니다. 이 글에서는 std::expected를 이용해 예외 없이 오류를 전파하고 조합하는 방법을, API 설계 관점에서 정리합니다.
운영 환경에서 오류 처리는 결국 “관측 가능성”과도 연결됩니다. 예를 들어 OOM이나 리소스 고갈이 원인일 때는 애플리케이션 레벨의 오류 모델링만으로는 부족할 수 있습니다. 그런 경우에는 Linux OOM Killer 로그 추적과 메모리 누수 진단 같은 운영 진단과 함께 설계하는 게 좋습니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 둘 중 하나를 담습니다.
- 성공: 타입
T의 값 - 실패: 타입
E의 오류
std::optional<T>가 “값이 있거나 없다”라면, std::expected<T, E>는 “값이 있거나, 없으면 왜 없는지를 알려준다”에 가깝습니다.
언제 쓰면 좋은가
- 예외를 금지하거나 제한하는 코드베이스
- 실패가 정상 플로우의 일부인 경우(파싱, 조회, 파일/네트워크)
- 실패 원인을 호출자에게 풍부하게 전달하고 싶은 경우
- 여러 단계 작업을 합성(체이닝)하면서 오류를 자연스럽게 전파하고 싶은 경우
기본 사용법: 성공/실패 생성과 검사
아래 예시는 파일을 읽어 문자열을 반환하되, 실패 시 오류 코드를 반환합니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
using ReadResult = std::expected<std::string, std::error_code>;
ReadResult read_all_text(const std::string& path) {
std::ifstream ifs(path, std::ios::binary);
if (!ifs) {
// errno 기반으로 error_code 구성
return std::unexpected(std::error_code(errno, std::generic_category()));
}
std::string data((std::istreambuf_iterator<char>(ifs)),
std::istreambuf_iterator<char>());
if (!ifs.good() && !ifs.eof()) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return data;
}
int main() {
auto r = read_all_text("/tmp/missing.txt");
if (!r) {
// r.error()로 오류 접근
return 1;
}
// r.value()로 성공 값 접근
auto& text = r.value();
(void)text;
return 0;
}
핵심 포인트는 두 가지입니다.
- 실패를 만들 때는
std::unexpected(e)를 사용 - 호출자는
if (!r)또는r.has_value()로 성공 여부를 확인
value()는 실패 상태에서 호출하면 예외를 던질 수 있습니다(구현에 따라bad_expected_access). 예외를 완전히 배제하는 정책이라면value()대신*r(역참조) 또는r.value_or(...)같은 패턴을 팀 룰로 정하는 게 안전합니다.
오류 타입 E 설계: std::error_code vs 커스텀 에러
E는 무엇이든 될 수 있지만, 실무에서는 크게 두 갈래로 나뉩니다.
1) std::error_code 기반
- 장점: 표준 생태계와 잘 맞음(시스템 호출, 파일 I/O)
- 단점: 도메인 특화 정보(예: 어떤 필드가 잘못됐는지)를 담기 어려움
2) 커스텀 에러 타입(도메인 에러)
파싱/검증/비즈니스 규칙처럼 “실패 이유”를 풍부하게 전달해야 한다면 커스텀 타입이 더 낫습니다.
#include <expected>
#include <string>
struct ParseError {
enum class Code {
Empty,
InvalidChar,
Overflow
} code;
std::size_t position{}; // 오류 위치
std::string message;
};
using IntResult = std::expected<int, ParseError>;
IntResult parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError{ParseError::Code::Empty, 0, "empty input"});
}
long long v = 0;
for (std::size_t i = 0; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') {
return std::unexpected(ParseError{ParseError::Code::InvalidChar, i, "non-digit"});
}
v = v * 10 + (c - '0');
if (v > std::numeric_limits<int>::max()) {
return std::unexpected(ParseError{ParseError::Code::Overflow, i, "too large"});
}
}
return static_cast<int>(v);
}
이렇게 하면 호출자는 “실패했다”를 넘어 “어디서 왜 실패했는지”를 제어 흐름 안에서 다룰 수 있습니다.
조합(체이닝) 패턴: 단계별 실패 전파
std::expected의 가장 큰 장점은 여러 단계를 자연스럽게 연결할 수 있다는 점입니다. C++23에는 and_then, transform, or_else, transform_error 같은 멤버가 제공됩니다(구현/표준 라이브러리 버전에 따라 제공 범위가 다를 수 있으니 컴파일러/라이브러리 문서를 확인하세요).
and_then: 성공이면 다음 단계로, 실패면 그대로
#include <expected>
#include <string>
struct Error { std::string msg; };
using R1 = std::expected<std::string, Error>;
using R2 = std::expected<int, Error>;
R1 read_config();
R2 parse_port(const std::string&);
std::expected<int, Error> load_port() {
return read_config()
.and_then([](const std::string& cfg) {
return parse_port(cfg);
});
}
예외였다면 try/catch로 감쌌을 로직이, 이제는 “값 변환 파이프라인”처럼 보입니다.
transform: 성공 값만 매핑
auto to_length = [](const std::string& s) { return s.size(); };
std::expected<std::size_t, Error> len = read_config().transform(to_length);
or_else: 실패를 복구하거나 대체
std::expected<std::string, Error> cfg = read_config().or_else([](const Error& e) {
// 기본 설정으로 폴백
return std::expected<std::string, Error>("port=8080");
});
transform_error: 오류 타입/메시지 변환
상위 레이어로 올라가며 오류를 감싸거나, 로깅/분류를 위해 오류를 정규화할 때 유용합니다.
struct AppError { int code; std::string msg; };
std::expected<std::string, AppError> r = read_config().transform_error(
[](const Error& e) {
return AppError{1001, e.msg};
}
);
expected<void, E>로 “성공 값 없는” 작업 표현
성공 시 반환할 값이 없고, 실패만 의미 있는 함수도 많습니다(쓰기, 삭제, 커밋 등). 이때는 std::expected<void, E>가 깔끔합니다.
#include <expected>
#include <system_error>
using Status = std::expected<void, std::error_code>;
Status write_file(const std::string& path, std::string_view data) {
std::ofstream ofs(path, std::ios::binary);
if (!ofs) return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
ofs.write(data.data(), static_cast<std::streamsize>(data.size()));
if (!ofs) return std::unexpected(std::make_error_code(std::errc::io_error));
return {}; // 성공
}
호출 측은 if (!status)로만 처리하면 됩니다.
예외 없는 API를 설계할 때의 실전 규칙
1) “실패가 가능한가?”가 API 시그니처에 드러나야 한다
- 실패 가능:
std::expected<T, E> - 실패 불가(논리적으로): 그냥
T
모든 함수를 무조건 expected로 감싸면 호출 코드가 과도하게 장황해질 수 있습니다. 실패가 설계상 불가능하거나 내부적으로 처리 가능한 경우는 과감히 일반 반환을 유지하세요.
2) 오류 E는 “호출자가 할 수 있는 액션” 중심으로 모델링
오류를 지나치게 세분화하면 오히려 호출자가 처리할 수 있는 분기가 늘어 복잡해집니다.
- 재시도 가능한가?
- 사용자 입력을 고치면 되는가?
- 운영자 조치가 필요한가?
이런 관점으로 에러 코드를 설계하면 상위 레이어에서 정책을 세우기 쉽습니다.
3) 로깅은 expected 내부가 아니라 경계에서
expected는 “오류를 값으로 전달”하는 장치입니다. 여기저기서 즉시 로깅하면 중복 로그가 폭발합니다.
- 라이브러리/도메인 레이어: 오류를 생성해 전달
- 애플리케이션 경계(핸들러, 작업 루프): 한 번만 로깅/메트릭
이 방식은 분산 시스템에서도 유리합니다. 예를 들어 TLS/인증서 문제처럼 환경 요인이 큰 오류는 경계에서 컨텍스트(대상 호스트, SNI, 인증서 체인)를 함께 남겨야 원인 분석이 됩니다. 관련해서는 EKS Pod→RDS TLS 오류 - 인증서·SNI 해결법 같은 글의 “컨텍스트 있는 로그” 관점을 참고할 만합니다.
4) “초기 반환(early return)”은 여전히 최고의 친구
체이닝이 항상 가독성이 좋은 건 아닙니다. 복잡한 분기/리소스 관리가 섞이면 명시적인 초기 반환이 더 명확합니다.
#include <expected>
template <class T, class E>
std::expected<T, E> do_work() {
auto a = step1();
if (!a) return std::unexpected(a.error());
auto b = step2(*a);
if (!b) return std::unexpected(b.error());
return step3(*b);
}
이 패턴은 디버깅도 쉽고, 팀원들이 빠르게 이해합니다.
std::expected와 다른 선택지 비교
std::optional
- 실패 이유가 필요 없으면
optional이 더 단순 - 실패 이유가 필요하면
expected
예외
- 장점: 성공 경로가 깔끔, 깊은 호출 스택에서 전파가 쉬움
- 단점: 정책/성능/FFI/테스트에서 부담
현실적인 절충안은 “외부 경계에서는 예외를 잡아 expected로 변환”하거나, 반대로 “내부는 expected, 최상위만 예외” 같은 계층 전략입니다.
std::variant<T, E>
expected는 성공/실패라는 의도가 명확하고, 관련 유틸리티가 붙어 있음variant는 더 일반적이지만 호출 측에서 패턴 매칭이 번거로울 수 있음
빌드/컴파일러 체크 포인트
std::expected는 C++23 기능입니다. 컴파일 옵션에-std=c++23또는 동등 옵션이 필요합니다.- 표준 라이브러리 구현 상태는 컴파일러/플랫폼에 따라 다를 수 있습니다. 만약 제공이 늦는 환경이라면
tl::expected같은 대체 구현을 고려할 수 있지만, 장기적으로는 표준으로 수렴하는 편이 유지보수에 유리합니다.
마무리: 예외 없는 오류 처리는 “명시성”을 선택하는 것
std::expected는 단순히 예외를 대체하는 도구가 아니라, 실패를 타입 시스템 위로 끌어올려 호출자가 실패 가능성을 무시하기 어렵게 만드는 장치입니다.
- 도메인에 맞는 오류 타입을 설계하고
- 경계에서 한 번만 로깅하며
- 체이닝과 early return을 상황에 맞게 섞으면
예외 없이도 충분히 우아하고 견고한 오류 처리 스타일을 만들 수 있습니다.
추가로, 운영 환경에서의 “실패”는 코드만으로 설명되지 않는 경우가 많습니다. 리소스 고갈이나 커널 개입 같은 이슈는 애플리케이션 오류 모델과 별개로 추적해야 하니, 필요하다면 Linux OOM Killer 로그 추적과 메모리 누수 진단도 함께 참고해보세요.