- Published on
C++23 std - -expected로 예외 제거·성능 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 C++ 코드에서 예외는 생산성을 올려주지만, 고성능 경로에서는 비용과 불확실성을 만들기도 합니다. 특히 "실패가 정상적인 흐름"(입력 파싱, 캐시 미스, 네트워크 타임아웃, 파일 없음)인 코드에서 예외는 제어 흐름을 뒤틀고, 프로파일링 시 핫패스의 분기 예측과 인라이닝을 망치며, 경우에 따라 바이너리 크기와 언와인딩 메타데이터까지 늘립니다.
C++23의 std::expected는 이런 상황에서 "값 또는 에러"를 타입으로 표현해, 예외를 쓰지 않고도 깔끔한 오류 전파를 가능하게 합니다. 이 글에서는 std::expected를 도입해 예외를 제거하고 성능을 튜닝하는 실전 패턴을 정리합니다.
참고: 성능 문제는 결국 "측정과 원인 추적"이 핵심입니다. 장애/성능 원인 추적 관점은 systemd 서비스 자동 재시작 원인 추적 가이드 같은 글의 접근법(가설-관측-검증)과도 통합니다.
왜 예외가 느리거나 위험해질까
예외 자체가 "항상" 느린 것은 아닙니다. 대부분의 구현에서 정상 경로(throw가 발생하지 않는 경로)는 비교적 저렴합니다. 하지만 다음 조건이 겹치면 문제가 커집니다.
- 실패가 자주 발생하는 로직에서 예외를 사용한다
- 파싱 실패, 캐시 미스, 네트워크 재시도 등은 "예외"가 아니라 "상태"에 가깝습니다.
- 예외 전파로 인해 함수가 인라인되지 않거나, 컴파일러 최적화가 보수적으로 변한다
- 예외 언와인딩/핸들링이 실제로 자주 실행된다
- 이때는 스택 언와인딩 비용이 크게 발생합니다.
- ABI/플랫폼/컴파일 플래그에 따라 바이너리 크기와 I-Cache 압박이 증가한다
정리하면, 예외는 "드물게 발생하는 진짜 예외 상황"에 적합하고, "빈번한 실패"에는 부적합합니다. std::expected는 후자를 타입으로 모델링합니다.
std::expected 핵심 개념
std::expected<T, E>는 다음 중 하나를 담습니다.
- 성공 값
T - 실패 값
E(에러 타입)
주요 API는 아래와 같습니다.
has_value()/operator bool()value()(값 접근, 실패면 예외를 던질 수 있음)error()(에러 접근)value_or(default)and_then,transform,or_else(모나딕 체이닝)
중요 포인트는 "실패가 예외가 아니라 반환값"이라는 점입니다. 호출자는 실패를 반드시 고려해야 하고, 컴파일러는 제어 흐름을 더 명확히 최적화할 여지가 생깁니다.
에러 타입 E 설계: std::error_code vs 커스텀 enum
E는 무엇이든 될 수 있지만, 성능과 API 안정성을 생각하면 다음 두 가지가 실전에서 많이 쓰입니다.
1) 경량 enum + 부가 정보는 별도 로깅
- 장점: 크기 작고 비교/분기 비용이 낮음
- 단점: 상세 메시지를 바로 담기 어려움
#include <expected>
#include <string_view>
enum class ParseErr {
empty,
invalid_digit,
overflow
};
std::expected<int, ParseErr> parse_int(std::string_view s);
2) std::error_code로 시스템/라이브러리 오류 통합
- 장점: OS 오류, 라이브러리 오류를 표준 방식으로 전달
- 단점: 경우에 따라 enum보다 무겁고, 도메인 설계가 필요
#include <expected>
#include <system_error>
std::expected<void, std::error_code> write_all(int fd, const void* buf, size_t n);
실전 팁: 핫패스에서는 E의 크기가 곧 expected의 크기가 됩니다. E에 std::string 같은 동적 할당 객체를 넣으면 실패 경로뿐 아니라 객체 크기/이동 비용이 정상 경로에도 영향을 줍니다.
예외 기반 코드를 expected로 바꾸는 대표 패턴
패턴 A: throw 대신 unexpected 반환
기존 예외 기반 코드:
int parse_int_throw(std::string_view s) {
if (s.empty()) throw std::runtime_error("empty");
// ...
return 123;
}
std::expected로 변경:
#include <expected>
#include <string_view>
enum class ParseErr { empty, invalid_digit, overflow };
std::expected<int, ParseErr> parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseErr::empty);
}
long long v = 0;
for (char c : s) {
if (c < '0' || c > '9') {
return std::unexpected(ParseErr::invalid_digit);
}
v = v * 10 + (c - '0');
if (v > 2147483647LL) {
return std::unexpected(ParseErr::overflow);
}
}
return static_cast<int>(v);
}
호출부:
auto r = parse_int(input);
if (!r) {
// r.error()로 분기
return;
}
int x = *r; // 또는 r.value()
패턴 B: 중첩 if 지옥을 and_then으로 평탄화
expected는 체이닝으로 오류 전파를 깔끔하게 만들 수 있습니다.
#include <expected>
#include <string>
#include <string_view>
enum class Err { parse, not_found, io };
std::expected<int, Err> parse(std::string_view);
std::expected<std::string, Err> load_user(int id);
std::expected<void, Err> save_cache(int id, std::string_view data);
std::expected<void, Err> pipeline(std::string_view s) {
return parse(s)
.and_then([](int id) { return load_user(id); })
.and_then([&](const std::string& data) {
// 캡처로 id가 필요하면 구조를 조금 바꾸거나 pair를 전달
return save_cache(0, data);
});
}
주의: and_then 람다 캡처/복사 비용이 핫패스에서 문제가 될 수 있습니다. 정말 뜨거운 경로라면 단순한 if (!r) return unexpected(...) 스타일이 더 빠를 때가 많습니다. "가독성"과 "성능"의 균형을 팀 기준으로 정하세요.
패턴 C: 경계(boundary)에서만 예외로 변환
외부 API가 예외를 기대하거나, 상위 계층이 예외 기반인 경우도 있습니다. 이때는 내부는 expected, 경계에서만 변환하는 전략이 좋습니다.
#include <expected>
#include <stdexcept>
enum class Err { io, parse };
std::expected<int, Err> compute_expected();
int compute_throwing() {
auto r = compute_expected();
if (!r) {
throw std::runtime_error("compute failed");
}
return *r;
}
이 방식은 "핫패스는 예외 제거" + "외부 인터페이스 호환"을 동시에 만족합니다.
성능 튜닝 관점에서의 체크리스트
1) 실패가 빈번한가
- 실패가 빈번하면 예외는 거의 항상 손해입니다.
expected는 실패를 빠른 분기와 값 전달로 처리할 수 있습니다.
2) expected의 크기와 복사/이동 비용
std::expected<T, E>는 내부적으로 T와 E 중 하나를 저장합니다. 따라서 대략적으로 다음이 성립합니다.
- 객체 크기
≈ max(sizeof(T), sizeof(E)) + 상태 플래그
튜닝 팁:
T가 크면expected<std::unique_ptr<T>, E>같은 간접 참조를 고려E는 가능한 한 작은 값 타입(enum, 작은 struct)으로- 문자열 에러 메시지는
std::string_view(수명 관리 주의) 또는 로깅으로 분리
3) 핫패스에서의 분기 예측
성공이 대부분이라면 다음 형태가 일반적으로 유리합니다.
auto r = f();
if (!r) [[unlikely]] {
return std::unexpected(r.error());
}
// 성공 경로
use(*r);
[[unlikely]]는 컴파일러에게 힌트를 줄 수 있습니다(효과는 컴파일러/플래그에 따라 다름).
4) 예외 비활성화(-fno-exceptions)는 신중히
예외를 안 쓰는 것과 예외를 컴파일 레벨에서 끄는 것은 다릅니다.
- 예외를 끄면 바이너리/런타임 특성이 바뀌고, 일부 라이브러리와 호환 문제가 생길 수 있습니다.
expected로 "논리적 예외"를 제거하되, 플랫폼/서드파티 요구로 예외는 켜 둔 채 경계에서만 사용해도 충분한 경우가 많습니다.
5) 측정: 마이크로벤치만 믿지 말 것
- 마이크로벤치는 CPU 캐시/분기 패턴이 실제와 다를 수 있습니다.
- 실제 트래픽/실제 데이터 분포에서 실패율이 어떤지부터 계측하세요.
이런 "원인-관측" 방식은 웹 성능에서 렌더링 폭증을 분석할 때와 비슷합니다. 프론트엔드 쪽이지만 접근법 자체는 참고할 만합니다: Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo
실전 예제: 파일 읽기 API를 expected로 재설계
예외 기반 파일 읽기는 흔히 다음처럼 작성됩니다.
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
std::string read_all_throw(const std::string& path) {
std::ifstream ifs(path);
if (!ifs) throw std::runtime_error("open failed");
std::ostringstream oss;
oss << ifs.rdbuf();
return oss.str();
}
expected 버전은 호출자가 실패를 정상 흐름으로 처리할 수 있게 합니다.
#include <expected>
#include <fstream>
#include <sstream>
#include <string>
enum class FileErr { open_failed, read_failed };
std::expected<std::string, FileErr> read_all(const std::string& path) {
std::ifstream ifs(path, std::ios::binary);
if (!ifs) {
return std::unexpected(FileErr::open_failed);
}
std::ostringstream oss;
oss << ifs.rdbuf();
if (!ifs.good() && !ifs.eof()) {
return std::unexpected(FileErr::read_failed);
}
return oss.str();
}
호출부에서의 이점:
- "파일이 없으면 기본 설정으로" 같은 정책을 예외 처리 없이 구현 가능
- 실패율이 높은 환경(컨테이너에서 설정 파일 optional)에서 비용 예측 가능
auto cfg = read_all("/etc/myapp/config.json");
if (!cfg) {
// 기본 설정 사용
} else {
// 파싱 진행
}
에러 전파를 더 깔끔하게: TRY 매크로 패턴
C++에는 Rust의 ? 같은 문법이 없어서, expected를 쓰면 반복적인 early-return이 늘 수 있습니다. 팀 내 합의가 있다면 아래처럼 TRY 매크로를 제한적으로 쓰기도 합니다.
#include <expected>
#define TRY(var, expr) \
auto var##_tmp = (expr); \
if (!var##_tmp) return std::unexpected(var##_tmp.error()); \
auto var = std::move(*var##_tmp)
enum class Err { io, parse };
std::expected<int, Err> step1();
std::expected<int, Err> step2(int);
std::expected<int, Err> run() {
TRY(a, step1());
TRY(b, step2(a));
return b;
}
주의점:
- 매크로는 디버깅/스코프/이름 충돌 문제가 있으니 최소화
- 에러 타입 변환이 필요하면(하위 함수의
E1에서 상위의E2로) 매크로가 오히려 복잡해질 수 있음
expected 도입 전략: 한 번에 갈아엎지 말기
대규모 코드베이스에서 예외를 expected로 전환할 때는 "경계부터"가 안전합니다.
- 실패가 잦은 모듈(파서, 캐시, 네트워크 재시도, storage adapter)부터
expected로 전환 - 상위 계층은 당분간 예외 기반이어도 괜찮음(경계에서 변환)
- 프로파일링으로 병목이 사라졌는지 확인
- 팀 코딩 규칙 정리
- 어떤 경우에 예외를 허용하는지(진짜 예외)
- 어떤 경우에
expected를 강제하는지(빈번 실패) - 에러 타입 표준(공통 enum 또는
error_code도메인)
이런 단계적 전환은 운영 환경에서의 리스크를 줄입니다. 운영에서 재시작 루프나 장애가 나면 원인 추적 비용이 급증하듯이, 에러 처리 전략 변경은 작은 실수도 큰 장애로 이어질 수 있습니다. 운영 관점의 디버깅 루틴은 systemd 서비스 재시작 루프, 10분 디버깅 같은 접근을 참고해도 좋습니다.
결론: expected는 "에러를 타입으로" 만들어 성능과 예측 가능성을 얻는다
- 실패가 자주 일어나는 로직에서 예외를 쓰면, 비용이 커지고 제어 흐름이 불투명해집니다.
std::expected는 성공/실패를 반환 타입으로 명시해 호출자가 정책을 갖고 처리하게 만들고, 핫패스 성능을 더 예측 가능하게 합니다.- 성능 튜닝의 핵심은
E타입을 가볍게 설계하고, 경계에서만 예외와 상호 변환하며, 실제 실패율/데이터 분포를 계측하는 것입니다.
다음 단계로는, 여러분의 코드에서 "실패가 정상"인 API를 1~2개 골라 expected로 바꿔보고, 실패율이 높은 테스트 케이스에서 CPU 프로파일과 바이너리 크기 변화를 함께 비교해보는 것을 권합니다.