Firestore로 리더보드 빌드

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를 사용했지만 텍스트 편집기에서도 사용합니다.

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

또는 원하는 디렉터리에 클론합니다.

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

시작점

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

  • index.html에는 개발자 콘솔에서 함수를 호출하여 출력을 확인할 수 있는 글루 스크립트가 포함되어 있습니다. 이를 사용하여 백엔드와 인터페이스하고 함수 호출 결과를 확인해 보겠습니다. 실제 시나리오에서는 게임에서 직접 이러한 백엔드 호출을 실행합니다. 이 Codelab에서는 게임을 사용하지 않습니다. 리더보드에 점수를 추가할 때마다 게임을 플레이하는 데 시간이 너무 오래 걸리기 때문입니다.
  • functions/index.js에는 모든 Cloud 함수가 포함되어 있습니다. 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. 빌드 메뉴에서 함수를 클릭하고, 메시지가 표시되면 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 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 데이터베이스에서 점수를 삭제하고 다음 섹션을 준비합니다.

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

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

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

우선 점수를 거의 균일한 버킷으로 분배할 수 있습니다. 이 경우 사용자가 기록하는 점수의 값에 대한 어느 정도의 지식이 필요합니다. 예를 들어 경쟁 게임에서 기술 평가에 대한 리더보드를 구축한다면 사용자의 기술 평점은 거의 항상 정규 분포로 표시됩니다. 무작위 점수 생성 함수는 JavaScript의 Math.random()를 사용하여 거의 균등한 분포를 보이므로 버킷을 균등하게 나눕니다.

이 예에서는 편의상 3개의 버킷을 사용하지만 실제 앱에서 이 구현을 사용하면 더 많은 버킷이 더 빠른 결과를 얻을 수 있습니다. 트리가 얕을수록 컬렉션 가져오기가 더 적고 잠금 경합도 줄어듭니다.

플레이어의 순위는 점수가 더 높은 플레이어 수의 합계에 해당 플레이어의 순위 1을 더한 값으로 계산됩니다. scores의 각 컬렉션에는 각각 범위가 있는 문서 3개, 각 범위에 있는 문서 수, 해당 하위 컬렉션 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,
  };
}

업데이트는 추가 연습으로 남습니다. JS 콘솔에서 leaderboard.addScore(id, score)leaderboard.getRank(id) 메서드를 사용하여 점수를 추가하고 가져와 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에서 네 가지 다른 리더보드를 빌드했습니다. 정확성과 속도에 대한 게임의 요구사항에 따라 합리적인 비용으로 적합한 것을 선택할 수 있습니다.

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