실시간 리더보드 엔지니어링 후기

5

Web3 기반의 랭킹 서비스를 운영하는 팀에서 백엔드와 프론트엔드를 맡고 있다. (풀스택이라는 말은 쓰기가 꺼려진다.) 유저는 NFT를 수집하고 합성하면서 랭크를 올리고, 시즌마다 순위에 따라 보상을 받는 시스템이다. 말 그대로 '레벨업'하는 재미를 주는 서비스인데, 이 랭킹 시스템이 내 골칫덩이였다.

초기 구조

랭킹 시스템 설계는 처음엔 단순했다. API가 내려주는 데이터는 세 가지밖에 없었다.

  1. Top 100 랭커 리스트
  2. 나의 순위
  3. 전체 참여자 수
기존 코드는 DB에서 전체 데이터를 긁어와서 정렬한 다음, 내 순위를 찾는 정도였다.
const playerScores = await scoreService.getSeasonScoreRecords({
  seasonId: currentSeason.id,
  limit: scoreCalculationLimit,
});

// 내 순위 찾기 (단순 무식하게...)
const myScoreIndex = playerScores.findIndex(
  (score) => score.user?.walletAddress === userAddress
);
const myPosition = myScoreIndex + 1 <= scoreCalculationLimit ?
  myScoreIndex + 1 : 0;

문제는 여기서 시작됐다. 순위 집계는 1000위까지 하기로 했는데, 내 순위를 찾으려면 1000명을 전부 정렬하고 순회해야 했다. 이 API는 유저 프로필, 랭킹 페이지 등 여러 곳에서 호출됐다.

API 구조도 복잡했다. 유저가 점수를 조회할 때마다 API 서버는 온체인 데이터를 조회하고 DB를 업데이트했다. 블록체인 조회가 필요할 때마다 응답 시간은 늘어났다. 거기에 다른 문제도 쌓여 있었다.

  • 유저 프로필에선 랭커 리스트가 필요 없는데 전체 데이터를 가져오는 오버페칭
  • 점수 변경 이력 추적 불가
  • 캐싱 없음
그래서 가장 먼저 떠올린 건 Redis였다. 5분 정도의 TTL이면 충분하지 않을까? 근데 이걸 실제로 적용하진 않았다. 유저들은 자신의 순위가 실시간으로 반영되길 기대할 텐데, 5분 동안이나 기다리게 할 순 없었다. 특히 순위에 따라 보상이 걸려있는 상황에서는, 시즌 막판에 약간의 점수 차를 역전하려는 유저 액션이 집중될 수 있으므로 데이터 정합성이 더 중요하다. 3등으로 표시된 유저가 실제로는 4등이라면?

결국 이건 단순히 캐시를 붙이는 문제가 아니었다. 랭킹 시스템 전반을 다시 설계해야 했다. 성능도 개선하고, 사용자 경험도 높이고, 운영도 편하게. 그리고 규모가 늘어났을 때도 대공사가 딱히 필요 없게끔.

일단 당장 할 수 있는 것부터 시작했다. 첫 번째로 API를 분리했다. 랭커 리스트가 필요한 경우와 내 순위만 필요한 경우를 나눈 거다. 프론트엔드에서도 각 화면에 필요한 API만 호출하도록 수정했다. 오버페칭은 이걸로 해결되었다.

그 다음은 정적 데이터였다. 시즌 메타 정보는 4주~6주에 한 번씩 바뀌는데, 같은 걸 매번 조회할 필요는 없지 않나? Redis에 캐싱 처리했다. 문제는 랭커 리스트를 어떻게 관리할지였다. 배열로 관리하자니 조회/수정할 때마다 시간이 너무 오래 걸렸다. 그래서 대안을 찾기로 했다.

Redis Sorted Set으로 갈아타기

배열로 랭킹을 구현하는 건 고통의 시작이었다. 성능 면에서 보자면:

  • 새로운 점수 등록: O(log n) — 이진 탐색으로 위치 찾기
  • 점수 업데이트: O(n) — 위치 찾고 재정렬
  • 특정 유저 순위 조회: O(n) — 처음부터 뒤져야 함
1000명이면 1000명을 전부 순회해야 하니, 유저가 늘어날수록 느려질 수밖에 없다. "내 순위가 몇 등이야?"라는 단순한 질문에도 서버가 헥헥대고 있으니 답답했다.

그러다 Redis Sorted Set이라는 구세주를 발견했다.

  • 등록/업데이트: O(log n) — Skip List 덕분
  • 순위 조회: O(log n)
  • 범위 조회(상위 100명): O(log n + m)
이게 바로 O(n)과 O(log n)의 차이다. 유저가 1만 명일 때, O(n)은 1만 번 연산이지만, O(log n)은 고작 14번이면 끝난다. 게다가 Redis가 알아서 정렬도 해주고 동시성 처리도 해준다. 메모리에 올려두고 실시간으로 처리하니까 응답 속도도 빠르다.

물론 1000명 정도의 유저로는 체감이 잘 안 된다. 하지만 오픈한 지 1개월 밖에 안 됐는데 이 정도면, 앞으로 더 늘어날 게 뻔했다. 어차피 준비에 투입하는 시간에 큰 차이가 없다면 이런 것도 미리 고려해두는 게 나중에 훨씬 편하다.

Skip List는 어떻게 동작하나?

정렬된 배열에서 50번째 요소를 찾으려면 앞에서부터 하나씩 세어가야 한다. 근데 영어 사전에서 'skip'을 찾으려고 'a'부터 훑진 않는다. 중간 즈음부터 펼쳐보고, 뒤로 갈지 앞으로 갈지 결정한다.

Skip List도 비슷한 원리다. 데이터를 여러 층으로 만들어놓고 위에서부터 찾아내려오는 방식이다. 40을 찾는다고 해보자:

Skip List 구조
  1. 맨 위층에서 10과 50을 보고 "40은 이 사이에 있겠다."
  2. 중간층에서 10, 30, 50을 보고 "40은 30과 50 사이에 있겠다."
  3. 맨 아래층에서 딱 찾는다.
이거 하나로 O(n)이 아니라 O(log n)이 가능하다. 알고리즘 시간복잡도가 이렇게 체감되는 순간이 또 있을까?

실제로 어떻게 썼나

이론은 그만하고 실제 코드를 보자. Redis Sorted Set은 생각보다 훨씬 직관적인 API를 제공한다.

// 랭킹 점수 업데이트
async updatePlayerPoints(seasonId: string, playerId: string, points: number) {
  const key = `leaderboard:${seasonId}`
  await this.redis.zadd(key, points, playerId)
}

// 내 순위 조회
async getPlayerRanking(seasonId: string, playerId: string) {
  const key = `leaderboard:${seasonId}`
  const position = await this.redis.zrevrank(key, playerId)
  return position === null ? null : position + 1
}

// 상위 랭커 조회
async getTopPlayers(seasonId: string, count: number = 1000) {
  const key = `leaderboard:${seasonId}`
  return await this.redis.zrevrange(key, 0, count - 1, 'WITHSCORES')
}

결과와 배운 점

숫자로 말하자면, P991 응답 시간이 5.38초에서 1.8초로 줄었다. 평균 응답시간도 999ms에서 771ms로 개선됐고. 개인적으로는 P99가 더 의미 있다. 시즌 막판에 유저들이 몰릴 때도 견딜 만하다는 얘기니까. 1%의 유저라도 답답함을 느끼면 서비스의 강성 안티로 돌변할 수 있다는 것도 잊지 말자.

이 단순해 보이는 변화 뒤에는 꽤 긴 여정이 있었다. 문제를 발견하고, 해결책을 찾고, 구현하고, 또 개선하는 과정. 특히 달리는 자동차의 바퀴를 갈아끼우는 이런 종류의 큰 작업에는 뭐든지 step by step이 중요하다. 우선 분리가 필요한 API를 분리해서 리더보드 개선 작업의 영향도를 줄여두었다. 그리고 Redis를 도입하는 김에 정적 데이터에 대한 캐싱도 적용했다. Redis Sorted Set 도입만으로 모든 문제가 해결된 건 아니었다. 5.38초에서 1.8초로 줄어든 건 여러 개선 작업이 누적된 결과였다. 특히 온체인 데이터를 매번 조회하던 방식이 개선된 것도 큰 몫을 했다. "은탄환"보다는 여러 작은 개선들이 모여 의미 있는 변화를 만든 셈이다.

이런 일들을 하다보면 "잘 돌아가는데 왜 자꾸 건드리냐"는 반응을 마주할 때가 있다. 하지만 유저 경험이 좋아지는 걸 보면 그만한 보람이 있다. 나 자신도 어떤 때는 꽤 까다로운 유저라서, 눈에 밟히는 것들을 개선하는 건 결국 나를 위한 길이기도 하다.

  1. 상위 99%의 응답 시간 — 가장 느린 1%의 요청