1. 소개
Cloud Spanner는 성능 및 고가용성을 희생하지 않고도 ACID 트랜잭션 및 SQL 시맨틱스를 제공하는 수평 확장이 가능하고, 전역으로 분산되었고, 완전히 관리되는 관계형 데이터베이스 서비스입니다.
이러한 기능 덕분에 Spanner는 글로벌 플레이어 기반을 지원하거나 데이터 일관성을 우려하는 게임의 아키텍처에 적합합니다.
이 실습에서는 리전 Spanner 데이터베이스와 상호작용하는 두 개의 Go 서비스를 만들어 플레이어가 가입하고 플레이를 시작할 수 있도록 합니다.

다음으로 Python 로드 프레임워크 Locust.io를 활용하여 플레이어가 가입하고 게임을 플레이하는 것을 시뮬레이션하는 데이터를 생성합니다. 그런 다음 Spanner를 쿼리하여 플레이 중인 플레이어 수와 플레이어의 승리한 게임 수 대비 플레이한 게임 수에 관한 통계를 확인합니다.
마지막으로 이 실습에서 만든 리소스를 정리합니다.
빌드할 항목
이 실습에서 학습할 내용은 다음과 같습니다.
- Spanner 인스턴스 만들기
- 플레이어 가입을 처리하기 위해 Go로 작성된 프로필 서비스 배포
- Go로 작성된 랜덤 대결 서비스를 배포하여 플레이어를 게임에 할당하고, 승자를 결정하고, 플레이어의 게임 통계를 업데이트합니다.
학습할 내용
- Cloud Spanner 인스턴스를 설정하는 방법
- 게임 데이터베이스 및 스키마를 만드는 방법
- Cloud Spanner와 함께 작동하도록 Go 앱을 배포하는 방법
- Locust를 사용하여 데이터를 생성하는 방법
- Cloud Spanner에서 데이터를 쿼리하여 게임 및 플레이어에 관한 질문에 답변하는 방법
필요한 항목
2. 설정 및 요건
프로젝트 만들기
아직 Google 계정(Gmail 또는 Google Apps)이 없으면 계정을 만들어야 합니다. Google Cloud Platform 콘솔 ( console.cloud.google.com)에 로그인하고 새 프로젝트를 만듭니다.
프로젝트가 이미 있으면 Console 왼쪽 위에서 프로젝트 선택 풀다운 메뉴를 클릭합니다.

그리고 표시된 대화상자에서 '새 프로젝트' 버튼을 클릭하여 새 프로젝트를 만듭니다.

아직 프로젝트가 없으면 첫 번째 프로젝트를 만들기 위해 다음과 비슷한 대화상자가 표시됩니다.

이후의 프로젝트 만들기 대화상자에서 새 프로젝트의 세부정보를 입력할 수 있습니다.

모든 Google Cloud 프로젝트에서 고유한 이름인 프로젝트 ID를 기억하세요(위의 이름은 이미 사용되었으므로 사용할 수 없습니다). 이 이름은 나중에 Codelab에서 PROJECT_ID로 참조됩니다.
그런 다음 Google Cloud 리소스를 사용하고 Cloud Spanner API를 사용 설정하기 위해서는 아직 완료하지 않은 경우 Developers Console에서 결제를 사용 설정해야 합니다.

이 codelab을 실행하는 과정에는 많은 비용이 들지 않지만 더 많은 리소스를 사용하려고 하거나 실행 중일 경우 비용이 더 들 수 있습니다(이 문서 마지막의 '삭제' 섹션 참조). Google Cloud Spanner 가격 책정은 여기를 참조하세요.
Google Cloud Platform 신규 사용자는 $300 상당의 무료 체험판을 사용할 수 있으므로, 이 Codelab을 완전히 무료로 사용할 수 있습니다.
Google Cloud Shell 설정
Google Cloud 및 Spanner를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.
이 Debian 기반 가상 머신에는 필요한 모든 개발 도구가 로드되어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 즉, 이 Codelab에 필요한 것은 브라우저뿐입니다(Chromebook에서도 작동 가능).
- Cloud 콘솔에서 Cloud Shell을 활성화하려면 Cloud Shell 활성화
를 클릭합니다. 환경을 프로비저닝하고 연결하는 데 몇 분 정도만 걸립니다.


Cloud Shell에 연결되면 인증이 완료되었고 프로젝트가 해당 PROJECT_ID로 이미 설정된 것을 확인할 수 있습니다.
gcloud auth list
명령어 결과
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
명령어 결과
[core]
project = <PROJECT_ID>
어떤 이유로든 프로젝트가 설정되지 않았으면 다음 명령어를 실행하면 됩니다.
gcloud config set project <PROJECT_ID>
PROJECT_ID를 찾고 계신가요? 설정 단계에서 사용한 ID를 확인하거나 Cloud Console 대시보드에서 확인하세요.

또한 Cloud Shell은 기본적으로 이후 명령어를 실행할 때 유용할 수 있는 몇 가지 환경 변수를 설정합니다.
echo $GOOGLE_CLOUD_PROJECT
명령어 결과
<PROJECT_ID>
코드 다운로드
Cloud Shell에서 이 실습의 코드를 다운로드할 수 있습니다. 이는 v0.1.0 출시를 기반으로 하므로 해당 태그를 체크아웃하세요.
git clone https://github.com/cloudspannerecosystem/spanner-gaming-sample.git
cd spanner-gaming-sample/
# Check out v0.1.0 release
git checkout tags/v0.1.0 -b v0.1.0-branch
명령어 결과
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
Locust 부하 생성기 설정
Locust는 REST API 엔드포인트를 테스트하는 데 유용한 Python 부하 테스트 프레임워크입니다. 이 Codelab의 'generators' 디렉터리에는 다음과 같은 2가지 부하 테스트가 있습니다.
- authentication_server.py: 플레이어를 생성하고 단일 지점 조회를 모방하기 위해 임의의 플레이어를 가져오는 작업을 포함합니다.
- match_server.py: 게임을 만들고 게임을 종료하는 작업을 포함합니다. 게임을 만들면 현재 게임을 플레이하고 있지 않은 무작위 플레이어 100명이 할당됩니다. 게임을 종료하면 games_played 및 games_won 통계가 업데이트되며 해당 플레이어가 향후 게임에 배정될 수 있습니다.
Cloud Shell에서 Locust를 실행하려면 Python 3.7 이상이 필요합니다. Cloud Shell에는 Python 3.9가 함께 제공되므로 버전을 확인하기만 하면 됩니다.
python -V
명령어 결과
Python 3.9.12
이제 Locust의 요구사항을 설치할 수 있습니다.
pip3 install -r requirements.txt
명령어 결과
Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0
이제 새로 설치된 locust 바이너리를 찾을 수 있도록 PATH를 업데이트합니다.
PATH=~/.local/bin":$PATH"
which locust
명령어 결과
/home/<user>/.local/bin/locust
요약
이 단계에서는 프로젝트가 없는 경우 프로젝트를 설정하고, Cloud Shell을 활성화하고, 이 실습의 코드를 다운로드했습니다.
마지막으로 실습 후반에 부하 생성을 위해 Locust를 설정합니다.
다음 단계
이제 Cloud Spanner 인스턴스와 데이터베이스를 설정합니다.
3. Spanner 인스턴스와 데이터베이스 만들기
Spanner 인스턴스 만들기
이 단계에서는 Codelab을 위해 Spanner 인스턴스를 설정합니다. 왼쪽 위에 있는 햄버거 메뉴
에서 Spanner 항목
을 검색하거나 '/'를 누르고 'Spanner'를 입력하여 Spanner를 검색합니다.

그런 후
를 클릭하고 해당 인스턴스 이름에 cloudspanner-gaming를 입력하고, 구성을 선택 (us-central1와 같은 리전 인스턴스 선택)하여 양식을 작성하고, 노드 수를 설정합니다. 이 Codelab에서는 500 processing units만 있으면 됩니다.
마지막으로 '만들기'를 클릭하면 몇 초 지나지 않아 Cloud Spanner 인스턴스가 준비됩니다.

데이터베이스 및 스키마 만들기
인스턴스가 실행되면 데이터베이스를 만들 수 있습니다. Spanner는 단일 인스턴스에서 여러 데이터베이스를 허용합니다.
데이터베이스는 스키마를 정의하는 곳입니다. 데이터베이스에 액세스할 수 있는 사용자를 제어하고, 맞춤 암호화를 설정하고, 옵티마이저를 구성하고, 보관 기간을 설정할 수도 있습니다.
멀티 리전 인스턴스에서는 기본 리더를 구성할 수도 있습니다. Spanner의 데이터베이스에 대해 자세히 알아보기
이 Codelab에서는 기본 옵션으로 데이터베이스를 만들고 생성 시 스키마를 제공합니다.
이 실습에서는 players와 games라는 두 개의 테이블을 만듭니다.

플레이어는 시간이 지남에 따라 여러 게임에 참여할 수 있지만 한 번에 하나의 게임에만 참여할 수 있습니다. 플레이어는 games_played, games_won과 같은 흥미로운 통계를 추적하기 위해 JSON 데이터 유형으로 stats도 보유합니다. 나중에 다른 통계가 추가될 수 있으므로 이는 플레이어의 스키마가 없는 열입니다.
게임은 Spanner의 ARRAY 데이터 유형을 사용하여 참여한 플레이어를 추적합니다. 게임이 종료될 때까지 게임의 승자와 종료 속성이 채워지지 않습니다.
플레이어의 current_game이 유효한 게임인지 확인하는 외래 키가 하나 있습니다.
이제 인스턴스 개요에서 '데이터베이스 만들기'를 클릭하여 데이터베이스를 만듭니다.

그런 다음 세부정보를 입력합니다. 중요한 옵션은 데이터베이스 이름과 다이얼렉트입니다. 이 예에서는 데이터베이스 이름을 sample-game으로 지정하고 Google 표준 SQL 언어를 선택했습니다.
스키마의 경우 이 DDL을 복사하여 상자에 붙여넣습니다.
CREATE TABLE games (
gameUUID STRING(36) NOT NULL,
players ARRAY<STRING(36)> NOT NULL,
winner STRING(36),
created TIMESTAMP,
finished TIMESTAMP,
) PRIMARY KEY(gameUUID);
CREATE TABLE players (
playerUUID STRING(36) NOT NULL,
player_name STRING(64) NOT NULL,
email STRING(MAX) NOT NULL,
password_hash BYTES(60) NOT NULL,
created TIMESTAMP,
updated TIMESTAMP,
stats JSON,
account_balance NUMERIC NOT NULL DEFAULT (0.00),
is_logged_in BOOL,
last_login TIMESTAMP,
valid_email BOOL,
current_game STRING(36),
FOREIGN KEY (current_game) REFERENCES games (gameUUID),
) PRIMARY KEY(playerUUID);
CREATE UNIQUE INDEX PlayerAuthentication ON players(email) STORING (password_hash);
CREATE INDEX PlayerGame ON players(current_game);
CREATE UNIQUE INDEX PlayerName ON players(player_name);
그런 다음 만들기 버튼을 클릭하고 데이터베이스가 생성될 때까지 몇 초 정도 기다립니다.
데이터베이스 만들기 페이지는 다음과 같이 표시됩니다.

이제 코드 실습에서 나중에 사용할 환경 변수를 Cloud Shell에서 설정해야 합니다. 따라서 인스턴스 ID를 기록해 두고 Cloud Shell에서 INSTANCE_ID와 DATABASE_ID를 설정합니다.

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
요약
이 단계에서는 Spanner 인스턴스와 sample-game 데이터베이스를 만들었습니다. 이 샘플 게임에서 사용하는 스키마도 정의했습니다.
다음 단계
다음으로 플레이어가 게임을 플레이하기 위해 가입할 수 있도록 프로필 서비스를 배포합니다.
4. 프로필 서비스 배포
서비스 개요
프로필 서비스는 gin 프레임워크를 활용하는 Go로 작성된 REST API입니다.

이 API에서 플레이어는 게임을 플레이하기 위해 가입할 수 있습니다. 이는 플레이어 이름, 이메일, 비밀번호를 허용하는 간단한 POST 명령어로 생성됩니다. 비밀번호는 bcrypt로 암호화되고 해시는 데이터베이스에 저장됩니다.
이메일은 고유 식별자로 처리되는 반면 player_name은 게임의 표시 목적으로 사용됩니다.
이 API는 현재 로그인을 처리하지 않지만 추가 연습으로 구현할 수 있습니다.
프로필 서비스의 ./src/golang/profile-service/main.go 파일은 다음과 같이 두 가지 기본 엔드포인트를 노출합니다.
func main() {
configuration, _ := config.NewConfig()
router := gin.Default()
router.SetTrustedProxies(nil)
router.Use(setSpannerConnection(configuration))
router.POST("/players", createPlayer)
router.GET("/players", getPlayerUUIDs)
router.GET("/players/:id", getPlayerByID)
router.Run(configuration.Server.URL())
}
이러한 엔드포인트의 코드는 player 모델로 라우팅됩니다.
func getPlayerByID(c *gin.Context) {
var playerUUID = c.Param("id")
ctx, client := getSpannerConnection(c)
player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
if err != nil {
c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
return
}
c.IndentedJSON(http.StatusOK, player)
}
func createPlayer(c *gin.Context) {
var player models.Player
if err := c.BindJSON(&player); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ctx, client := getSpannerConnection(c)
err := player.AddPlayer(ctx, client)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}
서비스에서 가장 먼저 하는 일 중 하나는 Spanner 연결을 설정하는 것입니다. 이는 서비스의 세션 풀을 만들기 위해 서비스 수준에서 구현됩니다.
func setSpannerConnection() gin.HandlerFunc {
ctx := context.Background()
client, err := spanner.NewClient(ctx, configuration.Spanner.URL())
if err != nil {
log.Fatal(err)
}
return func(c *gin.Context) {
c.Set("spanner_client", *client)
c.Set("spanner_context", ctx)
c.Next()
}
}
Player 및 PlayerStats는 다음과 같이 정의된 구조체입니다.
type Player struct {
PlayerUUID string `json:"playerUUID" validate:"omitempty,uuid4"`
Player_name string `json:"player_name" validate:"required_with=Password Email"`
Email string `json:"email" validate:"required_with=Player_name Password,email"`
// not stored in DB
Password string `json:"password" validate:"required_with=Player_name Email"`
// stored in DB
Password_hash []byte `json:"password_hash"`
created time.Time
updated time.Time
Stats spanner.NullJSON `json:"stats"`
Account_balance big.Rat `json:"account_balance"`
last_login time.Time
is_logged_in bool
valid_email bool
Current_game string `json:"current_game" validate:"omitempty,uuid4"`
}
type PlayerStats struct {
Games_played spanner.NullInt64 `json:"games_played"`
Games_won spanner.NullInt64 `json:"games_won"`
}
플레이어를 추가하는 함수는 ReadWrite 트랜잭션 내에서 DML 삽입을 활용합니다. 플레이어 추가는 일괄 삽입이 아닌 단일 문이기 때문입니다. 함수는 다음과 같습니다.
func (p *Player) AddPlayer(ctx context.Context, client spanner.Client) error {
// Validate based on struct validation rules
err := p.Validate()
if err != nil {
return err
}
// take supplied password+salt, hash. Store in user_password
passHash, err := hashPassword(p.Password)
if err != nil {
return errors.New("Unable to hash password")
}
p.Password_hash = passHash
// Generate UUIDv4
p.PlayerUUID = generateUUID()
// Initialize player stats
emptyStats := spanner.NullJSON{Value: PlayerStats{
Games_played: spanner.NullInt64{Int64: 0, Valid: true},
Games_won: spanner.NullInt64{Int64: 0, Valid: true},
}, Valid: true}
// insert into spanner
_, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
stmt := spanner.Statement{
SQL: `INSERT players (playerUUID, player_name, email, password_hash, created, stats) VALUES
(@playerUUID, @playerName, @email, @passwordHash, CURRENT_TIMESTAMP(), @pStats)
`,
Params: map[string]interface{}{
"playerUUID": p.PlayerUUID,
"playerName": p.Player_name,
"email": p.Email,
"passwordHash": p.Password_hash,
"pStats": emptyStats,
},
}
_, err := txn.Update(ctx, stmt)
return err
})
if err != nil {
return err
}
// return empty error on success
return nil
}
UUID를 기반으로 플레이어를 가져오기 위해 간단한 읽기가 실행됩니다. 이렇게 하면 플레이어 playerUUID, player_name, email 및 stats가 검색됩니다.
func GetPlayerByUUID(ctx context.Context, client spanner.Client, uuid string) (Player, error) {
row, err := client.Single().ReadRow(ctx, "players",
spanner.Key{uuid}, []string{"playerUUID", "player_name", "email", "stats"})
if err != nil {
return Player{}, err
}
player := Player{}
err = row.ToStruct(&player)
if err != nil {
return Player{}, err
}
return player, nil
}
기본적으로 서비스는 환경 변수를 사용하여 구성됩니다. ./src/golang/profile-service/config/config.go 파일의 관련 섹션을 참고하세요.
func NewConfig() (Config, error) {
*snip*
// Server defaults
viper.SetDefault("server.host", "localhost")
viper.SetDefault("server.port", 8080)
// Bind environment variable override
viper.BindEnv("server.host", "SERVICE_HOST")
viper.BindEnv("server.port", "SERVICE_PORT")
viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")
*snip*
return c, nil
}
기본 동작은 localhost:8080에서 서비스를 실행하는 것입니다.
이 정보를 사용하여 서비스를 실행할 수 있습니다.
프로필 서비스 실행
go 명령어를 사용하여 서비스를 실행합니다. 이렇게 하면 종속 항목이 다운로드되고 포트 8080에서 실행되는 서비스가 설정됩니다.
cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &
명령어 출력:
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] POST /players --> main.createPlayer (4 handlers)
[GIN-debug] GET /players --> main.getPlayerUUIDs (4 handlers)
[GIN-debug] GET /players/:id --> main.getPlayerByID (4 handlers)
[GIN-debug] GET /players/:id/stats --> main.getPlayerStats (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080
curl 명령어를 실행하여 서비스를 테스트합니다.
curl http://localhost:8080/players \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'
명령어 출력:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 18:55:08 GMT
Content-Length: 38
"506a1ab6-ee5b-4882-9bb1-ef9159a72989"
요약
이 단계에서는 플레이어가 게임을 플레이하기 위해 가입할 수 있는 프로필 서비스를 배포하고 POST API 호출을 실행하여 새 플레이어를 만들어 서비스를 테스트했습니다.
다음 단계
다음 단계에서는 매치 메이킹 서비스를 배포합니다.
5. 매치 메이킹 서비스 배포
서비스 개요
매치 메이킹 서비스는 gin 프레임워크를 활용하는 Go로 작성된 REST API입니다.

이 API에서 게임은 생성되고 종료됩니다. 게임이 생성되면 현재 게임을 플레이하고 있지 않은 10명의 플레이어가 게임에 할당됩니다.
게임이 종료되면 무작위로 승자가 선택되고 각 플레이어의 games_played 및 games_won 통계가 조정됩니다. 또한 각 플레이어가 더 이상 플레이하지 않으며 향후 게임을 플레이할 수 있음을 나타내도록 업데이트됩니다.
매치메이킹 서비스의 ./src/golang/matchmaking-service/main.go 파일은 profile 서비스와 유사한 설정과 코드를 따르므로 여기서는 반복하지 않습니다. 이 서비스는 다음과 같이 두 가지 기본 엔드포인트를 노출합니다.
func main() {
router := gin.Default()
router.SetTrustedProxies(nil)
router.Use(setSpannerConnection())
router.POST("/games/create", createGame)
router.PUT("/games/close", closeGame)
router.Run(configuration.Server.URL())
}
이 서비스는 Game 구조체와 간소화된 Player 및 PlayerStats 구조체를 제공합니다.
type Game struct {
GameUUID string `json:"gameUUID"`
Players []string `json:"players"`
Winner string `json:"winner"`
Created time.Time `json:"created"`
Finished spanner.NullTime `json:"finished"`
}
type Player struct {
PlayerUUID string `json:"playerUUID"`
Stats spanner.NullJSON `json:"stats"`
Current_game string `json:"current_game"`
}
type PlayerStats struct {
Games_played int `json:"games_played"`
Games_won int `json:"games_won"`
}
게임을 만들기 위해 매치메이킹 서비스는 현재 게임을 플레이하고 있지 않은 100명의 플레이어를 무작위로 선택합니다.
Spanner 변이는 게임을 만들고 플레이어를 할당하는 데 사용됩니다. 변이는 대규모 변경사항에 DML보다 성능이 우수하기 때문입니다.
// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
// Initialize game values
g.GameUUID = generateUUID()
numPlayers := 10
// Create and assign
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
var m []*spanner.Mutation
// get players
query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
stmt := spanner.Statement{SQL: query}
iter := txn.Query(ctx, stmt)
playerRows, err := readRows(iter)
if err != nil {
return err
}
var playerUUIDs []string
for _, row := range playerRows {
var pUUID string
if err := row.Columns(&pUUID); err != nil {
return err
}
playerUUIDs = append(playerUUIDs, pUUID)
}
// Create the game
gCols := []string{"gameUUID", "players", "created"}
m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))
// Update players to lock into this game
for _, p := range playerUUIDs {
pCols := []string{"playerUUID", "current_game"}
m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
}
txn.BufferWrite(m)
return nil
})
if err != nil {
return err
}
return nil
}
무작위 플레이어 선택은 GoogleSQL의 TABLESPACE RESERVOIR 기능을 사용하여 SQL로 실행됩니다.
게임을 종료하는 것은 약간 더 복잡합니다. 게임 플레이어 중에서 무작위로 승자를 선택하고, 게임이 종료된 시간을 표시하고, 각 플레이어의 games_played 및 games_won 통계를 업데이트합니다.
이러한 복잡성과 변경사항의 양으로 인해 돌연변이가 다시 게임을 종료하는 데 선택됩니다.
func determineWinner(playerUUIDs []string) string {
if len(playerUUIDs) == 0 {
return ""
}
var winnerUUID string
rand.Seed(time.Now().UnixNano())
offset := rand.Intn(len(playerUUIDs))
winnerUUID = playerUUIDs[offset]
return winnerUUID
}
// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
for _, p := range players {
// Modify stats
var pStats PlayerStats
json.Unmarshal([]byte(p.Stats.String()), &pStats)
pStats.Games_played = pStats.Games_played + 1
if p.PlayerUUID == g.Winner {
pStats.Games_won = pStats.Games_won + 1
}
updatedStats, _ := json.Marshal(pStats)
p.Stats.UnmarshalJSON(updatedStats)
// Update player
// If player's current game isn't the same as this game, that's an error
if p.Current_game != g.GameUUID {
errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
return errors.New(errorMsg)
}
cols := []string{"playerUUID", "current_game", "stats"}
newGame := spanner.NullString{
StringVal: "",
Valid: false,
}
txn.BufferWrite([]*spanner.Mutation{
spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
})
}
return nil
}
// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
// Close game
_, err := client.ReadWriteTransaction(ctx,
func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
// Get game players
playerUUIDs, players, err := g.getGamePlayers(ctx, txn)
if err != nil {
return err
}
// Might be an issue if there are no players!
if len(playerUUIDs) == 0 {
errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
return errors.New(errorMsg)
}
// Get random winner
g.Winner = determineWinner(playerUUIDs)
// Validate game finished time is null
row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
if err != nil {
return err
}
if err := row.Column(0, &g.Finished); err != nil {
return err
}
// If time is not null, then the game is already marked as finished.
// That's an error.
if !g.Finished.IsNull() {
errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
return errors.New(errorMsg)
}
cols := []string{"gameUUID", "finished", "winner"}
txn.BufferWrite([]*spanner.Mutation{
spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
})
// Update each player to increment stats.games_played
// (and stats.games_won if winner), and set current_game
// to null so they can be chosen for a new game
playerErr := g.updateGamePlayers(ctx, players, txn)
if playerErr != nil {
return playerErr
}
return nil
})
if err != nil {
return err
}
return nil
}
구성도 서비스의 ./src/golang/matchmaking-service/config/config.go에 설명된 대로 환경 변수를 통해 처리됩니다.
// Server defaults
viper.SetDefault("server.host", "localhost")
viper.SetDefault("server.port", 8081)
// Bind environment variable override
viper.BindEnv("server.host", "SERVICE_HOST")
viper.BindEnv("server.port", "SERVICE_PORT")
viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")
프로필 서비스와의 충돌을 방지하기 위해 이 서비스는 기본적으로 localhost:8081에서 실행됩니다.
이제 이 정보를 사용하여 매치메이킹 서비스를 실행할 수 있습니다.
매치 메이킹 서비스 실행
go 명령어를 사용하여 서비스를 실행합니다. 이렇게 하면 포트 8082에서 실행되는 서비스가 설정됩니다. 이 서비스에는 profile-service와 동일한 종속 항목이 많으므로 새 종속 항목이 다운로드되지 않습니다.
cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &
명령어 출력:
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] POST /games/create --> main.createGame (4 handlers)
[GIN-debug] PUT /games/close --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081
게임 만들기
서비스를 테스트하여 게임을 만듭니다. 먼저 Cloud Shell에서 새 터미널을 엽니다.

그런 다음 다음 curl 명령어를 실행합니다.
curl http://localhost:8081/games/create \
--include \
--header "Content-Type: application/json" \
--request "POST"
명령어 출력:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 19:38:45 GMT
Content-Length: 38
"f45b0f7f-405b-4e67-a3b8-a624e990285d"
게임 닫기
curl http://localhost:8081/games/close \
--include \
--header "Content-Type: application/json" \
--data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
--request "PUT"
명령어 출력:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: <date> 19:43:58 GMT
Content-Length: 38
"506a1ab6-ee5b-4882-9bb1-ef9159a72989"
요약
이 단계에서는 게임을 만들고 플레이어를 해당 게임에 할당하는 작업을 처리하는 매치메이킹 서비스를 배포했습니다. 이 서비스는 무작위로 승자를 선택하고 games_played 및 games_won에 대한 모든 게임 플레이어의 통계를 업데이트하는 게임 종료도 처리합니다.
다음 단계
이제 서비스가 실행되었으므로 플레이어가 가입하고 게임을 플레이하도록 유도할 차례입니다.
6. 재생 시작
이제 프로필 및 매치메이킹 서비스가 실행 중이므로 제공된 locust 생성기를 사용하여 부하를 생성할 수 있습니다.
Locust는 생성기를 실행하기 위한 웹 인터페이스를 제공하지만 이 실습에서는 명령줄 (–headless 옵션)을 사용합니다.
플레이어 가입
먼저 플레이어를 생성해야 합니다.
./generators/authentication_server.py 파일에서 플레이어를 만드는 Python 코드는 다음과 같습니다.
class PlayerLoad(HttpUser):
def on_start(self):
global pUUIDs
pUUIDs = []
def generatePlayerName(self):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
def generatePassword(self):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
def generateEmail(self):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32) + ['@'] +
random.choices(['gmail', 'yahoo', 'microsoft']) + ['.com'])
@task
def createPlayer(self):
headers = {"Content-Type": "application/json"}
data = {"player_name": self.generatePlayerName(), "email": self.generateEmail(), "password": self.generatePassword()}
with self.client.post("/players", data=json.dumps(data), headers=headers, catch_response=True) as response:
try:
pUUIDs.append(response.json())
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'gameUUID'")
플레이어 이름, 이메일, 비밀번호는 무작위로 생성됩니다.
성공적으로 가입한 플레이어는 읽기 부하를 생성하기 위해 두 번째 작업에 의해 검색됩니다.
@task(5)
def getPlayer(self):
# No player UUIDs are in memory, reschedule task to run again later.
if len(pUUIDs) == 0:
raise RescheduleTask()
# Get first player in our list, removing it to avoid contention from concurrent requests
pUUID = pUUIDs[0]
del pUUIDs[0]
headers = {"Content-Type": "application/json"}
self.client.get(f"/players/{pUUID}", headers=headers, name="/players/[playerUUID]")
다음 명령어는 한 번에 두 개의 스레드 (u=2)를 사용하여 30초 동안 (t=30s) 새 플레이어를 생성하는 ./generators/authentication_server.py 파일을 호출합니다.
cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s
플레이어가 게임에 참여
이제 플레이어가 가입했으므로 게임을 시작하고 싶어할 것입니다.
./generators/match_server.py 파일에서 게임을 만들고 닫는 Python 코드는 다음과 같습니다.
from locust import HttpUser, task
from locust.exception import RescheduleTask
import json
class GameMatch(HttpUser):
def on_start(self):
global openGames
openGames = []
@task(2)
def createGame(self):
headers = {"Content-Type": "application/json"}
# Create the game, then store the response in memory of list of open games.
with self.client.post("/games/create", headers=headers, catch_response=True) as response:
try:
openGames.append({"gameUUID": response.json()})
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'gameUUID'")
@task
def closeGame(self):
# No open games are in memory, reschedule task to run again later.
if len(openGames) == 0:
raise RescheduleTask()
headers = {"Content-Type": "application/json"}
# Close the first open game in our list, removing it to avoid
# contention from concurrent requests
game = openGames[0]
del openGames[0]
data = {"gameUUID": game["gameUUID"]}
self.client.put("/games/close", data=json.dumps(data), headers=headers)
이 생성기를 실행하면 게임이 2:1 (열기:닫기) 비율로 열리고 닫힙니다. 이 명령어는 10초 동안 생성기를 실행합니다(-t=10s).
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
요약
이 단계에서는 게임을 플레이하기 위해 가입하는 플레이어를 시뮬레이션한 다음 매치메이킹 서비스를 사용하여 게임을 플레이하는 플레이어를 시뮬레이션했습니다. 이러한 시뮬레이션에서는 Locust Python 프레임워크를 활용하여 서비스의 REST API에 요청을 발행했습니다.
플레이어를 만들고 게임을 플레이하는 데 걸리는 시간과 동시 사용자 수 (-u)를 자유롭게 수정하세요.
다음 단계
시뮬레이션 후 Spanner를 쿼리하여 다양한 통계를 확인해야 합니다.
7. 게임 통계 가져오기
이제 플레이어가 가입하고 게임을 플레이할 수 있는 시뮬레이션을 실행했으므로 통계를 확인해야 합니다.
이렇게 하려면 Cloud Console을 사용하여 Spanner에 쿼리 요청을 실행합니다.

공개 게임과 비공개 게임 확인
종료된 게임은 finished 타임스탬프가 채워져 있는 게임이고, 진행 중인 게임은 finished가 NULL입니다. 이 값은 게임이 닫힐 때 설정됩니다.
따라서 다음 쿼리를 사용하면 열려 있는 게임과 닫힌 게임의 수를 확인할 수 있습니다.
SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)
결과:
|
|
|
|
|
|
플레이 중인 플레이어 수와 플레이하지 않는 플레이어 수 확인
current_game 열이 설정된 경우 플레이어가 게임을 플레이하고 있는 것입니다. 그렇지 않으면 현재 게임을 플레이하고 있지 않습니다.
따라서 현재 플레이 중인 플레이어와 플레이하지 않는 플레이어를 비교하려면 다음 쿼리를 사용하세요.
SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)
결과:
|
|
|
|
|
|
최고 수상자 결정
게임이 종료되면 플레이어 중 한 명이 무작위로 선택되어 승자가 됩니다. 해당 플레이어의 games_won 통계는 게임을 종료하는 동안 증가합니다.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
결과:
playerUUID | stats |
07e247c5-f88e-4bca-a7bc-12d2485f2f2b | {"games_played":49,"games_won":1} |
09b72595-40af-4406-a000-2fb56c58fe92 | {"games_played":56,"games_won":1} |
1002385b-02a0-462b-a8e7-05c9b27223aa | {"games_played":66,"games_won":1} |
13ec3770-7ae3-495f-9b53-6322d8e8d6c3 | {"games_played":44,"games_won":1} |
15513852-3f2a-494f-b437-fe7125d15f1b | {"games_played":49,"games_won":1} |
17faec64-4f77-475c-8df8-6ab026cf6698 | {"games_played":50,"games_won":1} |
1abfcb27-037d-446d-bb7a-b5cd17b5733d | {"games_played":63,"games_won":1} |
2109a33e-88bd-4e74-a35c-a7914d9e3bde | {"games_played":56,"games_won":2} |
222e37d9-06b0-4674-865d-a0e5fb80121e | {"games_played":60,"games_won":1} |
22ced15c-0da6-4fd9-8cb2-1ffd233b3c56 | {"games_played":50,"games_won":1} |
요약
이 단계에서는 Cloud 콘솔을 사용하여 Spanner를 쿼리하여 플레이어와 게임의 다양한 통계를 검토했습니다.
다음 단계
이제 정리할 시간입니다.
8. 정리 (선택사항)
정리하려면 Cloud Console의 Cloud Spanner 섹션으로 이동하고 'Cloud Spanner 인스턴스 설정' Codelab 단계에서 만든 'cloudspanner-gaming' 인스턴스를 삭제하면 됩니다.
9. 축하합니다.
축하합니다. Spanner에 샘플 게임을 배포했습니다.
다음 단계
이 실습에서는 Go 드라이버를 사용하여 Spanner로 작업하는 다양한 주제를 살펴보았습니다. 이를 통해 다음과 같은 중요한 개념을 더 잘 이해할 수 있습니다.
- 스키마 설계
- DML과 변형 비교
- Golang 작업
게임의 백엔드로 Spanner를 사용하는 또 다른 예시를 보려면 Cloud Spanner 게임 거래소 Codelab을 참고하세요.