Published on

C++23 std - -expected로 예외 제거·성능 튜닝

Authors

서버/클라이언트 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의 크기가 됩니다. Estd::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>는 내부적으로 TE 중 하나를 저장합니다. 따라서 대략적으로 다음이 성립합니다.

  • 객체 크기 ≈ 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로 전환할 때는 "경계부터"가 안전합니다.

  1. 실패가 잦은 모듈(파서, 캐시, 네트워크 재시도, storage adapter)부터 expected로 전환
  2. 상위 계층은 당분간 예외 기반이어도 괜찮음(경계에서 변환)
  3. 프로파일링으로 병목이 사라졌는지 확인
  4. 팀 코딩 규칙 정리
    • 어떤 경우에 예외를 허용하는지(진짜 예외)
    • 어떤 경우에 expected를 강제하는지(빈번 실패)
    • 에러 타입 표준(공통 enum 또는 error_code 도메인)

이런 단계적 전환은 운영 환경에서의 리스크를 줄입니다. 운영에서 재시작 루프나 장애가 나면 원인 추적 비용이 급증하듯이, 에러 처리 전략 변경은 작은 실수도 큰 장애로 이어질 수 있습니다. 운영 관점의 디버깅 루틴은 systemd 서비스 재시작 루프, 10분 디버깅 같은 접근을 참고해도 좋습니다.

결론: expected는 "에러를 타입으로" 만들어 성능과 예측 가능성을 얻는다

  • 실패가 자주 일어나는 로직에서 예외를 쓰면, 비용이 커지고 제어 흐름이 불투명해집니다.
  • std::expected는 성공/실패를 반환 타입으로 명시해 호출자가 정책을 갖고 처리하게 만들고, 핫패스 성능을 더 예측 가능하게 합니다.
  • 성능 튜닝의 핵심은 E 타입을 가볍게 설계하고, 경계에서만 예외와 상호 변환하며, 실제 실패율/데이터 분포를 계측하는 것입니다.

다음 단계로는, 여러분의 코드에서 "실패가 정상"인 API를 1~2개 골라 expected로 바꿔보고, 실패율이 높은 테스트 케이스에서 CPU 프로파일과 바이너리 크기 변화를 함께 비교해보는 것을 권합니다.