CodeGym /행동 /C++ SELF /std::expected<T, E> — 성공/오류 모델로서

std::expected<T, E> — 성공/오류 모델로서

C++ SELF
레벨 23, 레슨 2
사용 가능

1. «매직 값» 문제

처음 프로그램을 작성할 때는 흔히 이렇게 하고 싶어집니다: «실패하면 -1을 반환하자». 이는 사용자가 실제로 -1을 입력했을 때(예: 취소하려고)나, 0도 유효한 응답인 경우엔 논리적으로 깨집니다. 그때부터 코드는 추측 게임이 됩니다: «이게 실제 결과인가, 아니면 오류인가?».

더 나쁜 상황은 오류를 문자열 "error"로 표현하고 나서 어디에서 검사했는지 잊어버려 "error"로 계산을 계속하는 경우입니다(컴파일러가 막아주겠지만 이미 기분이 상합니다). 따라서 내부에 유효한 결과나 오류 정보 중 하나가 있음을 직접 표현하는 타입이 필요합니다.

이 생각에서 std::expected<T, E>으로 옵니다.

미니 표: optional vs expected vs «-1 반환»

보통 이 지점에서 모델들을 솔직하게 비교하는 것이 유용합니다. 초보자들은 종종 optional을 모든 곳에 쓰려다가 정보가 부족하다는 것에 놀랍니다.

결과 모델 “나쁜 결과”의 의미 오류 이유가 있는가? 전형적인 호출 모습
“매직 값” (-1, "") “실패했으니 알아서 추측하라” 아니요
int x = f(); if (x == -1) ...
std::optional<T> “값이 없음” 아니요(혹은 다른 곳에 숨겨짐)
auto x = f(); if (!x) ...
std::expected<T, E> “값 또는 오류” 예(E에 있음)
auto r = f(); if (!r) use(r.error());

expected의 요점은 호출자가 실패 이유를 맞추려 하지 않아도 되고, 실제 데이터와 충돌하는 «특별 값»을 만들어 내지 않아도 된다는 것입니다.

2. std::expected<T, E>란 무엇이며 어떻게 반환하는가

std::expected<T, E>는 값 타입 T(성공) 또는 오류 타입 E(실패) 중 하나를 담는 객체입니다. 즉, 사용자가 직접 동기화해야 하는 «값 + 플래그»가 아니라, 연산 결과 모델을 제공하는 표준 라이브러리 타입입니다.

핵심을 느껴야 합니다: expected는 «값이 없을 수도 있다»(optional)와 달리 «성공했거나, 실패한 이유가 있다»는 의미입니다. 실패 이유가 바로 E입니다. 간단한 예제에서는 enum class나 std::string이 E 역할을 할 수 있지만, E는 가능한 한 다룰 수 있는 데이터여야 합니다.

std::expected를 사용하려면 보통 헤더 <expected>와 C++23 표준이 필요합니다.

#include <expected>

환경에 <expected>가 없다면 표준 라이브러리/컴파일러가 아직 지원하지 않거나 빌드 표준이 맞지 않는 것입니다. 그때는 임시로 std::variant<T, E>로 같은 계약을 모델링할 수 있습니다 — 아이디어는 같고 문법이 약간 덜 편할 뿐입니다.

결과 생성: 성공은 값 그대로, 오류는 std::unexpected를 통해

성공을 반환할 때는 단순히 T를 반환하면 됩니다. 즉, «숫자를 얻었으니 숫자를 반환»하는 식입니다.

오류를 반환할 때는 명시적 오류 상태를 std::unexpected<E>{...}로 반환합니다. 심리적으로 중요한 점은 unexpected를 보면 작성자(및 코드 리뷰어)가 «아, 여기는 의도적으로 오류를 반환하는 곳이구나»라고 즉시 알 수 있다는 것입니다.

std::unexpected<E>에는 오류를 가져오는 접근자 error()가 있으며, 이런 접근자의 존재 자체가 표준 라이브러리의 일부입니다.

미니 예제: 한 자릿수만 파싱하는 간단한 함수입니다(예제를 단순하게 해서 expected에 집중합니다).

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotDigit };

[[nodiscard]] std::expected<int, ParseErr> parse_one_digit(std::string_view s) {
    if (s.empty()) {
        return std::unexpected<ParseErr>{ParseErr::Empty};
    }
    char c = s[0];
    if (c < '0' || c > '9') {
        return std::unexpected<ParseErr>{ParseErr::NotDigit};
    }
    return static_cast<int>(c - '0');
}

[[nodiscard]]에 주목하세요: 이 어트리뷰트는 결과를 무시하지 않도록 하는 아이디어로, expected와 함께 특히 유용합니다. expected를 무시하면 잠재적 오류를 문자 그대로 무시하는 것입니다.

3. expected 사용법: 검사, 접근, 그리고 오류 «전달»

std::expected<T, E>를 받으면 두 가지 기본 단계가 있습니다: 먼저 성공인지 검사하고, 그다음에 데이터를 꺼냅니다.

검사는 다음과 같이 합니다:

  • if (res) — 성공
  • if (!res) — 실패

또는 더 명시적으로:

  • res.has_value()

그다음 어떻게 할지는 여러분이 결정합니다:

  • 성공이면 res.value()(또는 *res)를 사용합니다;
  • 실패면 res.error()를 사용합니다.

중요한 점: value()는 오류 상태일 때 호출하면 안 됩니다. 이는 단순히 «무언가 반환»하는 것이 아니라 사용 계약을 위반하는 행위이며, 구현에 따라 프로그램이 비정상 종료될 수 있습니다. 따라서 규율을 지켜 먼저 if로 검사한 뒤 접근합니다.

#include <expected>
#include <iostream>
#include <string_view>

enum class ParseErr { Empty, NotDigit };
std::expected<int, ParseErr> parse_one_digit(std::string_view s);

int main() {
    auto r = parse_one_digit("x");

    if (!r) {
        std::cout << "parse failed\n"; // parse failed
        return 1;
    }

    std::cout << "value=" << r.value() << "\n"; // value=...
    return 0;
}

네, optional과 비슷하지만 중요한 차이가 있습니다: 여기서는 무엇이 잘못되었는지(예: ParseErr)에 대한 설명이 항상 존재한다는 점입니다.

오류를 위로 전달하기: 초기 return이 더 유용해짐

expected를 반환하기 시작하면 유용한 프로그래밍 스타일이 생깁니다: 함수가 더 이상 올바르게 계속할 수 없으면 기본값을 만들어 쓰지 않고 오류를 위로 반환합니다. 호출자가 어떻게 처리할지 결정합니다.

이는 초기 return과 잘 어울립니다: 중첩된 if가 줄고, 복잡한 «사다리» 구조가 줄어들며 코드가 더 선형적으로 읽힙니다.

예를 들어 텍스트 명령 «done 12»에서 작업의 id를 반환하는 함수를 만들어 보겠습니다(파싱 오류일 경우 오류 반환). 아직 복잡한 오류 타입은 쓰지 않고 코드로만 처리합니다.

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber };

[[nodiscard]] std::expected<int, ParseErr> parse_int(std::string_view s);

[[nodiscard]] std::expected<int, ParseErr> parse_done_id(std::string_view arg) {
    auto id = parse_int(arg);
    if (!id) {
        return std::unexpected<ParseErr>{id.error()};
    }
    return id.value();
}

이 예제는 «그저 오류를 전달한» 것처럼 보이지만, 실제 코드에서는 parse_int와 return 사이에 추가 검사가 들어가는 경우가 많습니다(예: id는 양수여야 함). 그런 지점에서 expected가 빛을 발합니다.

id가 0보다 큰지 검사하고 새로운 오류 코드를 추가해 보겠습니다:

#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber, NonPositive };

[[nodiscard]] std::expected<int, ParseErr> parse_positive_id(std::string_view s) {
    auto id = parse_int(s);
    if (!id) {
        return std::unexpected<ParseErr>{id.error()};
    }
    if (id.value() <= 0) {
        return std::unexpected<ParseErr>{ParseErr::NonPositive};
    }
    return id.value();
}

이제 계약이 더 풍성해졌고, 호출자는 우리가 왜 실패했는지 추측할 필요가 없습니다.

4. CLI 애플리케이션 TaskLite 예제: 예외 없이 숫자 파싱

개념을 실제 문맥에서 확인하기 위해 간단한 콘솔 애플리케이션 TaskLite를 상상해 봅시다: 사용자가 명령을 입력하면 우리는 std::vector에 작업 목록을 유지합니다. 파일이나 복잡한 클래스는 없이 단순한 데이터 구조와 입출력만 사용합니다.

작업 구조는 다음과 같습니다:

#include <string>

struct Task {
    int id{};
    std::string text;
    bool done{false};
};

이런 애플리케이션에서 가장 자주 발생하는 고통 포인트 중 하나는 숫자 파싱입니다(예: id). 사용자가 «abc»를 입력했을 때 우리는 오류를 올바르게 보고해야 하며, 모든 것이 정상인 척하면 안 됩니다.

parse_int 함수를 만들어 expected<int, ParseErr>를 반환하도록 합시다.

std::from_chars를 사용하면 예외 없이 숫자를 파싱할 수 있고(우리는 이미 이 아이디어를 다뤘습니다), expected는 결과를 감싸는 데 이상적입니다: 숫자이거나 오류입니다.

#include <charconv>
#include <expected>
#include <string_view>

enum class ParseErr { Empty, NotNumber };

[[nodiscard]] std::expected<int, ParseErr> parse_int(std::string_view s) {
    if (s.empty()) {
        return std::unexpected<ParseErr>{ParseErr::Empty};
    }

    int value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::unexpected<ParseErr>{ParseErr::NotNumber};
    }

    return value;
}

여기서는 파싱이 성공했는지와 문자열 전체가 숫자였는지(예: "12abc" 같은 꼬리가 없는지)를 확인합니다.

main에서 결과 처리: 오류는 따로, 정상 출력은 따로

CLI를 작성할 때 오류를 «발생한 곳에서» 출력하고 싶어지지만, 시간이 지나면 한 함수는 출력하고 다른 함수는 출력하지 않아 정리가 안 되고 일관성이 깨집니다.

우리는 최소한 expected를 한 가지 스타일로 검사하자는 목표를 세웁니다. 규율만으로도 충분합니다: «expected를 받으면 → 검사 → 오류면 사용자에게 알림».

예를 들어 «done ID» 명령이 있고 ID를 문자열로 읽는다면:

#include <iostream>
#include <string>

int main() {
    std::string id_text;
    std::cin >> id_text;

    auto id = parse_positive_id(id_text);
    if (!id) {
        std::cout << "bad id\n"; // bad id
        return 1;
    }

    std::cout << "ok, id=" << id.value() << "\n"; // ok, id=...
    return 0;
}

메시지는 지금 단순하지만 충분합니다. 오류 메시지 포맷을 멋지게 만드는 것은 별도의 큰 주제입니다: «사람에게 이해하기 쉬운 메시지를 주고, 프로그램에는 편한 오류 코드를 제공하는 방법». 지금은 오류가 타입으로 표현되어 잃어버리지 않는 것만 중요합니다.

5. expected가 좋은 선택인 경우와 다른 선택이 나은 경우

처음에는 «이제 모든 것을 expected로!» 하고 싶은 유혹이 있습니다. 하지만 좋은 엔지니어링은 한 타입에 대한 숭배가 아니라 상황에 맞는 판단입니다.

std::expected는 연산이 «대부분은 성공하지만 가끔 기대 가능한 실패가 있고, 그 실패 원인을 구분해야 할 때» 가장 적합합니다. 사용자 입력 파싱은 이상적인 예시입니다: 오류는 예외적 상황이 아니라 정상적인 분기입니다.

std::optional<T>는 실패 이유가 중요하지 않을 때가 더 낫습니다. 예를 들어 컨테이너 검색: 찾았거나 못 찾았거나, «왜 못 찾았는지»는 별로 중요하지 않을 때입니다.

std::variant는 결과가 성공/오류 이상의 여러 경우일 때 적합합니다. 예를 들어 계산 결과가 «숫자», «경고+숫자», «오류», «확인이 필요» 등 여러 의미를 가질 때입니다. 이런 경우는 이분법 모델이 아니기 때문에 variant가 맞습니다.

매직 값은 역사적 유물로 남겨두세요. 플로피 디스크처럼 낭만적일 수는 있지만 중요한 데이터를 보관하는 수단으로는 적절하지 않습니다.

6. std::expected 사용 시 흔한 실수

실수 #1: 결과를 검사하지 않고 value()를 호출하는 것.
초보자가 가장 자주 범하는 버그입니다: «여기선 괜찮을 거야»라는 확신은 나쁜 입력에서 무너지기 쉽습니다. 습관을 들이세요: 먼저 if (!res)로 확인하고 그다음 res.value()를 사용합니다.

실수 #2: 오류를 암묵적으로 반환하고 가독성을 잃는 것.
때때로 사람들은 return ParseErr::NotNumber처럼 쓰고 컴파일러가 «이게 오류다»라고 알아서 처리해주길 바랍니다. 하지만 expected에서는 오류를 명시적으로 std::unexpected<E>{...}로 만드는 것이 관례입니다. 이렇게 하면 코드를 읽는 사람이 «여기는 실패를 반환하는 곳이다»라는 신호를 즉시 받습니다.

실수 #3: 한 계약에서 expected와 기본값을 섞는 것.
예를 들어 파싱에 실패하면 0을 반환하면서도 다른 곳에서는 unexpected를 반환하면 API가 다시 추측 게임이 됩니다. 함수가 expected를 반환하기로 했으면 계약을 엄격히 지키세요: 성공은 실제 값, 실패는 std::unexpected입니다.

실수 #4: 오류 타입을 너무 빈약하게 만들고 모든 곳에 «bad input»을 출력하는 것.
모든 오류를 단일 코드로 합치면 expected의 장점을 잃어버립니다. 최소한 몇 가지 의미 있는 이유(Empty, NotNumber, OutOfRange)를 구분하는 것이 좋습니다. 나중에 더 많은 컨텍스트를 저장하고 싶다면 확장하면 됩니다.

실수 #5: [[nodiscard]]를 무시하고 결과를 버리는 것.
expected는 «확인해라»라는 신호입니다. [[nodiscard]]를 사용하지 않으면 실수로 parse_int(s);처럼 결과를 아무 것도 하지 않고 버릴 수 있습니다. [[nodiscard]]를 추가하면 컴파일 단계에서 이를 잡을 가능성이 높아집니다.

1
과제
C++ SELF, 레벨 23, 레슨 2
잠금
한 자리 숫자
한 자리 숫자
1
과제
C++ SELF, 레벨 23, 레슨 2
잠금
정직한 파서
정직한 파서
1
과제
C++ SELF, 레벨 23, 레슨 2
잠금
나이 확인
나이 확인
1
과제
C++ SELF, 레벨 23, 레슨 2
잠금
계산기 한 줄 입력
계산기 한 줄 입력
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION