개발/REDIS

#6🏆 Sorted Sets: 랭킹/리더보드

cedis 2026. 3. 19. 10:08
실시간 게임 랭킹, 인기 게시물 순위, 주간 판매량 TOP10... Redis Sorted Set 하나면 O(log N)으로 즉시 구현됩니다.

🎮 실시간 랭킹, 어떻게 만드나요?

모바일 게임에서 "주간 랭킹" 기능을 만든다고 생각해 봅시다. 수십만 명의 점수를 매번 DB에서 ORDER BY로 정렬해서 가져오면 어떻게 될까요? 랭킹 화면을 열 때마다 전체 테이블을 스캔해야 하고, 동시 접속자가 많아지면 DB가 과부하에 걸립니다.

Redis의 Sorted Set(정렬된 집합)은 이 문제를 원천적으로 해결합니다. 데이터를 넣는 순간부터 자동으로 정렬되어 있고, 순위 조회는 O(log N)으로 매우 빠릅니다. 100만 명의 랭킹도 밀리초 수준에서 조회할 수 있죠.

Sorted Set은 각 멤버에 점수(score)를 붙여서 저장합니다. 점수는 부동소수점 실수이며, 같은 점수라면 멤버 이름의 사전순으로 정렬됩니다. 점수가 바뀌면 자동으로 순위도 바뀝니다.

이번 편에서는 게임 리더보드를 예시로 ZADD, ZRANGE, ZREVRANK 등 핵심 명령어를 배우고, Python과 Node.js로 실제 랭킹 API를 구현해 봅니다.

💡 Sorted Set의 3요소
score(점수): 정렬 기준이 되는 숫자 (예: 300.5)
member(멤버): 실제 저장되는 고유한 값 (예: "userA")
rank(순위): 0부터 시작하는 인덱스 (0 = 1등)

📊 핵심 개념 1: Score, Member, Rank

Sorted Set은 내부적으로 해시맵 + 스킵 리스트(Skip List)라는 자료구조를 사용합니다. 스킵 리스트 덕분에 정렬 상태를 유지하면서도 삽입/삭제/검색 모두 O(log N) 성능을 냅니다.

🥇
userB — 300점 (rank: 0)
🥈
userC — 200점 (rank: 1)
🥉
userA — 120점 (rank: 2)
⚠️ rank는 0부터 시작!
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 같은 형태로 설계할 수 있습니다. (소수점 자리에 시간을 숨기는 기법)

redis-cli
# 동점 시 먼저 달성한 사람 우선 (시간 가중치)
# 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을 설정합니다.

redis-cli
# 주간 리더보드 (키에 주차 포함)
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로 리더보드 만들기

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 구현

Python
# 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로 구현

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 * 금지!
랭킹 키가 많아질 때 KEYS leaderboard:*로 조회하면 Redis가 블로킹됩니다. 반드시 SCAN 0 MATCH leaderboard:* COUNT 100을 사용하세요.

📌 6편 핵심 요약

  1. Sorted Set = score + member: 넣는 순간 자동 정렬, O(log N) 성능
  2. rank는 0-indexed: 사용자에게 보여줄 때 +1 처리 필요
  3. ZINCRBY: 점수 원자적 증가 — 동시 접속자가 많아도 안전
  4. 기간별 키 + TTL: 주간/월간 랭킹 분리 관리
  5. 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는 몇 등인가요?
🥇 1등입니다. ZREVRANK는 0-indexed이므로 0 = 1등, 1 = 2등입니다.
Q2. 동점자가 생겼을 때 Redis Sorted Set의 기본 정렬 기준은?
🔤 멤버 이름의 사전순(lexicographic order)입니다. "userA"는 "userB"보다 앞에 옵니다.
Q3. ZRANGE leaderboard:weekly 0 2 WITHSCORES 와 ZRANGE ... REV WITHSCORES의 차이는?
📈 REV 없이는 낮은 점수부터(오름차순), REV를 붙이면 높은 점수부터(내림차순)입니다. 리더보드에서는 REV를 사용해야 1등부터 보입니다.

📝 과제 3가지

  1. 월간 랭킹 만들기: 키 이름을 leaderboard:2026-03으로 하고 TTL을 31일로 설정하세요.
  2. 내 주변 순위: 특정 사용자의 순위 ±2명을 보여주는 "주변 순위" 기능을 구현하세요.
  3. 하위 정리: ZREMRANGEBYRANK로 하위 50% 사용자를 삭제하고, 남은 멤버를 확인하세요.