1. 소개
최종 업데이트: 2023년 1월 27일
리더보드를 만들려면 무엇이 필요한가요?
기본적으로 리더보드는 점수 표에 불과하지만 한 가지 복잡한 요소가 있습니다. 특정 점수의 순위를 읽으려면 어떤 종류의 순서든 다른 모든 점수를 알아야 합니다. 또한 게임이 인기를 얻으면 리더보드가 커지고 자주 읽고 써야 합니다. 성공적인 리더보드를 구축하려면 이러한 순위 결정 작업을 신속하게 처리할 수 있어야 합니다.
빌드할 항목
이 Codelab에서는 각각 다른 시나리오에 적합한 다양한 리더보드를 구현합니다.
학습할 내용
다음과 같은 4가지 리더보드를 구현하는 방법을 알아봅니다.
- 간단한 레코드 집계를 사용하여 순위를 결정하는 순진한 구현
- 저렴하고 주기적으로 업데이트되는 리더보드
- 나무에 관한 헛소리로 가득 찬 실시간 리더보드
- 규모가 매우 큰 플레이어층의 대략적인 순위를 보여주는 확률적 (확률적) 리더보드
필요한 사항
- 최신 버전의 Chrome(107 이상)
- Node.js 16 이상(nvm을 사용하는 경우
nvm --version
를 실행하여 버전 번호를 확인) - 유료 Firebase Blaze 요금제 (선택사항)
- Firebase CLI v11.16.0 이상
CLI를 설치하려면npm install -g firebase-tools
를 실행하거나 CLI 문서에서 추가 설치 옵션을 참고하세요. - JavaScript, Cloud Firestore, Cloud Functions, Chrome DevTools에 관한 지식
2. 설정
코드 가져오기
이 프로젝트에 필요한 모든 것을 Git 저장소에 넣었습니다. 시작하려면 코드를 가져와 원하는 개발 환경에서 열어야 합니다. 이 Codelab에서는 VS Code를 사용했지만 어떤 텍스트 편집기나 사용할 수 있습니다.
또는 선택한 디렉터리로 클론합니다.
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
시작점
우리 프로젝트는 현재 일부 빈 함수가 있는 빈 슬레이트입니다.
index.html
에는 개발 콘솔에서 함수를 호출하고 출력을 확인할 수 있는 글루 스크립트가 포함되어 있습니다. 이를 사용하여 백엔드와 연결하고 함수 호출의 결과를 확인합니다. 실제 시나리오에서는 게임에서 이러한 백엔드 호출을 직접 실행합니다. 이 Codelab에서는 리더보드에 점수를 추가할 때마다 게임을 플레이하는 데 시간이 너무 오래 걸리므로 게임을 사용하지 않습니다.functions/index.js
에는 모든 Cloud Functions가 포함되어 있습니다.addScores
및deleteScores
와 같은 몇 가지 유틸리티 함수와 이 Codelab에서 구현할 함수(다른 파일의 도우미 함수를 호출함)를 볼 수 있습니다.functions/functions-helpers.js
에는 구현할 빈 함수가 포함되어 있습니다. 각 리더보드에 대해 읽기, 만들기, 업데이트 함수를 구현합니다. 또한 구현 선택이 구현의 복잡성과 확장 성능에 어떤 영향을 미치는지 확인할 수 있습니다.functions/utils.js
에는 더 많은 유틸리티 함수가 포함되어 있습니다. 이 Codelab에서는 이 파일을 다루지 않습니다.
Firebase 프로젝트 만들기 및 구성
- Firebase Console에서 프로젝트 추가를 클릭합니다.
- 새 프로젝트를 만들려면 원하는 프로젝트 이름을 입력합니다.
이렇게 하면 프로젝트 ID (프로젝트 이름 아래에 표시됨)도 프로젝트 이름에 따라 설정됩니다. 필요한 경우 프로젝트 ID에서 수정 아이콘을 클릭하여 추가로 맞춤설정할 수 있습니다. - 메시지가 표시되면 Firebase 약관을 검토하고 이에 동의합니다.
- 계속을 클릭합니다.
- 이 프로젝트에 Google 애널리틱스 사용 설정 옵션을 선택한 다음 계속을 클릭합니다.
- 사용할 기존 Google 애널리틱스 계정을 선택하거나 새 계정 만들기를 선택하여 새 계정을 만듭니다.
- 프로젝트 만들기를 클릭합니다.
- 프로젝트가 생성되면 계속을 클릭합니다.
- Build 메뉴에서 Functions를 클릭하고 메시지가 표시되면 Blaze 결제 요금제를 사용하도록 프로젝트를 업그레이드합니다.
- 빌드 메뉴에서 Firestore 데이터베이스를 클릭합니다.
- 표시된 데이터베이스 만들기 대화상자에서 테스트 모드에서 시작을 선택한 다음 다음을 클릭합니다.
- Cloud Firestore 위치 드롭다운에서 리전을 선택한 다음 사용 설정을 클릭합니다.
리더보드 구성 및 실행
- 터미널에서 프로젝트 루트로 이동하고
firebase use --add
를 실행합니다. 방금 만든 Firebase 프로젝트를 선택합니다. - 프로젝트 루트에서
firebase emulators:start --only hosting
를 실행합니다. - 브라우저에서
localhost:5000
으로 이동합니다. - Chrome DevTools의 JavaScript 콘솔을 열고
leaderboard.js
를 가져옵니다.const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- 콘솔에서
leaderboard.codelab();
를 실행합니다. 환영 메시지가 표시되면 완료된 것입니다. 그렇지 않으면 에뮬레이터를 종료하고 2~4단계를 다시 실행합니다.
첫 번째 리더보드 구현으로 이동하겠습니다.
3. 간단한 리더보드 구현
이 섹션을 마치면 리더보드에 점수를 추가하고 순위를 확인할 수 있습니다.
시작하기 전에 이 리더보드 구현의 작동 방식을 설명하겠습니다. 모든 플레이어는 하나의 컬렉션에 저장되며 플레이어의 순위를 가져오는 작업은 컬렉션을 가져오고 몇 명의 앞서 있는지 계산하여 수행됩니다. 이렇게 하면 점수를 쉽게 삽입하고 업데이트할 수 있습니다. 새 점수를 삽입하려면 컬렉션에 점수를 추가하기만 하면 됩니다. 업데이트하려면 현재 사용자로 필터링한 다음 결과 문서를 업데이트합니다. 코드에서 어떤 모습인지 살펴보겠습니다.
functions/functions-helper.js
에서 createScore
함수를 구현합니다. 이 함수는 매우 간단합니다.
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
점수를 업데이트하려면 오류 검사를 추가하여 업데이트 중인 점수가 이미 있는지 확인하기만 하면 됩니다.
async function updateScore(playerID, newScore, firestore) {
const playerSnapshot = await firestore.collection("scores")
.where("user", "==", playerID).get();
if (playerSnapshot.size !== 1) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
const doc = firestore.doc(player.id);
return doc.update({
score: newScore,
});
}
마지막으로 간단하지만 확장성이 떨어지는 순위 함수가 있습니다.
async function readRank(playerID, firestore) {
const scores = await firestore.collection("scores")
.orderBy("score", "desc").get();
const player = `${playerID}`;
let rank = 1;
for (const doc of scores.docs) {
const user = `${doc.get("user")}`;
if (user === player) {
return {
user: player,
rank: rank,
score: doc.get("score"),
};
}
rank++;
}
// No user found
throw Error(`User not found in leaderboard: ${playerID}`);
}
테스트해 보겠습니다. 터미널에서 다음을 실행하여 함수를 배포합니다.
firebase deploy --only functions
그런 다음 Chrome의 JS 콘솔에서 다른 점수를 추가하여 다른 플레이어와의 순위를 확인합니다.
leaderboard.addScores(); // Results may take some time to appear.
이제 자체 점수를 믹스에 추가할 수 있습니다.
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
쓰기가 완료되면 콘솔에 'Score created'라는 응답이 표시됩니다. 대신 오류가 표시되나요? Firebase Console을 통해 Functions 로그를 열어 문제를 확인합니다.
마지막으로, 점수를 가져오고 업데이트할 수 있습니다.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
하지만 이 구현은 특정 점수의 순위를 가져오는 데 바람직하지 않은 선형 시간 및 메모리 요구사항을 제공합니다. 함수 실행 시간과 메모리는 모두 제한되어 있으므로 가져오기가 점점 느려질 뿐만 아니라 리더보드에 충분한 점수가 추가되면 함수가 결과를 반환하기 전에 시간 초과되거나 비정상 종료됩니다. 소수의 플레이어를 넘어 확장하려면 더 좋은 방법이 필요하겠죠.
Firestore 애호가라면 이 리더보드의 성능을 훨씬 더 높여주는 COUNT 집계 쿼리에 대해 알고 있을 것입니다. 맞습니다. COUNT 쿼리를 사용하면 성능이 계속 변하지만 백만 명 정도의 사용자 수 미만으로 확장됩니다.
하지만 컬렉션의 모든 문서를 열거할 예정이라면 모든 문서에 순위를 할당하고 가져와야 할 때 가져오기가 O(1) 시간과 메모리가 될 것이라고 생각할 수 있습니다. 다음으로 살펴볼 접근 방식은 주기적으로 업데이트되는 리더보드입니다.
4. 주기적으로 업데이트되는 리더보드 구현
이 접근 방식의 핵심은 순위를 문서 자체에 저장하는 것이므로 순위를 가져오면 추가 작업 없이 순위가 제공됩니다. 이를 위해서는 새로운 종류의 함수가 필요합니다.
index.js
에 다음을 추가합니다.
// Also add this to the top of your file
const admin = require("firebase-admin");
exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
// Schedule this when most of your users are offline to avoid
// database spikiness.
.timeZone("America/Los_Angeles")
.onRun((context) => {
const scores = admin.firestore().collection("scores");
scores.orderBy("score", "desc").get().then((snapshot) => {
let rank = 1;
const writes = [];
for (const docSnapshot of snapshot.docs) {
const docReference = scores.doc(docSnapshot.id);
writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
rank++;
}
Promise.all(writes).then((result) => {
console.log(`Writes completed with results: ${result}`);
});
});
return null;
});
이제 읽기, 업데이트, 쓰기 작업이 모두 간단하고 좋습니다. 쓰기와 업데이트는 둘 다 변경되지 않지만 읽기는 (functions-helpers.js
에서)됩니다.
async function readRank(playerID, firestore) {
const scores = firestore.collection("scores");
const playerSnapshot = await scores
.where("user", "==", playerID).get();
if (playerSnapshot.size === 0) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
if (player.get("rank") === undefined) {
// This score was added before our scheduled function could run,
// but this shouldn't be treated as an error
return {
user: playerID,
rank: null,
score: player.get("score"),
};
}
return {
user: playerID,
rank: player.get("rank"),
score: player.get("score"),
};
}
프로젝트에 결제 계정을 추가하지 않으면 이를 배포하고 테스트할 수 없습니다. 결제 계정이 있다면 예약 기능의 간격을 줄이고 함수가 리더보드 점수에 마술처럼 순위를 할당하는 것을 지켜보세요.
그렇지 않은 경우 예약된 함수를 삭제하고 다음 구현으로 건너뜁니다.
점수 컬렉션 옆에 있는 점 3개를 클릭하여 Firestore 데이터베이스에서 점수를 삭제하고 다음 섹션을 준비합니다.
5. 실시간 트리 리더보드 구현
이 방법은 데이터베이스 컬렉션 자체에 검색 데이터를 저장하는 방식으로 작동합니다. 균일한 컬렉션을 사용하는 대신 문서를 이동하면서 순회할 수 있는 모든 항목을 트리에 저장하는 것이 목표입니다. 이를 통해 특정 점수의 순위에 대한 이진(또는 n항) 검색을 실행할 수 있습니다. 어떤 모습일까요?
먼저 점수를 대략적으로 균등한 버킷으로 분산해야 합니다. 이를 위해서는 사용자가 기록하는 점수 값에 대한 지식이 어느 정도 필요합니다. 예를 들어 경쟁 게임에서 기술 등급 리더보드를 빌드하는 경우 사용자의 기술 등급은 거의 항상 정규적으로 분포됩니다. 무작위 점수 생성 함수는 JavaScript의 Math.random()
를 사용하므로 거의 균등하게 분포되므로 버킷을 균등하게 나눕니다.
이 예시에서는 편의상 버킷 3개를 사용하지만 실제 앱에서 이 구현을 사용할 경우 더 많은 버킷이 더 빠른 결과를 얻을 수 있습니다. 트리가 얕을수록 평균적으로 컬렉션 가져오기와 잠금 경합이 줄어듭니다.
플레이어의 순위는 점수가 높은 플레이어 수와 플레이어 본인의 점수를 더한 값으로 계산됩니다. scores
아래의 각 컬렉션은 각각 범위가 있는 문서 3개, 각 범위에 속한 문서 수, 해당하는 하위 컬렉션 3개를 저장합니다. 순위를 읽으려면 이 트리를 탐색하여 점수를 찾고 더 큰 점수의 합계를 추적합니다. 점수가 나오면 올바른 합계도 나옵니다.
작성은 훨씬 더 복잡합니다. 먼저, 여러 쓰기 또는 읽기가 동시에 발생할 때 발생하는 데이터 불일치를 방지하기 위해 모든 쓰기 작업을 트랜잭션 내에서 수행해야 합니다. 또한 새 문서를 작성하기 위해 트리를 순회하는 동안 위에서 설명한 모든 조건을 유지해야 합니다. 마지막으로 이 새로운 접근 방식의 모든 트리 복잡성과 모든 원본 문서를 저장해야 하는 필요성이 결합되므로 스토리지 비용이 약간 증가하지만 여전히 선형적입니다.
functions-helpers.js
:
async function createScore(playerID, score, firestore) {
/**
* This function assumes a minimum score of 0 and that value
* is between min and max.
* Returns the expected size of a bucket for a given score
* so that bucket sizes stay constant, to avoid expensive
* re-bucketing.
* @param {number} value The new score.
* @param {number} min The min of the previous range.
* @param {number} max The max of the previous range. Must be greater than
* min.
* @return {Object<string, number>} Returns an object containing the new min
* and max.
*/
function bucket(value, min, max) {
const bucketSize = (max - min) / 3;
const bucketMin = Math.floor(value / bucketSize) * bucketSize;
const bucketMax = bucketMin + bucketSize;
return {min: bucketMin, max: bucketMax};
}
/**
* A function used to store pending writes until all reads within a
* transaction have completed.
*
* @callback PendingWrite
* @param {admin.firestore.Transaction} transaction The transaction
* to be used for writes.
* @return {void}
*/
/**
* Recursively searches for the node to write the score to,
* then writes the score and updates any counters along the way.
* @param {number} id The user associated with the score.
* @param {number} value The new score.
* @param {admin.firestore.CollectionReference} coll The collection this
* value should be written to.
* @param {Object<string, number>} range An object with properties min and
* max defining the range this score should be in. Ranges cannot overlap
* without causing problems. Use the bucket function above to determine a
* root range from constant values to ensure consistency.
* @param {admin.firestore.Transaction} transaction The transaction used to
* ensure consistency during tree updates.
* @param {Array<PendingWrite>} pendingWrites A series of writes that should
* occur once all reads within a transaction have completed.
* @return {void} Write error/success is handled via the transaction object.
*/
async function writeScoreToCollection(
id, value, coll, range, transaction, pendingWrites) {
const snapshot = await transaction.get(coll);
if (snapshot.empty) {
// This is the first score to be inserted into this node.
for (const write of pendingWrites) {
write(transaction);
}
const docRef = coll.doc();
transaction.create(docRef, {exact: {score: value, user: id}});
return;
}
const min = range.min;
const max = range.max;
for (const node of snapshot.docs) {
const data = node.data();
if (data.exact !== undefined) {
// This node held an exact score.
const newRange = bucket(value, min, max);
const tempRange = bucket(data.exact.score, min, max);
if (newRange.min === tempRange.min &&
newRange.max === tempRange.max) {
// The scores belong in the same range, so we need to "demote" both
// to a lower level of the tree and convert this node to a range.
const rangeData = {
range: newRange,
count: 2,
};
for (const write of pendingWrites) {
write(transaction);
}
const docReference = node.ref;
transaction.set(docReference, rangeData);
transaction.create(docReference.collection("scores").doc(), data);
transaction.create(
docReference.collection("scores").doc(),
{exact: {score: value, user: id}},
);
return;
} else {
// The scores are in different ranges. Continue and try to find a
// range that fits this score.
continue;
}
}
if (data.range.min <= value && data.range.max > value) {
// The score belongs to this range that may have subvalues.
// Increment the range's count in pendingWrites, since
// subsequent recursion may incur more reads.
const docReference = node.ref;
const newCount = node.get("count") + 1;
pendingWrites.push((t) => {
t.update(docReference, {count: newCount});
});
const newRange = bucket(value, min, max);
return writeScoreToCollection(
id,
value,
docReference.collection("scores"),
newRange,
transaction,
pendingWrites,
);
}
}
// No appropriate range was found, create an `exact` value.
transaction.create(coll.doc(), {exact: {score: value, user: id}});
}
const scores = firestore.collection("scores");
const players = firestore.collection("players");
return firestore.runTransaction((transaction) => {
return writeScoreToCollection(
playerID, score, scores, {min: 0, max: 1000}, transaction, [],
).then(() => {
transaction.create(players.doc(), {
user: playerID,
score: score,
});
});
});
}
이는 확실히 단일 메서드 호출과 단 6줄의 코드인 마지막 구현보다 훨씬 복잡합니다. 이 메서드를 구현한 후 데이터베이스에 점수를 몇 개 추가하고 결과 트리의 구조를 관찰해 보세요. JS 콘솔에서 다음을 실행합니다.
leaderboard.addScores();
결과 데이터베이스 구조는 다음과 같이 트리 구조가 명확하게 표시되고 트리의 리프가 개별 점수를 나타냅니다.
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
이제 어려운 부분을 완료했으니 앞에서 설명한 대로 트리를 순회하여 점수를 읽을 수 있습니다.
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = firestore.collection("scores");
/**
* Recursively finds a player score in a collection.
* @param {string} id The player's ID, since some players may be tied.
* @param {number} value The player's score.
* @param {admin.firestore.CollectionReference} coll The collection to
* search.
* @param {number} currentCount The current count of players ahead of the
* player.
* @return {Promise<number>} The rank of the player (the number of players
* ahead of them plus one).
*/
async function findPlayerScoreInCollection(id, value, coll, currentCount) {
const snapshot = await coll.get();
for (const doc of snapshot.docs) {
if (doc.get("exact") !== undefined) {
// This is an exact score. If it matches the score we're looking
// for, return. Otherwise, check if it should be counted.
const exact = doc.data().exact;
if (exact.score === value) {
if (exact.user === id) {
// Score found.
return currentCount + 1;
} else {
// The player is tied with another. In this case, don't increment
// the count.
continue;
}
} else if (exact.score > value) {
// Increment count
currentCount++;
continue;
} else {
// Do nothing
continue;
}
} else {
// This is a range. If it matches the score we're looking for,
// search the range recursively, otherwise, check if it should be
// counted.
const range = doc.data().range;
const count = doc.get("count");
if (range.min > value) {
// The range is greater than the score, so add it to the rank
// count.
currentCount += count;
continue;
} else if (range.max <= value) {
// do nothing
continue;
} else {
const subcollection = doc.ref.collection("scores");
return findPlayerScoreInCollection(
id,
value,
subcollection,
currentCount,
);
}
}
}
// There was no range containing the score.
throw Error(`Range not found for score: ${value}`);
}
const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
return {
user: playerID,
rank: rank,
score: score,
};
}
업데이트는 추가 연습으로 남아 있습니다. leaderboard.addScore(id, score)
및 leaderboard.getRank(id)
메서드를 사용하여 JS 콘솔에서 점수를 추가하고 가져와 Firebase Console에서 리더보드가 어떻게 변경되는지 확인합니다.
그러나 이 구현에서는 로그 성능을 달성하기 위해 추가한 복잡성으로 인해 비용이 발생합니다.
- 첫째, 이 리더보드 구현에서는 잠금 경합 문제가 발생할 수 있습니다. 트랜잭션은 일관성을 유지하기 위해 문서 읽기 및 쓰기를 잠가야 하기 때문입니다.
- 둘째, Firestore는 하위 컬렉션 깊이를 100으로 제한합니다. 즉, 100개의 동점을 받은 후에는 하위 트리를 만들지 않아야 합니다. 이 구현에서는 그렇게 하지 않습니다.
- 마지막으로 이 리더보드는 트리가 균형을 이루고 있는 이상적인 경우에만 대수적으로 조정됩니다. 즉, 트리의 균형이 맞지 않을 경우 이 리더보드의 최악의 실적은 다시 한 번 선형입니다.
완료되면 Firebase Console을 통해 scores
및 players
컬렉션을 삭제하고 마지막 리더보드 구현으로 이동합니다.
6. 확률적 리더보드 구현
삽입 코드를 실행할 때 동시에 너무 많이 실행하면 거래 잠금 경합과 관련된 오류 메시지와 함께 함수가 실패하기 시작할 수 있습니다. 이 Codelab에서는 다루지 않는 해결 방법이 있지만 정확한 순위가 필요하지 않은 경우 이전 접근 방식의 복잡성을 모두 삭제하고 더 간단하고 빠른 방법을 사용할 수 있습니다. 정확한 순위 대신 플레이어 점수의 예상 순위를 반환하는 방법과 이로 인해 데이터베이스 로직이 어떻게 변경되는지 살펴보겠습니다.
이 접근 방식에서는 리더보드를 각각 예상되는 점수의 약 1%를 나타내는 100개의 버킷으로 나눕니다. 이 접근 방식은 점수 분포를 알지 못하는 경우에도 작동합니다. 이 경우 버킷 전체에서 대략적으로 균등한 점수 분포를 보장할 수는 없지만 점수가 분산되는 방식을 안다면 근사치의 정밀도를 높일 수 있습니다.
접근 방식은 다음과 같습니다. 이전과 마찬가지로 각 버킷은 내부 점수 수와 점수 범위를 저장합니다. 새 점수를 삽입하면 점수 버킷을 찾고 점수를 올립니다. 순위를 가져올 때는 더 이상 검색하지 않고 앞의 버킷을 합산한 다음 버킷 내에서 대략적인 순위를 구합니다. 이렇게 하면 매우 멋진 일정 시간 조회 및 삽입이 가능하며 훨씬 적은 코드가 필요합니다.
첫째, 삽입은
// Add this line to the top of your file.
const admin = require("firebase-admin");
// Implement this method (again).
async function createScore(playerID, score, firestore) {
const scores = await firestore.collection("scores").get();
if (scores.empty) {
// Create the buckets since they don't exist yet.
// In a real app, don't do this in your write function. Do it once
// manually and then keep the buckets in your database forever.
for (let i = 0; i < 10; i++) {
const min = i * 100;
const max = (i + 1) * 100;
const data = {
range: {
min: min,
max: max,
},
count: 0,
};
await firestore.collection("scores").doc().create(data);
}
throw Error("Database not initialized");
}
const buckets = await firestore.collection("scores")
.where("range.min", "<=", score).get();
for (const bucket of buckets.docs) {
const range = bucket.get("range");
if (score < range.max) {
const writeBatch = firestore.batch();
const playerDoc = firestore.collection("players").doc();
writeBatch.create(playerDoc, {
user: playerID,
score: score,
});
writeBatch.update(
bucket.ref,
{count: admin.firestore.FieldValue.increment(1)},
);
const scoreDoc = bucket.ref.collection("scores").doc();
writeBatch.create(scoreDoc, {
user: playerID,
score: score,
});
return writeBatch.commit();
}
}
}
이 삽입 코드에는 프로덕션에서는 이와 같은 작업을 하지 말라는 경고와 함께 데이터베이스 상태를 초기화하는 로직이 맨 위에 있습니다. 초기화 코드는 경합 상태로부터 전혀 보호되지 않으므로, 이렇게 하는 경우 여러 번의 동시 쓰기로 인해 여러 개의 중복 버킷이 생성되어 데이터베이스가 손상될 수 있습니다.
함수를 배포한 다음 삽입을 실행하여 개수가 0인 모든 버킷을 초기화합니다. 오류가 반환되므로 무시해도 됩니다.
leaderboard.addScore(999, 0); // The params aren't important here.
이제 데이터베이스가 올바르게 초기화되었으므로 addScores
를 실행하고 Firebase Console에서 데이터 구조를 확인할 수 있습니다. 결과 구조는 피상적으로는 비슷하지만 이전 구현보다 훨씬 더 평평합니다.
leaderboard.addScores();
점수를 확인하려면 다음 단계를 따르세요.
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = await firestore.collection("scores").get();
let currentCount = 1; // Player is rank 1 if there's 0 better players.
let interp = -1;
for (const bucket of scores.docs) {
const range = bucket.get("range");
const count = bucket.get("count");
if (score < range.min) {
currentCount += count;
} else if (score >= range.max) {
// do nothing
} else {
// interpolate where the user is in this bucket based on their score.
const relativePosition = (score - range.min) / (range.max - range.min);
interp = Math.round(count - (count * relativePosition));
}
}
if (interp === -1) {
// Didn't find a correct bucket
throw Error(`Score out of bounds: ${score}`);
}
return {
user: playerID,
rank: currentCount + interp,
score: score,
};
}
addScores
함수가 점수의 균일한 분포를 생성하고 버킷 내에서 선형 보간법을 사용했으므로 매우 정확한 결과를 얻고 사용자 수가 증가해도 리더보드의 성능이 저하되지 않으며 개수를 업데이트할 때 잠금 경합을 크게 걱정할 필요가 없습니다.
7. 부록: 부정행위
잠깐만요. 브라우저 탭의 JS 콘솔을 통해 Codelab에 값을 쓰는 경우 플레이어가 리더보드에 거짓말을 하여 공정하게 얻지 못한 높은 점수를 받았다고 말할 수 있지 않나요?
예, 가능합니다. 부정 행위를 방지하려면 보안 규칙을 통해 데이터베이스에 대한 클라이언트 쓰기를 사용 중지하고, 클라이언트가 Cloud Functions를 직접 호출할 수 없도록 안전하게 액세스한 다음, 서버에서 게임 내 작업을 검증한 후 리더보드에 점수 업데이트를 전송하는 것이 가장 효과적입니다.
이 전략은 속임수에 대한 만병통치약이 아니라는 점에 유의해야 합니다. 이 전략은 인센티브가 충분히 많기 때문에, 속임수는 서버 측 검증을 우회하는 방법을 찾을 수 있습니다. 또한 성공한 여러 대형 비디오 게임에서는 새로운 속임수를 식별하고 확산되는 것을 막기 위해 속임수와 고양이와 쥐를 계속 대고 놀고 있습니다. 이 현상으로 인해 발생하는 어려운 문제는 모든 게임의 서버 측 유효성 검사가 본질적으로 맞춤설정되어야 한다는 점입니다. Firebase는 사용자가 간단한 스크립트 클라이언트를 통해 게임을 복사하는 것을 방지하는 앱 체크와 같은 악용 방지 도구를 제공하지만, 전체적인 치트 방지 기능을 제공하는 서비스는 제공하지 않습니다.
서버 측 유효성 검사가 부족하면 충분히 인기 있는 게임이거나 속임수에 대한 장벽이 낮은 경우 최고 값이 모두 부정 행위자인 리더보드가 생성됩니다.
8. 축하합니다
축하합니다. Firebase에서 4개의 리더보드를 성공적으로 빌드했습니다. 게임의 정확도와 속도 요구사항에 따라 합리적인 비용으로 적합한 옵션을 선택할 수 있습니다.
다음으로 게임에 관한 학습 과정을 확인하세요.