C#·C++ 모던

C++23 std::optional 모나딕 연산으로 게임 조회 로직의 분기와 복사 줄이기

kr-gamedev 2026. 7. 12. 09:02

게임 서버와 엔진 코드는 끊임없이 무언가를 조회한다. 플레이어를 찾고, 장착 아이템을 확인하고, 스킬 데이터를 가져온 뒤 재사용 대기시간을 계산한다. 이 과정 중 하나라도 실패하면 처리를 중단해야 한다.

기존에는 std::optional을 반환해도 호출부에 if가 연속으로 쌓였다. C++23은 and_then, transform, or_else를 추가해 성공 경로를 하나의 파이프라인으로 표현한다. 예외나 동적 할당 없이 조회 실패를 다룰 수 있어 게임 핫패스에도 적용하기 좋다.

기존 방식에서 분기가 늘어나는 이유

플레이어의 장착 무기로 공격력을 계산한다고 해보자.

std::optional<int> GetEquippedWeaponPower(
    PlayerId playerId,
    const World& world,
    const ItemDatabase& items)
{
    const Player* player = world.FindPlayer(playerId);
    if (player == nullptr)
        return std::nullopt;

    if (!player->equippedWeaponId.has_value())
        return std::nullopt;

    const ItemData* item = items.Find(*player->equippedWeaponId);
    if (item == nullptr)
        return std::nullopt;

    return item->attackPower;
}

코드는 정확하지만 핵심인 성공 경로가 여러 검증문 사이에 흩어진다. 조회 단계가 늘어날수록 조기 반환 코드도 반복된다. 로깅이나 기본값 처리까지 섞이면 흐름을 읽기가 더 어려워진다.

and_then으로 실패 가능한 조회 연결하기

and_then은 현재 optional에 값이 있을 때만 함수를 호출한다. 호출 함수 역시 optional을 반환해야 하므로 실패 가능한 다음 작업을 자연스럽게 연결할 수 있다.

std::optional<std::reference_wrapper<const Player>>
FindPlayerRef(PlayerId id, const World& world)
{
    if (const Player* player = world.FindPlayer(id))
        return std::cref(*player);

    return std::nullopt;
}

std::optional<std::reference_wrapper<const ItemData>>
FindItemRef(ItemId id, const ItemDatabase& items)
{
    if (const ItemData* item = items.Find(id))
        return std::cref(*item);

    return std::nullopt;
}

std::optional<int> GetEquippedWeaponPower(
    PlayerId playerId,
    const World& world,
    const ItemDatabase& items)
{
    return FindPlayerRef(playerId, world)
        .and_then([](const Player& player) {
            return player.equippedWeaponId;
        })
        .and_then([&items](ItemId itemId) {
            return FindItemRef(itemId, items);
        })
        .transform([](const ItemData& item) {
            return item.attackPower;
        });
}

플레이어, 무기 ID, 아이템 데이터 중 하나라도 없으면 이후 람다는 실행되지 않는다. 마지막 transform은 정상 값을 공격력으로 변환하고 결과를 다시 optional<int>로 감싼다.

참조 조회에서 불필요한 복사 피하기

std::optional<T&>는 사용할 수 없다. 큰 PlayerItemDataoptional에 직접 담으면 객체가 복사될 수 있다. 조회 결과를 소유하지 않는 코드라면 포인터 또는 std::reference_wrapper를 사용하는 편이 명확하다.

optional<reference_wrapper<const T>> 자체는 대상 객체를 복사하거나 힙에 할당하지 않는다. 다만 참조 대상의 수명은 보장하지 않으므로 컨테이너 재할당이나 엔티티 제거가 발생할 수 있는 프레임 경계를 넘어 저장해서는 안 된다.

람다 인자에서 reference_wrapperconst T&로 변환되기 때문에 파이프라인도 비교적 간결하게 유지된다.

transform과 or_else의 역할 구분하기

transform은 실패하지 않는 값 변환에 적합하다. 공격력 보정처럼 입력이 존재하면 반드시 결과를 만들 수 있는 단계에 사용한다.

int finalPower = GetEquippedWeaponPower(id, world, items)
    .transform([buff](int power) {
        return static_cast<int>(power * buff);
    })
    .or_else([id] {
        LogMissingWeapon(id);
        return std::optional<int>{0};
    })
    .value();

or_else는 값이 없을 때만 호출된다. 로그를 남기거나 대체 optional을 반환하는 복구 지점으로 사용할 수 있다. 단순 기본값만 필요하다면 value_or(0)이 더 짧다.

중요한 기준은 단계의 의미다. 실패 가능한 조회에는 and_then, 실패하지 않는 변환에는 transform, 실패 후 복구에는 or_else를 배치하면 된다.

핫패스 적용 전 확인할 점

모나딕 연산은 문법을 정리할 뿐 컨테이너 조회 비용 자체를 줄이지는 않는다. 캡처한 람다와 짧은 함수는 최적화 빌드에서 대부분 인라인되지만, 실제 비용은 컴파일러 출력과 프로파일러로 확인해야 한다.

또한 모든 함수를 억지로 한 줄에 연결할 필요는 없다. 실패 원인별 통계가 필요하거나 중간 상태를 디버거에서 확인해야 한다면 명시적인 if가 더 낫다. 파이프라인은 실패를 하나의 부재 상태로 처리해도 되는 조회 코드에서 가장 효과적이다.

결론

C++23의 std::optional 모나딕 연산은 연속 조회 코드에서 반복되는 분기와 조기 반환을 줄인다. and_then, transform, or_else의 역할을 분리하면 성공 경로가 위에서 아래로 선명하게 드러난다.

큰 게임 객체는 reference_wrapper나 포인터로 참조해 복사를 피하고, 참조 수명과 컨테이너 변경 시점은 별도로 관리해야 한다. 성능을 과장하는 기능이라기보다, 실패 가능한 게임 로직을 비용 예측이 쉬운 형태로 구조화하는 C++23 도구에 가깝다.