C++23 std::expected: 예외 없이 에러 전달하기와 and_then 체이닝

이 글의 핵심

에러 코드를 반환하면 호출자가 확인을 빠뜨리기 쉽고, 예외는 어떤 함수가 실패할 수 있는지 시그니처에 드러나지 않는다는 문제가 있습니다. std::expected는 성공 값과 에러를 타입으로 강제해 두 방식의 중간을 제공하지만, 에러 타입 설계와 에러 상태에서 value()를 호출할 때의 동작을 이해해야 합니다. 다중 에러 타입, 에러 복구 패턴, 성능 관점의 고려 사항까지 정리했습니다.

expected란?

std::expected<T, E>는 C++23에서 도입된, 성공 값 T 또는 에러 E 중 하나를 담는 타입입니다. 함수가 값을 반환하거나 에러를 반환할 수 있음을 명시적으로 표현하며, 예외를 던지지 않고도 에러를 처리할 수 있습니다.

#include <expected>

std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};
    }
    return a / b;
}

반환 타입만 보고도 이 함수가 실패할 수 있고 실패하면 어떤 에러 타입이 오는지 알 수 있다는 것이 첫 번째 장점입니다. 에러 타입이 컴파일 타임에 정해지므로 호출자가 엉뚱한 타입으로 에러를 다룰 수 없고, and_then·transform 같은 모나딕 연산으로 실패할 수 있는 단계를 이어 붙일 수 있습니다.

성능은 조건이 붙는 이야기입니다. GCC·Clang·MSVC 64비트가 쓰는 테이블 기반(“zero-cost”) 예외 모델에서는 예외가 던져지지 않는 정상 경로에 추가 비용이 거의 없습니다. 반면 expected는 호출할 때마다 성공 여부를 검사하는 분기가 들어가고 반환 객체도 커집니다. 즉 실패가 드문 경로에서는 예외가 오히려 빠를 수 있고, expected가 유리한 것은 실패가 자주 일어나는 경로(사용자 입력 파싱, 캐시 미스, 네트워크 재시도)입니다. 예외를 던지는 순간에는 예외 객체 할당과 스택 언와인딩(언와인드 테이블 탐색)이 일어나 일반 반환보다 훨씬 비싸므로, 이것이 자주 반복되면 무시할 수 없게 됩니다.

실무에서 expected를 고르는 더 큰 이유는 성능보다 가독성과 강제성입니다. 게임 엔진이나 임베디드처럼 -fno-exceptions로 빌드하는 환경에서는 예외를 쓸 수 없어 오래전부터 에러 코드나 자체 Result 타입을 써 왔고, std::expected는 그 관행을 표준 타입으로 정리한 것입니다.

// ❌ 예외: 에러 가능성이 시그니처에 표시되지 않음
int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("0으로 나눌 수 없음");
    }
    return a / b;
}
// 호출자는 예외를 던질 수 있다는 것을 알 수 없음

// ✅ expected: 에러 가능성이 명시적
std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};
    }
    return a / b;
}
// 호출자는 에러를 처리해야 함을 알 수 있음

내부 구조를 단순화하면 다음과 같습니다.

// 개념적 구현
template<typename T, typename E>
class expected {
    union {
        T value_;
        E error_;
    };
    bool hasValue_;
    
public:
    expected(const T& value) : value_(value), hasValue_(true) {}
    expected(std::unexpected<E> error) : error_(error.value()), hasValue_(false) {}
    
    bool has_value() const { return hasValue_; }
    
    T& value() {
        if (!hasValue_) {
            throw std::bad_expected_access(error_);
        }
        return value_;
    }
    
    E& error() {
        return error_;
    }
};

개념 구현에서 눈여겨볼 점은 T와 E가 union으로 같은 메모리를 공유한다는 것입니다. 성공 값과 에러가 동시에 존재할 일은 없으니 둘 중 큰 쪽만큼의 공간과 상태 플래그 하나면 충분합니다. 그래서 sizeof(expected<T, E>)는 대략 max(sizeof(T), sizeof(E))에 플래그와 정렬 패딩을 더한 크기입니다. 또 하나는 expected 자체는 힙 할당을 하지 않는다는 점입니다. std::function이나 예외 객체와 달리 값이 반환 객체 안에 직접 들어 있습니다.

value()가 에러 상태에서 bad_expected_access를 던지는 부분도 중요합니다. 즉 expected를 쓴다고 예외가 완전히 사라지는 것이 아니라, 호출자가 확인을 빼먹고 value()를 부르면 결국 예외가 납니다. 예외 없이 빌드하는 환경에서 이 경로를 밟으면 보통 std::terminate나 abort로 이어집니다.

특징예외std::expected
에러 표시시그니처에 드러나지 않음반환 타입에 드러남
성능정상 경로는 빠름, 던질 때 비쌈매 호출 분기, 실패 비용 일정
에러 타입무엇이 던져질지 컴파일러가 알지 못함E로 고정
체이닝해당 없음(try 블록으로 묶음)and_then 등으로 연결
사용 시점예외적 상황호출자가 바로 처리할 일반적 실패
// 예외: 예외적 상황
void allocate(size_t size) {
    if (size > MAX_SIZE) {
        throw std::bad_alloc();  // 드문 상황
    }
}

// expected: 일반적 에러
std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};  // 흔한 상황
    }
    return a / b;
}

기본 사용

std::expected로 성공 값과 실패 원인을 함께 반환한 실행 결과

그림: 실제 실행 결과 — optional과 달리 실패 이유(Err)를 함께 돌려준다. 예외를 던지지 않으면서 호출한 쪽이 원인별로 처리할 수 있다. (환경: Ubuntu 24.04 · g++ 13.3)

#include <expected>

std::expected<int, std::string> result = divide(10, 2);

// 성공 확인
if (result) {
    std::cout << "결과: " << result.value() << std::endl;
} else {
    std::cout << "에러: " << result.error() << std::endl;
}

if (result)는 has_value()와 같습니다. 에러 쪽에 값을 넣을 때 std::unexpected{...}로 감싸야 하는 이유는, T와 E가 같은 타입이거나 서로 변환 가능한 경우(expected<int, int>, expected<std::string, std::string>) 그냥 return value;만 보고서는 성공인지 실패인지 구분할 수 없기 때문입니다. 감싸지 않고 에러 값을 반환하면 E가 T로 변환되지 않는 한 “could not convert … from ‘const char*’ to ‘std::expected<int, std::string>’” 같은 에러가 나고, 변환이 되는 경우라면 더 나쁘게도 성공 값으로 조용히 반환됩니다.

<expected> 헤더는 -std=c++23(또는 c++2b)이 필요하며, GCC 12, Clang 16(libc++), MSVC 19.33(VS 2022 17.3) 무렵부터 제공됩니다. 모나딕 연산(and_then 등)은 이보다 조금 늦게 들어온 구현도 있으니, __cpp_lib_expected 매크로 값(202211L 이상이면 모나딕 연산 포함)으로 확인할 수 있습니다. 그 이전 표준에서는 API가 거의 같은 tl::expected 라이브러리가 대안입니다.

실전 예시

예시 1: 파일 읽기

#include <expected>
#include <fstream>
#include <iostream>
#include <string>

enum class FileError {
    NotFound,
    PermissionDenied,
    ReadError
};

std::expected<std::string, FileError> readFile(const std::string& path) {
    std::ifstream file{path};
    
    if (!file) {
        return std::unexpected{FileError::NotFound};
    }
    
    std::string content;
    if (!std::getline(file, content, '\0')) {
        return std::unexpected{FileError::ReadError};
    }
    
    return content;
}

int main() {
    auto result = readFile("data.txt");
    
    if (result) {
        std::cout << "내용: " << *result << std::endl;
    } else {
        switch (result.error()) {
            case FileError::NotFound:
                std::cout << "파일 없음" << std::endl;
                break;
            case FileError::ReadError:
                std::cout << "읽기 실패" << std::endl;
                break;
        }
    }
}

에러 타입으로 enum class를 쓰면 호출자가 switch로 경우를 나눌 수 있고, 크기도 작습니다. 이 예제에는 짚어 둘 부분이 두 가지 있습니다. 첫째, switch에 PermissionDenied가 빠져 있어 -Wall에서 “enumeration value ‘PermissionDenied’ not handled in switch” 경고가 납니다. 이 경고는 에러 종류를 추가했을 때 처리 누락을 찾아 주는 장점이므로, default:로 덮어 버리지 말고 경고를 에러로 올려(-Werror=switch) 활용하는 편이 좋습니다. 둘째, ifstream이 열리지 않는 이유는 파일 없음과 권한 없음을 구분해 주지 않아서, 여기서는 모두 NotFound로 보고됩니다. 정확히 구분하려면 std::filesystem::exists나 errno를 추가로 확인해야 합니다. 또 getline(file, content, '\0')은 빈 파일에서 실패하므로, 빈 파일이 정상 입력이라면 std::istreambuf_iterator로 읽는 방식이 더 맞습니다.

enum만으로는 “어느 파일이 왜” 같은 맥락을 담기 어렵다는 한계도 있습니다. 로그에 남길 정보가 필요하면 예시 4처럼 코드와 메시지를 함께 담는 구조체를 쓰거나, 운영체제 에러를 그대로 전달하는 std::error_code를 E로 쓰는 방법이 있습니다.

예시 2: 체이닝

#include <expected>
#include <iostream>
#include <string>

// parseInt는 아래 "패턴 1: 파이프라인 처리"에 정의되어 있습니다.
std::expected<int, std::string> parseInt(const std::string& str);

std::expected<int, std::string> parseAndValidate(const std::string& str) {
    return parseInt(str)
        .and_then([](int x) -> std::expected<int, std::string> {
            if (x < 0) {
                return std::unexpected{"음수 불가"};
            }
            return x;
        })
        .and_then([](int x) -> std::expected<int, std::string> {
            if (x > 100) {
                return std::unexpected{"100 초과"};
            }
            return x;
        });
}

int main() {
    auto result = parseAndValidate("50");
    
    if (result) {
        std::cout << "유효: " << *result << std::endl;
    } else {
        std::cout << "에러: " << result.error() << std::endl;
    }
}

and_then은 성공 상태일 때만 람다를 호출하고, 에러 상태면 람다를 건너뛰고 에러를 그대로 다음 단계로 넘깁니다. 그래서 중간에 if (!r) return r;를 반복해서 쓸 필요가 없습니다. 람다가 반드시 expected를 반환해야 하고 에러 타입도 같아야 한다는 점이 규칙입니다. 반환 타입을 -> std::expected<int, std::string>로 명시한 것도 이 때문인데, 이를 빼면 한쪽 return은 std::unexpected<const char*>, 다른 쪽은 int로 추론되어 “inconsistent deduction for auto return type” 에러가 납니다.

예시 3: 변환

#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};
    }
    return a / b;
}

int main() {
    auto result = divide(10, 2)
        .transform([](int x) { return x * 2; })  // 성공 시 변환
        .or_else([](const std::string& err) {    // 에러 시 처리
            std::cout << "에러: " << err << std::endl;
            return std::expected<int, std::string>{0};
        });
    
    std::cout << "결과: " << result.value() << std::endl;
}

모나딕 연산 네 가지는 “무엇을 받아 무엇을 반환하는가”로 구분하면 헷갈리지 않습니다.

연산호출 조건람다가 반환하는 것
and_then성공일 때새 expected<U, E> (실패할 수 있는 다음 단계)
transform성공일 때일반 값 U (실패하지 않는 변환)
or_else에러일 때새 expected<T, G> (복구 또는 에러 재구성)
transform_error에러일 때새 에러 값 G (에러 타입 변환)

transform에 expected를 반환하는 람다를 넘기면 expected<expected<int, E>, E>처럼 중첩된 타입이 만들어지고, 반대로 and_then에 일반 값을 반환하는 람다를 넘기면 컴파일 에러가 납니다. 체인 순서도 의미가 있습니다. 위 예제는 transform 다음에 or_else를 두었기 때문에 복구된 기본값 0에는 * 2가 적용되지 않습니다. 순서를 바꾸면 복구값에도 변환이 적용됩니다(패턴 3).

예시 4: 복합 에러

#include <expected>
#include <string>

// fetch, parse는 각각 실패 시 빈 값을 돌려주는 함수라고 가정합니다.
enum class ErrorCode {
    NetworkError,
    ParseError,
    ValidationError
};

struct Error {
    ErrorCode code;
    std::string message;
};

std::expected<int, Error> fetchAndParse(const std::string& url) {
    auto response = fetch(url);
    if (!response) {
        return std::unexpected{Error{ErrorCode::NetworkError, "연결 실패"}};
    }
    
    auto parsed = parse(*response);
    if (!parsed) {
        return std::unexpected{Error{ErrorCode::ParseError, "파싱 실패"}};
    }
    
    if (*parsed < 0) {
        return std::unexpected{Error{ErrorCode::ValidationError, "음수 불가"}};
    }
    
    return *parsed;
}

값 접근

std::expected<int, std::string> result = divide(10, 2);

// has_value
if (result.has_value()) {
    std::cout << result.value() << std::endl;
}

// bool 변환
if (result) {
    std::cout << *result << std::endl;
}

// value_or
int val = result.value_or(0);

// error
if (!result) {
    std::cout << result.error() << std::endl;
}

value()는 에러 상태에서 std::bad_expected_access<E>를 던지고, *result와 result->는 상태를 확인하지 않으므로 에러 상태에서 쓰면 정의되지 않은 동작입니다. 반대로 error()를 성공 상태에서 호출해도 정의되지 않은 동작이므로, 두 접근 모두 if (result) 확인 뒤에 써야 합니다.

자주 발생하는 문제

문제 1: 예외

std::expected<int, std::string> result = divide(10, 0);

// ❌ 에러 시 예외
try {
    int val = result.value();  // std::bad_expected_access
} catch (const std::bad_expected_access<std::string>& e) {
    std::cout << "에러: " << e.error() << std::endl;
}

// ✅ 확인 후 접근
if (result) {
    int val = *result;
}

문제 2: void 타입

값 없이 성공/실패만 알리면 될 때는 expected<void, E>를 씁니다. 성공은 return {};로 반환합니다.

#include <expected>
#include <iostream>
#include <string>

// 성공만 표시 (failed는 실패 조건을 나타내는 예시 변수)
std::expected<void, std::string> execute() {
    if (failed) {
        return std::unexpected{"실행 실패"};
    }
    return {};  // 성공
}

int main() {
    auto result = execute();
    
    if (result) {
        std::cout << "성공" << std::endl;
    } else {
        std::cout << "에러: " << result.error() << std::endl;
    }
}

문제 3: 에러 타입

// 간단한 에러
std::expected<int, std::string> result1;

// 구조화된 에러
struct Error {
    int code;
    std::string message;
};
std::expected<int, Error> result2;

// enum 에러
enum class ErrorCode { NotFound, Invalid };
std::expected<int, ErrorCode> result3;

문제 4: 성능

// 실패 시: 스택 언와인딩이 없어 예외를 던지는 것보다 쌈
// 성공 시: 매 호출 분기 + 반환 값 크기 증가
// sizeof(expected<T, E>) ≈ max(sizeof(T), sizeof(E)) + 상태 플래그 + 패딩

반환 객체가 커지면 레지스터에 담지 못하고 호출자 스택 메모리를 거쳐 반환될 수 있습니다. expected<int, ErrorCode>처럼 작은 조합은 문제가 없지만, E로 std::string을 쓰면 성공 경로에서도 반환 객체가 string 크기만큼 커집니다. 핫 패스라면 에러 타입을 enum이나 std::error_code처럼 작게 유지하고, 자세한 메시지는 에러가 난 뒤에 만들도록 설계하는 편이 좋습니다.

예외 vs expected

// 예외
int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("0으로 나눌 수 없음");
    }
    return a / b;
}

// expected
std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};
    }
    return a / b;
}

// expected 장점:
// - 명시적 에러 처리
// - 실패가 잦은 경로에서 비용이 예측 가능
// - 타입 안전

둘 중 하나를 고르는 기준은 “호출자가 이 실패를 바로 옆에서 처리할 수 있는가”입니다. 사용자 입력이 숫자가 아닌 경우처럼 호출자가 즉시 대응할 수 있는 실패는 expected가 자연스럽습니다. 반대로 메모리 부족, 깨진 불변식, 설정 파일이 없어 프로그램을 계속할 수 없는 상황처럼 여러 계층을 건너 최상위에서 한 번에 처리해야 하는 실패는 예외가 더 적합합니다. expected로 이런 실패를 전파하면 모든 중간 함수가 에러를 받아 다시 반환하는 코드로 채워집니다.

기존 예외 기반 코드에 expected를 섞어 쓸 때 걸리는 부분은 생성자입니다. 생성자는 값을 반환할 수 없으니 expected를 쓸 수 없고, 결국 static std::expected<Foo, Error> Foo::create(...) 같은 팩토리 함수로 바꾸고 생성자는 private으로 숨기는 패턴이 됩니다. 연산자 오버로딩도 비슷한 제약이 있어, 코드베이스 전체를 expected로 바꾸기보다는 파싱·I/O 경계에서부터 적용하는 편이 현실적입니다.

실무 패턴

패턴 1: 파이프라인 처리

std::expected<int, std::string> parseInt(const std::string& str) {
    try {
        return std::stoi(str);  // 주의: "50abc"도 50으로 받아들임
    } catch (const std::exception&) {  // invalid_argument, out_of_range
        return std::unexpected{"파싱 실패"};
    }
}

std::expected<int, std::string> validateRange(int value) {
    if (value < 0 || value > 100) {
        return std::unexpected{"범위 초과: 0-100"};
    }
    return value;
}

std::expected<int, std::string> doubleValue(int value) {
    return value * 2;
}

// 파이프라인
auto result = parseInt("50")
    .and_then(validateRange)
    .and_then(doubleValue);

if (result) {
    std::cout << "결과: " << *result << '\n';  // 100
} else {
    std::cout << "에러: " << result.error() << '\n';
}

패턴 2: 다중 에러 타입

enum class NetworkError {
    ConnectionFailed,
    Timeout,
    InvalidResponse
};

enum class ParseError {
    InvalidFormat,
    MissingField
};

using Error = std::variant<NetworkError, ParseError>;

// connectionFailed, invalidFormat, data는 설명을 위한 가상의 값입니다.
std::expected<std::string, Error> fetchData(const std::string& url) {
    // 네트워크 요청
    if (connectionFailed) {
        return std::unexpected{NetworkError::ConnectionFailed};
    }
    
    // 파싱
    if (invalidFormat) {
        return std::unexpected{ParseError::InvalidFormat};
    }
    
    return data;
}

// 사용
auto result = fetchData("https://api.example.com");
if (!result) {
    std::visit(overloaded{
        [](NetworkError) {
            std::cout << "네트워크 에러\n";
        },
        [](ParseError) {
            std::cout << "파싱 에러\n";
        }
    }, result.error());
}

overloaded는 표준 라이브러리에 없는 도우미로, 보통 template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; 두 줄로 직접 정의합니다. variant로 에러를 묶으면 호출자가 모든 에러 종류를 빠짐없이 처리하도록 컴파일러가 강제해 준다는 장점이 있지만, 계층이 깊어질수록 variant에 들어갈 타입이 계속 늘어나는 문제가 있습니다. 그래서 보통은 계층 경계마다 transform_error로 하위 에러를 상위 계층의 에러 타입으로 변환해 올리는 방식을 씁니다. 예를 들어 저장소 계층의 DbError를 서비스 계층에서 ServiceError::Unavailable로 바꾸는 식입니다.

패턴 3: 에러 복구

std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected{"0으로 나눌 수 없음"};
    }
    return a / b;
}

// 에러 시 기본값 또는 대체 로직
auto result = divide(10, 0)
    .or_else([](const std::string& err) -> std::expected<int, std::string> {
        std::cerr << "에러 발생: " << err << ", 기본값 사용\n";
        return 0;  // 기본값
    })
    .transform([](int x) {
        return x * 2;  // 성공 시 변환
    });

std::cout << "결과: " << *result << '\n';  // 0

참고 자료