게임 서버와 엔진 코드는 끊임없이 무언가를 조회한다. 플레이어를 찾고, 장착 아이템을 확인하고, 스킬 데이터를 가져온 뒤 재사용 대기시간을 계산한다. 이 과정 중 하나라도 실패하면 처리를 중단해야 한다.
기존에는 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&>는 사용할 수 없다. 큰 Player나 ItemData를 optional에 직접 담으면 객체가 복사될 수 있다. 조회 결과를 소유하지 않는 코드라면 포인터 또는 std::reference_wrapper를 사용하는 편이 명확하다.
optional<reference_wrapper<const T>> 자체는 대상 객체를 복사하거나 힙에 할당하지 않는다. 다만 참조 대상의 수명은 보장하지 않으므로 컨테이너 재할당이나 엔티티 제거가 발생할 수 있는 프레임 경계를 넘어 저장해서는 안 된다.
람다 인자에서 reference_wrapper가 const 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 도구에 가깝다.
'C#·C++ 모던' 카테고리의 다른 글
| C# static abstract interface로 게임 수치 타입을 박싱 없이 설계하는 법 (0) | 2026.07.19 |
|---|---|
| C++23 std::to_underlying와 enum class로 게임 상태 플래그 제대로 쓰는 법 (0) | 2026.07.14 |
| C# 12 람다 기본값으로 게임 이벤트 필터를 더 작게 설계하기 (0) | 2026.07.07 |
| C++23 std::move_only_function으로 소유권 있는 게임 콜백 안전하게 넘기기 (0) | 2026.07.05 |
| C++23 std::scope_exit로 게임 상태 복구 코드를 안전하게 만들기 (0) | 2026.06.30 |