콘텐츠로 이동

ZoneWorld 따라 읽기

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

이 장을 읽고 나면

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

mesh.objects()
        .server()
        .addEntrySpot(ZoneEntrySpot.class)
        .addSpotFactory(
                ZoneWorldNames.ZONE_SPOT_TYPE,
                ZoneSpot.class,
                factory ->
                        factory.stableTypeLimit(topology.zoneCapacityValue())
                                .disableRelocation())
        .addActorFactory(
                ZoneWorldNames.PLAYER_ACTOR_TYPE,
                PlayerActor.class,
                PlayerActorFactory.class,
                factory ->
                        factory.preserveStateWith(
                                PlayerActorRelocationAdapter.class));

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/Program.java

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

Ops 쪽은 같은 fanout channel의 publisher다.

Server/src/main/java/systems/zlink/samples/zoneworld/server/Program.java

options.addFanoutChannel(ZoneWorldNames.BROADCAST_CHANNEL)
        .setRoutingIdPrefix("zoneworld-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/java/systems/zlink/samples/zoneworld/server/gateway/GameSession.java

return actors.getOrCreate(request.playerId(), ZoneWorldNames.PLAYER_ACTOR_TYPE)
        .inMesh(ZoneWorldNames.MESH)
        .request(ZLinkMessage.empty())
        .submit()
        .thenCompose(
                result -> {
                    if (result instanceof ZLinkActorCreateResult.Rejected rejected) {
                        return CompletableFuture.failedFuture(
                                new IllegalStateException(
                                        rejected.reply().isEmpty()
                                                ? "Player actor creation rejected"
                                                : rejected.reply()
                                                        .decode(
                                                                Messages.EnterWorldRes
                                                                        .class)
                                                        .error()));
                    }
                    ActorRef actorRef =
                            result instanceof ZLinkActorCreateResult.Created created
                                    ? created.actor()
                                    : ((ZLinkActorCreateResult.Existing) result).actor();
                    return context.actors()
                            .bindOrGet(actorRef)
                            .thenCompose(actor -> actor.relay(dispatch, payload));
                });

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/handlers/EntryZoneJoinHandler.java

actor.prepareEntry(ZoneWorldSpec.SPAWN_X, ZoneWorldSpec.SPAWN_Y, false, 0, 0);
String zone = ZoneWorldSpec.zoneOf(ZoneWorldSpec.SPAWN_X, ZoneWorldSpec.SPAWN_Y);
actor.context()
        .joinSpot(
                zone,
                new Messages.EnterZoneReq(
                        actor.actorId(),
                        ZoneWorldSpec.SPAWN_X,
                        ZoneWorldSpec.SPAWN_Y,
                        false,
                        true,
                        "",
                        false))
        .timeout(Duration.ofSeconds(10))
        .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/java/systems/zlink/samples/zoneworld/server/zone/spots/ZoneSpot.java

ZoneWorldSpec.MoveDecision decision =
        ZoneWorldSpec.validateMove(actor.x(), actor.y(), targetX, targetY);
if (!decision.accepted()) {
    if (!actor.isBot()) {
        return actor.send(
                new Messages.MoveRejectedNotify(decision.reason(), actor.x(), actor.y()));
    }
    return CompletableFuture.completedFuture(null);
}
String targetZone = ZoneWorldSpec.zoneOf(targetX, targetY);
if (!decision.zoneChanged()) {
    actor.updatePosition(targetX, targetY);
    census.record(context.spotId(), residents.size());
    context.outbound()
            .sendToSpot(
                    context.spotId(),
                    new Messages.UpdatePositionMsg(
                            actor.actorId(), targetX, targetY, actor.isBot()))
            .submit();
    if (actor.isBot()) return CompletableFuture.completedFuture(null);
    return actor.send(new Messages.ZoneStateNotify(context.spotId(), tick, statePlayers()));
}

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/spots/ZoneSpot.java

actor.prepareMove(targetX, targetY, targetZone);
actor.context()
        .joinSpot(
                targetZone,
                new 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/java/systems/zlink/samples/zoneworld/server/zone/actors/PlayerActorRelocationAdapter.java

@Override
public CompletionStage<byte[]> capture(
        PlayerActor actor, ZLinkRelocationCancellation cancellation) {
    try {
        return CompletableFuture.completedFuture(
                JSON.writeValueAsBytes(
                        new State(
                                actor.x(),
                                actor.y(),
                                actor.zoneId(),
                                actor.isBot(),
                                actor.dirX(),
                                actor.dirY(),
                                actor.pendingX(),
                                actor.pendingY(),
                                actor.pendingZone(),
                                actor.pendingJoin(),
                                actor.pendingPurpose(),
                                actor.completedJoins().stream()
                                        .map(
                                                value ->
                                                        new OperationId(
                                                                value.high(), value.low()))
                                        .toList())));
    } catch (Exception error) {
        return CompletableFuture.failedFuture(error);
    }
}

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/spots/ZoneSpot.java

if (maintenance.rejectsArrival(topology.nodeId(), zone, join.fromZoneId())) {
    return CompletableFuture.completedFuture(
            ZLinkSpotActorJoinResult.reject(
                    new Messages.EnterZoneRes(zone, "ZoneMaintenance")));
}
if (join.crashBoundaryProbe()) {
    System.out.println("crash-boundary join pending zone=" + zone + " actor=" + actorId);
    return new CompletableFuture<>();
}
pendingJoins.put(actorId, join);
return CompletableFuture.completedFuture(
        ZLinkSpotActorJoinResult.accept(new Messages.EnterZoneRes(zone, null)));

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/actors/PlayerActor.java

@Override
public CompletionStage<Void> onJoinCompleted(ZLinkActorJoinCompletion completion) {
    ZLinkActorJoinOperationId operationId =
            completion instanceof ZLinkActorJoinCompletion.Accepted accepted
                    ? accepted.operationId()
                    : completion instanceof ZLinkActorJoinCompletion.Rejected rejected
                            ? rejected.operationId()
                            : ((ZLinkActorJoinCompletion.Failed) completion).operationId();
    if (completedJoins.contains(operationId)) return CompletableFuture.completedFuture(null);
    rememberJoin(operationId);

    pendingJoin = false;
    if (completion instanceof ZLinkActorJoinCompletion.Accepted accepted) {
        Messages.EnterZoneRes reply = accepted.reply().decode(Messages.EnterZoneRes.class);
        String joinedZone =
                reply.zoneId().isBlank()
                        ? pendingZone == null || pendingZone.isBlank()
                                ? ZoneWorldSpec.zoneOf(pendingX, pendingY)
                                : pendingZone
                        : reply.zoneId();
        applyAtZone(pendingX, pendingY, joinedZone, isBot);
        pendingZone = null;
        if (pendingPurpose == JoinPurpose.INITIAL_HUMAN) {
            pendingPurpose = JoinPurpose.NONE;
            return send(new Messages.JoinWorldNotify(actorId, joinedZone, x, y, null));
        }
        if (pendingPurpose == JoinPurpose.CRASH_PROBE) {
            pendingPurpose = JoinPurpose.NONE;
            return send(new Messages.CrashRelocationProbeRes(null));
        }
        pendingPurpose = JoinPurpose.NONE;
        return CompletableFuture.completedFuture(null);
    }

    String reason =
            completion instanceof ZLinkActorJoinCompletion.Rejected rejected
                    ? rejected.reply().decode(Messages.EnterZoneRes.class).error()
                    : mapFailure(((ZLinkActorJoinCompletion.Failed) completion).kind().name());
    pendingZone = null;
    if (pendingPurpose == JoinPurpose.INITIAL_HUMAN) {
        pendingPurpose = JoinPurpose.NONE;
        return send(
                new Messages.JoinWorldNotify(
                        actorId,
                        ZoneWorldSpec.zoneOf(pendingX, pendingY),
                        pendingX,
                        pendingY,
                        reason));
    }
    if (pendingPurpose == JoinPurpose.CRASH_PROBE) {
        pendingPurpose = JoinPurpose.NONE;
        return send(new Messages.CrashRelocationProbeRes(reason));
    }
    pendingPurpose = JoinPurpose.NONE;
    if (!isBot) return send(new Messages.MoveRejectedNotify(reason, x, y));
    reverseDirection();
    return CompletableFuture.completedFuture(null);
}

옮겨도 남는 것과 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/java/systems/zlink/samples/zoneworld/server/zone/actors/PlayerActor.java

public CompletionStage<Void> send(Object message) {
    if (isBot) return CompletableFuture.completedFuture(null);
    return context.boundSession().send(message).submit();
}

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/spots/ZoneSpot.java

CompletionStage<Void> published = CompletableFuture.completedFuture(null);
for (String target : ZoneWorldSpec.adjacentZones(context.spotId())) {
    List<Messages.PlayerView> border =
            residents.values().stream()
                    .filter(
                            actor ->
                                    ZoneWorldSpec.inBorderBand(
                                            actor.x(), actor.y(), context.spotId(), target))
                    .map(
                            actor ->
                                    new Messages.PlayerView(
                                            actor.actorId(),
                                            actor.x(),
                                            actor.y(),
                                            context.spotId(),
                                            actor.isBot()))
                    .sorted(
                            Comparator.comparing(
                                    Messages.PlayerView::playerId,
                                    ZoneWorldSpec.UTF8_ORDER))
                    .toList();
    Messages.ZoneBorderEvent event =
            new Messages.ZoneBorderEvent(context.spotId(), target, tick, border);
    published =
            published.thenCompose(
                    ignored ->
                            context.outbound()
                                    .publish(
                                            ZoneWorldNames.ZONE_CHANNEL,
                                            ZoneWorldNames.borderTopic(
                                                    context.spotId(), target),
                                            event)
                                    .submit());
}
return published;

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/handlers/BorderSubscriptionHandlers.java

public static Class<?> forRoute(String fromZoneId, String toZoneId) {
    return switch (ZoneWorldNames.borderTopic(fromZoneId, toZoneId)) {
        case ZoneWorldNames.NW_NE -> NorthWestToNorthEast.class;
        case ZoneWorldNames.NW_SW -> NorthWestToSouthWest.class;
        case ZoneWorldNames.NE_NW -> NorthEastToNorthWest.class;
        case ZoneWorldNames.NE_SE -> NorthEastToSouthEast.class;
        case ZoneWorldNames.SW_NW -> SouthWestToNorthWest.class;
        case ZoneWorldNames.SW_SE -> SouthWestToSouthEast.class;
        case ZoneWorldNames.SE_NE -> SouthEastToNorthEast.class;
        case ZoneWorldNames.SE_SW -> SouthEastToSouthWest.class;
        default ->
                throw new IllegalArgumentException(
                        "unknown border route: " + fromZoneId + " -> " + toZoneId);
    };
}

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

8. 운영 — fanout과 node 관찰

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

↗ 크게 보기

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

return fanout.publish(
                ZoneWorldNames.BROADCAST_CHANNEL,
                ZoneWorldNames.MAINTENANCE_TOPIC,
                new Messages.NodeMaintenanceChangedEvent(
                        request.nodeId(), request.enabled()))
        .submit()
        .thenCompose(
                ignored ->
                        reply(
                                new Messages.SetMaintenanceRes(
                                        request.nodeId(),
                                        request.enabled(),
                                        node.zones(),
                                        null)));

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

Server/src/main/java/systems/zlink/samples/zoneworld/server/zone/handlers/NodeMaintenanceSubscriber.java

@Override
public CompletionStage<Void> handle(
        Messages.NodeMaintenanceChangedEvent message, ZLinkPublishMessageContext context) {
    state.apply(message.nodeId(), message.enabled());
    store.set(message.nodeId(), message.enabled());
    if (topology.nodeId().equals(message.nodeId())) {
        System.out.println(
                "maintenance state node=" + message.nodeId() + " enabled=" + message.enabled());
        ZoneStatusReporter reporter = statusReporter.getIfAvailable();
        if (reporter != null) {
            return reporter.reportNow();
        }
    }
    return CompletableFuture.completedFuture(null);
}

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

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

observation = runtime.observe(ZoneWorldNames.MESH, 32);
observation.subscribe(
        new Flow.Subscriber<ZLinkObservedStatus<ZLinkMeshNodeSnapshot>>() {
            @Override
            public void onSubscribe(Flow.Subscription subscription) {
                subscription.request(Long.MAX_VALUE);
            }

            @Override
            public void onNext(ZLinkObservedStatus<ZLinkMeshNodeSnapshot> observed) {
                registry.applyLiveRoutingIds(readyRoutingIds(observed.status()));
            }

            @Override
            public void onError(Throwable error) {
                System.out.println(
                        "node observation error mesh="
                                + ZoneWorldNames.MESH
                                + " detail="
                                + error.getMessage());
            }

            @Override
            public void onComplete() {}
        });

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

9. 실행과 검증

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

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

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

10. 관련 문서