C#·C++ 모던

C++23 std::to_underlying와 enum class로 게임 상태 플래그 제대로 쓰는 법

kr-gamedev 2026. 7. 14. 09:08

C#·C++ 모던 개념도 (AI 생성)

기절, 침묵, 무적처럼 동시에 여러 개가 적용되는 게임 상태는 비트 플래그로 표현하기 좋다. 문제는 enum class를 도입한 뒤다. 타입 안전성은 좋아지지만 비트 연산을 할 때마다 static_cast가 반복되고, 어느 순간 상태 변경 규칙까지 캐릭터 클래스 곳곳에 흩어진다.

C++23의 std::to_underlying을 사용하면 열거형을 기반 정수형으로 명확하게 변환할 수 있다. 여기에 작은 값 객체를 더하면 비트 연산뿐 아니라 유효성 검사와 직렬화 경계까지 한곳에서 관리할 수 있다.

1. 숫자가 아니라 상태로 선언한다

상태 값을 정수 상수로 선언하면 서로 다른 종류의 플래그도 실수로 섞을 수 있다. enum class는 이런 암시적 변환을 차단한다.

#include <cstdint>
#include <utility>

enum class Status : std::uint32_t
{
    None       = 0,
    Stun       = 1u << 0,
    Silence    = 1u << 1,
    Invincible = 1u << 2
};

constexpr Status operator|(Status lhs, Status rhs) noexcept
{
    return static_cast<Status>(
        std::to_underlying(lhs) | std::to_underlying(rhs));
}

std::to_underlying은 C++23 표준 라이브러리에 추가된 함수다. 변환 의도가 코드에 드러나므로 기반 타입을 직접 적는 static_cast<std::uint32_t>보다 열거형 변경에 강하다. 세부 명세는 cppreference의 std::to_underlying 문서에서 확인할 수 있다.

2. 비트 연산을 호출부에 노출하지 않는다

게임 로직에서 매번 마스크를 조합하면 조건식이 읽기 어려워진다. 상태 보관과 연산을 전담하는 StatusSet으로 책임을 모아 보자.

class StatusSet
{
public:
    constexpr bool HasAll(Status flags) const noexcept
    {
        const auto value = std::to_underlying(bits_);
        const auto mask = std::to_underlying(flags);
        return (value & mask) == mask;
    }

    constexpr bool HasAny(Status flags) const noexcept
    {
        return (std::to_underlying(bits_)
              & std::to_underlying(flags)) != 0;
    }

    constexpr void Add(Status flags) noexcept
    {
        bits_ = bits_ | flags;
    }

    constexpr void Remove(Status flags) noexcept
    {
        bits_ = static_cast<Status>(
            std::to_underlying(bits_) & ~std::to_underlying(flags));
    }

private:
    Status bits_ = Status::None;
};

HasAllHasAny를 구분한 점이 중요하다. 여러 플래그가 전달됐을 때 하나만 있어도 되는지, 전부 있어야 하는지가 함수 이름에 나타난다. 애매한 HasStatus 하나로 두 의미를 처리하면 전투 규칙 버그가 생기기 쉽다.

3. 캐릭터는 비트 연산보다 규칙을 말한다

상태 컨테이너를 사용하면 호출부는 구현 방식 대신 게임 규칙을 표현한다.

class Character
{
public:
    bool CanMove() const noexcept
    {
        return !statuses_.HasAny(Status::Stun);
    }

    bool CanCastSkill() const noexcept
    {
        return !statuses_.HasAny(Status::Stun | Status::Silence);
    }

    bool CanReceiveDamage() const noexcept
    {
        return !statuses_.HasAny(Status::Invincible);
    }

private:
    StatusSet statuses_;
};

이 구조는 단일 책임 원칙에도 잘 맞는다. Character는 행동 가능 여부를 결정하고, StatusSet은 플래그 표현과 조작을 담당한다. 나중에 상태 저장 방식을 바꿔도 전투 코드의 조건식까지 함께 수정할 필요가 줄어든다.

4. 저장과 네트워크 입력은 마스크로 방어한다

세이브 데이터나 서버 패킷에서 받은 값에는 현재 버전이 모르는 비트가 포함될 수 있다. 원시 값을 그대로 캐스팅하지 말고 허용된 비트만 남겨야 한다.

constexpr std::uint32_t KnownStatusMask =
    std::to_underlying(
        Status::Stun | Status::Silence | Status::Invincible);

Status DecodeStatus(std::uint32_t raw) noexcept
{
    return static_cast<Status>(raw & KnownStatusMask);
}

이 경계 처리는 클라이언트와 서버의 버전이 잠시 어긋났을 때 특히 유용하다. 내부 로직은 항상 유효한 상태만 받으며, 알 수 없는 비트가 우연히 게임 규칙에 영향을 주는 일도 막을 수 있다.

비트 플래그의 핵심은 압축이 아니라 책임 분리다. std::to_underlying으로 변환 의도를 분명히 하고, 실제 연산은 전용 타입 안에 가두면 상태 코드는 더 안전하고 읽기 쉬운 게임 규칙이 된다.