Published on

C++23 std - -expected로 예외 없는 오류처리와 RAII

Authors

서버나 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/catchError로 변환
#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++ 코드를 만들 수 있습니다.