콘텐츠로 이동

ZoneWorld 따라 읽기

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

이 장을 읽고 나면

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

샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 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/zone-node-module.ts

const zoneMesh = builder.addRouteMesh(ZoneWorldNames.zoneMesh).setRoutingIdPrefix('zn');
if (node.spotRouterAdvertiseHost !== undefined) {
  zoneMesh.setAdvertiseHost(node.spotRouterAdvertiseHost);
}
zoneMesh.listen(node.spotRouterEndpoint);
const objectServer = zoneMesh.objects().server();
objectServer.addEntrySpot(ZoneEntrySpot);
objectServer.addSpotFactory(ZoneSpot.name, ZoneSpot, (factory) =>
  factory.stableTypeLimit(node.zoneCapacity).disableRelocation()
);
objectServer.addActorFactory(
  ZoneWorldNames.playerActorType,
  PlayerActorFactory,
  (factory) => factory.preserveStateWith(PlayerActorRelocationAdapter)
);

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

Server/ZoneNode/zone-node-module.ts

builder
  .addFanoutChannel(ZoneWorldNames.broadcastChannel)
  .enableSubscriber()
  .addHandlerGroup('zone-broadcast');

Ops 쪽은 같은 fanout channel의 publisher다.

Server/Ops/ops-module.ts

builder
  .addFanoutChannel(ZoneWorldNames.broadcastChannel)
  .enablePublisher(ops.broadcastEndpoint)
  .setRoutingIdPrefix('zoneworld-ops');

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

4. 입장 — bind와 첫 zone join

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

↗ 크게 보기

Server/Gateway/player-session.ts

const ensured = await this.actors
  .getOrCreate(request.playerId, ZoneWorldNames.playerActorType)
  .inMesh(ZoneWorldNames.zoneMesh)
  .request(new PlayerActorCreateReq(request.playerId))
  .timeout(5_000)
  .submit();
if (ensured.status === 'rejected')
  throw new Error(`Player actor '${request.playerId}' creation was rejected.`);
const actor = await context.actors.bindOrGet(ensured.actor);

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

Server/ZoneNode/Infrastructure/ZLink/Handlers/player-handlers.ts

const targetZone = zoneOf(request.x, request.y);
actor.beginPendingJoin(request.isBot ? 'bot' : 'world');
try {
  actor.context
    .joinSpot(
      targetZone,
      new EnterZoneReq(actor.actorId, request.x, request.y, request.isBot, true)
    )
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}

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/Infrastructure/ZLink/Handlers/player-handlers.ts

const decision = validateMove(actor, x, y, () => false);
if (decision.kind === 'rejected') {
  await this.reject(actor, decision.reason);
  return;
}
const targetZone = zoneOf(x, y);
if (!decision.zoneChanged) {
  actor.x = x;
  actor.y = y;
  const spotRid = actor.context.spotId;
  if (spotRid === undefined)
    throw new Error(`Player '${actor.actorId}' is not joined to a zone.`);
  await this.spotOutbound
    .sendToSpot(spotRid, new UpdateZonePositionMsg(actor.actorId, x, y))
    .submit();
  return;
}
actor.beginPendingJoin(actor.isBot ? 'bot' : 'move');
try {
  actor.context
    .joinSpot(targetZone, new EnterZoneReq(actor.actorId, x, y, actor.isBot, false))
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}
console.log(
  `zone change scheduled player=${actor.actorId} from=${previousZone} to=${targetZone}`
);

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

Server/ZoneNode/Infrastructure/ZLink/Handlers/player-handlers.ts

actor.beginPendingJoin(actor.isBot ? 'bot' : 'move');
try {
  actor.context
    .joinSpot(targetZone, new EnterZoneReq(actor.actorId, x, y, actor.isBot, false))
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}
console.log(
  `zone change scheduled player=${actor.actorId} from=${previousZone} to=${targetZone}`
);

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/Infrastructure/ZLink/Actors/player-actor-relocation-adapter.ts

async capture(actor: PlayerActor): Promise<Uint8Array> {
  return new TextEncoder().encode(
    JSON.stringify({
      x: actor.x,
      y: actor.y,
      zoneId: actor.zoneId,
      isBot: actor.isBot,
      dirX: actor.dirX,
      dirY: actor.dirY,
      pendingJoin: actor.pendingJoin(),
      processedJoinOperations: actor.captureProcessedJoinOperations()
    } satisfies PlayerTransferState)
  );
}

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

Server/ZoneNode/Infrastructure/ZLink/Spots/zone-spot.ts

if (this.nodeState.rejectsArrival()) {
  console.log(
    `zone admission rejected zone=${String(this.context.spotId)} player=${actorId} reason=maintenance`
  );
  return {
    accepted: false,
    reply: new EnterZoneRes(String(this.context.spotId), 'ZoneMaintenance')
  };
}
console.log(
  `zone admission accepted zone=${String(this.context.spotId)} player=${actorId} initial=${enter.initialEntry}`
);
this.pendingJoins.set(actorId, enter);
return { accepted: true };

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

Server/ZoneNode/Infrastructure/ZLink/Actors/player-actor.ts

async onJoinCompleted(completion: ZLinkActorJoinCompletion): Promise<void> {
  const operationId = `${completion.operationId.high}:${completion.operationId.low}`;
  if (this.processedJoinOperations.has(operationId)) return;
  const kind = 'kind' in completion ? completion.kind : 'none';
  console.log(
    `actor join completed actor=${this.actorId} status=${completion.status}` + ` kind=${kind}`
  );
  const pending = this.pendingJoinKind;
  this.completePendingJoin();
  if (completion.status === 'accepted') {
    if (pending === 'world') await this.sendJoinResult(null);
    this.rememberJoinOperation(operationId);
    return;
  }
  if (pending === 'bot') {
    this.dirX *= -1;
    this.dirY *= -1;
    this.rememberJoinOperation(operationId);
    return;
  }
  let reason = 'ZoneMaintenance';
  if (completion.status === 'rejected' && completion.reply !== undefined) {
    reason = completion.reply.decode<EnterZoneRes>().error ?? reason;
  } else if (completion.status === 'failed') {
    reason = ZLinkFrameworkErrorKind[completion.kind];
  }
  if (pending === 'world') {
    await this.sendJoinResult(reason);
    this.rememberJoinOperation(operationId);
    return;
  }
  this.push(new MoveRejectedNotify(reason as never, this.x, this.y));
  this.rememberJoinOperation(operationId);
}

옮겨도 남는 것과 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/Infrastructure/ZLink/Actors/player-actor.ts

case 'ZoneStateNotify':
  actor.push(
    new ZoneStateNotify(
      value.zoneId as string,
      value.tick as number,
      value.players as ConstructorParameters<typeof ZoneStateNotify>[2]
    )
  );
  return;

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

Server/ZoneNode/Infrastructure/ZLink/Spots/zone-spot.ts

for (const adjacent of adjacentZones(state.zoneId)) {
  await this.publisher
    .publish(
      ZoneWorldNames.zoneMesh,
      ZoneWorldNames.bridgeMesh,
      ZoneWorldNames.borderTopic(state.zoneId, adjacent),
      new ZoneBorderEvent(state.zoneId, adjacent, tick, state.borderBandFor(adjacent))
    )
    .submit();
}

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

Server/ZoneNode/Infrastructure/ZLink/Handlers/zone-runtime-handlers.ts

function zoneBorderSubscriptionHandler(): ClassDecorator {
  const topics = Object.values(ZoneIds).flatMap((from) =>
    adjacentZones(from).map((to) => ZoneWorldNames.borderTopic(from, to))
  );
  return (target) => {
    for (const topic of topics) {
      zlinkSpotSubscriptionHandler({
        spot: () => ZoneSpot,
        channelName: ZoneWorldNames.bridgeMesh,
        topic
      })(target);
    }
  };
}

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

8. 운영 — fanout과 node 관찰

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

↗ 크게 보기

Server/Ops/ops-handlers.ts

await this.fanout
  .publish(
    ZoneWorldNames.broadcastChannel,
    new NodeMaintenanceChangedEvent(request.nodeId, request.enabled)
  )
  .submit();

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

Server/ZoneNode/Infrastructure/ZLink/Handlers/node-channel-handlers.ts

@Injectable()
@zlinkPublishHandler('zone-broadcast', PacketNames.nodeMaintenanceChangedEvent)
class MaintenanceChangedSubscriber implements ZLinkFanoutHandler<NodeMaintenanceChangedEvent> {
  constructor(private readonly state: NodeRuntimeState) {}

  async handle(
    message: NodeMaintenanceChangedEvent,
    context: ZLinkPublishMessageContext
  ): Promise<void> {
    this.state.setMaintenance(message.nodeId, message.enabled);
    console.log(
      `maintenance cache updated node=${message.nodeId} enabled=${message.enabled} topic=${context.topic}`
    );
  }
}

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

Server/Ops/ops-runtime-events.ts

for await (const observed of this.routeMeshRuntime.observe(
  ZoneWorldNames.zoneMesh,
  64,
  this.stop.signal
)) {
  this.publish(this.nodes.applyLiveRoutingIds(this.liveRoutingIds(observed.status)));
}

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

9. 실행과 검증

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

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

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

10. 관련 문서