Firestore로 리더보드 구축

1. 소개

최종 업데이트 날짜: 2023-01-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 콘솔 에서 프로젝트 추가를 클릭합니다.
  2. 새 프로젝트를 생성하려면 원하는 프로젝트 이름을 입력하세요.
    또한 프로젝트 이름 아래에 표시되는 프로젝트 ID를 프로젝트 이름에 따라 설정합니다. 선택적으로 프로젝트 ID의 편집 아이콘을 클릭하여 추가로 사용자 정의할 수 있습니다.
  3. 메시지가 표시되면 Firebase 약관을 검토하고 동의하세요.
  4. 계속 을 클릭합니다.
  5. 이 프로젝트에 대해 Google Analytics 활성화 옵션을 선택한 다음 계속 을 클릭합니다.
  6. 사용할 기존 Google Analytics 계정을 선택하거나 새 계정 만들기를 선택하여 새 계정을 만듭니다.
  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.

쓰기가 완료되면 콘솔에 "점수 생성됨"이라는 응답이 표시됩니다. 대신 오류가 표시되나요? Firebase 콘솔을 통해 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 데이터베이스에서 점수를 삭제하세요.

Firestore scores document page with\nDelete Collection activated

5. 실시간 트리 리더보드 구현

이 접근 방식은 데이터베이스 컬렉션 자체에 검색 데이터를 저장하는 방식으로 작동합니다. 통일된 컬렉션을 갖는 대신, 우리의 목표는 문서를 통해 이동할 수 있는 트리에 모든 것을 저장하는 것입니다. 이를 통해 주어진 점수 순위에 대해 이진(또는 n항) 검색을 수행할 수 있습니다. 그것은 어떤 모습일까요?

시작하려면 점수를 대략 균등한 버킷에 배포할 수 있기를 원할 것입니다. 이를 위해서는 사용자가 기록하는 점수 값에 대한 지식이 필요합니다. 예를 들어, 경쟁 게임에서 스킬 등급 순위표를 구축하는 경우 사용자의 스킬 등급은 거의 항상 정규 분포를 따릅니다. 무작위 점수 생성 기능은 JavaScript의 Math.random() 사용하는데, 이는 대략 균등한 분포를 가져오므로 버킷을 균등하게 나눌 것입니다.

이 예에서는 단순화를 위해 3개의 버킷을 사용하지만 실제 앱에서 이 구현을 사용하면 더 많은 버킷이 더 빠른 결과를 얻을 수 있다는 것을 알게 될 것입니다. 트리가 얕을수록 평균적으로 더 적은 컬렉션 가져오기 및 더 적은 잠금 경합을 의미합니다.

플레이어의 순위는 더 높은 점수를 얻은 플레이어 수에 플레이어 자신의 점수를 더한 값으로 결정됩니다. scores 아래의 각 컬렉션은 각각 범위가 있는 세 개의 문서, 각 범위 아래의 문서 수, 그리고 세 개의 해당 하위 컬렉션을 저장합니다. 순위를 읽기 위해 이 트리를 탐색하여 점수를 검색하고 더 높은 점수의 합계를 추적합니다. 점수를 찾으면 정확한 합계도 얻게 됩니다.

글쓰기는 훨씬 더 복잡합니다. 먼저, 여러 쓰기 또는 읽기가 동시에 발생할 때 데이터 불일치를 방지하기 위해 트랜잭션 내에서 모든 쓰기를 수행해야 합니다. 또한 새 문서를 작성하기 위해 트리를 탐색할 때 위에서 설명한 모든 조건을 유지해야 합니다. 그리고 마지막으로, 이 새로운 접근 방식의 모든 트리 복잡성과 원본 문서를 모두 저장해야 하기 때문에 저장 비용이 약간 증가하지만 여전히 선형입니다.

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 콘솔에서 리더보드가 어떻게 변경되는지 확인하세요.

그러나 이 구현을 사용하면 로그 성능을 달성하기 위해 추가한 복잡성으로 인해 비용이 발생합니다.

  • 첫째, 이 리더보드 구현에서는 잠금 경합 문제가 발생할 수 있습니다. 트랜잭션의 일관성을 유지하려면 문서에 대한 읽기 및 쓰기 잠금이 필요하기 때문입니다.
  • 둘째, Firestore는 100이라는 하위 컬렉션 깊이 제한을 적용합니다. 즉, 100점 동점 이후에는 하위 트리 생성을 피해야 하지만 이 구현에서는 그렇지 않습니다.
  • 마지막으로 이 순위표는 트리가 균형을 이루는 이상적인 경우에만 대수적으로 확장됩니다. 균형이 맞지 않으면 이 순위표의 최악의 성능은 다시 한번 선형이 됩니다.

완료되면 Firebase 콘솔을 통해 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 콘솔에서 데이터 구조를 확인할 수 있습니다. 결과 구조는 표면적으로 유사하지만 마지막 구현보다 훨씬 더 평평합니다.

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에서 네 가지 리더보드를 성공적으로 구축했습니다! 정확성과 속도에 대한 게임 요구 사항에 따라 합리적인 비용으로 자신에게 적합한 게임을 선택할 수 있습니다.

다음으로 게임 학습 경로를 확인해 보세요.