1. 소개
최종 업데이트: 2023년 1월 27일
리더보드를 빌드하려면 무엇이 필요한가요?
리더보드는 기본적으로 점수 표이지만 한 가지 복잡한 요소가 있습니다. 특정 점수의 순위를 읽으려면 모든 다른 점수를 어떤 순서로든 알아야 합니다. 또한 게임이 인기를 얻으면 리더보드가 커지고 자주 읽고 쓰게 됩니다. 성공적인 리더보드를 빌드하려면 이 순위 지정 작업을 빠르게 처리할 수 있어야 합니다.
빌드할 항목
이 Codelab에서는 각각 다른 시나리오에 적합한 다양한 리더보드를 구현합니다.
학습할 내용
다음과 같은 네 가지 리더보드를 구현하는 방법을 알아봅니다.
- 순위를 결정하기 위해 간단한 레코드 수를 사용하는 기본 구현
- 저렴하고 주기적으로 업데이트되는 리더보드
- 나무에 관한 넌센스가 포함된 실시간 리더보드
- 매우 큰 플레이어 기반의 대략적인 순위를 위한 확률적 리더보드
필요한 사항
- 최신 버전의 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 애널리틱스 계정을 선택하거나 새 계정 만들기를 선택하여 새 계정을 만듭니다.
- 프로젝트 만들기를 클릭합니다.
- 프로젝트가 생성되면 계속을 클릭합니다.
- 빌드 메뉴에서 함수를 클릭하고 메시지가 표시되면 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을 통해 함수 로그를 열어 무엇이 잘못되었는지 확인합니다.
마지막으로 점수를 가져와 업데이트할 수 있습니다.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
하지만 이 구현에서는 특정 점수의 순위를 가져오는 데 바람직하지 않은 선형 시간 및 메모리 요구사항이 발생합니다. 함수 실행 시간과 메모리가 모두 제한되어 있으므로 가져오기가 점점 느려질 뿐만 아니라 리더보드에 충분한 점수가 추가된 후에는 결과를 반환하기 전에 함수가 시간 초과되거나 비정상 종료됩니다. 소수의 플레이어를 넘어 확장하려면 더 나은 것이 필요합니다.
Firestore를 즐겨 사용한다면 COUNT 집계 쿼리를 알고 있을 것입니다. 이 쿼리를 사용하면 리더보드의 성능이 훨씬 향상됩니다. 맞습니다. COUNT 쿼리를 사용하면 사용자가 100만 명 이하인 경우 원활하게 확장되지만 성능은 여전히 선형입니다.
하지만 컬렉션의 모든 문서를 열거할 것이라면 모든 문서에 순위를 할당한 다음 문서를 가져와야 할 때 가져오기가 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개를 사용하지만 실제 앱에서 이 구현을 사용하는 경우 버킷이 많을수록 결과가 더 빨리 표시됩니다. 트리가 얕을수록 평균적으로 컬렉션 가져오기가 적고 잠금 경합이 적기 때문입니다.
플레이어의 순위는 점수가 더 높은 플레이어 수에 플레이어 자신을 나타내는 1을 더한 값입니다. 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에서는 다루지 않지만 이 문제를 해결하는 방법이 있습니다. 정확한 순위가 필요하지 않다면 이전 접근 방식의 복잡성을 모두 버리고 더 간단하고 빠른 방법을 사용할 수 있습니다. 정확한 순위 대신 플레이어 점수의 예상 순위를 반환하는 방법과 이로 인해 데이터베이스 로직이 어떻게 변경되는지 살펴보겠습니다.
이 접근 방식에서는 리더보드를 100개의 버킷으로 나누며 각 버킷은 예상 점수의 약 1%를 나타냅니다. 이 접근 방식은 점수 분포를 알지 못하는 경우에도 작동합니다. 이 경우 버킷 전체에 걸쳐 점수가 대략 균등하게 분포되도록 보장할 수는 없지만 점수 분포를 알고 있다면 근사치에서 더 높은 정밀도를 달성할 수 있습니다.
Google의 접근 방식은 다음과 같습니다. 이전과 마찬가지로 각 버킷은 범위 내 점수의 수와 점수 범위를 저장합니다. 새 점수를 삽입할 때는 점수의 버킷을 찾아 개수를 늘립니다. 순위를 가져올 때는 순위 앞의 버킷을 합산한 다음 더 검색하지 않고 버킷 내에서 근사치를 구합니다. 이렇게 하면 매우 멋진 상수 시간 조회 및 삽입이 가능하며 필요한 코드도 훨씬 적습니다.
먼저 삽입:
// 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가지 리더보드를 빌드했습니다. 정확성과 속도에 대한 게임의 요구사항에 따라 적절한 비용으로 적합한 옵션을 선택할 수 있습니다.
다음으로 게임 학습 과정을 확인하세요.