콘텐츠로 이동

ZoneWorld 따라 읽기

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

이 장을 읽고 나면

ZoneWorld 샘플을 편집기에 열고, player가 월드에 입장해 zone 경계를 넘어 다른 node로 옮겨지는 경로와, 운영 콘솔의 공지·점검이 모든 node에 도달하는 경로를 코드에서 따라갈 수 있다. 이 장의 코드는 framework/languages/java/samples/kotlin/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/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt

mesh
    .objects()
    .server()
    .addEntrySpot<ZoneEntrySpot>()
    .addSpotFactory<ZoneSpot>(ZoneWorldNames.ZONE_SPOT_TYPE) {
        stableTypeLimit(topology.zoneCapacityValue()).disableRelocation()
    }
    .addActorFactory<PlayerActor, PlayerActorFactory>(
        ZoneWorldNames.PLAYER_ACTOR_TYPE
    ) {
        preserveStateWith<PlayerActor, PlayerActorRelocationAdapter>()
    }

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt

options
    .addFanoutChannel(ZoneWorldNames.BROADCAST_CHANNEL)
    .enableSubscriber()
    .addHandlerGroup(ZoneWorldNames.BROADCAST_HANDLER_GROUP)

Ops 쪽은 같은 fanout channel의 publisher다.

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt

options
    .addFanoutChannel(ZoneWorldNames.BROADCAST_CHANNEL)
    .setRoutingIdPrefix("zoneworld-kotlin-ops-broadcast")
    .enablePublisher()

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

4. 입장 — bind와 첫 zone join

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

↗ 크게 보기

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/gateway/GameSession.kt

val result =
    kotlinActors
        .getOrCreate(request.playerId, ZoneWorldNames.PLAYER_ACTOR_TYPE)
        .inMesh(ZoneWorldNames.MESH)
        .request(ZLinkMessage.empty())
        .await()
if (result is ZLinkActorCreateResult.Rejected) error("actor creation rejected")
val actor =
    when (result) {
        is ZLinkActorCreateResult.Created -> result.actor
        is ZLinkActorCreateResult.Existing -> result.actor
        is ZLinkActorCreateResult.Rejected -> error("unreachable")
    }
sessionContext.actors().bindOrGet(actor).await().kotlin().relay(dispatch, payload).await()

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

actor.prepareEntry(request.x, request.y, request.isBot, request.dirX, request.dirY)
val zone = ZoneWorldSpec.zoneOf(request.x, request.y)
actor
    .context()
    .joinSpot(
        zone,
        Messages.EnterZoneReq(
            actor.actorId,
            request.x,
            request.y,
            request.isBot,
            true,
            "",
            false,
        ),
    )
    .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/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

val decision = ZoneWorldSpec.validateMove(actor.x, actor.y, targetX, targetY)
if (!decision.accepted) {
    if (!actor.isBot)
        actor.send(
            Messages.MoveRejectedNotify(decision.reason ?: "Rejected", actor.x, actor.y)
        )
    return
}
val targetZone = ZoneWorldSpec.zoneOf(targetX, targetY)
if (!decision.zoneChanged) {
    actor.updatePosition(targetX, targetY)
    context
        .outbound()
        .kotlin()
        .sendToSpot(
            context.spotId(),
            Messages.UpdatePositionMsg(actor.actorId, targetX, targetY, actor.isBot),
        )
        .await()
    if (!actor.isBot)
        kotlinActors
            .sendToActor(
                actor.actorId,
                Messages.DeliverZoneStateMsg(context.spotId(), tickValue, statePlayers()),
            )
            .await()
    return
}
actor.prepareMove(targetX, targetY, targetZone)
actor
    .context()
    .joinSpot(
        targetZone,
        Messages.EnterZoneReq(
            actor.actorId,
            targetX,
            targetY,
            actor.isBot,
            false,
            actor.zoneId,
            false,
        ),
    )
    .timeout(Duration.ofSeconds(10))
    .defer()
println(
    "zone transfer requested actor=${actor.actorId} from=${context.spotId()} to=$targetZone node=${topology.nodeValue()}"
)
return

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

actor.prepareMove(targetX, targetY, targetZone)
actor
    .context()
    .joinSpot(
        targetZone,
        Messages.EnterZoneReq(
            actor.actorId,
            targetX,
            targetY,
            actor.isBot,
            false,
            actor.zoneId,
            false,
        ),
    )
    .timeout(Duration.ofSeconds(10))
    .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/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

override fun capture(
    actor: PlayerActor,
    cancellation: ZLinkRelocationCancellation,
): CompletionStage<ByteArray> =
    CompletableFuture.completedFuture(
        mapper.writeValueAsBytes(
            State(
                actor.x,
                actor.y,
                actor.zoneId,
                actor.isBot,
                actor.dirX,
                actor.dirY,
                actor.pendingTargetX,
                actor.pendingTargetY,
                actor.pendingTargetZone,
                actor.pending,
                actor.pendingPurposeName,
                actor.completedJoinIds.map { OperationId(it.high(), it.low()) },
            )
        )
    )

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

if (maintenance.rejectsArrival(topology.nodeValue(), zone, join.fromZoneId)) {
    return ZLinkSpotActorJoinResult.reject(Messages.EnterZoneRes(zone, "ZoneMaintenance"))
}
if (join.crashBoundaryProbe) {
    println("crash-boundary join pending zone=$zone actor=$actorId")
    awaitCancellation()
}
pending[actorId] = join
return ZLinkSpotActorJoinResult.accept(Messages.EnterZoneRes(zone))

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

override suspend fun onJoinCompletedSuspending(completion: ZLinkActorJoinCompletion) {
    val operationId =
        when (completion) {
            is ZLinkActorJoinCompletion.Accepted -> completion.operationId()
            is ZLinkActorJoinCompletion.Rejected -> completion.operationId()
            is ZLinkActorJoinCompletion.Failed -> completion.operationId()
        }
    if (operationId in completedJoins) return
    rememberJoin(operationId)
    pendingJoin = false
    when (completion) {
        is ZLinkActorJoinCompletion.Accepted -> {
            val reply = completion.reply().decode<Messages.EnterZoneRes>()
            val joinedZone =
                reply.zoneId.ifBlank {
                    pendingZone.ifBlank { ZoneWorldSpec.zoneOf(pendingX, pendingY) }
                }
            applyAtZone(pendingX, pendingY, joinedZone, isBot)
            pendingZone = ""
            when (pendingPurpose) {
                JoinPurpose.INITIAL_HUMAN ->
                    send(Messages.JoinWorldNotify(actorId, joinedZone, x, y))
                JoinPurpose.CRASH_PROBE -> send(Messages.CrashRelocationProbeRes())
                else -> Unit
            }
            pendingPurpose = JoinPurpose.NONE
        }
        else -> {
            val reason =
                when (completion) {
                    is ZLinkActorJoinCompletion.Rejected ->
                        completion.reply().decode<Messages.EnterZoneRes>().error ?: "Rejected"
                    is ZLinkActorJoinCompletion.Failed -> mapFailure(completion.kind().name)
                    else -> error("unreachable")
                }
            pendingZone = ""
            when (pendingPurpose) {
                JoinPurpose.INITIAL_HUMAN ->
                    send(
                        Messages.JoinWorldNotify(
                            actorId,
                            ZoneWorldSpec.zoneOf(pendingX, pendingY),
                            pendingX,
                            pendingY,
                            reason,
                        )
                    )
                JoinPurpose.CRASH_PROBE -> send(Messages.CrashRelocationProbeRes(reason))
                else ->
                    if (!isBot) send(Messages.MoveRejectedNotify(reason, x, y))
                    else reverseDirection()
            }
            pendingPurpose = JoinPurpose.NONE
        }
    }
}

옮겨도 남는 것과 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/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

suspend fun deliverState(actor: PlayerActor, message: Messages.DeliverZoneStateMsg) {
    if (residents[actor.actorId] !== actor) return
    actor.send(Messages.ZoneStateNotify(message.zoneId, message.tick, message.players))
}

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

private suspend fun publishBorders() {
    ZoneWorldSpec.adjacentZones(context.spotId()).forEach { target ->
        val players =
            residents.values
                .filter { ZoneWorldSpec.inBorderBand(it.x, it.y, context.spotId(), target) }
                .map { Messages.PlayerView(it.actorId, it.x, it.y, context.spotId(), it.isBot) }
                .sortedWith(compareBy(ZoneWorldSpec.utf8Order) { it.playerId })
        context
            .outbound()
            .kotlin()
            .publish(
                ZoneWorldNames.ZONE_CHANNEL,
                ZoneWorldNames.borderTopic(context.spotId(), target),
                Messages.ZoneBorderEvent(context.spotId(), target, tickValue, players),
            )
            .await()
    }
}

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

ZoneWorldSpec.adjacentZones(context.spotId()).forEach { fromZoneId ->
    BorderSubscriptionHandlers.registerForRoute(
        context.handlers(),
        fromZoneId,
        context.spotId(),
    )
}

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

8. 운영 — fanout과 node 관찰

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

↗ 크게 보기

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/ops/OpsSession.kt

kotlinFanout
    .publish(
        ZoneWorldNames.BROADCAST_CHANNEL,
        ZoneWorldNames.MAINTENANCE_TOPIC,
        Messages.NodeMaintenanceChangedEvent(request.nodeId, request.enabled),
    )
    .await()
reply(Messages.SetMaintenanceRes(request.nodeId, request.enabled, node.zones))

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt

@ZLinkHandlerGroup(ZoneWorldNames.BROADCAST_HANDLER_GROUP)
class NodeMaintenanceSubscriber(
    private val state: NodeMaintenanceState,
    private val store: MaintenanceStore,
    private val topology: SampleTopology,
    private val statusReporter: ObjectProvider<ZoneStatusReporter>,
) : ZLinkSuspendingPublishHandler<Messages.NodeMaintenanceChangedEvent> {
    override suspend fun handle(
        message: Messages.NodeMaintenanceChangedEvent,
        context: ZLinkPublishMessageContext,
    ) {
        state.apply(message.nodeId, message.enabled)
        store.set(message.nodeId, message.enabled)
        if (topology.nodeValue() == message.nodeId) {
            println("maintenance state node=${message.nodeId} enabled=${message.enabled}")
            statusReporter.ifAvailable?.let { it.reportNow() }
        }
    }
}

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

Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/ops/NodeLivenessObserver.kt

val publisher = runtime.observe(ZoneWorldNames.MESH, 32)
observation = publisher
publisher.subscribe(
    object : Flow.Subscriber<ZLinkObservedStatus<ZLinkMeshNodeSnapshot>> {
        override fun onSubscribe(subscription: Flow.Subscription) {
            subscription.request(Long.MAX_VALUE)
        }

        override fun onNext(observed: ZLinkObservedStatus<ZLinkMeshNodeSnapshot>) {
            registry.applyLiveRoutingIds(readyRoutingIds(observed.status()))
        }

        override fun onError(error: Throwable) {
            println(
                "node observation error mesh=${ZoneWorldNames.MESH}" +
                    " detail=${error.message}"
            )
        }

        override fun onComplete() {}
    }
)

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

9. 실행과 검증

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

framework/languages/java/samples/kotlin/ZoneWorld/run_sample.sh

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

10. 관련 문서