본문으로 건너뛰기
뒤로가기

[C++] World Server 3: 게임 로직의 계산과 상태 반영을 분리하다

업데이트:

TL;DR — 네트워크 event를 게임 로직에서 바로 사용하지 않고, 한 tick의 계산에 필요한 input으로 변환했습니다. Phase는 읽기 전용 World State를 기준으로 결과만 계산하고, 실제 authoritative state 변경은 Coordinator의 commit 단계에서 수행합니다. 현재 계산은 순차 실행하지만, 공유 상태를 직접 수정하지 않는 계산 경계를 만들어 향후 입력 구간을 나누어 실행할 수 있는 seam을 마련했습니다.

Table of contents

Open Table of contents

들어가며

앞선 Double Buffer 도입기에서는 비동기적으로 들어오는 event 중 어디까지를 현재 epoch에서 처리할지 확정했습니다.

이번에 살펴볼 내용은 World Server에서 이 sealed batch에 들어온 클라이언트의 요청을 어떤 방식으로 공정하게 처리할 것인지에 대한 내용입니다.

말했듯이 Network event를 해석하는 과정에서 World State를 바로 변경하면 입력 처리, 게임 로직 계산과 결과 반영에 따른 상태 변경이 한곳에 결합됩니다. 여러 entity의 계산을 나누려 해도 모든 작업이 같은 World State를 수정하므로 실행 순서와 lock을 함께 고려해야 하는 더 복잡한 문제가 발생하게 됩니다.

이를 분리하기 위해 현재 tick의 입력을 값으로 확정하고, 계산 결과를 별도 타입으로 만든 뒤, Coordinator가 결과를 World State에 반영하도록 구성했습니다.

이 글에서 다루는 내용:

사전 지식: server-authoritative simulation과 fixed-step game loop에 대한 기본 이해를 전제로 합니다.


1. Event 처리와 World State 변경을 분리한다

NetworkRuntime에서 전달된 event에는 packet type, payload와 session identity처럼 네트워크 통신 경계를 통과하는 데 필요한 정보가 포함됩니다.

반면 movement 계산에는 현재 entity, 이동 방향, 속도와 fixed delta처럼 simulation에 필요한 값만 있으면 됩니다.

Event를 받은 위치에서 World State까지 바로 변경하면 각 단계의 책임을 구분하기 어려워집니다.

void HandleEvent(const MoveEvent& event)
{
    Entity& entity = world.Find(event.entityId);
    entity.position += event.direction * entity.speed;
}

이 구조에서는 event 해석과 상태 변경이 같은 함수에 있습니다. 여러 작업이 병렬로 실행되면 동일한 상태를 수정할 수 있고, 먼저 실행된 작업의 결과를 뒤의 작업이 보게 됩니다. 계산 중간에 실패하면 어디까지 변경되었는지도 별도로 추적해야 합니다.

그래서 현재 구조에서는 다음 세 경계를 분리했습니다.

flowchart LR
    Event["Network Event"] -->|"검증과 변환"| Input["Tick Input"]
    Input -->|"읽기 전용 계산"| Result["Typed Result"]
    Result -->|"Coordinator commit"| State["Canonical World State"]

2. Event를 tick 전용 input으로 변환한다

플레이어의 이동을 처리하기 위한 Movement Packet을 기반으로 설명을 진행하겠습니다.

Movement packet을 해석한 결과는 먼저 WorldMovementCommandStore에 기록됩니다. 그리고 실제 Tick을 처리할 때 WorldMovementTickInputBuilder가 현재 session과 command를 대조하고, simulation에 사용할 WorldMovementTickInput을 만듭니다.

struct WorldMovementTickInput final
{
    WorldSessionKey sessionKey{};
    std::uint32_t playerId = 0;
    WorldEntityKey entityKey{};
    float movementInputX = 0.0f;
    float movementInputY = 0.0f;
    bool usesControlMovement = false;
    float headingRadians = 0.0f;
    float moveSpeed = 0.0f;
};WorldMovementTickInput.h

WorldTickInput은 builder가 만든 Movement 전용 Input을 값으로 소유하고 phase에는 const view만 제공합니다.

const WorldTickInput tickInput{serverTick, std::move(movementInputs)};
const std::span<const WorldMovementTickInput> immutableMovementInputs =
    tickInput.MovementInputs();WorldTickProcessor.cpp

여기서 immutable은 World 전체를 복사해 snapshot으로 보관한다는 뜻이 아닙니다. 현재 tick에 사용할 입력 대상 집합을 확정하고, 계산 단계에는 이를 변경할 수 없는 view로 전달한다는 의미입니다.

이 변환으로 network protocol과 simulation 모델도 분리됩니다. Packet 필드가 바뀌어도 phase의 입력 계약이 반드시 함께 바뀌지는 않으며, phase는 payload decoding이나 session 검증을 반복하지 않습니다.


3. 계산은 typed result만 만든다

Movement phase는 앞에서 만들어 전달한 immutable input과 WorldReadView를 받아 entity별 변경 결과를 계산합니다. WorldReadViewWorldEntityManager를 읽기 전용으로 조회하는 facade입니다. View가 사용되는 동안 World owner는 원본 state를 변경하지 않습니다.

WorldMovementPhase::Compute(
    immutableMovementInputs,
    fixedDeltaSeconds,
    readView,
    updates);WorldMovementPhase.cpp

Phase에서도 WorldEntityManager에 계산 결과를 직접 쓰지 않습니다.

대신 Entity의 component를 읽어 복사본에서 위치와 속도를 계산한 뒤 WorldMovementEntityUpdate에 기록합니다.

struct WorldMovementEntityUpdate final
{
    WorldEntityKey entityKey{};
    TransformComponent transform;
    MotionComponent motion;
};WorldMovementPhaseResult.h

결과 타입이 변경 범위도 제한합니다. Movement result에는 TransformComponentMotionComponent만 있으므로 movement phase가 다른 component를 임의로 변경할 수 없습니다. Gameplay phase도 자원 획득, 점수 증가, 사망과 round transition을 각각의 결과 타입으로 표현합니다.

여러 Phase가 존재하더라도 각 Phase에서 처리되는 연산은 공유 World State를 직접 수정하지 않고, Phase의 결과를 다른 Phase의 Input으로 건네주는 방식으로 협업하게 됩니다.

Movement phase는 입력 span과 출력 span을 같은 경계로 나눌 수 있어, 이후 actor나 job이 각 subspan을 계산하는 구조로 확장할 수 있습니다.

동일한 immutable input과 read view
  ├─ 입력 구간 A 계산 → 결과 구간 A
  ├─ 입력 구간 B 계산 → 결과 구간 B
  └─ 입력 구간 C 계산 → 결과 구간 C

                   결과를 하나로 확정

4. Coordinator가 계산 결과를 반영한다

Phase의 계산이 성공하면 결과를 WorldMovementPhaseResult로 확정하고 committer에 전달합니다.

const WorldMovementPhaseResult movementResult{std::move(updates)};
WorldMovementPhaseCommitter::Commit(movementResult, entityManager);WorldTickProcessor.cpp

WorldMovementPhaseResult는 계산된 결과를 WorldEntityKey 순으로 정렬해 보관합니다. 현재 movement update는 entity별로 독립적이어서 정렬 여부가 최종 상태를 바꾸지는 않습니다. 다만 입력 생성 순서나 향후 작업 완료 순서와 관계없이 committer가 항상 같은 순서로 결과를 적용하도록 기준을 고정합니다.

이후 Committer는 정렬된 결과를 순서대로 조회해 실제 WorldEntityManager의 component를 교체합니다.

Compute phase
  World State를 읽음
  → 결괏값을 계산함
  → canonical state를 변경하지 않음

Commit phase
  확정된 계산 결과를 읽음
  → 정해진 순서로 authoritative state를 변경함

Gameplay도 같은 방향을 사용합니다. WorldGameplayPhase::Compute()WorldGameplayPhaseResult를 반환하고, 계산에 성공한 경우에만 WorldGameplayCommitter::Commit()이 entity와 gameplay state를 변경합니다.

이 구조를 통해 데이터를 변경하는 영역과, 변경된 데이터를 World State에 반영할 수 있는 영역을 구분하는 경계를 Coordinator가 소유하는 commit으로 제한할 수 있게 됩니다.


정리하며

Double Buffer는 비동기 event stream에서 현재 epoch가 처리할 입력 범위를 확정했습니다. 이번 구조는 그 event를 simulation input으로 바꾸고, 계산과 authoritative mutation을 다시 분리합니다.

Phase는 immutable input과 읽기 전용 World State를 이용해 typed result만 만듭니다. Coordinator는 계산이 끝난 결과를 정해진 순서로 canonical state에 반영합니다. 덕분에 phase의 계산은 공유 상태를 직접 변경하지 않는 형태가 되었고, 향후 입력 구간을 여러 worker에 나누더라도 mutation owner와 commit 순서를 유지할 수 있는 seam을 확보했습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] World Server 1: Fixed-Step과 Bounded Catch-Up
다음 글
[C++] C++ 객체를 C#에 전달하기