Bingo 따라 읽기¶
가이드 홈 | 이전: 14. 샘플 고르기 — 내 문제에 가까운 예제부터 | 다음: TicTacToe 따라 읽기
다른 언어로 보기 — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
이 장을 읽고 나면
Bingo 샘플을 편집기에 열고, client의 인증부터 게임 종료까지 메시지가 어느 서버의 어느 코드를 지나는지 따라갈 수 있다. 이 장의 코드는 언어별 예제 저장소의 Bingo 샘플에서 가져온다.
샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 framework 기능과 그것을 설명하는 장을 소스가 놓인 순서대로 따라간다. 이 장은 Bingo 샘플의 역할과 코드 위치, 주요 메시지 흐름, 실행 검증을 소스 순서대로 설명한다. 요구사항, 메시지 계약과 검증 기준은 Bingo 시나리오에서 참고한다.
1. 이 샘플이 보여 주는 것¶
client는 Session 서버의 STREAM 연결 하나만 유지한다. 인증은 API가, 대기방 예약은 Matchmaking이, 게임 진행은 room을 소유한 Play가 처리하고, 서버 사이의 연결과 object의 위치는 Location Store가 해석한다. 한 판은 인증 → 매칭 → room join → card 제출 → timer 추첨과 push → 승자 확정과 reward publish → cleanup 순서로 진행되며, 이 장의 절도 같은 순서다.
이 구성이 framework 기능을 가장 많이 사용하는 이유는 흐름마다 다른 기능이 필요하기 때문이다. 연결 하나에 player를 묶는 데는 session binding이, 대기방 예약에는 요청이 올 때 만들어지는 Instance Spot이, room에는 참가자·timer·push를 한 줄로 처리하는 User Spot이, 다른 Play 서버의 관전자에게 알리는 데는 Logical Multicast가 쓰인다.
2. 역할과 코드 위치¶
역할마다 process가 따로 있고, 코드도 Server/<역할> 아래에 같은 이름으로 나뉜다. 언어별
package 경로는 다르지만 디렉터리 이름은 같다.
| 역할 | process 수 | 소유하는 것 | 코드 위치 |
|---|---|---|---|
| Session | 2 | STREAM 연결, 인증 전 packet 처리, Actor bind와 relay | Server/Session |
| API | 2 | 인증, player record, 매칭 조율 | Server/Api |
| Matchmaking | 1 | level bucket별 대기방 예약 — Redis가 결정을 보관한다 | Server/Matchmaking |
| Play | 2 | player Actor, room Spot, timer, push, reward publish | Server/Play/Infrastructure |
| Client | 1 | 인증부터 관찰 종료까지의 시나리오와 self-check | Client |
Bingo 규칙 — card 검증, 번호 추첨, mark와 승자 판정 — 은 Server/Play/Domain에 있고 framework
type을 참조하지 않는다. 이 장은 그 규칙을 다루지 않는다. 메시지 이름과 field는 shared의
Protobuf schema가 정하며, 이 샘플만 payload가 Protobuf다.
3. 서버 구성¶
각 역할의 host 구성이 그 역할이 무엇을 받고 무엇을 호출하는지 보여 준다. Session은 STREAM 연결을 받고, Play mesh의 object를 호출하며, API channel을 호출한다.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/session/SessionServerApplication.kt
val node = options.addRouteMesh(SampleNames.Mesh)
node.listen(topology.selectedSessionRouterEndpoint()).setRoutingIdPrefix("session")
node.objects().client()
options.addClientServerChannel(SampleNames.ApiChannel).client()
node.channelName(SampleNames.RoomSpotDiscovery).client()
options
.addStreamNode(SampleNames.StreamNode)
.bind(topology.selectedStreamEndpoint())
.enableActorDispatch()
.registerSession<BingoSession>()
stream node에 session type을 등록하면 인증된 session에 묶인 Actor로 packet이 전달된다 — managed 언어는 actor dispatch를 함께 켠다. mesh와 channel 등록에 상대 endpoint가 없다 — 어느 node가 어느 이름을 맡는지는 Location Store가 해석한다(Location).
Play는 Entry Spot, player Actor factory, room Spot factory를 같은 mesh에 등록한다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/PlayServerApplication.kt
.addSpotFactory<BingoRoomSpot>(SampleNames.RoomSpotType) {
executionMode(ZLinkUserSpotExecutionMode.SPOT_WIDE)
relocationCoordinationMode(
ZLinkSpotRelocationCoordinationMode.APPLICATION_SIGNALED
)
preserveStateWith<BingoRoomSpot, BingoRoomRelocationAdapter>()
}
Entry Spot은 node마다 하나이고 Actor가 처음 만들어지는 자리다. room은 id마다 하나인 User
Spot이며 SpotWide로 실행된다. handler는 managed 언어에서는 scan으로, C++에서는 builder로
등록한다(Handler와 메시지 처리). 종류별 차이는
활성화와 수명이 다룬다.
4. 인증과 session·Actor bind¶
client가 보낸 첫 packet은 인증 요청이다. Session은 API에 token을 검증시키고, 전역 id로 player Actor를 만들거나 찾고, 그 Actor를 현재 session에 묶는다.
session handler는 API channel에 요청을 보내고, 결과가 유효하면 Play mesh에서 Actor를 얻는다. Actor의 생성 payload가 이때 함께 전달된다.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/session/sessions/handlers/AuthenticateSessionHandler.kt
val authenticated =
kotlinRoutes
.requestToChannel<AuthenticatePlayerRes>(
SampleNames.ApiChannel,
AuthenticatePlayerReq(request.accessToken),
)
.timeout(SampleTimings.RequestTimeout)
.await()
if (
!authenticated.accepted ||
authenticated.actorId.isBlank() ||
authenticated.displayName.isBlank()
) {
throw IllegalStateException(authenticated.reason ?: "Player authentication failed.")
}
val actor =
actors
.kotlin()
.getOrCreate(authenticated.actorId, SampleNames.PlayerActorType)
.request(EnsurePlayerActorReq(authenticated.actorId, authenticated.displayName))
.await()
Actor를 얻는 호출은 어느 Play node에 만들지 정하지 않는다. 이미 있으면 그 Actor를, 없으면 새로 만든 Actor를 돌려주며, 어느 쪽이든 Session은 반환된 참조를 그대로 묶는다.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/session/sessions/handlers/AuthenticateSessionHandler.kt
context.actors().bind(requireActor(actor)).await()
context
.client()
.kotlin()
.reply(AuthenticateRes(authenticated.actorId, authenticated.displayName))
.await()
묶인 뒤에 도착하는 packet은 session handler가 아니라 Actor의 handler가 받는다. session의 dispatch callback이 그 분기를 만든다 — 등록된 session handler가 처리하지 못한 packet은 묶인 Actor로 relay된다.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/session/sessions/BingoSession.kt
override suspend fun onDispatchSuspending(
dispatch: ZLinkSessionDispatchContext,
payload: ZLinkMessage,
) {
val handled = handlers.tryHandle(context, dispatch, payload).await()
if (handled) {
if (context.actors().bound().size == 1) {
boundActorId = context.actors().bound()[0].actorId()
}
return
}
val actor = requireSingleBoundActor(dispatch.packetName())
actor.kotlin().relay(dispatch, payload).await()
}
Session은 Play endpoint를 저장하지 않는다. Actor가 relocation으로 옮겨져도 framework가 relay 경로를 갱신한다. 묶는 기능은 Session과 Actor 연결이, 묶음의 개수와 경로 갱신은 Session 묶음의 동작 원리가 다룬다.
5. 매칭과 room 준비¶
매칭 요청은 묶인 Actor로 relay되어 Entry Spot의 actor request handler에 도착한다. handler는
API에 매칭을 요청하고, 받은 RoomId로 room join을 예약한 뒤 응답한다.
이 흐름에서는 join이 handler 종료 뒤에 시작되므로 매칭 응답만으로 game start를 판단하지 않는다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/entryspot/handlers/MatchBingoActorHandler.kt
val matched =
kotlinRoutes
.requestToChannel<MatchBingoApiRes>(
SampleNames.ApiChannel,
MatchBingoApiReq(actor.actorId(), actor.displayName, message.mode),
)
.timeout(SampleTimings.RequestTimeout)
.await()
actor.trackDeferredJoin(matched.roomId)
actor
.context()
.joinSpot(
matched.roomId,
BingoRoomJoinReq(matched.roomId, actor.actorId(), actor.displayName, false),
)
.timeout(SampleTimings.RequestTimeout)
.defer()
join은 handler 안에서 실행되지 않고 handler가 끝난 뒤에 실행된다. 그래서 매칭 응답의 state는
join 전의 WaitingForPlayers이고, 게임 시작은 뒤에 오는 push로 확인한다. 예약의 규칙은
Actor membership이 다룬다.
API는 level bucket을 id로 하는 Matchmaker Instance Spot에 예약을 요청하고, 돌려받은 RoomId로
room Spot을 만들거나 찾는다.
Server/Api/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/api/handlers/MatchBingoHandler.kt
val levelBucket = "1-10"
val allocated =
kotlinRoutes
.requestToSpot<ReserveBingoRoomRes>(
"match:$levelBucket",
ReserveBingoRoomReq(request.actorId, request.mode, levelBucket),
)
.instanceSpot(SampleNames.MatchmakerSpotType)
.inMesh(SampleNames.MatchmakingMesh)
.timeout(SampleTimings.RequestTimeout)
.await()
kotlinSpots
.getOrCreate(allocated.roomId, SampleNames.RoomSpotType)
.inMesh(SampleNames.Mesh)
.request(BingoRoomCreateReq(allocated.settings))
.timeout(SampleTimings.RequestTimeout)
.await()
Instance Spot은 첫 요청이 도착할 때 만들어진다. 같은 id로 동시에 온 요청은 하나의 생성을
기다리며, room의 getOrCreate도 같은 규칙을 따른다 — API는 어느 Play node에 room을 만들지
정하지 않는다. Matchmaker는 예약을 Redis에서 원자적으로 결정하므로 process memory에 복구
근거를 두지 않고, 활동이 없으면 timer로 자신을 닫는다.
Server/Matchmaking/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/matchmaking/BingoMatchmaker.kt
fun closeIfIdle() {
if (
inFlight.get() == 0 && Duration.between(lastActivity, Instant.now()) >= IdleCloseAfter
) {
instanceContext.close()
}
}
닫힌 뒤의 다음 요청은 새 generation의 Instance Spot을 만든다. 종류별 생성 시점은 활성화와 수명이, Instance Spot 호출은 Spot이 다룬다.
6. room join과 Yield¶
예약된 join이 실행되면 room Spot의 joined callback이 호출된다. player 분기는 API에서 player
record를 읽어야 하는데, 그 왕복 동안 room의 실행권을 반납하지 않으면 다른 player의 join과 timer가
기다린다. 그래서 요청을 Yield로 보낸다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomSpot.kt
val record =
outbound
.requestToChannel<GetPlayerRecordRes>(
SampleNames.ApiChannel,
GetPlayerRecordReq(actor.actorId()),
)
.timeout(SampleTimings.RequestTimeout)
.yield()
val game = this.game
if (pendingJoins[actor.actorId()] !== request || game == null || !game.canAcceptPlayer()) {
pendingJoins.remove(actor.actorId())
context.leaveActor(actor).await()
return
}
pendingJoins.remove(actor.actorId())
join(actor, request, record.wins, record.losses)
logger.info(
"bingo-record fetched actor={} wins={} losses={}",
actor.actorId(),
record.wins,
record.losses,
)
이 요청에서 Yield가 turn을 반납하고 재개 뒤 pending join을 다시 확인하는 이유는
실행 모델 §5가 다룬다.
7. card, 추첨 timer와 push¶
두 player가 card를 제출하면 room의 timer가 추첨을 시작한다. tick마다 번호 하나를 뽑아 두 card를 mark하고, 갱신된 state를 두 player에게 push한다.
timer handler는 room Spot의 turn 안에서 실행되므로 card 제출 handler와 겹치지 않는다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomSpot.kt
suspend fun tick() {
val game = this.game
if (game == null || cleanupStarted) {
return
}
val change = game.drawNext()
publishEvents(change.events, actors::get)
publishWinner(change)
leaveFinishedActors(change)
if (change.state.status == BingoRoomGame.Finished) {
context.relocationReady().defer()
}
}
push는 각 player Actor에 묶인 session으로 보낸다. Actor가 어느 Play node에 있든, session이 어느 Session node에 있든, 보내는 쪽은 Actor의 bound session만 가리킨다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomSpot.kt
BingoRoomEventKind.NUMBER_DRAWN ->
recipient.push(
BingoNumberDrawnNotify(
event.state.roomId,
event.state.drawSeq,
event.drawnNumber,
event.state,
)
)
timer 등록과 tick의 실행 위치는 Timer와 worker가, bound session으로 보내는 경로는 Session 묶음의 동작 원리가 다룬다.
8. reward의 Logical Multicast¶
승자가 확정되면 room은 종료 push를 보낸 뒤 reward event를 publish한다. 관전자는 다른 Play node에 있을 수 있으므로 특정 Spot을 가리켜 보내지 않고, topic으로 범위를 정해 발행한다 — managed 언어는 channel 이름을 함께 적는다.
publish 완료는 관전자의 처리 완료를 보장하지 않으므로 관전자 notify로 전달 결과를 확인한다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomSpot.kt
context
.outbound()
.kotlin()
.publish(
SampleNames.RoomRewardChannel,
SampleNames.WinnerTopic,
BingoRewardAcquiredEvent(
state.roomId,
winner,
state.drawSeq,
"rare-golden-dauber",
"Golden Dauber",
"Legendary",
),
)
.await()
각 Play node의 관찰용 room이 같은 topic을 구독한다. 구독 handler는 event의 RoomId가 자신이
관찰하는 room과 일치할 때만 관전자 Actor의 bound session으로 notify를 보낸다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/handlers/BingoRewardAcquiredEventHandler.kt
@ZLinkSpotSubscription(topic = SampleNames.WinnerTopic)
class BingoRewardAcquiredEventHandler :
ZLinkSuspendingSpotSubscriptionHandler<BingoRoomSpot, BingoRewardAcquiredEvent> {
override suspend fun handle(spot: BingoRoomSpot, event: BingoRewardAcquiredEvent) {
spot.announceReward(event)
}
}
publish가 정상으로 끝났다는 것은 발행이 시작되었다는 뜻이지 구독자가 처리했다는 뜻이 아니다. client는 publish 결과가 아니라 관전자에게 도착한 notify로 전달을 확인한다. pub/sub의 형태와 대상 선택은 Channel 동작 원리가 다룬다.
9. 종료 cleanup과 disconnect¶
게임이 끝나면 room은 player Actor를 내보낸다. Actor는 Entry Spot으로 돌아가고, Entry Spot이
destroy 표시를 확인해 Actor를 정리한다. room이 직접 destroy하지 않는 이유는 leave callback이
API에 결과를 기록하는 동안 Yield로 실행권을 반납하기 때문이다.
room은 Actor를 직접 destroy하지 않고 Entry Spot callback이 destroy를 시작하게 한다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomSpot.kt
private suspend fun leaveFinishedActors(change: BingoRoomGame.Change) {
if (cleanupStarted || change.state.status != BingoRoomGame.Finished) {
return
}
cleanupStarted = true
for (actor in actors.values.toList()) {
actor.markForDestroyAfterRoomLeave()
context.leaveActor(actor).await()
}
}
Entry Spot의 joined callback이 표시를 확인하고 destroy를 호출한다. destroy는 Actor 객체, registry와 session 묶음을 정리하며 leave callback을 다시 호출하지 않는다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/entryspot/BingoEntrySpot.kt
override suspend fun onJoinedActorSuspending(actor: PlayerActor) {
if (actor.destroyAfterEntrySpotJoin) {
context.destroyActor(actor).await()
logger.info("bingo-lifecycle entry-destroy-complete actor={}", actor.actorId())
}
}
client 연결이 끊기는 것은 별개의 경로다. 연결이 끊기면 framework가 그 시점에 묶여 있던 Actor 전체에 끊김을 알린다. session의 disconnect callback은 그 사실을 로그로 남길 뿐 Actor를 destroy하거나 room에서 내보내지 않는다 — 일부 언어는 통지를 직접 호출하기도 하지만, 자동 통지와 겹쳐도 callback은 한 번만 실행된다.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/session/sessions/BingoSession.kt
override suspend fun onDisconnectedSuspending() {
// Framework cleanup owns disconnect notification; this callback only records
// the sample lifecycle evidence without submitting another notification.
boundActorId?.let { actorId ->
logger.info("bingo-lifecycle session-disconnect actor={} destroy=false", actorId)
}
}
Actor가 Entry Spot으로 돌아가는 순서는 활성화와 수명이, 끊김 통지가 도착하는 곳은 Session 묶음의 동작 원리가 다룬다.
10. relocation 대비¶
room Spot factory는 SpotWide 실행과 application이 알리는 relocation 시점을 선택하고, 상태를
보존할 adapter를 지정한다. Node 구현은 room Spot의 relocation을 끄고 player Actor의 adapter만
둔다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/PlayServerApplication.kt
// SPOT_WIDE is the default. Naming it here keeps the choice visible:
// every callback of this room runs through one gate.
.addSpotFactory<BingoRoomSpot>(SampleNames.RoomSpotType) {
executionMode(ZLinkUserSpotExecutionMode.SPOT_WIDE)
relocationCoordinationMode(
ZLinkSpotRelocationCoordinationMode.APPLICATION_SIGNALED
)
preserveStateWith<BingoRoomSpot, BingoRoomRelocationAdapter>()
}
adapter는 domain 상태만 직렬화한다. queue, timer, accepted journal과 owner fence는 framework가 옮기므로 adapter payload에 넣지 않는다.
Server/Play/src/main/kotlin/systems/zlink/samples/kotlin/bingo/server/play/infrastructure/zlink/spots/bingoroomspot/BingoRoomRelocationAdapter.kt
override fun capture(
spot: BingoRoomSpot,
cancellation: ZLinkRelocationCancellation,
): CompletionStage<ByteArray> {
ensureActive(cancellation)
val state = spot.captureRelocationState()
return CompletableFuture.completedFuture(
json.writeValueAsBytes(Payload(state.settings, state.state))
)
}
relocation을 켠 구현의 room은 한 round의 push와 publish가 끝난 turn에서 relocation-ready를 예약한다 — 앞 절의 timer handler 끝에 그 호출이 있다. 이동 단위와 되돌릴 수 있는 지점은 Relocation이 다룬다.
11. 실행과 검증¶
runner 하나가 Redis 컨테이너, 서버 process와 client 시나리오를 함께 띄운다.
client는 인증 결과, 매칭 state, push 순서와 reward notify를 assertion으로 확인하고 마지막에
bingo=completed를 출력한다. runner는 서버 로그의 lifecycle 행을 세어 bingo-placement=completed를
출력한다. 확인 항목과 로그 문자열은
Bingo 시나리오가 정한다.
12. 관련 문서¶
- 다른 샘플과의 비교와 선택 기준: 샘플 고르기
- 요구사항, 메시지 계약과 완료 기준: Bingo 시나리오
- 같은 게임을 수동 연결·수동 등록으로 만든 구성: TicTacToe 따라 읽기