Devin.KR

optional·variant·expected 로 실패와 상태 표현하기

개발자KR 조회 12

이 장에서 배우는 것

앞 장에서 주문 목록을 걸러 내는 파이프라인을 만들었다. 이번에는 주문 하나를 처리하는 동안 생기는 부재, 실패, 상태 변화를 타입으로 표현한다. 조회 결과가 없다는 뜻과 주문을 거절했다는 뜻은 다르다. 검증을 통과한 주문과 체결이 끝난 주문도 서로 다른 정보를 가진다. 이 차이를 반환값과 상태 타입에 담으면 호출자가 확인해야 할 조건이 코드에 드러난다.

기본서에서 배운 자원 획득 즉시 초기화(RAII)는 객체의 수명에 자원 정리를 연결한다. 이번에 다룰 타입은 그 위에서 값의 존재 여부와 처리 결과를 표현한다. 자원 정리와 실패 전달은 서로 대체하는 기능이 아니라 함께 사용하는 설계 요소다.

  • std::optional로 정상적인 값의 부재를 반환하고 안전하게 확인한다.
  • std::variant와 std::visit로 주문 상태와 전이 규칙을 작성한다.
  • C++23의 std::expected가 표현하는 성공과 실패를 이해하고 C++20 대안을 구성한다.
  • 반환값으로 처리할 실패와 예외로 전달할 실패의 경계를 정한다.

문제 상황

작은 주문 처리 엔진에 종목별 기준 가격 조회를 추가한다고 가정한다. 주문은 식별자, 종목 코드, 수량을 가진다. 등록된 종목이고 수량이 허용 범위에 들어가면 기준 가격으로 체결한다. 등록되지 않은 종목이나 잘못된 수량은 거절한다. 이 예제에서는 외부 거래소와 통신하지 않으며 기준 가격도 프로그램 안에 고정한다.

처음에는 가격 조회 함수가 실패하면 0을 반환하고, 검증 함수가 실패하면 false를 반환할 수 있다. 그러나 0이 유효한 가격인지, 종목을 찾지 못했다는 표시인지 함수 선언만으로 알기 어렵다. false만 받은 호출자는 수량을 수정해야 하는지 종목 코드를 수정해야 하는지도 판단할 수 없다.

상태 표현에도 문제가 생긴다. validated, filled, rejected라는 세 개의 불리언 멤버를 두면 검증되지 않았는데 체결되었거나, 체결과 거절이 동시에 표시된 조합이 만들어질 수 있다. 개발자가 모든 대입 순서를 기억하는 대신, 한 시점에 하나의 상태만 보관하도록 타입을 바꾸는 편이 낫다.

이 장에서는 조회의 부재에는 optional, 검증 결과와 주문 상태에는 variant를 사용한다. 완성 프로그램은 C++20으로 구성하고, 같은 검증 인터페이스를 C++23의 expected로 옮기는 방법을 별도로 설명한다.

값의 부재와 실패 이유를 구분한다

optional은 값이 있거나 없다

std::optional<T>는 T 객체 하나를 담거나 아무 값도 담지 않는다. 빈 결과는 std::nullopt로 반환한다. 다음 함수에서 종목을 찾지 못하는 일은 조회가 끝난 뒤 얻는 정상적인 결과다. 호출자가 알아야 할 정보도 가격의 존재 여부뿐이다.

std::optional<int> reference_price(std::string_view symbol) {
    if (symbol == "ALPHA") {
        return 1200;
    }
    return std::nullopt;
}

optional은 값의 저장 공간을 객체 안에 포함한다. 값 하나를 감싼다는 이유만으로 별도의 동적 할당을 하지 않는다. 다만 담긴 타입이 std::string처럼 내부에서 메모리를 할당할 수는 있다. 값이 없는 상태와 담긴 값 자체의 상태도 구별해야 한다. std::optional<int>{0}은 비어 있지 않고 정수 0을 담는다.

확인은 if (price)나 price.has_value()로 한다. 확인한 뒤에는 *price로 값에 접근할 수 있다. 비어 있는 객체를 *로 역참조하면 C++20에서는 정의되지 않은 동작이다. value()는 비어 있으면 std::bad_optional_access 예외를 던진다. 두 접근 방식의 차이를 알고 선택해야 한다.

value_or는 부재를 기본값으로 바꾸는 정책이 맞을 때 사용한다. 표시할 설명이 없으면 빈 문자열을 쓰는 것은 자연스러울 수 있다. 반면 가격이 없을 때 0으로 체결하는 것은 조회 실패를 정상 가격으로 바꾸므로 주문 엔진의 정책에 맞지 않는다.

expected는 실패 쪽에도 정보를 둔다

std::expected<T, E>는 성공 값 T 또는 오류 값 E를 보관하는 C++23 타입이다. optional의 빈 상태에는 이유가 없지만 expected의 오류 상태에는 거절 사유, 오류 코드 같은 정보를 넣는다. 호출자는 성공 여부를 확인하고 성공 값이나 오류 값 중 해당하는 쪽을 읽는다.

optional은 값의 부재를 표현하고 expected는 실패 이유까지 보관한다

다음 코드는 뒤에서 정의할 Order, Validated, Error를 사용한 C++23 인터페이스 예시다. 완성 프로그램에는 포함하지 않는다. std::unexpected는 반환하는 값이 성공 값이 아니라 오류 값임을 표시한다.

#include <expected>

std::expected<Validated, Error> validate23(const Order& order) {
    if (order.quantity <= 0 || order.quantity > 1000) {
        return std::unexpected(Error::BadQuantity);
    }

    const auto price = reference_price(order.symbol);
    if (!price) {
        return std::unexpected(Error::UnknownSymbol);
    }

    return Validated{order, *price};
}

호출자는 if (result)로 성공을 확인한 뒤 *result를 읽고, 실패한 분기에서 result.error()를 읽는다. error()는 성공 상태에서 호출하는 검사 함수가 아니다. 오류를 보관하고 있다는 전제 아래 오류에 접근하는 함수다. 성공 값을 읽는 value()는 실패 상태에서 std::bad_expected_access<E>를 던진다.

C++23 모드만 켰다고 모든 환경에서 이 코드를 사용할 수 있는 것은 아니다. 컴파일러와 함께 사용하는 표준 라이브러리도 <expected>를 지원해야 한다. 기본 기능의 제공 여부는 해당 헤더가 있는 환경에서 기능 검사 매크로 __cpp_lib_expected의 값이 202202L 이상인지 확인하는 방식으로 판단할 수 있다. 이 장의 C++20 실행 경로는 그 지원에 의존하지 않는다.

C++20에서는 std::variant<Validated, Error>로 두 결과를 표현할 수 있다. 다만 이 타입 자체에는 어느 쪽이 성공인지에 대한 전용 인터페이스가 없다. 타입 별칭과 함수 계약으로 의미를 정해야 한다. 프로젝트 전반에서 같은 결과 형식을 자주 쓴다면 오류 접근 규칙을 모은 별도 결과 타입이나 검토된 라이브러리를 고려할 수 있다. 표준 라이브러리의 이름 공간에 자체 expected를 추가해서는 안 된다.

호출자가 필요한 정보에 따라 반환 타입을 고른다
질문타입주문 엔진 예
값이 존재하는가optional<T>기준 가격 조회
어떤 종류의 값인가variant<A, B, ...>접수·검증·체결·거절 상태
성공했는가, 실패 이유는 무엇인가expected<T, E>C++23 검증 결과
C++20에서 두 결과를 구분하는가variant<T, E>성공·오류 대안을 둔 검증 결과

variant로 상태와 전이 규칙을 묶는다

std::variant는 지정한 여러 타입 가운데 하나의 값을 보관한다. 각각의 타입을 대안이라고 부른다. 상태 기계(state machine)에 적용하면 현재 상태에 필요한 데이터만 해당 대안에 둘 수 있다. 접수 상태는 원래 주문을, 검증 상태는 주문과 확정한 기준 가격을, 체결 상태는 식별자와 체결 금액을 가진다. 거절 상태는 식별자와 거절 이유를 가진다.

이렇게 구성하면 체결 상태에 가격 확정 여부를 묻는 불리언을 추가할 필요가 없다. 체결되었다는 사실은 활성 대안의 타입으로 드러난다. 다만 variant가 업무 규칙까지 자동으로 보장하는 것은 아니다. 외부 코드가 Filled를 직접 만들어 대입할 수 있다면 검증을 건너뛸 수 있다. 예제에서는 전이를 한 함수에 모으고, 더 큰 프로그램에서는 그 함수를 감싼 클래스가 상태 변경을 통제하게 할 수 있다.

std::visit은 현재 보관 중인 대안에 맞는 호출을 수행한다. 완성 코드의 Overloaded는 여러 람다의 호출 연산자를 한 객체에 모으는 작은 보조 타입이다. 각 람다의 인자 타입이 담당 상태를 나타낸다. 일반적인 std::visit 호출에서는 가능한 대안에 대한 호출이 모두 유효해야 하며 반환 타입과 값 범주도 일치해야 한다. 전이 방문자의 각 람다에 -> State를 적어 반환 형식을 맞춘다.

접수 주문은 검증 성공 후 체결되거나 검증 실패로 거절된다

이 예제의 사건은 검증 요청과 체결 요청 두 가지다. 접수 상태에서 검증 요청을 받으면 검증하거나 거절한다. 검증 상태에서 체결 요청을 받으면 체결한다. 그 밖의 조합은 현재 상태를 유지한다. 이미 체결되었거나 거절된 주문도 이후 사건을 무시한다. 이러한 정책은 타입과 별도로 명시해야 한다. 실제 서비스에서는 허용되지 않은 사건을 오류로 반환하는 정책을 선택할 수도 있다.

전이 함수는 현재 상태를 상수 참조로 읽고 새 상태를 반환한다. 방문 중인 객체를 직접 다른 대안으로 바꾸지 않으므로, 방문자가 참조하던 데이터의 수명을 중간에 끝내지 않는다. 또한 전이 계산 안에서 로그를 출력하지 않는다. 반환된 상태를 적용한 뒤 호출자가 체결 결과를 기록한다. 다만 상태 적용과 영속 로그 기록을 하나의 거래로 묶는 기능까지 제공하는 예제는 아니다.

variant에는 예외와 관련된 경계도 있다. 대안을 변경하는 일부 연산에서 생성이 실패하면 아무 대안도 보관하지 않는 상태가 될 수 있으며, 이를 valueless_by_exception()으로 확인한다. 이것은 주문 거절을 나타내는 정상 상태가 아니다. 이 상태에 visit을 적용하면 std::bad_variant_access가 발생한다. 따라서 “여러 대안 중 하나”라는 업무 모델과 객체 갱신 중 발생할 수 있는 예외를 구분해야 한다.

반환값과 예외의 경계를 정한다

반환값으로 실패를 표현할지는 실패가 자주 생기는지만으로 정하지 않는다. 바로 다음 호출자가 그 결과를 보고 분기해야 하는지, 어떤 복구 행동을 할 수 있는지, 오류 정보를 어느 계층까지 전달해야 하는지를 함께 본다. 주문 수량이 허용 범위를 벗어났다면 접수 응답에 거절 이유를 넣어야 한다. 검증 결과로 오류를 돌려주는 편이 이 흐름을 잘 드러낸다.

반면 문자열 복사 중 메모리 할당이 실패하는 상황은 이 예제의 종목 오류나 수량 오류에 해당하지 않는다. 이를 모두 주문 거절로 바꾸면 운영 장애가 사용자 입력 문제처럼 기록된다. 예외를 사용하는 코드베이스에서는 이러한 예외가 상위 처리 경계로 전달되게 하고, 그 경계에서 요청 중단이나 프로세스 종료 정책을 적용할 수 있다.

expected를 반환하는 함수도 예외를 던질 수 있다. 성공 값이나 오류 값을 만드는 과정에서 메모리 할당이 일어날 수 있기 때문이다. 반환 타입은 명시적으로 표현한 실패의 통로를 알려 줄 뿐, 함수 전체가 예외를 던지지 않는다고 보장하지 않는다. 같은 이유로 완성 코드의 validate나 next_state에 근거 없이 noexcept를 붙이지 않는다.

RAII는 두 방식 모두에서 필요하다. 오류 값을 반환하며 함수를 빠져나가도 지역 객체가 정리되고, 예외로 스택을 되감아도 생성이 완료된 지역 객체가 정리된다. 오류를 반환값으로 옮겼다는 이유로 자원 관리 규칙이 느슨해지는 것은 아니다. 또한 결과를 무시하지 않도록 중요한 검증 함수에는 [[nodiscard]]를 붙이는 것이 도움이 된다.

완성 코드

다음 내용을 UTF-8로 main.cpp에 저장한다. 수량은 1부터 1000까지 허용한다. 기준 가격은 정수 단위로 표현하고 체결 금액은 long long으로 계산한다. 유효한 주문 하나, 수량이 잘못된 주문 하나, 종목을 찾을 수 없는 주문 하나를 순서대로 처리한다.

#include <iostream>
#include <optional>
#include <string>
#include <string_view>
#include <variant>

// [1] 방문자 구성
template <class... Fs>
struct Overloaded : Fs... {
    using Fs::operator()...;
};

template <class... Fs>
Overloaded(Fs...) -> Overloaded<Fs...>;

// [2] 주문, 오류, 상태
struct Order {
    int id;
    std::string symbol;
    int quantity;
};

enum class Error { BadQuantity, UnknownSymbol };

struct Received {
    Order order;
};

struct Validated {
    Order order;
    int unit_price;
};

struct Filled {
    int id;
    long long amount;
};

struct Rejected {
    int id;
    Error error;
};

using Validation = std::variant<Validated, Error>;
using State = std::variant<Received, Validated, Filled, Rejected>;

enum class Event { Validate, Execute };

// [3] 조회와 오류 설명
std::optional<int> reference_price(std::string_view symbol) {
    if (symbol == "ALPHA") {
        return 1200;
    }
    if (symbol == "BETA") {
        return 2500;
    }
    return std::nullopt;
}

std::string_view error_text(Error error) {
    switch (error) {
    case Error::BadQuantity:
        return "수량 범위 오류";
    case Error::UnknownSymbol:
        return "미등록 종목";
    }
    return "알 수 없는 오류";
}

// [4] 검증 결과
[[nodiscard]] Validation validate(const Order& order) {
    if (order.quantity <= 0 || order.quantity > 1000) {
        return Error::BadQuantity;
    }

    const auto price = reference_price(order.symbol);
    if (!price) {
        return Error::UnknownSymbol;
    }

    return Validated{order, *price};
}

// [5] 상태 전이
[[nodiscard]] State next_state(const State& state, Event event) {
    return std::visit(Overloaded{
        [event](const Received& current) -> State {
            if (event != Event::Validate) {
                return current;
            }

            const auto result = validate(current.order);
            return std::visit(Overloaded{
                [](const Validated& value) -> State {
                    return value;
                },
                [&current](Error error) -> State {
                    return Rejected{current.order.id, error};
                }
            }, result);
        },
        [event](const Validated& current) -> State {
            if (event != Event::Execute) {
                return current;
            }

            const long long amount =
                static_cast<long long>(current.order.quantity)
                * current.unit_price;
            return Filled{current.order.id, amount};
        },
        [](const Filled& current) -> State {
            return current;
        },
        [](const Rejected& current) -> State {
            return current;
        }
    }, state);
}

// [6] 상태 표시
void print_state(const State& state) {
    std::visit(Overloaded{
        [](const Received& current) {
            std::cout << "주문 " << current.order.id << ": 접수\n";
        },
        [](const Validated& current) {
            std::cout << "주문 " << current.order.id
                      << ": 검증 완료, 단가 "
                      << current.unit_price << '\n';
        },
        [](const Filled& current) {
            std::cout << "주문 " << current.id
                      << ": 체결 완료, 금액 "
                      << current.amount << '\n';
        },
        [](const Rejected& current) {
            std::cout << "주문 " << current.id
                      << ": 거절, "
                      << error_text(current.error) << '\n';
        }
    }, state);
}

// [7] 주문 접수부터 체결 로그까지
int main() {
    const Order orders[] = {
        {101, "ALPHA", 3},
        {102, "BETA", 0},
        {103, "UNKNOWN", 2}
    };

    for (const auto& order : orders) {
        State state = Received{order};
        print_state(state);

        state = next_state(state, Event::Validate);
        print_state(state);

        if (std::holds_alternative<Validated>(state)) {
            state = next_state(state, Event::Execute);
            print_state(state);

            if (const auto* filled = std::get_if<Filled>(&state)) {
                std::cout << "체결 로그: " << filled->id
                          << ' ' << filled->amount << '\n';
            }
        }
    }
}

줄별 해설

[1]은 여러 람다를 하나의 방문자로 묶는다. using Fs::operator()...는 각 기반 타입의 호출 연산자를 가져온다. 그 아래 추론 가이드는 Overloaded{람다들}을 작성할 때 람다 타입을 템플릿 인자로 추론하게 한다. 주문 정책은 이 보조 타입에 들어 있지 않다.

[2]에서 Order는 종목 문자열을 소유한다. 상태가 바뀌거나 함수가 반환되어도 외부 문자열의 수명에 의존하지 않게 하려는 선택이다. Validated는 검증할 때 사용한 가격을 함께 보관한다. 체결 시점에 조회 함수를 다시 호출하지 않으므로 검증과 체결 사이에 가격을 어떤 기준으로 사용할지 명확하다.

Validation과 State는 둘 다 variant이지만 목적이 다르다. 전자는 검증 호출 한 번의 결과이고 후자는 주문의 현재 상태다. 검증 오류만으로는 주문 식별자를 알 수 없으므로, 전이 함수가 오류와 기존 주문 식별자를 결합해 Rejected를 만든다. 검증 함수가 화면 출력 형식이나 상태 저장 방식까지 알 필요는 없다.

[3]의 조회 함수는 인자로 받은 string_view를 호출 중에만 읽는다. 어디에도 저장하지 않으므로 주문 문자열이 호출 동안 살아 있으면 된다. error_text가 반환하는 뷰는 문자열 리터럴을 가리켜 함수가 끝난 뒤에도 유효하다. 같은 뷰 타입을 사용하더라도 참조 대상의 수명을 각각 살펴야 한다.

[4]는 수량부터 검사한다. 따라서 수량과 종목이 모두 잘못된 주문에는 수량 오류를 반환한다. 오류의 우선순위도 인터페이스의 관찰 가능한 동작이다. 여러 오류를 한꺼번에 보여 주어야 한다면 오류 목록을 반환하는 다른 계약이 필요하다. 여기서는 첫 오류 하나만 반환한다.

price가 비어 있으면 즉시 반환하므로 그 아래의 *price는 값이 있는 경로에서만 실행된다. 성공 시에는 주문을 복사하고 가격을 덧붙여 Validated를 만든다. 이 복사에서 예외가 발생할 수 있다는 사실은 업무상 검증 오류 두 가지와 별개다.

[5]의 바깥 방문은 현재 주문 상태를 선택한다. 접수 상태를 처리하는 람다 안의 방문은 검증 성공과 실패를 선택한다. 두 방문 모두 State를 반환하지만 판단하는 대상은 서로 다르다. 오류를 처리하는 람다가 참조하는 current는 동기적으로 실행되는 내부 방문 동안 유효하며, 반환값에 그 참조를 저장하지 않는다.

검증 상태의 곱셈은 수량을 먼저 long long으로 변환한다. 곱셈이 끝난 뒤 결과만 큰 타입에 대입하면 작은 정수 타입에서 이미 발생한 오버플로를 되돌릴 수 없다. 여기서는 수량과 가격의 범위도 작게 제한되어 있다. 일반적인 금액 처리에서는 큰 타입을 선택하는 것에 더해 입력 범위와 곱셈 한계까지 검사해야 한다.

체결 상태와 거절 상태를 처리하는 람다는 현재 값을 그대로 반환한다. 따라서 상태 전이 계산 자체는 종료 상태에 같은 사건을 다시 적용해도 같은 결과를 낸다. 그러나 로그나 결제 같은 외부 작업의 중복 방지까지 보장하지는 않는다. 그 작업을 반복 호출하는 코드에는 별도의 중복 처리 정책이 필요하다.

[6]은 네 상태를 모두 처리하는 출력 방문자다. 새 상태 타입을 추가하면 이 방문자도 검토해야 한다. 모든 타입을 받아 버리는 포괄적인 람다를 두지 않았으므로 처리하지 않은 상태가 생기면 컴파일 과정에서 드러난다.

[7]은 접수, 검증, 체결의 호출 순서를 정한다. state = next_state(...)에서는 오른쪽의 새 상태 계산이 끝난 뒤 대입한다. 계산 도중 예외가 나면 기존 상태는 바뀌지 않는다. 다만 마지막 대입 연산 자체의 예외 보장은 대안 타입과 수행되는 연산에 달려 있으므로, 이 형태만 보고 모든 상태 갱신이 강한 예외 보장을 제공한다고 일반화해서는 안 된다.

검증을 통과한 경로에서만 체결 사건을 보내고, 체결 결과는 get_if로 확인한다. 반환된 포인터는 state 안의 객체를 가리키며 소유권을 갖지 않는다. 이 포인터를 사용하는 동안 상태를 바꾸거나 파괴하지 않는다. 예제의 체결 로그는 표준 출력에 남기는 한 줄이며 파일 저장이나 장애 복구 기능은 포함하지 않는다.

실행 결과

macOS 또는 Linux에서 다음 명령으로 빌드하고 실행한다. C++20을 지원하는 컴파일러와 표준 라이브러리가 필요하다. 프로그램은 스레드를 만들지 않지만 책의 공통 빌드 옵션을 그대로 사용한다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o order_engine
./order_engine

예상 출력은 다음과 같다. 101번 주문만 검증 상태에 도달하므로 체결 로그도 한 줄만 생성된다.

주문 101: 접수
주문 101: 검증 완료, 단가 1200
주문 101: 체결 완료, 금액 3600
체결 로그: 101 3600
주문 102: 접수
주문 102: 거절, 수량 범위 오류
주문 103: 접수
주문 103: 거절, 미등록 종목

실무에서 자주 틀리는 것

빈 optional을 기본 가격으로 숨긴다

다음 코드를 검증 함수에 넣으면 미등록 종목도 단가 0인 검증 완료 주문이 된다. 문법상 유효하지만 부재를 처리하는 정책이 잘못되었다.

// 틀린 코드: 가격 부재를 정상 가격으로 바꾼다.
const int price = reference_price(order.symbol).value_or(0);
return Validated{order, price};

검증 단계에서는 조회 부재를 종목 오류로 바꿔 호출자에게 전달한다. optional을 반환한 조회 함수와 오류 이유를 반환하는 검증 함수의 책임이 구분된다.

// 고친 코드: Validation을 반환하는 함수 안에서 사용한다.
const auto price = reference_price(order.symbol);
if (!price) {
    return Error::UnknownSymbol;
}
return Validated{order, *price};

활성 대안을 확인하지 않고 get을 호출한다

다음 코드는 상태가 체결 상태라는 보장이 없는데 체결 정보를 읽는다. 다른 대안이 활성화되어 있으면 std::bad_variant_access가 발생한다.

// 틀린 코드
const auto& filled = std::get<Filled>(state);
std::cout << filled.amount << '\n';

체결 상태일 때만 선택적으로 처리하려면 get_if가 맞다. 모든 상태를 처리해야 한다면 visit을 사용한다. 이미 앞선 코드로 활성 대안이 보장된다면 get 자체가 잘못된 선택은 아니다.

// 고친 코드
if (const auto* filled = std::get_if<Filled>(&state)) {
    std::cout << filled->amount << '\n';
}

상태를 바꾼 뒤 이전 대안의 참조를 사용한다

대안의 멤버를 참조한 채 다른 대안을 대입하면 기존 객체의 수명이 끝난다. 다음의 symbol 참조는 상태 변경 뒤에 사용할 수 없다.

// 틀린 코드: 이 지점에서 state는 Validated라고 가정한다.
const auto& ready = std::get<Validated>(state);
const auto& symbol = ready.order.symbol;
state = Filled{ready.order.id, 3600};
std::cout << symbol << '\n';

상태 변경 뒤에도 필요한 데이터는 소유하는 값으로 먼저 보관한다. 포인터나 뷰만 복사하면 참조 대상의 수명 문제는 그대로 남는다.

// 고친 코드
const auto& ready = std::get<Validated>(state);
const std::string symbol = ready.order.symbol;
const int id = ready.order.id;
state = Filled{id, 3600};
std::cout << symbol << '\n';

모든 예외를 업무 오류 하나로 바꾼다

다음 코드는 Validation을 반환하는 별도 래퍼 함수의 본문이라고 가정한다. 메모리 할당 실패까지 미등록 종목으로 바꾸면 실제 실패 이유를 잃는다.

// 틀린 코드
try {
    return validate(order);
} catch (...) {
    return Error::UnknownSymbol;
}

이 래퍼가 예외를 복구할 책임이 없다면 업무 결과는 그대로 반환하고 예외도 상위 경계로 전달한다. 외부 API의 특정 예외를 결과 오류로 변환해야 할 때는 그 의미를 보존하는 오류 항목을 마련하고 해당 예외만 처리한다.

// 고친 코드
return validate(order);

한눈에 보기

값 접근 전에 확인할 조건과 각 타입의 역할
도구표현하는 것접근 전 확인설계상 주의점
optional값 또는 부재if (value)부재 이유는 담지 않는다
variant여러 대안 중 현재 값visit 또는 get_if전이 규칙은 별도로 작성한다
expected성공 값 또는 오류 값성공·실패 분기C++23 라이브러리 지원이 필요하다
예외상위 처리 경계로 전달할 실패처리 경계의 복구 정책업무 오류로 무조건 치환하지 않는다

조회에서 값이 없다는 사실을 검증 단계가 거절 이유로 해석하고, 전이 단계가 그 이유를 주문의 거절 상태로 연결했다. 같은 사건도 계층에 따라 표현이 달라진다. 다음 장에서 처리 실행을 별도 스레드로 옮길 때에도 이러한 결과 타입과 상태 규칙은 유지할 수 있다. 다만 이 타입들만으로 공유 상태에 대한 동시 접근이 안전해지지는 않는다.

연습 문제

  1. 주문 목록에 {104, "BETA", 1001}과 {105, "BETA", 1000}을 추가한다. 각 주문의 출력과 체결 금액을 예측하고, 수량 경계 검사에서 두 주문이 갈리는 이유를 설명한다.
  2. 접수 상태에 Event::Execute를 전달한 뒤 다시 Event::Validate를 전달하면 상태가 어떻게 바뀌는지 설명한다. 허용되지 않은 사건을 오류로 알리는 요구가 추가되면 전이 함수의 반환 타입을 어떻게 바꿀지 제안한다.
  3. value_or(load_fallback_price())에서 원래 값이 존재하면 대체 가격 함수가 호출되지 않는다고 가정한 코드가 있다. 이 가정의 문제를 설명하고, 값이 없을 때만 함수를 호출하도록 고친다.
  4. C++23 환경에서 validate23의 반환값을 받아 성공하면 검증 상태를, 실패하면 거절 상태를 반환하는 State checked_state(const Order& order)를 작성한다. 이 함수에 noexcept를 붙여도 되는지도 설명한다.

정답과 해설

1. 허용 범위의 끝값도 유효하다

104번 주문은 상한 1000을 초과하므로 거절된다. 105번 주문은 상한과 같으므로 검증을 통과하고 1000 × 2500인 2500000을 체결 금액으로 갖는다. 기존 출력 뒤에 다음 줄들이 추가된다.

주문 104: 접수
주문 104: 거절, 수량 범위 오류
주문 105: 접수
주문 105: 검증 완료, 단가 2500
주문 105: 체결 완료, 금액 2500000
체결 로그: 105 2500000

상한 검사가 > 1000이므로 1000은 허용된다. 검사를 >= 1000으로 바꾸면 요구한 범위와 다른 동작이 된다.

2. 상태와 사건을 함께 검사한다

접수 상태에서 체결 요청을 받으면 현재 접수 상태를 그대로 반환한다. 이후 검증 요청을 받으면 그때 검증하며, 주문 내용에 따라 검증 상태 또는 거절 상태로 바뀐다. 체결 요청을 먼저 보냈다는 사실은 별도로 보관하지 않는다.

허용되지 않은 사건을 보고해야 한다면 C++20에서는 std::variant<State, TransitionError>, C++23에서는 std::expected<State, TransitionError>를 전이 결과로 사용할 수 있다. 호출자는 새 상태를 받은 경우에만 상태를 적용한다. 검증 거절은 정상적으로 만들어진 주문 상태이고, 잘못된 사건은 전이 요청의 오류라는 구분을 유지한다.

3. 함수 인자는 호출 전에 평가된다

value_or는 대체 값을 만드는 작업을 지연하지 않는다. 함수 인자를 평가해야 하므로 원래 값이 있어도 load_fallback_price()가 호출된다. 조건 연산자는 선택된 쪽만 평가하므로 다음과 같이 고친다.

const auto price = reference_price("ALPHA");
const int selected = price ? *price : load_fallback_price();

이 코드는 대체 가격 사용이 허용된 업무라는 가정 아래의 답이다. 완성 프로그램의 검증 규칙처럼 가격 부재가 거절 사유라면 대체 가격을 구하지 않고 오류를 반환해야 한다.

4. 성공과 오류를 해당 분기에서 읽는다

다음은 C++23 전용 답안이다. 앞서 제시한 validate23와 상태 타입 정의가 있는 환경에서 사용한다.

State checked_state(const Order& order) {
    const auto result = validate23(order);
    if (result) {
        return *result;
    }
    return Rejected{order.id, result.error()};
}

성공 분기에서만 성공 값을 역참조하고 실패 분기에서만 오류 값을 읽는다. 성공 결과를 만들거나 상태로 복사할 때 주문의 문자열 복사가 일어날 수 있으므로 noexcept를 붙일 근거가 없다. 오류를 결과 타입으로 표현한다는 약속과 예외를 던지지 않는다는 약속은 각각 검토해야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.