ZoneWorld 따라 읽기¶
가이드 홈 | 이전: GameQuest 따라 읽기 | 다음: 15. E2E 테스트 — client로 시스템 전체를 검증하기
다른 언어로 보기 — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
이 장을 읽고 나면
ZoneWorld 샘플을 편집기에 열고, player가 월드에 입장해 zone 경계를 넘어 다른 node로 옮겨지는
경로와, 운영 콘솔의 공지·점검이 모든 node에 도달하는 경로를 코드에서 따라갈 수 있다. 이 장의
코드는 framework/languages/dotnet/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/Program.cs
var mesh = options
.AddRouteMesh(ZoneWorldNames.MeshName)
.SetRoutingIdPrefix("zn")
.Listen(node.MeshEndpoint);
if (!string.IsNullOrWhiteSpace(node.MeshAdvertiseHost))
mesh.SetAdvertiseHost(node.MeshAdvertiseHost);
mesh.Objects()
.Server()
.AddEntrySpot<ZoneEntrySpot>()
.AddActorFactory<PlayerActor, PlayerActorFactory>(
ZoneWorldNames.PlayerActorType,
factory => factory.PreserveStateWith<PlayerActorRelocationAdapter>()
)
.AddSpotFactory<ZoneSpot>(
ZoneWorldNames.ZoneSpotType,
factory =>
factory
// Every eligible process requests all four global ZoneIds. Capacity is the
// only placement input; readiness uses the same declared local capacity.
.StableTypeLimit(node.ZoneCapacity)
.DisableRelocation()
);
player Actor factory는 상태를 보존하는 adapter를 지정하고, zone Spot factory는 relocation을 끈다. mesh와 별도로 classic fanout channel의 subscriber를 등록한다.
Server/ZoneNode/Program.cs
options
.AddFanoutChannel(ZoneWorldNames.BroadcastChannel)
.EnableSubscriber()
.AddHandler<WorldAnnounceSubscriber, WorldAnnounceEvent>()
.AddHandler<NodeMaintenanceChangedSubscriber, NodeMaintenanceChangedEvent>();
Ops 쪽은 같은 fanout channel의 publisher다.
Server/Ops/Program.cs
capacity로 정하는 placement는 활성화와 수명이, fanout channel은 Channel 메시징이 다룬다.
4. 입장 — bind와 첫 zone join¶
브라우저가 Gateway에 접속해 join을 보내면 session은 PlayerId로 Actor를 만들거나 찾아 현재
session에 묶고, join packet을 그 Actor로 relay한다.
Server/Gateway/Infrastructure/ZLink/Sessions/PlayerSession.cs
var actorRef = await actors
.GetOrCreate(playerId, ZoneWorldNames.PlayerActorType)
.InMesh(ZoneWorldNames.MeshName)
.Request(ZLinkMessage.Empty)
.Async(cancellationToken) switch
{
ZLinkActorCreateResult.Existing value => value.Actor,
ZLinkActorCreateResult.Created value => value.Actor,
_ => throw new InvalidOperationException("Player Actor creation was rejected."),
};
await context.Actors.BindOrGetAsync(actorRef, cancellationToken);
Actor는 Entry Spot에서 시작한다. Entry Spot의 handler는 좌표로 zone을 정하고 그 zone Spot에 join을 예약한다.
Server/ZoneNode/Infrastructure/ZLink/Spots/ZoneEntrySpot.cs
var zoneId = ZoneWorldSpec.ZoneOf(message.X, message.Y);
actor.TrackDeferredJoin(
new PlayerPosition(message.X, message.Y),
message.IsBot ? PlayerJoinPurpose.InitialBotEntry : PlayerJoinPurpose.InitialHumanEntry
);
actor
.Context.JoinSpot(
zoneId,
new EnterZoneReq(
actor.ActorId,
message.X,
message.Y,
message.IsBot,
InitialEntry: true,
FromZoneId: null
)
)
.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/Infrastructure/ZLink/Spots/Handlers/PlayerMoveHandlers.cs
switch (moves.Decide(actor.Position, toX, toY))
{
case MoveDecision.Rejected rejected:
await RejectAsync(actor, rejected.Reason, cancellationToken);
return;
case MoveDecision.Accepted { ZoneChanged: false } stayed:
actor.MoveTo(stayed.To);
await spots
.SendToSpot(
spot.ZoneId,
new UpdatePositionMsg(actor.ActorId, stayed.To.X, stayed.To.Y, actor.IsBot)
)
.Async();
return;
case MoveDecision.Accepted accepted:
await ChangeZoneAsync(
actor,
accepted.To,
PlayerJoinPurpose.ZoneChange,
crashBoundaryProbe: false,
cancellationToken
);
return;
}
같은 zone 안이면 Actor가 좌표를 갱신하고 zone Spot에 사본 갱신을 보낸다. zone이 바뀌면 target zone Spot에 join을 예약한다.
Server/ZoneNode/Infrastructure/ZLink/Spots/Handlers/PlayerMoveHandlers.cs
actor.TrackDeferredJoin(to, purpose);
actor
.Context.JoinSpot(
to.ZoneId,
new EnterZoneReq(
actor.ActorId,
to.X,
to.Y,
actor.IsBot,
InitialEntry: false,
FromZoneId: actor.ZoneId,
CrashBoundaryProbe: crashBoundaryProbe
)
)
.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/Infrastructure/ZLink/Actors/PlayerActorFactory.cs
public ValueTask<byte[]> CaptureAsync(PlayerActor actor, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult(
JsonSerializer.SerializeToUtf8Bytes(
new PlayerRelocationState(
actor.Position.X,
actor.Position.Y,
actor.ZoneId,
actor.IsBot,
actor.DirX,
actor.DirY,
actor.PendingJoins,
actor.ProcessedJoinOperations
)
)
);
}
target zone Spot의 admission이 최종 판정자다. 그 node가 점검 중이면 join을 거부하고, 아니면 pending 으로 기록한 뒤 수락한다.
Server/ZoneNode/Infrastructure/ZLink/Spots/ZoneSpot.cs
if (maintenance.RejectsArrival(ZoneId, enter.FromZoneId))
{
logger.LogInformation(
"zone spot: join rejected, node under maintenance. zone={ZoneId}, player={PlayerId}, from_zone={FromZoneId}",
ZoneId,
enter.PlayerId,
enter.FromZoneId ?? "<new>"
);
return ZLinkSpotActorJoinResult.Reject(
new EnterZoneRes(ZoneId, MoveRejectReasons.ZoneMaintenance)
);
}
_pendingJoins[actorId] = enter;
return ZLinkSpotActorJoinResult.Accept(new EnterZoneRes(ZoneId));
join의 결과는 Actor의 completion callback에 도착한다. callback은 operation id로 중복을 걸러내고, 수락이면 입장 응답을, 거부면 실패 notify를 bound session으로 보낸다. 거부된 이동의 좌표는 그대로다.
Server/ZoneNode/Infrastructure/ZLink/Actors/PlayerActor.cs
public async ValueTask OnJoinCompletedAsync(
ZLinkActorJoinCompletion completion,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
var operationId = GetOperationId(completion);
if (_processedJoinOperations.Contains(operationId))
return;
var pending =
_pendingJoins.Count > 0
? _pendingJoins.Peek()
: throw new InvalidOperationException(
$"Deferred Actor Join completion has no application intent. actor={ActorId}"
);
switch (completion)
{
case ZLinkActorJoinCompletion.Accepted:
if (pending.Purpose is PlayerJoinPurpose.InitialHumanEntry)
await SendJoinWorldResultAsync(pending.Target, error: null, cancellationToken);
else if (pending.Purpose is PlayerJoinPurpose.CrashBoundaryProbe)
await SendCrashProbeResultAsync(error: null, cancellationToken);
break;
case ZLinkActorJoinCompletion.Rejected { Reply: { } reply }:
await NotifyJoinFailureAsync(
pending,
reply.Decode<EnterZoneRes>().Error ?? MoveRejectReasons.ZoneMaintenance,
cancellationToken
);
break;
case ZLinkActorJoinCompletion.Rejected:
await NotifyJoinFailureAsync(
pending,
MoveRejectReasons.ZoneMaintenance,
cancellationToken
);
break;
case ZLinkActorJoinCompletion.Failed failed:
await NotifyJoinFailureAsync(pending, failed.Kind.ToString(), cancellationToken);
break;
}
RememberJoinOperation(operationId);
if (_pendingJoins.Count > 0)
_pendingJoins.Dequeue();
}
옮겨도 남는 것과 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/Spots/Handlers/PlayerMoveHandlers.cs
await actor
.Context.BoundSession.Send(
new ZoneStateNotify(message.ZoneId, message.Tick, message.Players)
)
.Async();
경계 근처 상태는 인접 zone별 topic으로 publish한다. 보내는 zone과 받는 zone을 topic 이름에 모두 넣으므로 무관한 zone에는 전달되지 않는다.
Server/ZoneNode/Infrastructure/ZLink/Spots/ZoneSpot.cs
foreach (var borderEvent in output.BorderEvents)
{
await Context
.Outbound.Publish(
ZoneWorldNames.ZoneChannel,
ZoneWorldNames.BorderTopic(ZoneId, borderEvent.ToZoneId),
borderEvent
)
.Async(cancellationToken);
}
각 zone Spot은 자기 인접 zone에서 오는 topic만 구독한다.
Server/ZoneNode/Infrastructure/ZLink/Spots/ZoneSpot.cs
foreach (var fromZoneId in World.AdjacentZones(ZoneId))
Context.Handlers.AddSubscribe<ZoneBorderSubscriptionHandler>(
ZoneWorldNames.ZoneChannel,
ZoneWorldNames.BorderTopic(fromZoneId, ZoneId)
);
timer는 Timer와 worker가, topic으로 대상을 고르는 Logical Multicast는 Channel 동작 원리가 다룬다.
8. 운영 — fanout과 node 관찰¶
Ops는 공지와 점검 desired state를 fanout으로 publish한다. publisher는 node 목록을 갖지 않는다 — node를 추가해도 이쪽 코드는 바뀌지 않는다.
Server/Ops/Infrastructure/ZLink/WorldOperationsAdapter.cs
public async ValueTask PublishMaintenanceChangeAsync(
string nodeId,
bool enabled,
CancellationToken cancellationToken
) =>
await fanout
.Publish(
ZoneWorldNames.BroadcastChannel,
ZoneWorldNames.MaintenanceTopic,
new NodeMaintenanceChangedEvent(nodeId, enabled)
)
.Async(cancellationToken);
각 ZoneNode의 subscriber는 자기 NodeId 몫만 로컬 정책에 적용한다. 이 cache가 오래되어도 최종
판정은 앞 절의 target admission이 한다.
Server/ZoneNode/Infrastructure/ZLink/Handlers/FanoutSubscribers.cs
internal sealed class NodeMaintenanceChangedSubscriber(
NodeMaintenancePolicy maintenance,
ILogger<NodeMaintenanceChangedSubscriber> logger
) : IZLinkFanoutHandler<NodeMaintenanceChangedEvent>
{
public ValueTask HandleAsync(
NodeMaintenanceChangedEvent message,
ZLinkPublishMessageContext context,
CancellationToken cancellationToken
)
{
maintenance.Apply(message.NodeId, message.Enabled);
logger.LogInformation(
"maintenance cache updated. observer={ObserverNodeId}, node={NodeId}, enabled={Enabled}",
maintenance.OwnNodeId,
message.NodeId,
message.Enabled
);
return ValueTask.CompletedTask;
}
}
node가 등록되고 연결됐는지는 요청이 아니라 runtime status의 관찰로 안다. Ops는 mesh의 status를 observe해 Ready peer 집합의 변화를 node 연결 상태로 옮긴다.
Server/Ops/Infrastructure/ZLink/Monitoring/OpsEventHandlers.cs
var previous = initialReady;
await foreach (
var status in runtime
.ObserveAsync(ZoneWorldNames.MeshName, stoppingToken)
.ConfigureAwait(false)
)
{
var current = status
.Status.Peers.Where(static peer => peer.State == ZLinkPeerState.Ready)
.Select(static peer => peer.NodeRid.ToString())
.ToHashSet(StringComparer.Ordinal);
await nodes.ApplyLiveRoutingIdsAsync(current, stoppingToken);
foreach (var rid in current.Except(previous))
await ApplyAsync(rid, true, stoppingToken);
foreach (var rid in previous.Except(current))
await ApplyAsync(rid, false, stoppingToken);
previous = current;
}
fanout의 형태는 Channel 동작 원리가, runtime status와 drain은 운영과 lifecycle이 다룬다.
9. 실행과 검증¶
runner 하나가 Redis 컨테이너, 서버 process와 브라우저 시나리오를 함께 띄운다.
브라우저 시나리오는 경계 이동, relocation 뒤에도 유지되는 연결, 공지 도달과 점검 중 admission 거부를 확인한다. 확인 항목과 시나리오 id는 ZoneWorld 시나리오가 정한다.
10. 관련 문서¶
- 다른 샘플과의 비교와 선택 기준: 샘플 고르기
- 요구사항, 메시지 계약과 완료 기준: ZoneWorld 시나리오
- Actor가 다른 node의 room에 join하는 같은 경로를 가장 작은 구성으로 본 것: TicTacToe 따라 읽기