콘텐츠로 이동

ZoneWorld 따라 읽기

가이드 홈 | 이전: GameQuest 따라 읽기 | 다음: 15. E2E 테스트 — client로 시스템 전체를 검증하기

이 장을 읽고 나면

ZoneWorld 샘플을 편집기에 열고, player가 월드에 입장해 zone 경계를 넘어 다른 node로 옮겨지는 경로와, 운영 콘솔의 공지·점검이 모든 node에 도달하는 경로를 코드에서 따라갈 수 있다. 이 장의 코드는 framework/languages/cpp/samples/ZoneWorld에서 그대로 실행된다.

샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 framework 기능과 그것을 설명하는 장을 소스가 놓인 순서대로 따라간다. 이 장에는 계약을 소유하는 스펙 문서가 없다. 요구사항, 메시지 계약과 검증 기준은 ZoneWorld 시나리오가 소유하며, 이 장은 그것을 다시 적지 않는다.

1. 이 샘플이 보여 주는 것

월드는 zone으로 나뉘고, 각 zone은 id를 가진 User Spot이다. 어느 ZoneNode가 어느 zone을 맡는지는 설정이 아니라 Location Store의 placement가 정한다. player는 id를 가진 Actor이며, zone 경계를 넘는 것은 인접 zone Spot에 join하는 것이다 — owner가 다르면 framework가 Actor를 옮기고, client의 연결은 그대로 유지된다. 운영 콘솔은 node 목록 없이 공지와 점검을 보낸다.

↗ 크게 보기

이 장이 따라가는 흐름은 입장과 bind → 이동과 경계 join → relocation과 join 완료 → zone tick의 push와 경계 snapshot → 운영 fanout과 node 관찰 순서다. "여러 node에 무언가를 한다"가 상황마다 다른 표면을 쓴다는 것이 이 샘플의 주제이고, 표면별 코드가 그 순서로 나온다.

2. 역할과 코드 위치

역할 process 수 소유하는 것 코드 위치
Gateway 1 game STREAM, player Actor bind, relay와 push Server/Gateway
ZoneNode 2 Entry Spot, zone Spot, player Actor, bot timer, local report Server/ZoneNode
Ops 1 ops STREAM, runtime event 수집, fanout publish, maintenance store Server/Ops
Client 브라우저 game 화면과 ops 콘솔 Client

이동 규칙과 zone 좌표는 Server/ZoneNode/Domain에 있고 framework type을 참조하지 않는다. 메시지는 Shared의 JSON 계약이 정한다.

3. 서버 구성 — mesh, capacity와 fanout

ZoneNode는 Entry Spot, player Actor factory와 zone Spot factory를 등록한다. zone Spot factory는 node당 수용량을 선언한다 — 모든 ZoneNode가 모든 zone을 요청하지만, 수용량 때문에 각 node는 일부만 소유하게 된다.

Server/ZoneNode/main.cpp

auto mesh = options.add_route_mesh (names_t::mesh);
mesh.set_automatic_routing_id_prefix ("zn");
if (configuration.mesh_advertise_host)
    mesh.set_advertise_host (*configuration.mesh_advertise_host);
mesh.listen (configuration.mesh_endpoint);
mesh.channel (names_t::zone_channel).server ();
mesh.channel (names_t::report_channel).client ();
mesh.channel (names_t::ops_channel (configuration.node_id))
  .server ()
  .add_request_handler<apply_maintenance_handler_t,
                       apply_node_maintenance_req_t,
                       apply_node_maintenance_res_t> ()
  .add_request_handler<diagnostics_handler_t,
                       get_node_diagnostics_req_t,
                       get_node_diagnostics_res_t> ();
mesh.objects ()
  .server ()
  .add_entry_spot<zone_entry_spot_t> ()
  .add_spot_factory<zone_spot_t, fw::actor_client_t> (names_t::zone_spot)
  .set_stable_type_limit (configuration.zone_capacity)
  .disable_relocation ()
  .add_actor_factory<player_actor_t, player_actor_factory_t> (names_t::player_actor)
  .preserve_state_with<player_relocation_adapter_t> ();

player Actor factory는 상태를 보존하는 adapter를 지정하고, zone Spot factory는 relocation을 끈다. mesh와 별도로 classic fanout channel의 subscriber를 등록한다.

Server/ZoneNode/main.cpp

options.handlers ()
  .group ("zoneworld-broadcast")
  .add_publish<maintenance_fanout_handler_t> ()
  .add_publish<announce_fanout_handler_t> ();
options.add_fanout_channel (names_t::broadcast_channel)
  .enable_subscriber ()
  .use_handler_group ("zoneworld-broadcast");

Ops 쪽은 같은 fanout channel의 publisher다.

Server/Ops/main.cpp

options.add_fanout_channel (names_t::broadcast_channel)
  .set_routing_id (zlink::routing_id_t::from ("zoneworld-ops-publisher"))
  .enable_publisher (configuration.broadcast_endpoint);

capacity로 정하는 placement는 활성화와 수명이, fanout channel은 Channel 메시징이 다룬다.

4. 입장 — bind와 첫 zone join

브라우저가 Gateway에 접속해 join을 보내면 session은 PlayerId로 Actor를 만들거나 찾아 현재 session에 묶고, join packet을 그 Actor로 relay한다.

↗ 크게 보기

Server/Gateway/main.cpp

auto &actors = stream.actors ();
auto located = actors.get_or_create (names_t::player_actor, request.player_id);
if (!located)
    throw fw::framework_exception_t (located.error_kind (),
                                     "player actor could not be located");
auto bound = co_await actors.bind_or_get (located.value ().ref ()).async ();
_player_id = std::string (bound.actor_id ());

Actor는 Entry Spot에서 시작한다. Entry Spot의 handler는 좌표로 zone을 정하고 그 zone Spot에 join을 예약한다.

Server/ZoneNode/main.cpp

inline void prepare_join (player_actor_t &actor, int x, int y, bool initial, bool crash = false)
{
    actor.pending_join = true;
    actor.pending_initial_entry = initial;
    actor.pending_crash_probe = crash;
    actor.pending_x = x;
    actor.pending_y = y;
    actor.pending_zone_id = zone_of (x, y);
    actor.context ()
      .join_spot (actor.pending_zone_id,
                  enter_zone_req_t{actor.player_id,
                                   x,
                                   y,
                                   actor.is_bot,
                                   initial,
                                   actor.initial_entry ? std::nullopt
                                                       : std::optional<std::string> (actor.zone_id),
                                   crash})
      // The crash probe must outlive the owner lease TTL (15 s) plus the
      // Location poll so the join ends at the Unavailable boundary, not at
      // its own deadline (same 30 s the java sample uses for the probe).
      .timeout (std::chrono::seconds (crash ? 30 : 15))
      .defer ();
}

join은 handler가 끝난 뒤 실행되고, 결과는 Actor의 completion callback으로 도착한다. 입장 응답은 그 callback에서 보낸다 — 응답이 왔다는 것은 target zone의 admission까지 끝났다는 뜻이다. 묶기는 Session과 Actor 연결이, 예약의 규칙은 Actor membership이 다룬다.

5. 이동 — 같은 zone 안과 경계 너머

이동 요청은 묶인 Actor로 relay되어 zone Spot의 actor send handler에 도착한다. 판정은 domain이 하고, 결과에 따라 거부, 같은 zone 안의 이동, zone 변경으로 나뉜다.

Server/ZoneNode/main.cpp

if (const auto error = validate_move (actor.x, actor.y, message.x, message.y, false)) {
    if (actor.is_bot) {
        actor.dir_x = -actor.dir_x;
        actor.dir_y = -actor.dir_y;
        std::cout << "zoneworld-bot-reversed player=" << actor.player_id << " reason=" << *error
                  << " position=" << actor.x << ',' << actor.y << " dir=" << actor.dir_x << ','
                  << actor.dir_y << std::endl;
    } else {
        actor.context ()
          .bound_session ()
          .send (move_rejected_notify_t{*error, actor.x, actor.y})
          .async ();
    }
    return false;
}
const auto target = zone_of (message.x, message.y);
if (target != actor.zone_id) {
    prepare_join (actor, message.x, message.y, false);
    return false;
}
actor.x = message.x;
actor.y = message.y;
return true;

같은 zone 안이면 Actor가 좌표를 갱신하고 zone Spot에 사본 갱신을 보낸다. zone이 바뀌면 target zone Spot에 join을 예약한다.

Server/ZoneNode/main.cpp

actor.context ()
  .join_spot (actor.pending_zone_id,
              enter_zone_req_t{actor.player_id,
                               x,
                               y,
                               actor.is_bot,
                               initial,
                               actor.initial_entry ? std::nullopt
                                                   : std::optional<std::string> (actor.zone_id),
                               crash})
  // The crash probe must outlive the owner lease TTL (15 s) plus the
  // Location poll so the join ends at the Unavailable boundary, not at
  // its own deadline (same 30 s the java sample uses for the probe).
  .timeout (std::chrono::seconds (crash ? 30 : 15))
  .defer ();

application은 target의 owner가 같은 node인지 다른 node인지 구분하지 않는다. 같은 node면 membership만 바뀌고, 다른 node면 framework가 join 안에서 Actor를 옮긴다. 이동 중 이전 owner에 도착한 메시지는 target으로 전달된다.

6. relocation과 join 완료

owner가 바뀔 때 Actor의 application 상태는 factory에 지정한 adapter가 직렬화한다. 좌표, zone, bot 방향과 pending join만 담고, queue와 timer는 framework가 옮긴다.

↗ 크게 보기

Server/ZoneNode/player_actor_relocation_adapter.hpp

fw::task_t<std::vector<std::byte>> capture (player_actor_t &actor, std::stop_token) override
{
    const auto message = zlink::message_t::from_json (
      player_state_t{actor.x,
                     actor.y,
                     actor.zone_id,
                     actor.is_bot,
                     actor.dir_x,
                     actor.dir_y,
                     actor.initial_entry,
                     actor.pending_join,
                     actor.pending_initial_entry,
                     actor.pending_crash_probe,
                     actor.pending_x,
                     actor.pending_y,
                     actor.pending_zone_id,
                     actor.completed_join_operation_order});
    co_return std::vector<std::byte> (message.bytes ().begin (), message.bytes ().end ());
}

target zone Spot의 admission이 최종 판정자다. 그 node가 점검 중이면 join을 거부하고, 아니면 pending 으로 기록한 뒤 수락한다.

Server/ZoneNode/main.cpp

if (g_node_state->maintenance.load ()
    && (!enter.from_zone_id || *enter.from_zone_id != _context.spot_id ()))
    co_return fw::spot_actor_join_result_t::reject (
      enter_zone_res_t{_context.spot_id (), reject_reason_t::zone_maintenance});
{
    std::lock_guard lock (_mutex);
    _pending[std::string (actor_id)] = enter;
}
co_return fw::spot_actor_join_result_t::accept (
  enter_zone_res_t{_context.spot_id (), std::nullopt});

join의 결과는 Actor의 completion callback에 도착한다. callback은 operation id로 중복을 걸러내고, 수락이면 입장 응답을, 거부면 실패 notify를 bound session으로 보낸다. 거부된 이동의 좌표는 그대로다.

Server/ZoneNode/player_actor_relocation_adapter.hpp

const auto operation = std::visit (
  [] (const auto &value) {
      return std::pair{value.operation_id_high, value.operation_id_low};
  },
  completion);
if (completed_join_operations.contains (operation))
    co_return;
if (std::holds_alternative<fw::actor_join_accepted_t> (completion)) {
    const std::string line = std::format (
      "zoneworld-join-accepted player={} zone={} pending-initial={} bot={}\n",
      player_id,
      zone_id,
      pending_initial_entry ? "true" : "false",
      is_bot ? "true" : "false");
    std::cerr << line;
    if (pending_initial_entry && !is_bot) {
        co_await _context.bound_session ()
          .send (join_world_res_t{player_id, zone_id, x, y, std::nullopt})
          .async ();
        std::cout << "zoneworld-join-response player=" << player_id << " zone=" << zone_id
                  << std::endl;
    }
    pending_join = false;
    initial_entry = false;
    pending_crash_probe = false;
} else if (const auto *failed = std::get_if<fw::actor_join_failed_t> (&completion)) {
    const std::string line = std::format ("zoneworld-join-failed player={} kind={}\n",
                                          player_id,
                                          static_cast<int> (failed->error_kind));
    std::cerr << line;
    const auto error = framework_error_name (failed->error_kind);
    if (pending_crash_probe && !is_bot) {
        co_await _context.bound_session ()
          .send (crash_relocation_probe_res_t{error})
          .async ();
    } else if (pending_initial_entry && !is_bot) {
        co_await _context.bound_session ()
          .send (join_world_res_t{player_id, zone_id, x, y, error})
          .async ();
    }
    pending_join = false;
    pending_crash_probe = false;
} else if (const auto *rejected = std::get_if<fw::actor_join_rejected_t> (&completion)) {
    const std::string line = std::format ("zoneworld-join-rejected player={}\n", player_id);
    std::cerr << line;
    auto reason = std::string (reject_reason_t::zone_maintenance);
    if (rejected->reply) {
        reason = rejected->reply->decode<enter_zone_res_t> ().error.value_or (reason);
    }
    if (is_bot) {
        dir_x = -dir_x;
        dir_y = -dir_y;
    } else if (pending_initial_entry) {
        co_await _context.bound_session ()
          .send (join_world_res_t{player_id, zone_id, x, y, reason})
          .async ();
    } else {
        co_await _context.bound_session ()
          .send (move_rejected_notify_t{reason, x, y})
          .async ();
    }
    pending_join = false;
    pending_crash_probe = false;
}
remember_join_operation (operation);

옮겨도 남는 것과 adapter의 역할은 Relocation이, 이동 중 연결이 유지되는 규칙은 Session 묶음의 동작 원리가 다룬다.

7. zone tick — push와 경계 snapshot

zone Spot은 timer로 tick을 돌며 자기 zone과 인접 snapshot으로 state notify를 만든다. notify는 각 player Actor로 보내지고, Actor의 handler가 bound session으로 push한다. bot은 bound session이 없는 같은 type의 Actor다.

▶ 둘러보기에서 직접 눌러 보기 — RouteMesh pub/sub

Server/ZoneNode/main.cpp

if (!actor.is_bot)
    actor.context ()
      .bound_session ()
      .send (zone_state_notify_t{message.zone_id, message.tick, message.players})
      .async ();

경계 근처 상태는 인접 zone별 topic으로 publish한다. 보내는 zone과 받는 zone을 topic 이름에 모두 넣으므로 무관한 zone에는 전달되지 않는다.

Server/ZoneNode/main.cpp

for (const auto &to : adjacent_zones (_context.spot_id ())) {
    std::vector<player_view_t> border_players;
    std::copy_if (local.begin (),
                  local.end (),
                  std::back_inserter (border_players),
                  [] (const auto &player) {
                      return std::abs (player.x - spec_t::zone_split) <= spec_t::border_band
                             || std::abs (player.y - spec_t::zone_split)
                                  <= spec_t::border_band;
                  });
    _context
      .publish (
        border_topic (_context.spot_id (), to),
        zone_border_event_t{_context.spot_id (), to, _tick, std::move (border_players)})
      .async ();
}

각 zone Spot은 자기 인접 zone에서 오는 topic만 구독한다.

Server/ZoneNode/main.cpp

for (const auto &from : adjacent_zones (_context.spot_id ()))
    _context.handlers ().add_subscribe<&zone_spot_t::border> (
      border_topic (from, _context.spot_id ()));

timer는 Timer와 worker가, topic으로 대상을 고르는 Logical Multicast는 Channel 동작 원리가 다룬다.

8. 운영 — fanout과 node 관찰

Ops는 공지와 점검 desired state를 fanout으로 publish한다. publisher는 node 목록을 갖지 않는다 — node를 추가해도 이쪽 코드는 바뀌지 않는다.

↗ 크게 보기

Server/Ops/main.cpp

co_await _publisher
  .publish (names_t::broadcast_channel,
            names_t::maintenance_topic,
            node_maintenance_changed_event_t{request.node_id, request.enabled})
  .async ();

각 ZoneNode의 subscriber는 자기 NodeId 몫만 로컬 정책에 적용한다. 이 cache가 오래되어도 최종 판정은 앞 절의 target admission이 한다.

Server/ZoneNode/main.cpp

struct maintenance_fanout_handler_t
{
    using event_type = node_maintenance_changed_event_t;
    static constexpr const char *topic_name = names_t::maintenance_topic;
    void handle (const node_maintenance_changed_event_t &event,
                 const fw::publish_message_context_t &)
    {
        if (event.node_id == g_node_state->node_id)
            g_node_state->maintenance.store (event.enabled);
    }
};

node가 등록되고 연결됐는지는 요청이 아니라 runtime status의 관찰로 안다. Ops는 mesh의 status를 observe해 Ready peer 집합의 변화를 node 연결 상태로 옮긴다.

Server/Ops/main.cpp

_observation = runtime.observe (
  names_t::mesh,
  64,
  [this] (const fw::observed_status_t<fw::mesh_node_snapshot_t> &observed) {
      apply_snapshot (observed.status);
  });

fanout의 형태는 Channel 동작 원리가, runtime status와 drain은 운영과 lifecycle이 다룬다.

9. 실행과 검증

runner 하나가 Redis 컨테이너, 서버 process와 브라우저 시나리오를 함께 띄운다.

framework/languages/cpp/samples/ZoneWorld/run_sample.sh

브라우저 시나리오는 경계 이동, relocation 뒤에도 유지되는 연결, 공지 도달과 점검 중 admission 거부를 확인한다. 확인 항목과 시나리오 id는 ZoneWorld 시나리오가 정한다.

10. 관련 문서