Firestore로 리더보드 빌드

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를 사용했지만 어떤 텍스트 편집기나 사용할 수 있습니다.

하고 다운로드한 ZIP 파일의 압축을 풉니다.

또는 선택한 디렉터리로 클론합니다.

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

시작점

우리 프로젝트는 현재 일부 빈 함수가 있는 빈 슬레이트입니다.

  • index.html에는 개발 콘솔에서 함수를 호출하고 출력을 확인할 수 있는 글루 스크립트가 포함되어 있습니다. 이를 사용하여 백엔드와 연결하고 함수 호출의 결과를 확인합니다. 실제 시나리오에서는 게임에서 직접 백엔드 호출을 수행합니다. 리더보드에 점수를 추가할 때마다 게임을 플레이하는 데 시간이 너무 오래 걸리기 때문에 이 Codelab에서는 게임을 사용하지 않습니다.
  • functions/index.js에는 모든 Cloud Functions가 포함되어 있습니다. addScoresdeleteScores와 같은 일부 유틸리티 함수와 이 Codelab에서 구현할 함수로, 다른 파일에서 도우미 함수를 호출합니다.
  • functions/functions-helpers.js에는 구현할 빈 함수가 포함되어 있습니다. 각 리더보드에 대해 읽기, 만들기, 업데이트 함수를 구현합니다. 또한 구현 선택이 구현의 복잡성과 확장 성능에 어떤 영향을 미치는지 확인할 수 있습니다.
  • functions/utils.js에는 더 많은 유틸리티 함수가 포함되어 있습니다. 이 Codelab에서는 이 파일을 다루지 않습니다.

Firebase 프로젝트 만들기 및 구성

  1. Firebase Console에서 프로젝트 추가를 클릭합니다.
  2. 새 프로젝트를 만들려면 원하는 프로젝트 이름을 입력합니다.
    이렇게 하면 프로젝트 ID(프로젝트 이름 아래에 표시됨)도 프로젝트 이름을 기반으로 설정됩니다. 필요한 경우 프로젝트 ID에서 수정 아이콘을 클릭하여 추가로 맞춤설정할 수 있습니다.
  3. 메시지가 표시되면 Firebase 약관을 검토하고 이에 동의합니다.
  4. 계속을 클릭합니다.
  5. 이 프로젝트에 Google 애널리틱스 사용 설정 옵션을 선택한 다음 계속을 클릭합니다.
  6. 사용할 기존 Google 애널리틱스 계정을 선택하거나 새 계정 만들기를 선택하여 새 계정을 만듭니다.
  7. 프로젝트 만들기를 클릭합니다.
  8. 프로젝트가 생성되면 계속을 클릭합니다.
  9. Build 메뉴에서 Functions를 클릭하고 메시지가 표시되면 Blaze 요금제를 사용하도록 프로젝트를 업그레이드합니다.
  10. 빌드 메뉴에서 Firestore 데이터베이스를 클릭합니다.
  11. 표시된 데이터베이스 만들기 대화상자에서 테스트 모드에서 시작을 선택한 다음 다음을 클릭합니다.
  12. Cloud Firestore 위치 드롭다운에서 리전을 선택한 다음 사용 설정을 클릭합니다.

리더보드 구성 및 실행

  1. 터미널에서 프로젝트 루트로 이동하여 firebase use --add를 실행합니다. 방금 만든 Firebase 프로젝트를 선택합니다.
  2. 프로젝트 루트에서 firebase emulators:start --only hosting를 실행합니다.
  3. 브라우저에서 localhost:5000으로 이동합니다.
  4. Chrome DevTools의 JavaScript 콘솔을 열고 leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
    를 가져옵니다.
  5. 콘솔에서 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 쿼리를 사용하면 사용자 수가 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 데이터베이스에서 점수를 삭제하고 다음 섹션을 준비합니다.

컬렉션 삭제가 활성화된\nFirestore 점수 문서 페이지

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을 통해 scoresplayers 컬렉션을 삭제합니다. 그러면 마지막 리더보드 구현으로 넘어갑니다.

6. 확률적 리더보드 구현

삽입 코드를 실행할 때 동시에 너무 많이 실행하면 거래 잠금 경합과 관련된 오류 메시지와 함께 함수가 실패하기 시작할 수 있습니다. 이 Codelab에서는 다루지 않는 해결 방법이 있지만 정확한 순위가 필요하지 않은 경우 이전 접근 방식의 복잡성을 모두 삭제하고 더 간단하고 빠른 방법을 사용할 수 있습니다. 정확한 순위 대신 플레이어 점수의 예상 순위를 반환하는 방법과 이로 인해 데이터베이스 로직이 어떻게 변경되는지 살펴보겠습니다.

이 방식에서는 리더보드를 100개의 버킷으로 나누는데, 각 버킷은 각각 받을 것으로 예상되는 점수의 약 1%를 나타냅니다. 이 접근 방식은 점수 분포를 알지 못해도 작동합니다. 이 경우 버킷 전체에 걸쳐 점수가 대략적으로 균등하게 분포되도록 보장할 방법이 없지만 점수가 어떻게 분포되는지 알면 더 정확한 근사치를 얻을 수 있습니다.

접근 방식은 다음과 같습니다. 이전과 마찬가지로 각 버킷은 내부 점수 수와 점수 범위를 저장합니다. 새 점수를 삽입하면 점수의 버킷을 찾아 개수를 늘립니다. 순위를 가져올 때는 앞서 있는 버킷을 합산한 다음 추가로 검색하는 대신 버킷 내에서 근사치를 산출합니다. 이를 통해 지속적으로 조회 및 삽입할 수 있으며 필요한 코드가 훨씬 적습니다.

첫째, 삽입은

// 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는 사용자가 간단한 스크립트 클라이언트를 통해 게임을 복사하지 못하게 하는 앱 체크와 같은 악용 방지 도구를 제공하지만, Firebase는 전체적인 부정행위 방지에 해당하는 서비스는 제공하지 않습니다.

서버 측 검증이 이루어지지 않으면 인기가 충분히 높거나 치팅의 장벽이 충분히 낮은 게임의 경우 상위 값이 모두 치터인 리더보드가 생성됩니다.

8. 축하합니다

축하합니다. Firebase에서 4개의 리더보드를 성공적으로 빌드했습니다. 게임의 정확성과 속도 요구사항에 따라 합리적인 비용으로 원하는 게임을 선택할 수 있습니다.

다음으로는 게임 학습 과정을 확인해 보세요.