Chapter 10

실시간 게임 순위표

  • 10.1 문제 이해 및 설계 범위 확정
  • 10.2 개략적 설계안 제시
  • 10.3 상세 설계
  • 10.4 마무리

**실시간 게임 순위표(Leaderboard)**는 모바일 게임에서 "내가 지금 몇 등이지?"를 보여주는 기능이야. 단순해 보이지만 사용자가 수백만 명이면 순위 계산이 만만치 않지. 핵심 도구는 Redis의 sorted set이야.

기능 요구사항을 보면:

  • 게임 결과에 따라 사용자의 점수가 갱신된다
  • 실시간 Top 10 순위를 표시한다
  • 특정 사용자의 현재 순위를 표시한다
  • 특정 사용자 주변 순위(예: 내 위아래 4명씩)를 표시한다

비기능 요구사항은:

  • 실시간 갱신 — 점수가 변하면 순위가 즉시 반영돼야 해
  • 높은 확장성 — DAU 500만 명, 동시 접속 2,500만 명 수준
  • 낮은 지연시간 — 순위 조회가 수 밀리초 이내

규모 계산 — 하루에 사용자당 평균 10게임을 한다면, 하루 5,000만 건의 점수 업데이트. 피크 시간대 QPS는 초당 약 2,500건 정도야.

일단 가장 직관적인 방법을 생각해 보자. users 테이블에 score 컬럼이 있고, 순위를 구하려면:

SELECT *, RANK() OVER (ORDER BY score DESC) AS ranking FROM users

이건 전체 테이블을 스캔해야 해. 500만 행이면 한 번의 순위 조회에 몇 초가 걸릴 수 있어. 실시간 순위표에는 부적합하지.

인덱스를 잘 걸어도 "내가 몇 등이냐"를 구하려면 나보다 점수가 높은 행의 수를 세야 하니까, 데이터가 많으면 느려져.

이 문제를 아름답게 푸는 자료구조가 **Redis의 sorted set(ZSET)**이야.

Sorted set은 각 멤버에 **score(점수)**를 부여하고, 점수 기준으로 정렬된 상태를 항상 유지해. 핵심 연산들의 시간 복잡도가 이래.

  • ZADD key score member — 멤버 추가 또는 점수 갱신: O(log n)
  • ZREVRANK key member — 점수 내림차순에서 멤버의 순위: O(log n)
  • ZREVRANGE key start stop — 순위 범위의 멤버 목록: O(log n + k) (k = 반환 개수)
  • ZREVRANGEBYSCORE key max min — 점수 범위의 멤버 목록

500만 멤버여도 log(5,000,000) ≈ 22이니까, 한 번의 순위 조회에 22번 정도의 비교 연산이면 돼. 밀리초 이하지.

내부적으로 sorted set은 **스킵 리스트(skip list)**와 해시 테이블을 조합한 자료구조야. 스킵 리스트 덕분에 정렬 순서를 유지하면서 O(log n) 검색이 가능하고, 해시 테이블로 특정 멤버의 점수를 O(1)에 조회하지.

점수 갱신 흐름을 보면:

  1. 게임 서버가 게임 결과를 API 서버에 전송
  2. API 서버가 Redis에 ZADD leaderboard <new_score> <user_id> 실행
  3. Redis가 sorted set 내에서 해당 사용자의 점수를 갱신하고 순서를 재배치

이게 끝이야. 별도의 "순위 재계산" 과정이 없어. sorted set이 항상 정렬된 상태를 유지하니까.

순위 조회 흐름을 보면:

Top 10 조회: ZREVRANGE leaderboard 0 9 WITHSCORES → 상위 10명의 사용자 ID와 점수 반환

내 순위 조회: ZREVRANK leaderboard <user_id> → 0-based 순위 반환 (1 더하면 실제 순위)

내 주변 순위: 먼저 내 순위를 구하고, ZREVRANGE leaderboard (rank-4) (rank+4) 로 주변 9명을 가져와

Redis에는 user_id와 score만 저장하고, 사용자 이름이나 프로필 이미지 같은 상세 정보는 **별도의 DB(MySQL, Redis Hash 등)**에 저장해. 순위표를 보여줄 때 user_id 목록으로 상세 정보를 조회해서 합치지.

점수가 같으면 누가 더 높은 순위인가? 여러 전략이 가능해.

  • 먼저 달성한 사람이 높은 순위 — score에 타임스탬프를 미세하게 반영. 예: score = actual_score * 1000000 + (MAX_TIMESTAMP - timestamp). 같은 점수라도 먼저 달성한 사람의 보정 점수가 더 높지.
  • 단순히 Redis의 기본 사전순 정렬에 맡기기 — 동점이면 멤버 이름의 사전순으로 정렬돼. 보통은 별로 유용하지 않아.

사용자 500만 명이면 각 엔트리에 약 100바이트(사용자 ID + 점수 + 내부 오버헤드)가 필요하다 하면 약 500MB. Redis 인스턴스 하나로 충분하지.

사용자가 5억 명이 되면? 50GB로 단일 Redis 인스턴스로는 어려워. 이때는 샤딩이 필요해.

고정 파티션 — 점수 범위로 나눠. 0-100점은 샤드 1, 101-200점은 샤드 2... 이런 식. 문제는 점수 분포가 균등하지 않으면 특정 샤드에 데이터가 몰리지.

해시 파티션 — user_id를 해싱해서 샤드를 결정. 데이터 분포는 균등하지만, 글로벌 순위를 구하려면 모든 샤드에 질의해서 결과를 합쳐야 해. Top 10을 구하려면 각 샤드의 Top 10을 가져와서 합친 다음 정렬하지.

실전에서는 점수 범위 파티션이 순위 계산에 더 유리해. "내 순위"를 구할 때 나보다 점수가 높은 범위의 샤드들의 크기를 합산하면 되니까.

Redis는 메모리 기반이니까 서버가 죽으면 데이터가 날아갈 수 있어. **AOF(Append Only File)**나 RDB 스냅샷으로 영속성을 확보하고, **복제(replica)**를 두어서 장애 시 빠르게 복구하지.

추가로 점수 업데이트를 MySQL 같은 영속 DB에도 기록해두면, Redis가 완전히 날아가도 DB에서 다시 구축할 수 있어.

순위표는 적절한 자료구조를 선택하면 난이도가 확 내려가는 전형적인 문제야. RDBMS로 접근하면 막히지만, Redis sorted set을 쓰면 깔끔하게 풀리지. 면접에서는 "왜 RDBMS가 안 되는지"를 먼저 설명하고, sorted set으로 자연스럽게 넘어가는 흐름이 좋아.


정리

10장 읽고 기억할 거 세 가지:

  1. Redis sorted set은 O(log n)에 순위 조회와 점수 갱신이 가능한 완벽한 자료구조야. 내부적으로 스킵 리스트를 사용해서 항상 정렬 상태를 유지하지.
  2. RDBMS로 순위를 구하려면 전체 스캔이나 COUNT가 필요해서 대규모에서 비현실적이야. 이게 Redis를 선택하는 이유지.
  3. 대규모에서는 Redis를 점수 범위로 샤딩하면 글로벌 순위 계산이 효율적이야. 영속성은 AOF/RDB + 별도 DB 백업으로 확보하지.