LabHub

Redis 와 캐싱 · 랭킹을 ZSet 으로 · 이론

랭킹을 DB 로 뽑으면 왜 무너지는가

LabHub 에서 이어서 보기

한 줄 요약

ORDER BY score DESC LIMIT 10 은 한 명의 순위를 물어보는 순간 무너진다. ZSet 은 정렬 상태를 항상 유지하고 순위를 O(log N) 으로 돌려준다.

왜 이게 필요했나

리더보드 요구사항은 대개 이렇게 옵니다. 상위 10명을 보여 주고, 내 순위도 보여 주고, 내 앞뒤 5명도 보여 주고, 점수는 실시간으로 바뀐다.

RDB 로 하면 첫 요구는 쉽습니다. ORDER BY score DESC LIMIT 10 에 인덱스를 걸면 됩니다. 두 번째부터 무너집니다. "내 순위"는 나보다 점수가 높은 사람 수를 세는 것이고, SELECT COUNT(*) WHERE score > my_score 는 인덱스가 있어도 그 범위를 전부 세야 합니다. 사용자 100만 명 중 하위권 사용자의 순위를 물으면 99만 행을 셉니다. 그리고 이 쿼리가 사용자마다 매 요청 들어옵니다.

세 번째 요구인 앞뒤 5명은 더 나쁩니다. 오프셋을 알아야 하므로 순위 계산이 선행됩니다. 네 번째인 실시간 갱신은 그 인덱스에 계속 쓰기가 들어온다는 뜻이라 락 경합까지 더합니다.

어떻게 동작하나

Redis 의 정렬 집합은 이 문제를 위해 만들어진 것처럼 맞습니다. 내부적으로 스킵 리스트와 해시 테이블을 함께 유지해서, 멤버로 점수를 찾는 것과 점수 순으로 범위를 훑는 것을 둘 다 빠르게 합니다.

핵심 명령은 다섯 개입니다. ZADD 로 점수와 함께 넣고, ZINCRBY 로 원자적으로 더하고, ZREVRANGE 로 상위 N 을 뽑고, ZREVRANK 로 특정 멤버의 순위를 얻고, ZSCORE 로 점수를 봅니다. 순위와 범위 조회가 전부 O(log N) 이거나 O(log N + M) 입니다. 100만 명이든 1000만 명이든 순위 조회가 밀리초를 넘지 않습니다.

동점 처리가 실무의 첫 관문입니다. Redis 는 점수가 같으면 멤버 문자열의 사전순으로 정렬합니다. 그런데 제품 요구는 대개 "같은 점수면 먼저 달성한 사람이 위"입니다. 해법은 복합 점수입니다. 점수와 시각을 하나의 실수로 접습니다.

composite = points + (1 - ts / 1e10)# points 5000, ts 1795000000 -> 5000.8205# 같은 points 라면 ts 가 작을수록(먼저 달성) composite 가 크다

소수부가 항상 0과 1 사이이므로 정수 경계를 넘지 않고, 점수 순서는 그대로 유지되면서 동점만 시각으로 갈립니다.

두 번째 관문은 기간별 랭킹입니다. 전체 랭킹과 주간 랭킹을 따로 두고, 주간 키에 TTL 을 겁니다. lb:weekly:2026-W34 같은 키 이름에 기간을 넣으면 만료가 자동으로 정리해 줍니다. 여러 기간을 합산해야 하면 ZUNIONSTORE 가 가중치까지 받아 한 번에 처리합니다.

현장에서 만나는 모습

랭킹은 Redis 가 캐시가 아니라 1차 저장소가 되는 드문 경우입니다. 그래서 영속성을 켜 두거나, 원본 이벤트(어떤 사용자가 언제 몇 점을 얻었는가)를 다른 곳에 남겨 재구축할 수 있게 해야 합니다. 저자의 권고는 후자입니다 — 랭킹은 파생 데이터이므로 언제든 다시 만들 수 있어야 합니다.

그리고 페이지네이션에 오프셋을 쓰는 것은 ZSet 에서는 문제가 되지 않습니다. ZREVRANGE key 99 108 은 스킵 리스트를 타고 바로 갑니다. RDB 의 OFFSET 99 와 성격이 완전히 다릅니다.

다음 실습에서 할 것

2,000건의 플레이 기록으로 200명의 리더보드를 만듭니다. 상위 10명, 특정 사용자 순위, 100위대 페이지, 동점 규칙, 주간 랭킹과 합산까지 전부 구현하고, 마지막에 순위 조회 API 를 띄워 1,000회 조회 시간을 측정합니다.