Redis를 활용한 실시간 리더보드 구현: 100만 동시 사용자 지원하기
게임이나 경쟁 기반 서비스에서 실시간 랭킹 시스템은 핵심 기능입니다. 사용자들은 자신의 순위가 즉시 반영되기를 기대합니다. 이번 글에서는 Redis를 활용해 100만 동시 사용자를 지원하는 리더보드를 구현했던 경험을 상세히 공유합니다.
프로젝트 배경
우리 팀이 운영하는 모바일 게임에서 기존 MySQL 기반 랭킹 시스템의 한계에 직면했습니다:
- 점수 업데이트 시 전체 테이블 재정렬 필요 → 지연 발생
- 순위 조회 쿼리가 무거움 (
ORDER BY score DESC) - 피크 시간대 DB 부하로 응답 지연 3초 이상
이 문제를 해결하기 위해 Redis Sorted Set을 도입하기로 결정했습니다.
요구사항 정의
새로운 리더보드 시스템의 요구사항은 다음과 같았습니다:
- 실시간 업데이트: 점수가 올라가면 즉시 반영
- 다양한 랭킹 뷰: 전체 / 친구 / 지역별 랭킹 제공
- 확장성: 100만 동시 사용자 트래픽 감당
- 응답 시간: p99 기준 50ms 이하
Redis Sorted Set 소개
Redis의 Sorted Set(ZSET)은 점수(score)를 기준으로 자동 정렬되는 자료구조입니다. 리더보드에 최적화된 이유:
- 삽입/업데이트: O(log N)
- 순위 조회: O(log N)
- 범위 조회: O(log N + M) (M은 결과 개수)
기본 구현
점수 등록 및 업데이트
# Python 예시 (redis-py 사용)
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
def update_score(user_id: str, score: int, leaderboard: str = "global"):
"""점수 등록 - 기존 점수보다 높을 때만 업데이트"""
key = f"leaderboard:{leaderboard}"
current = r.zscore(key, user_id)
if current is None or score > current:
r.zadd(key, {user_id: score})
return True
return False
# 사용 예
update_score("user:123", 15000, "weekly")랭킹 조회
def get_top_players(leaderboard: str, limit: int = 100):
"""상위 N명 조회"""
key = f"leaderboard:{leaderboard}"
# ZREVRANGE: 높은 점수부터 내림차순
results = r.zrevrange(key, 0, limit - 1, withscores=True)
return [{"user_id": uid.decode(), "score": int(score), "rank": idx + 1}
for idx, (uid, score) in enumerate(results)]
def get_user_rank(user_id: str, leaderboard: str = "global"):
"""특정 유저 순위 조회"""
key = f"leaderboard:{leaderboard}"
rank = r.zrevrank(key, user_id) # 0-indexed
score = r.zscore(key, user_id)
return {"rank": rank + 1 if rank is not None else None, "score": score}
def get_nearby_players(user_id: str, leaderboard: str, range_size: int = 5):
"""내 주변 순위 조회 (위아래 5명씩)"""
key = f"leaderboard:{leaderboard}"
rank = r.zrevrank(key, user_id)
if rank is None:
return []
start = max(0, rank - range_size)
end = rank + range_size
results = r.zrevrange(key, start, end, withscores=True)
return [{"user_id": uid.decode(), "score": int(score), "rank": start + idx + 1}
for idx, (uid, score) in enumerate(results)]성능 최적화 전략
1. Pipeline 처리
여러 명령을 한 번에 전송하여 네트워크 RTT를 최소화합니다:
def batch_update_scores(updates: list):
"""배치 점수 업데이트 - [{user_id, score}, ...]"""
pipe = r.pipeline()
for item in updates:
pipe.zadd("leaderboard:global", {item["user_id"]: item["score"]})
pipe.execute() # 한 번의 네트워크 요청으로 처리2. Lua Script로 원자성 보장
점수 업데이트와 추가 로직을 원자적으로 처리:
-- update_if_higher.lua
local key = KEYS[1]
local user_id = ARGV[1]
local new_score = tonumber(ARGV[2])
local current = redis.call('ZSCORE', key, user_id)
if not current or new_score > tonumber(current) then
redis.call('ZADD', key, new_score, user_id)
return 1
end
return 0# Python에서 Lua 스크립트 실행
update_script = r.register_script(open('update_if_higher.lua').read())
result = update_script(keys=['leaderboard:global'], args=['user:123', 15000])3. Redis Cluster로 수평 확장
단일 Redis로 100만 사용자를 감당하기 어려워 Cluster를 도입했습니다:
# redis-cluster.conf
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
# 샤딩 키 설계
# leaderboard:{weekly}:shard_0
# leaderboard:{weekly}:shard_1
# Hash Tag로 같은 노드에 배치4. TTL 기반 주기적 리셋
def create_weekly_leaderboard():
"""주간 리더보드 생성 (7일 후 자동 삭제)"""
week_num = datetime.now().isocalendar()[1]
key = f"leaderboard:weekly:{week_num}"
r.expire(key, 60 * 60 * 24 * 7) # 7일 TTL친구 랭킹 구현
친구 목록 기반 소셜 랭킹도 구현했습니다:
def get_friends_ranking(user_id: str):
"""친구들 사이에서의 랭킹"""
# 친구 목록 조회 (Set 자료구조)
friends = r.smembers(f"friends:{user_id}")
friends.add(user_id.encode()) # 자신도 포함
# Pipeline으로 모든 친구 점수 조회
pipe = r.pipeline()
for friend in friends:
pipe.zscore("leaderboard:global", friend)
scores = pipe.execute()
# 점수로 정렬하여 랭킹 생성
friend_scores = [(f.decode(), s) for f, s in zip(friends, scores) if s]
friend_scores.sort(key=lambda x: x[1], reverse=True)
return [{"user_id": uid, "score": int(s), "rank": i + 1}
for i, (uid, s) in enumerate(friend_scores)]모니터링 및 알림
# Prometheus 메트릭 수집
from prometheus_client import Histogram
leaderboard_latency = Histogram(
'leaderboard_operation_seconds',
'Leaderboard operation latency',
['operation']
)
@leaderboard_latency.labels('get_rank').time()
def get_user_rank_monitored(user_id):
return get_user_rank(user_id)성과
Redis 리더보드 도입 후 측정한 성과:
| 지표 | 이전 (MySQL) | 이후 (Redis) |
|---|---|---|
| 응답시간 p50 | 450ms | 3ms |
| 응답시간 p99 | 3,200ms | 10ms |
| 처리량 | 500 req/s | 50,000 req/s |
| 가용성 | 99.5% | 99.99% |
Redis Sorted Set은 실시간 랭킹에 최적화된 솔루션임을 다시 한번 확인했습니다. 특히 게임이나 경쟁 기반 서비스를 개발한다면 강력히 추천합니다.