Redis 와 캐싱 · 랭킹을 ZSet 으로 · 실습
리더보드를 ZSet 으로 구현하고 검증하기
목표
정렬 집합 하나로 실시간 리더보드의 전체 요구사항 — 상위 N, 개인 순위, 페이지네이션, 동점 규칙, 기간별 랭킹, 합산 — 을 구현하고 조회 성능을 숫자로 확인한다.
왜 중요한가
랭킹은 관계형 DB 가 가장 못하는 일 중 하나입니다. 상위 10명은 인덱스로 쉽지만, "내 순위"는 나보다 점수가 높은 사람을 전부 세야 하므로 하위권 사용자 한 명의 조회가 99만 행을 훑습니다. 그리고 그 쿼리가 사용자마다 매 요청 들어오고, 동시에 점수 갱신 쓰기가 같은 인덱스로 몰립니다. 정렬 집합은 스킵 리스트와 해시 테이블을 함께 유지해 순위 조회와 범위 조회를 모두 O(log N) 으로 만듭니다. 100만 명이든 1000만 명이든 밀리초입니다. 이 실습에서 특히 신경 써야 할 부분은 6번 스텝의 동점 처리입니다. Redis 의 기본 동점 규칙은 멤버 문자열 사전순인데, 제품 요구는 거의 항상 "먼저 달성한 사람이 위"입니다. 점수와 시각을 하나의 실수로 접는 이 기법은 리더보드를 만들 때마다 쓰게 됩니다.
단계
1. /root/lb/load.py 로 /opt/fixtures/rd/plays.csv(헤더 user_id,points,ts)를 읽어 lb:global 에 사용자별 누적 점수를 넣는다. ZCARD lb:global 이 200 이어야 한다.
2. 상위 10명을 점수와 함께 /root/lb/top10.txt 에 <순위> <user_id> <점수> 형식으로 10줄 적는다. 1위가 첫 줄이다.
3. /root/lb/myrank.txt 에 사용자 u042 의 정보를 user=u042 rank=<1부터> score=<점수> 한 줄로 적는다.
4. /root/lb/incr.py 로 u042 에 50점을 원자적으로 더한다. 소스에 ZSCORE 로 읽어 ZADD 로 쓰는 패턴이 있으면 안 된다. 실행 후 점수가 정확히 50 늘어야 한다.
5. /root/lb/page.txt 에 100위부터 109위까지 10명을 <순위> <user_id> 형식으로 적는다. 첫 줄의 순위가 100 이어야 한다.
6. /root/lb/tie.py 로 lb:tie 를 만든다. 점수는 points + (1 - ts / 1e10) 로 접는다. /opt/fixtures/rd/ties.csv 의 동점자 5명이 ts 오름차순으로 상위에 오게 하고 결과를 /root/lb/tie.txt 에 5줄 적는다.
7. lb:weekly:2026-W34 를 만들고 TTL 을 604800 이하로 건다. ZUNIONSTORE lb:combined 2 lb:global lb:weekly:2026-W34 WEIGHTS 1 2 로 합산 키를 만든다. ZCARD lb:combined 가 200 이상이어야 한다.
8. /root/lb/api.py 를 127.0.0.1:8150 에 띄운다. GET /rank/u042 는 {"user":"u042","rank":<정수>,"score":<수>,"top3":[...]} 를 준다. /root/lb/bench.txt 에 queries=1000 total_ms=<정수> avg_ms=<수> 를 적고 avg_ms 는 5 미만이어야 한다.
참고
- 누적:
ZINCRBY lb:global <점수> <user>— 읽고 쓰면 동시 갱신에서 값이 사라집니다. - 상위 N:
ZREVRANGE lb:global 0 9 WITHSCORES - 순위:
ZREVRANK lb:global u042는 0부터 셉니다. - 랭킹은 파생 데이터입니다. 원본 이벤트를 남겨 두면 언제든 재구축할 수 있습니다.
- 흔한 실수 1:
ZRANGE를 써서 낮은 점수부터 가져오는 것. - 흔한 실수 2: 동점 처리에 소수부가 1을 넘는 값을 더해 점수 순서 자체가 뒤집히는 것.
단계 8개
- 플레이 기록으로 리더보드 채우기
- 상위 10명 뽑기
- 특정 사용자의 순위 구하기
- 점수를 원자적으로 누적하기
- 100위대 페이지 뽑기
- 동점을 먼저 달성한 순으로 정렬하기
- 주간 랭킹과 합산 만들기
- 순위 조회 API 띄우고 성능 재기