본문 바로가기
iOS

[iOS] Firebase만 가지고 리더보드 만들어보기

by DuncanKim 2024. 7. 29.
728x90

[iOS] Firebase만 가지고 리더보드 만들어보기

 

 

랭킹보드

사용자의 점수를 가지고 랭킹을 구성해야 되는 메인화면을 만들어야 했다. 유저들이 쌓는 출석 누적점수에 따라 순위가 변동되는 뷰를 만들어야 했는데, 어떻게 만들지 고민이었다.

 

순위를 보여줄 수는 있는데 그러면 동점자를 어떻게 해야 할 것이며, 매번 users를 전부 쿼리로 줄을 세워서 가지고 올 수도 있는데 이러면 근처의 랭킹은 어떻게 가져올 것이며, 누군가 조회를 하는 도중에 점수를 올렸다면 어떻게 대응할 것이며... 이런 여러 가지 것들의 문제상황을 생각했었다. 이 부분들을 해결하기 위해 리더보드 구현하는 방법을 서칭 하다가 Redis로 리더보드를 구현하는 방법을 보았는데, Firebase로도 비슷하게 따라 할 수 있을 것 같아서 진행시켜 보았다.

 

아래에서는 Firebase Firestore, Functions로 유저의 랭킹을 관리하고 표시하는 방법을 알아볼 것이다.

 

 

1. 구상과 계획

 

구현해야 할 기능은 랭킹 보여주기 / 랭킹 업데이트 하기 / 랭킹 변동 시 알림 보내기였다. 일단 동작하는 기능들을 정의하기 위해 오프라인에서 랭킹판을 관리한다고 생각해 보았다.

 

첫 번째로 필요한 것은 "보드판"이다. 누군가의 이름과 점수 등의 정보가 적힌 점수판. 이 점수판을 보면 랭킹을 알 수 있다. 누군가의 점수가 올라간다면, 이 보드판에도 반영이 되고, 이 보드판의 랭킹을 재조정하게 된다. 이 랭킹을 보면, 사람들은 누가 몇 위에 위치하고 있는지 알 수 있는 것이다. 다만, 어떤 사람의 모든 정보를 담고 있지는 않다. 간략하게 그 사람을 인식할 수 있는 정보만을 담고 있다.

 

이 정보들을 담고 있는 하나의 데이터베이스가 필요하다. 다만, 일정 필드를 기준으로 하여 정렬이 될 수 있어야 한다는 조건을 가진다. 이렇게 될 경우, NoSQL인 Firebase의 문서 정렬로는 관리하기가 어렵다. 다만, 하나의 문서 안에, 하나의 필드를 생성하고, 그 필드 내부에 "배열"로 이 데이터를 관리한다면? 가능할 것이다.

 

 

두 번째로 필요한 것은 점수 기록과 갱신이다. 플레이어가 새로운 점수를 갱신하면 심판이 파악하여 그 기록을 보드판에 갱신시켜주어야 한다. 여기서는 플레이어가 점수가 올라간 것을 파악해야 하고, 랭킹 보드판에 갱신을 해서 순위를 변동시켜줘야 하는지에 대한 고민을 해야 하고, 변동시켜줘야 하면 변동시켜 주면 된다.

 

이 경우 데이터베이스에 어떤 값이 변경되었는지 관찰하는 함수가 필요하다. Firebase의 Functions를 가지고 users 내부의 문서 중 어느 하나의 필드값이 변경되었다면 실행되는 로직으로 관리를 할 수가 있다.

 

 

세 번째로 필요한 것은 (대북확성기) 알림이다. 이는 심판이 알려주는 것을 생각해 보면 되겠다. 이 심판이 랭킹 보드를 변경한 후 "랭킹이 변동되었습니다~!"라고 소리치는 것과 같다. 일단 랭킹 보드가 변경되었다는 것을 알아야 되고, 이것을 트리거로 해서 알림을 전송하는 방식으로 진행하면 된다.

 

이것은 랭킹보드의 배열을 관찰하고, 변화를 트리거 삼아서 이전 데이터의 순서와 이후 데이터의 순서를 비교 분석하면 된다. 비교하여 변동이 있으면 랭킹이 변동된 것이고, 내 순위의 변화를 체크해 보거나 1위 변동이 있는지를 확인해 보면 된다.

 

 

2. 구현


이렇게 일단은 계획을 해두었고, 하나씩 구현을 해보았다.

 

1) 보드판

 

(1) 보드판 세팅

 

일단은 ranking이라는 컬렉션을 만들고, 그 안에 totalRanking 문서를 만들었다. 추후 다른 기준에 따른 랭킹이 생길 수도 있기 때문에 랭킹 컬렉션을 따로 둔 것이다.

 

이렇게 하고, 하나의 문서 안에 "하나"의 필드를 만든다. 그 필드의 경우, Array 형식의 map 타입을 갖도록 해야 한다. 그래야 클라이언트 단에서 파싱 후 디코딩을 할 때 모델로 변환을 할 수가 있다.

 

콘솔에서 먼저 만들어도 되고, Function 구현을 하면 자동으로 만들어지게 할 수도 있지만, 나는 처음 개발을 할 때에는 일단 콘솔에 문서를 하나 정도 추가하고 하는 방식을 선호하여 일단은 totalRanking에 0번 인덱스를 채울 수 있는 데이터만 시험으로 생성해서 넣었다.

 

 

(2) 배열 내부의 타입 선언

export interface RankInfo {
  id: string;
  userId: string;
  userNickname: string;
  attendanceScore: number;
  imageURL?: string;
}

 

타입은 위와 같다. id는 RankInfo 자체의 id, 이 랭킹 정보를 가진 user의 userId, 유저의 닉네임을 담고 있는 userNickname, 점수를 담은 attendanceScore, user의 imageURL을 담고 있는 imageURL로 구성했다. 이 형태로 현재는 totalRank 배열에 저장되고, attendaceScore를 기준오르 정렬이 되는 것이다.

 

이 데이터 구조는 iOS 클라이언트 단에서도 동일하게 쓰인다.

 

 

(3) totalRanking 문서 관련 CRUD Function

 

보드를 만드는 것에 있어서는 랭킹에 데이터를 추가하는 것, 그리고 유저가 만약에 탈퇴했을 때, 랭킹에서 제외하는 것을 생각하고 함수를 만들었다.

 

* 유저를 랭킹에 추가하는 Util 함수

export async function addUserToRanking(userId: string, nickname: string, attendanceScore: number, imageURL?: string) {
  const rankingDocRef = db.collection("ranking").doc("totalRanking");

  // 새로운 유저의 랭킹 정보 생성
  const newRankInfo: RankInfo = {
    id: admin.firestore().collection("ranking").doc().id, // 고유 문서 번호로 ID 생성
    userId: userId,
    userNickname: nickname,
    attendanceScore: attendanceScore,
    imageURL: imageURL || "", // imageURL이 없으면 빈 문자열로 설정
  };

  try {
    await db.runTransaction(async (transaction) => {
      const rankingDoc = await transaction.get(rankingDocRef);
      if (!rankingDoc.exists) {
        // totalRanking 문서가 존재하지 않는 경우 새로 생성
        transaction.set(rankingDocRef, { totalRanking: [newRankInfo] });
      } else {
        // 기존 totalRanking 데이터 가져오기
        const rankingData = rankingDoc.data()?.totalRanking || [];
        // 새로운 유저 추가
        rankingData.push(newRankInfo);
        // 업데이트
        transaction.update(rankingDocRef, { totalRanking: rankingData });
      }
    });
    console.log("User ${userId} 를 랭킹에 추가했습니다.");
  } catch (error) {
    console.error("User ${userId} 를 랭킹에 추가하는 도중 오류가 발생했습니다. :", error);
  }
}

 

-> totalRanking 필드에 유저의 정보를 추가하는 부분이다. 상위 진행함수에서 userId 등 유저의 정보를 받아와서 필드 맨 마지막에 추가해 준다. 이렇게 되면, 0점인 유저가 많을 때(동점자) 처리가 가능해진다. 늦게 가입하거나, 늦게 점수를 올린 사람이 후순위가 되어 동점자를 처리하는 문제도 해결할 수 있다.

 

* Create 메인함수

export const onUserCreateInRanking = functions.firestore
  .document("users/{userId}")
  .onCreate(async (snap, context) => {
    const newUser = snap.data();
    const userId = context.params.userId;

    if (!newUser) {
      console.error("새로운 유저를 찾을 수 없습니다. ID: ${userId}");
      return;
    }

    const userNickname = newUser.nickname || "Unknown"; // nickname 필드가 없을 경우 'Unknown'으로 설정
    const attendanceScore = newUser.attendanceScore || 0; // attendanceScore 필드가 없을 경우 0으로 설정
    const imageURL = newUser.imageURL; // imageURL은 undefined일 수 있음

    await addUserToRanking(userId, userNickname, attendanceScore, imageURL);
  });

 

-> .onCreate라는 강력한 이벤트 핸들러를 제공한다. 우리는 관찰자를 심고 해제하고 하는 것에 관여를 하지 않고, 저 핸들러를 사용해서 users에 새로 추가된 데이터를 찾아서 이런 방식으로 기능을 구현할 수 있다.

 

* 회원탈퇴 시 Ranking에서 유저 정보 Delete 하는 Util 함수

export async function removeUserFromRanking(userId: string) {
  const rankingDocRef = db.collection("ranking").doc("totalRanking");

  try {
    await db.runTransaction(async (transaction) => {
      const rankingDoc = await transaction.get(rankingDocRef);

      if (!rankingDoc.exists) {
        console.error("totalRanking 문서가 존재하지 않습니다.");
        return;
      }

      const rankingData = rankingDoc.data()?.totalRanking || [];

      // 해당 유저의 인덱스를 찾음
      const userIndex = rankingData.findIndex((rankInfo: any) => rankInfo.userId === userId);

      if (userIndex === -1) {
        console.error(`User ${userId} 데이터를 totalRanking에서 찾을 수 없습니다.`);
        return;
      }

      // 유저 데이터를 totalRanking 배열에서 제거
      rankingData.splice(userIndex, 1);

      // totalRanking 문서 업데이트
      transaction.update(rankingDocRef, { totalRanking: rankingData });
      console.log(`User ${userId} 가 totalRanking에서 제거되었습니다.`);
    });
  } catch (error) {
    console.error(`Error removing user ${userId} from totalRanking:`, error);
  }
}

 

-> totalRanking에 특정 인덱스에 위치할 유저의 정보를 삭제한다. Create와 마찬가지로, 이 함수도 users 중 어느 하나가 삭제되는 이벤트 핸들러를 통해 동일하게 작동하도록 되어 있다.

 

 

 

2) 점수 관찰과 기록 그리고 순위 갱신

 

추가, 삭제가 되었으니, 이제 특정 유저의 점수가 올라가는지 관찰하고, 그 점수를 기록하는 것이 필요하다. onCreate 핸들러와 비슷하게 작동하는 것이 있는데, onUpdate가 바로 그것이다.

 

어떤 한 유저의 정보가 update 될 경우, 그 update 트리거를 통해서

 

a. totalRanking 배열에 있는 스코어 값을 변경하고, 배열을 재정렬
b. 재정렬하기 전과 정렬 후의 순서를 비교하여 변경되었으면 변경된 사람에게 알림 전송
c. 재정렬하기 전과 정렬 후의 순서를 비교하여 1위 또는 포디움 유저가 변경된 경우 관련 사람에게 알림 전송

 

이 기능들을 실행시킨다.

 

cf. 지금 현재 짜놓은 구조가 하나의 함수가 여러 개의 역할을 하고 있어서 마음에 안 들긴 한다. 추후 다른 카테고리별 랭킹을 구현할 때 대규모 리팩토링을 진행하지 않을까 싶다.

 

코드가 상당히 길고, 하기 때문에, 일부만 살펴보면 다음과 같다.

 

export const updateAttendanceScore = functions.firestore
  .document("users/{userId}")
  .onUpdate(async (change, context) => {
    const newValue = change.after.data();
    const previousValue = change.before.data();

    // attendanceScore가 변경되었는지 확인합니다
    if (newValue.attendanceScore === previousValue.attendanceScore) {
      return null;
    }

 

이렇게 이전 값과 새로운 값을 상수로 지정해 두고, 이 두 개 사이의 변화를 추적하는 것이다.

RankInfo에 닉네임과 imageURL도 있기 때문에, 닉네임 또는 프로필 사진을 변경해도, 이 부분이 호출된다.

 

이런 식으로 해서, 유저의 정보와 새롭게 변경된 정보를 세팅하고,

 

* Ranking Update Util 함수

export async function updateUserInRanking(userId: string, userNickname: string, imageURL: string) {
  const rankingDocRef = db.collection("ranking").doc("totalRanking");

  try {
    await db.runTransaction(async (transaction) => {
      const rankingDoc = await transaction.get(rankingDocRef);

      if (!rankingDoc.exists) {
        console.error("totalRanking 문서가 존재하지 않습니다.");
        return;
      }

      const rankingData = rankingDoc.data()?.totalRanking || [];

      // 해당 유저의 인덱스를 찾음
      const userIndex = rankingData.findIndex((rankInfo: any) => rankInfo.userId === userId);

      if (userIndex === -1) {
        console.error("User ${userId} 데이터를 찾을 수 없습니다.");
        return;
      }

      // 유저 데이터 업데이트
      rankingData[userIndex].userNickname = userNickname;
      rankingData[userIndex].imageURL = imageURL;

      // totalRanking 문서 업데이트
      transaction.update(rankingDocRef, { totalRanking: rankingData });
      console.log(`User ${userId} 의 새로운 정보가 업데이트 되었습니다..`);
    });
  } catch (error) {
    console.error(`Error updating user ${userId} in totalRanking:`, error);
  }
}

유저의 인덱스를 찾고, 거기의 유저의 정보만 수정해 주는 방식으로 코드를 구현했다.

 

 

3) 갱신에 따른 알림 전송

 

이렇게 갱신이 끝나면, 몇 가지 상황을 정해놓고, 그 상황을 인식하는 함수를 만들어놓고, 그 상황인지를 판단한다.

 

a. 1위가 변경된 상황
b. 포디움 순위가 변동된 상황
c. 유저의 순위가 변동된 상황

 

 

나의 경우, 이 세 가지에 대한 대응이 필요했는데, c의 경우에는 유저와 순위가 바뀐 사람에게도 알림이 가도록 했다. 위의 업데이트 함수에 많이 의존되어 있는데, 업데이트 함수에서 이러한 상황을 감지한 경우, sendNotification을 보낸다.

 

* 특정 디바이스 토큰으로 FCM 전송

export const processNotification = async (notification: NotificationData) => {
  const receiverDoc = await db.collection("users").doc(notification.receiver).get();
  if (!receiverDoc.exists) {
    console.log(`Receiver with ID ${notification.receiver} not found`);
    return;
  }

  const receiverData = receiverDoc.data() as UserData;
  const deviceToken = receiverData.deviceToken;

  if (!deviceToken) {
    console.log(`No device token found for user ${notification.receiver}`);
    return;
  }

  const now = admin.firestore.Timestamp.now();
  const lastSent = notificationQueue[deviceToken];

  if (lastSent && now.seconds - lastSent.seconds < 5) {
    console.log(`Notification to ${deviceToken} skipped due to recent delivery`);
    return;
  }

  notificationQueue[deviceToken] = now;

  const { title, content } = notificationTypeToMessage(notification.notificationType);
  await sendPushNotification(deviceToken, title, content);
};

 

특정 DeviceToken을 가진 유저에게 알림을 전송하는 부분이다. 이 부분은 알림과 더 많은 관련이 있어 여기에서 설명하기에는 그런 것 같다. 간단히 이야기하자면, notifications라는 타입을 하나 가지고 있는데, 이것을 바탕으로 관련이 있는 receiver에게 푸시 알림을 보내는 방식이라고 이해하면 된다. 이 부분은 알림과 더 많은 관련이 있으니, 추후 서버 푸시 알림 구현 포스팅에서 더 자세히 다룰 예정이다.

 

이렇게 update 과정에서 특정한 상황에 알림을 보내는 것도 가능하게 되었다.

 

 

3. 마치며

 

모든 코드를 연 것은 아니지만, 일단은 기본적인 흐름은 모두 설명한 것 같다. 하나의 랭킹보드를 가지고 유저의 정보 변화를 트리거 삼아서 랭킹 보드판을 계속 고쳐주고, 랭킹 보드판을 클라이언트가 뷰에서 표현할 수 있도록 구조를 변경해 두었다. 간단한 랭킹 보드판은 이 정도 선에서 모두 만들 수 있을 것이라 생각된다.

 

다 만들고 나니, totalRanking 배열의 타입을 map 타입이 아니라 reference 타입으로 했으면 안 됐나? 하는 생각이 든다. 그러면 RankInfo로 타입을 하나 더 만들어서 모델을 갖고 놀 필요 없이 User 모델 하나만 가지고도 관리가 될 수도 있을 텐데 말이다. 그렇지만, 일반적인 표현과정에서 user 모델을 계속 불러오는 것은 낭비 같아서, 일단은 "캐싱"용도로 보드판을 관리한다고도 생각할 수 있어서 다시 reference로 바꾸지는 않았다. reference로 둔다면, 필드를 읽어오기 위해 더 많은 읽기를 써야 할 수도 있다고 생각했다.

 

또한 불편한 점을 하나 찾았다. 만약 유저가 점수를 올린 후 랭킹이 변동된다면, 일정 시간 동안 db에 값이 업데이트되고 function이 작동하는 시간이 있을 텐데, 이 시간을 고려해서 클라이언트의 뷰를 업데이트시켜주어야 한다는 것이다. 이것 자체를 핸들링하기 상당히 어려워서 메인화면의 랭킹은 한 번씩 랭킹 업데이트가 즉각적으로 되지 않는 문제가 있었다. 그래서 어쩔 수 없이 새로고침 버튼을 만들기는 했다. 그런데 이런 이벤트 핸들러도 있는데, function이 다 동작했다는 것을 알려주는 함수도 있지 않을까 한다. 나중에 절실하게 필요할 때가 올 것 같은데, 그때 가서 한 번 더 알아보려고 한다.

 

처음에는 실시간 랭킹보드를 구현한다는 생각에 많은 가정상황을 생각해 봐서 진척이 나지 않았었다. 그렇지만, 이번 개발을 통해 오프라인 상황으로 치환하여 기능을 계획하고, 하나씩 논리적으로 구현해 나가면 개발은 조금 더 쉬워질 수 있다는 것을 알 수 있었다.

 

랭킹 개발은 일단 여기에서 끝! 다음에는 서버 푸시 알림을 알아보겠다!

728x90

댓글