🎮 실시간 랭킹, 어떻게 만드나요?
모바일 게임에서 "주간 랭킹" 기능을 만든다고 생각해 봅시다. 수십만 명의 점수를 매번 DB에서 ORDER BY로 정렬해서 가져오면 어떻게 될까요? 랭킹 화면을 열 때마다 전체 테이블을 스캔해야 하고, 동시 접속자가 많아지면 DB가 과부하에 걸립니다.
Redis의 Sorted Set(정렬된 집합)은 이 문제를 원천적으로 해결합니다. 데이터를 넣는 순간부터 자동으로 정렬되어 있고, 순위 조회는 O(log N)으로 매우 빠릅니다. 100만 명의 랭킹도 밀리초 수준에서 조회할 수 있죠.
Sorted Set은 각 멤버에 점수(score)를 붙여서 저장합니다. 점수는 부동소수점 실수이며, 같은 점수라면 멤버 이름의 사전순으로 정렬됩니다. 점수가 바뀌면 자동으로 순위도 바뀝니다.
이번 편에서는 게임 리더보드를 예시로 ZADD, ZRANGE, ZREVRANK 등 핵심 명령어를 배우고, Python과 Node.js로 실제 랭킹 API를 구현해 봅니다.
score(점수): 정렬 기준이 되는 숫자 (예: 300.5)
member(멤버): 실제 저장되는 고유한 값 (예: "userA")
rank(순위): 0부터 시작하는 인덱스 (0 = 1등)
📊 핵심 개념 1: Score, Member, Rank
Sorted Set은 내부적으로 해시맵 + 스킵 리스트(Skip List)라는 자료구조를 사용합니다. 스킵 리스트 덕분에 정렬 상태를 유지하면서도 삽입/삭제/검색 모두 O(log N) 성능을 냅니다.
Redis의 rank(순위)는 0-indexed입니다. ZREVRANK에서 1등의 rank는 0, 2등은 1입니다. 사용자에게 보여줄 때는 rank + 1을 해야 합니다.
핵심 명령어 정리
| 명령어 | 설명 |
|---|---|
ZADD key score member |
멤버 추가 (이미 있으면 점수 업데이트) |
ZINCRBY key amount member |
점수 증가 (원자적) |
ZSCORE key member |
특정 멤버의 점수 조회 |
ZRANK key member |
오름차순 순위 (낮은 점수 = 0) |
ZREVRANK key member |
내림차순 순위 (높은 점수 = 0) |
ZRANGE key 0 -1 REV WITHSCORES |
높은 점수부터 전체 조회 |
ZCARD key |
멤버 수 조회 |
ZREM key member |
멤버 삭제 |
🔢 핵심 개념 2: 점수 설계 패턴
Sorted Set을 잘 쓰려면 score를 어떻게 설계하느냐가 중요합니다. 단순히 점수만 넣을 수도 있지만, 실무에서는 더 정교한 설계가 필요합니다.
패턴 1: 동점 처리 — 시간을 소수점으로 활용
같은 점수가 여러 명이면 Redis는 멤버 이름 사전순으로 정렬합니다. 하지만 "같은 점수라면 먼저 달성한 사람이 상위"를 구현하고 싶다면, 점수를 score - timestamp/1e13 같은 형태로 설계할 수 있습니다. (소수점 자리에 시간을 숨기는 기법)
# 동점 시 먼저 달성한 사람 우선 (시간 가중치)
# score = 점수 * 1e6 + (1e6 - timestamp_sec % 1e6)
# → 같은 점수라면 timestamp가 작은 쪽(먼저 달성)이 score가 높음
# 예시에서는 단순화해서 기본 점수만 사용
ZADD leaderboard:weekly 300 userB
ZADD leaderboard:weekly 300 userA # 동점 → 사전순: userA가 앞
ZRANGE leaderboard:weekly 0 -1 REV WITHSCORES
# 1) "userB" "300" ← B가 사전순 뒤
# 2) "userA" "300"
패턴 2: 기간별 리더보드 — 키 이름에 날짜 포함
주간 랭킹, 월간 랭킹을 따로 관리하려면 키 이름에 날짜를 넣고 TTL을 설정합니다.
# 주간 리더보드 (키에 주차 포함)
ZADD leaderboard:2026-W12 120 userA
ZADD leaderboard:2026-W12 300 userB
ZADD leaderboard:2026-W12 200 userC
# 1주일 후 자동 삭제 (7일 = 604800초)
EXPIRE leaderboard:2026-W12 604800
🔬 Lab 5: redis-cli로 리더보드 만들기
# Lab 5 — 주간 리더보드 실습
DEL leaderboard:weekly
# 점수 등록
ZADD leaderboard:weekly 120 userA
ZADD leaderboard:weekly 300 userB
ZADD leaderboard:weekly 200 userC
ZADD leaderboard:weekly 250 userD
ZADD leaderboard:weekly 180 userE
# TOP 3 조회 (높은 점수 기준)
ZRANGE leaderboard:weekly 0 2 REV WITHSCORES
# 결과:
# 1) "userB" "300"
# 2) "userD" "250"
# 3) "userC" "200"
# userC의 현재 순위 (0-indexed)
ZREVRANK leaderboard:weekly userC # → 2 (3등)
# userA의 점수 조회
ZSCORE leaderboard:weekly userA # → "120"
# userA 점수 50점 추가 (원자적)
ZINCRBY leaderboard:weekly 50 userA
ZSCORE leaderboard:weekly userA # → "170"
# 전체 멤버 수
ZCARD leaderboard:weekly # → 5
# 하위 2명 조회 (낮은 점수 기준)
ZRANGE leaderboard:weekly 0 1 WITHSCORES
# 1) "userA" "170" ← ZINCRBY 후 변경됨
# 2) "userE" "180"
🐍 Lab 5: Python으로 랭킹 API 구현
# pip install redis
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
BOARD_KEY = "leaderboard:weekly"
def add_score(user_id: str, score: float):
"""점수 등록 또는 업데이트"""
r.zadd(BOARD_KEY, {user_id: score})
print(f"[등록] {user_id}: {score}점")
def increment_score(user_id: str, amount: float):
"""점수 증가 (원자적)"""
new_score = r.zincrby(BOARD_KEY, amount, user_id)
print(f"[증가] {user_id}: +{amount}점 → 현재 {new_score}점")
return new_score
def get_rank(user_id: str) -> int:
"""사용자 순위 조회 (1-indexed로 변환)"""
rank = r.zrevrank(BOARD_KEY, user_id)
if rank is None:
return -1
return rank + 1 # 0-indexed → 1-indexed
def get_top_n(n: int = 10):
"""상위 N명 조회"""
results = r.zrange(BOARD_KEY, 0, n - 1, rev=True, withscores=True)
print(f"\n🏆 TOP {n} 리더보드")
print("-" * 30)
for i, (member, score) in enumerate(results, 1):
print(f" {i}위: {member} — {int(score)}점")
def get_nearby_ranks(user_id: str, window: int = 2):
"""특정 사용자 주변 순위 조회"""
rank = r.zrevrank(BOARD_KEY, user_id)
if rank is None:
print(f"{user_id} 랭킹 없음")
return
start = max(0, rank - window)
end = rank + window
results = r.zrange(BOARD_KEY, start, end, rev=True, withscores=True)
print(f"\n📍 {user_id} 주변 순위 (±{window})")
print("-" * 30)
for i, (member, score) in enumerate(results, start + 1):
marker = " ← 나" if member == user_id else ""
print(f" {i}위: {member} — {int(score)}점{marker}")
# ── 실행 ──
if __name__ == "__main__":
r.delete(BOARD_KEY)
# 점수 등록
users = [("userA", 120), ("userB", 300), ("userC", 200),
("userD", 250), ("userE", 180)]
for uid, score in users:
add_score(uid, score)
# 상위 3명
get_top_n(3)
# userA 점수 증가
increment_score("userA", 100)
# userA의 순위
print(f"\nuserA 현재 순위: {get_rank('userA')}위")
# userC 주변 순위
get_nearby_ranks("userC", window=1)
# TTL 설정 (7일)
r.expire(BOARD_KEY, 604800)
print(f"\nTTL 설정 완료: {r.ttl(BOARD_KEY)}초 후 만료")
🟢 Lab 5: Node.js로 구현
// npm install redis
import { createClient } from "redis";
const client = createClient();
client.on("error", (err) => console.error("Redis Client Error", err));
await client.connect();
const BOARD_KEY = "leaderboard:weekly:node";
// 점수 등록
async function addScore(userId, score) {
await client.zAdd(BOARD_KEY, [{ score, value: userId }]);
console.log(`[등록] ${userId}: ${score}점`);
}
// 점수 증가
async function incrementScore(userId, amount) {
const newScore = await client.zIncrBy(BOARD_KEY, amount, userId);
console.log(`[증가] ${userId}: +${amount}점 → 현재 ${newScore}점`);
return newScore;
}
// 순위 조회 (1-indexed)
async function getRank(userId) {
const rank = await client.zRevRank(BOARD_KEY, userId);
return rank !== null ? rank + 1 : -1;
}
// TOP N 조회
async function getTopN(n = 10) {
const results = await client.zRangeWithScores(BOARD_KEY, 0, n - 1, { REV: true });
console.log(`\n🏆 TOP ${n} 리더보드`);
results.forEach((item, i) => {
console.log(` ${i + 1}위: ${item.value} — ${Math.floor(item.score)}점`);
});
}
// 실행
await client.del(BOARD_KEY);
const users = [["userA", 120], ["userB", 300], ["userC", 200], ["userD", 250], ["userE", 180]];
for (const [uid, score] of users) {
await addScore(uid, score);
}
await getTopN(3);
await incrementScore("userA", 100);
console.log(`\nuserA 현재 순위: ${await getRank("userA")}위`);
await client.expire(BOARD_KEY, 604800); // 7일 TTL
await client.quit();
⚠️ 실전 팁: 점수 설계 시 주의사항
| 상황 | 권장 접근 |
|---|---|
| 동점 처리가 필요할 때 | 점수에 타임스탬프를 소수점으로 숨기기 |
| 기간별 랭킹 | 키에 날짜/주차 포함 + TTL 설정 |
| 점수가 매우 클 때 | Redis score는 64비트 부동소수점 — 큰 수는 정밀도 주의 |
| 랭킹 캐시가 너무 커질 때 | ZREMRANGEBYRANK로 하위 N명 주기적 삭제 |
| 여러 조건 복합 정렬 | score를 수식으로 합성 (단, 과도한 수학은 피할 것) |
랭킹 키가 많아질 때
KEYS leaderboard:*로 조회하면 Redis가 블로킹됩니다. 반드시 SCAN 0 MATCH leaderboard:* COUNT 100을 사용하세요.📌 6편 핵심 요약
- Sorted Set = score + member: 넣는 순간 자동 정렬, O(log N) 성능
- rank는 0-indexed: 사용자에게 보여줄 때 +1 처리 필요
- ZINCRBY: 점수 원자적 증가 — 동시 접속자가 많아도 안전
- 기간별 키 + TTL: 주간/월간 랭킹 분리 관리
- ZRANGE ... REV WITHSCORES: 높은 점수부터 순위 조회 핵심 명령
✅ 6편 체크리스트
- ZADD로 멤버와 점수를 등록했다
- ZRANGE ... REV WITHSCORES로 상위 N명을 조회했다
- ZREVRANK로 특정 사용자의 순위를 확인했다 (0-indexed)
- ZINCRBY로 점수를 원자적으로 증가시켰다
- 키 이름에 날짜를 넣고 TTL을 설정했다
- Python 또는 Node.js로 랭킹 API를 구현했다
🧠 퀴즈 3문항
Q1. ZREVRANK leaderboard:weekly userB 결과가 0이면 userB는 몇 등인가요?
Q2. 동점자가 생겼을 때 Redis Sorted Set의 기본 정렬 기준은?
Q3. ZRANGE leaderboard:weekly 0 2 WITHSCORES 와 ZRANGE ... REV WITHSCORES의 차이는?
📝 과제 3가지
- 월간 랭킹 만들기: 키 이름을
leaderboard:2026-03으로 하고 TTL을 31일로 설정하세요. - 내 주변 순위: 특정 사용자의 순위 ±2명을 보여주는 "주변 순위" 기능을 구현하세요.
- 하위 정리: ZREMRANGEBYRANK로 하위 50% 사용자를 삭제하고, 남은 멤버를 확인하세요.
'개발 > REDIS' 카테고리의 다른 글
| #8 ⚔️ 동시성 제어: 트랜잭션(MULTI/EXEC)·WATCH (0) | 2026.03.19 |
|---|---|
| #7 📡 Pub/Sub vs Streams: 실시간 메시징 (0) | 2026.03.19 |
| #5 📬 Lists·Sets: 큐와 중복 방지 (0) | 2026.03.19 |
| #4 List와 Queue 활용하기: 비동기 작업 큐 만들기 (0) | 2026.03.17 |
| #3 Strings: 카운터·간단 캐시 만들기 (0) | 2026.03.17 |