- Published on
C++23 std - -expected로 예외 없이 오류 전파하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 코드든, 라이브러리 코드든 “실패할 수 있는 함수”를 어떻게 설계하느냐는 품질과 유지보수성을 크게 좌우합니다. 전통적으로 C++에서는 예외(throw) 또는 에러 코드(int, errno, bool)로 실패를 표현해 왔습니다. 하지만 예외는 런타임 비용/ABI/경계(FFI) 문제로 꺼려지는 경우가 많고, 에러 코드는 호출자가 쉽게 무시하거나(반환값 체크 누락) 실패 원인을 충분히 전달하지 못하는 문제가 있습니다.
C++23의 std::expected는 “성공 값 또는 오류 값”을 타입으로 강제해, 예외 없이도 오류를 명시적으로 전달하는 표준 도구입니다. 이 글에서는 std::expected의 핵심 개념, 실전 설계 포인트(오류 타입 설계, 변환/합성, 로깅, 경계 처리)와 함께, 예외 기반 코드에서 점진적으로 옮기는 방법까지 다룹니다.
운영 환경에서 오류 전파가 흐릿하면, 결국 장애 분석 비용이 폭증합니다. 예를 들어 프로세스가 비정상 종료되거나 재시작 루프에 빠질 때 원인 파악이 느려지는데, 이런 상황에서는 로그와 오류 체인을 얼마나 잘 남기느냐가 관건입니다. 관련해서는 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl 같은 글도 함께 참고하면 좋습니다.
std::expected 한 줄 정의
std::expected<T, E>는 다음 둘 중 하나를 담습니다.
- 성공: 값
T - 실패: 오류
E
즉, 반환 타입이 std::expected라면 “이 함수는 실패할 수 있다”가 시그니처로 드러납니다. 호출자는 반드시 결과를 확인하고, 실패라면 오류를 꺼내 처리해야 합니다.
C++23에서 헤더는 #include <expected> 입니다.
왜 예외 대신 expected인가
예외가 나쁜 도구라는 뜻은 아닙니다. 다만 아래 조건에서는 expected가 더 적합한 경우가 많습니다.
- 라이브러리/플랫폼 경계: 예외를 ABI 경계 밖으로 던지기 어렵거나 금지된 환경
- 성능/예측 가능성: 예외는 “진짜 예외 상황”에는 좋지만, 실패가 빈번한 경로(파싱, I/O, 검증)에서는 제어 흐름이 불투명해질 수 있음
- 테스트/가독성: 실패가 타입으로 표현되면, 테스트에서 특정 오류를 정밀하게 검증하기 쉬움
- 정책 분리: “실패를 어떻게 처리할지(재시도/로그/대체값)”를 호출자 계층에서 결정하기 쉬움
또한 expected는 실패 원인을 풍부하게 담을 수 있어, 단순 bool/int보다 운영 관점에서 훨씬 유리합니다.
기본 사용법: 생성, 검사, 값/오류 꺼내기
아래는 파일을 읽어 문자열로 반환하는 예시입니다.
#include <expected>
#include <fstream>
#include <sstream>
#include <string>
enum class ReadError {
OpenFailed,
ReadFailed
};
std::expected<std::string, ReadError> read_file(const std::string& path) {
std::ifstream ifs(path);
if (!ifs.is_open()) {
return std::unexpected(ReadError::OpenFailed);
}
std::ostringstream oss;
oss << ifs.rdbuf();
if (ifs.bad()) {
return std::unexpected(ReadError::ReadFailed);
}
return oss.str();
}
int main() {
auto r = read_file("/tmp/config.json");
if (!r) {
// r.error()로 오류를 꺼내 처리
return 1;
}
// 성공이면 *r 또는 r.value()로 값 접근
const std::string& content = *r;
(void)content;
return 0;
}
핵심 포인트:
- 실패 반환은
std::unexpected(E)로 명시 - 성공 여부는
if (r)또는r.has_value() - 성공 값은
*r,r.value() - 오류 값은
r.error()
오류 타입 E를 어떻게 설계할까
E는 단순 enum class부터 구조체까지 다양하게 설계할 수 있습니다. 실무에서는 다음을 권장합니다.
1) “분류용 코드”와 “맥락 정보”를 분리
운영에서 중요한 건 “무슨 오류인지”뿐 아니라 “어디서/왜/무엇을 하다 실패했는지”입니다.
#include <expected>
#include <string>
enum class Errc {
InvalidInput,
NotFound,
PermissionDenied,
IoError,
ParseError
};
struct Error {
Errc code;
std::string message; // 사람에게 보여줄 맥락
std::string context; // 함수/리소스/추적 정보
};
template <class T>
using Result = std::expected<T, Error>;
code: 분기 처리용(프로그램 로직)message/context: 로그/관측성용(운영/디버깅)
2) std::error_code/std::system_error와의 조합
OS 에러(파일, 소켓 등)는 errno 기반으로 떨어지는 경우가 많습니다. 이때 std::error_code를 오류 타입에 포함시키면 이식성과 표현력이 좋아집니다.
#include <expected>
#include <string>
#include <system_error>
struct SysError {
std::error_code ec;
std::string what;
};
template <class T>
using SysResult = std::expected<T, SysError>;
합성(Composition): 실패를 위로 “그대로” 올리기
expected의 진짜 장점은 “실패를 자연스럽게 전파”할 수 있다는 점입니다. 아래는 read_file 결과를 JSON 파서로 넘기는 상황을 가정합니다.
#include <expected>
#include <string>
struct Json {
// ...
};
enum class ParseErr {
InvalidJson
};
std::expected<Json, ParseErr> parse_json(const std::string& s);
// 서로 다른 오류 타입을 하나로 합치는 예시
enum class LoadConfigErr {
ReadOpenFailed,
ReadFailed,
InvalidJson
};
std::expected<Json, LoadConfigErr> load_config(const std::string& path) {
auto content = read_file(path);
if (!content) {
switch (content.error()) {
case ReadError::OpenFailed:
return std::unexpected(LoadConfigErr::ReadOpenFailed);
case ReadError::ReadFailed:
return std::unexpected(LoadConfigErr::ReadFailed);
}
}
auto json = parse_json(*content);
if (!json) {
return std::unexpected(LoadConfigErr::InvalidJson);
}
return *json;
}
이 패턴은 명시적이고 안전하지만, 변환 코드가 길어질 수 있습니다. 실무에서는 아래처럼 “오류에 맥락을 덧붙이는 방식”을 많이 씁니다.
오류에 맥락 추가하기: transform_error 패턴
C++23 std::expected는 transform/transform_error/and_then/or_else 같은 멤버 함수를 제공합니다(구현/표준 라이브러리 버전에 따라 지원 범위가 다를 수 있으니 컴파일러/표준 라이브러리 문서를 확인하세요).
오류 타입을 구조체로 설계했다면, 하위 함수의 오류를 상위로 올릴 때 “호출 맥락”을 추가하기 좋습니다.
#include <expected>
#include <string>
enum class Errc { IoError, ParseError };
struct Error {
Errc code;
std::string message;
std::string context;
};
template <class T>
using Result = std::expected<T, Error>;
Result<std::string> read_file2(const std::string& path);
Result<int> parse_port(const std::string& s);
Result<int> load_port(const std::string& path) {
return read_file2(path)
.transform_error([&](Error e) {
e.context = "load_port: read_file2(" + path + ")";
return e;
})
.and_then([&](const std::string& content) {
return parse_port(content)
.transform_error([&](Error e) {
e.context = "load_port: parse_port";
return e;
});
});
}
이렇게 하면:
- 하위 레벨은 자기 책임(파일 읽기/파싱)만 표현
- 상위 레벨은 “어떤 작업 흐름에서 실패했는지”를 context로 추가
- 로깅/모니터링에서 오류 원인 추적이 쉬워짐
분산 시스템에서 타임아웃/데드라인 같은 오류도 결국 “어디에서 발생했는지”가 중요합니다. 네트워크 호출이 섞이는 코드라면 Go gRPC 데드라인 초과 해결 - context·LB·Keepalive처럼 데드라인 전파/관측성 관점도 함께 챙기는 게 좋습니다.
호출자에서의 처리 전략: or_else, 기본값, 리트라이
예외를 쓰지 않는다고 해서 “모든 곳에서 if 문으로 분기”해야 하는 건 아닙니다. 실패 시 대체 경로를 제공하는 것도 가능합니다.
#include <expected>
#include <string>
struct Error {
int code;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
Result<std::string> fetch_remote_config();
Result<std::string> read_local_config();
Result<std::string> load_config_best_effort() {
return fetch_remote_config().or_else([&](const Error& /*e*/) {
// 원격 실패 시 로컬로 폴백
return read_local_config();
});
}
또는 “정말로 기본값이 합리적인 경우”에만 value_or를 사용할 수 있습니다.
int port = load_port("/etc/app/port").value_or(8080);
주의할 점은, value_or는 오류를 삼켜버리므로 운영 관점에서 중요한 실패라면 로그/메트릭을 남기고 사용해야 합니다.
예외 기반 코드와의 공존: 점진적 마이그레이션
이미 예외 기반으로 작성된 코드가 많다면, 한 번에 전부 바꾸기 어렵습니다. 다음 두 가지 브리지를 두면 점진적으로 옮길 수 있습니다.
1) 예외를 expected로 감싸기
#include <expected>
#include <string>
#include <exception>
struct Error {
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
int may_throw();
Result<int> wrap_exception() {
try {
return may_throw();
} catch (const std::exception& e) {
return std::unexpected(Error{e.what()});
} catch (...) {
return std::unexpected(Error{"unknown exception"});
}
}
2) expected를 예외로 변환하기(경계에서만)
애플리케이션 최상단에서만 예외로 변환해도 됩니다. 예를 들어 CLI 도구는 마지막에 실패 메시지를 출력하고 종료하면 충분합니다.
#include <expected>
#include <stdexcept>
#include <string>
struct Error { std::string message; };
template <class T>
using Result = std::expected<T, Error>;
template <class T>
T value_or_throw(Result<T> r) {
if (!r) {
throw std::runtime_error(r.error().message);
}
return *r;
}
이렇게 하면 내부는 expected로 명시적 전파를 하고, 외부 인터페이스(기존 예외 기반 API)와도 연결할 수 있습니다.
실전 팁: 실패를 무시하기 어렵게 만들기
- 반환 타입을
std::expected로 바꾸면 호출부에서 컴파일 단계에서 “성공/실패 처리”를 강제할 수 있습니다. - 가능하다면 결과를 바로 사용하지 말고, 중간 단계에서
and_then/transform_error로 파이프라인을 구성해 “성공 경로”와 “실패 경로”를 읽기 쉽게 만드세요. - 오류 타입에
context를 넣고 상위에서 덧붙이면, 장애 시점에 로그만으로도 원인에 훨씬 빨리 도달합니다.
운영에서 재시작 루프나 비정상 종료를 만났을 때도, “어떤 단계에서 실패했는지”가 명확하면 복구가 빨라집니다. 그런 관점의 디버깅은 K8s CrashLoopBackOff 즉시 원인 찾는 법도 함께 참고할 만합니다.
expected를 쓸 때의 주의점
- 오류 타입의 크기
E가 너무 크면 반환/이동 비용이 커질 수 있습니다.- 문자열을 담아야 한다면, 꼭 필요한 범위에서만 담거나
std::string이동을 활용하세요.
- 오류의 표준화
- 팀/프로젝트 내에서
Errc같은 공통 코드 체계를 만들면 상위 계층에서 처리 정책을 세우기 쉽습니다.
- 로깅 위치
- 모든 하위 함수에서 로그를 찍으면 중복 로그가 폭발합니다.
- 보통은 “정책을 결정하는 계층”에서 한 번만 로깅하고, 하위는 오류를 풍부하게 만들어 올리는 편이 낫습니다.
- 예외 금지 규칙과의 정합성
expected를 쓰더라도 내부에서 예외가 발생할 수 있는 코드(메모리 할당, 표준 라이브러리 호출)가 존재합니다.- 정말 예외가 절대 나오면 안 되는 환경이라면 컴파일 옵션/코딩 규칙과 함께, 예외 발생 가능 지점을 통제해야 합니다.
정리
std::expected는 C++에서 “실패 가능한 연산”을 타입 시스템으로 끌어올려, 예외 없이도 안정적으로 오류를 전파하게 해줍니다. 핵심은 다음입니다.
- 반환 타입으로 실패 가능성을 드러내고, 호출자가 무시하기 어렵게 만든다
- 오류 타입
E를 설계해 코드 분기와 운영 맥락을 동시에 만족시킨다 and_then,transform_error,or_else같은 합성으로 성공/실패 흐름을 깔끔하게 만든다- 예외 기반 코드와는 경계에서만 변환해 점진적으로 마이그레이션한다
다음 단계로는, 프로젝트의 주요 실패 경로(파일 I/O, 네트워크, 파싱, 설정 로딩)를 골라 std::expected로 바꾸고, 오류 타입에 context를 누적해 “한 번의 로그로 원인에 도달”하는 형태로 발전시키는 것을 권합니다.