LabHub
배우기 러닝패스 코스

Redis and Caching

Why Pulling Rankings From the Database Falls Over

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

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

Flow map: 메모리에 전부 올라간다. · 키에 넣는 식별자를 짧게 유지하는 것 · 하위권 사용자의 정확한 순위를 아무도 보지 않는다면 · 범위가 넓은 조회 하나가 다른 모든 요청을 멈춰 세운다.

왜 이게 필요했나

리더보드 요구사항은 대개 이렇게 옵니다. 상위 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 와 성격이 완전히 다릅니다.

큰 랭킹을 다룰 때

정렬 집합은 빠르지만 메모리에 전부 올라간다. 사용자가 늘면 이 사실이 설계를 좌우하기 시작한다.

멤버 하나가 차지하는 크기는 멤버 문자열 길이에 비례하므로, 키에 넣는 식별자를 짧게 유지하는 것만으로 상당한 차이가 난다. 사용자 이름 대신 숫자 ID 를 쓰고, 그 ID 를 문자열이 아니라 정수로 표현할 수 있는 형태로 두는 식이다. 그리고 랭킹에 굳이 전원을 담을 필요가 없는 경우가 많다. 하위권 사용자의 정확한 순위를 아무도 보지 않는다면, 상위 몇만 명만 유지하고 나머지는 "상위 N 밖" 으로 처리하는 편이 훨씬 싸다. ZREMRANGEBYRANK 로 주기적으로 잘라 내면 된다.

명령 하나가 오래 걸리는 것도 조심해야 한다. Redis 는 한 번에 하나의 명령을 처리하므로, 범위가 넓은 조회 하나가 다른 모든 요청을 멈춰 세운다. ZRANGE key 0 -1 로 전체를 가져오는 코드가 100만 멤버짜리 키에 걸리면 그 순간 서비스 전체가 굳는다. 페이지 크기를 상한으로 강제하고, 전체를 훑어야 하는 작업은 ZSCAN 으로 나눠 도는 것이 원칙이다.

여러 기간을 합치는 ZUNIONSTORE 도 같은 이유로 주의가 필요하다. 큰 집합 여럿을 합치면 그 시간 동안 다른 요청이 밀린다. 실시간 요청 경로에서 부르지 말고, 미리 계산해 두고 결과만 읽게 만드는 편이 안전하다.

마지막으로 랭킹은 부정행위가 가장 먼저 시도되는 자리이기도 하다. 점수를 올리는 요청을 클라이언트가 그대로 보내게 두면 누군가는 그 요청을 직접 만들어 보낸다. 점수는 서버가 계산해야 하고, 같은 사건으로 두 번 올라가지 않도록 앞에서 본 멱등성 키가 여기서도 그대로 쓰인다. 랭킹의 값어치는 정확성에서 나오므로, 한 번 신뢰를 잃으면 그 기능 자체가 무의미해집니다.

다음 실습에서 할 것

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