junyeokk
Blog
Database·2024. 11. 26

Redis Sorted Set 랭킹

실시간 랭킹 시스템을 구현한다고 생각해보자. 게시글 조회수 기반 인기 글 Top 10, 실시간 검색어 순위, 게임 리더보드 같은 기능이다. RDBMS로 이걸 구현하면 어떻게 될까?

sql
SELECT post_id, view_count
FROM posts
ORDER BY view_count DESC
LIMIT 10;

데이터가 적으면 잘 동작한다. 하지만 조회수가 갱신될 때마다 UPDATE를 치고, 순위를 조회할 때마다 ORDER BY로 전체 테이블을 정렬해야 한다. 데이터가 수십만 건이 되면 인덱스가 있어도 부담이 커지고, 실시간으로 빈번하게 갱신되는 데이터에는 적합하지 않다.

Redis의 Sorted Set은 이 문제를 위해 설계된 자료구조다. 모든 멤버가 score를 가지고, score 기준으로 항상 정렬된 상태를 유지한다. 삽입, 삭제, 점수 갱신, 범위 조회 모두 O(log N)이라서 데이터가 많아져도 일관된 성능을 보장한다.

Sorted Set의 내부 구조

Sorted Set은 두 가지 자료구조를 동시에 사용한다.

Skip List + Hash Table 조합이다. Skip List는 정렬된 순서를 유지하면서 O(log N)으로 삽입/삭제/범위 조회를 가능하게 하고, Hash Table은 특정 멤버의 score를 O(1)로 조회할 수 있게 해준다.

왜 단순한 Balanced BST(AVL, Red-Black Tree)가 아닌 Skip List를 선택했을까? Redis의 창시자 Salvatore Sanfilippo는 이렇게 설명했다:

  1. Skip List는 구현이 단순하다. 디버깅과 유지보수가 쉽다.
  2. 범위 조회(ZRANGEBYSCORE 등)에서 Skip List가 자연스럽다. 링크드 리스트 기반이라 순회가 간단하다.
  3. 평균적인 성능이 Balanced Tree와 동등하다.

작은 Sorted Set(멤버 128개 이하, 각 멤버 64바이트 이하)은 메모리 효율을 위해 ziplist(Redis 7.0부터는 listpack)로 저장된다. 크기가 커지면 자동으로 Skip List + Hash Table 구조로 변환된다. 이 임계값은 설정으로 조절할 수 있다.

zset-max-ziplist-entries 128 zset-max-ziplist-value 64

기본 명령어

ZADD — 멤버 추가/갱신

bash
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

이미 존재하는 멤버에 ZADD를 실행하면 score가 갱신된다. 이 특성 때문에 별도의 UPDATE 없이 ZADD 하나로 삽입과 갱신을 모두 처리할 수 있다.

ZADD에는 유용한 옵션들이 있다:

bash
# ZADD key score member [score member ...]
ZADD trending 150 "post:1001"
ZADD trending 230 "post:1002" 180 "post:1003"

GTLT는 Redis 6.2부터 지원한다. 최고 점수만 유지하고 싶은 리더보드에서 GT가 유용하다.

ZINCRBY — 점수 증가

bash
ZADD key NX score member   # 멤버가 없을 때만 추가 (기존 멤버 score 갱신 안 함)
ZADD key XX score member   # 멤버가 있을 때만 갱신 (새 멤버 추가 안 함)
ZADD key GT score member   # 새 score가 기존보다 클 때만 갱신
ZADD key LT score member   # 새 score가 기존보다 작을 때만 갱신

조회수처럼 점수를 누적해야 할 때 사용한다. 멤버가 없으면 score 0에서 시작해서 increment를 더한다. ZADD와 달리 "현재 값에서 더하기"이기 때문에 동시성 문제가 없다. Redis가 싱글 스레드로 명령을 처리하므로 Race Condition이 발생하지 않는다.

ZREVRANGE — 높은 점수 순 조회

bash
# ZINCRBY key increment member
ZINCRBY trending 1 "post:1001"   # post:1001의 score를 1 증가
ZINCRBY trending -5 "post:1002"  # 음수로 감소도 가능

score가 높은 순서대로 멤버를 가져온다. 0 9는 상위 10개를 의미한다. WITHSCORES를 붙이면 각 멤버의 score도 함께 반환한다.

참고: Redis 6.2부터 ZRANGEREV 옵션이 추가되어 ZREVRANGE를 대체할 수 있다. ZREVRANGE는 deprecated 예정이지만 당분간 계속 사용 가능하다.

bash
# ZREVRANGE key start stop [WITHSCORES]
ZREVRANGE trending 0 9 WITHSCORES

ZRANGE — 낮은 점수 순 조회

bash
# Redis 6.2+ 새 문법
ZRANGE trending 0 9 REV WITHSCORES

score가 낮은 순서대로 반환한다. 오래된 순, 낮은 점수 순으로 조회할 때 사용한다.

ZSCORE — 특정 멤버의 점수 조회

bash
ZRANGE trending 0 9 WITHSCORES

O(1)로 특정 멤버의 현재 score를 가져온다. 내부적으로 Hash Table에서 바로 조회한다.

ZRANK / ZREVRANK — 순위 조회

bash
ZSCORE trending "post:1001"   # "150"

특정 멤버가 전체에서 몇 등인지 O(log N)으로 알 수 있다. 이건 RDBMS에서 구현하려면 COUNT(*) WHERE score > ? 같은 쿼리가 필요한데, 데이터가 많으면 무거운 연산이 된다.

ZREM — 멤버 삭제

bash
ZRANK trending "post:1001"     # 낮은 점수 기준 순위 (0-indexed)
ZREVRANK trending "post:1001"  # 높은 점수 기준 순위 (0-indexed)

ZCARD — 멤버 수 조회

bash
ZREM trending "post:1001"
ZREM trending "post:1001" "post:1002"  # 여러 개 동시 삭제

ZRANGEBYSCORE — 점수 범위로 조회

bash
ZCARD trending   # Sorted Set의 전체 멤버 수

(를 붙이면 해당 값을 포함하지 않는다(exclusive). -inf, +inf로 최솟값/최댓값 경계를 나타낸다.

Node.js에서 사용 (ioredis)

ioredis를 사용하면 이 명령어들을 그대로 메서드로 호출할 수 있다.

typescript
ZRANGEBYSCORE trending 100 200            # score 100~200 멤버
ZRANGEBYSCORE trending -inf +inf          # 전체
ZRANGEBYSCORE trending (100 200           # score > 100 AND score <= 200
ZRANGEBYSCORE trending 100 200 LIMIT 0 5  # 결과에서 5개만

WITHSCORES 결과는 [member, score, member, score, ...] 형태의 flat 배열로 반환되기 때문에 직접 파싱해야 한다. 이 점이 처음에 좀 불편할 수 있다.

특정 멤버의 점수와 순위

typescript
import Redis from 'ioredis';

const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 점수 증가
await redis.zincrby('trending', 1, 'post:1001');

// 상위 4개 조회 (점수 포함)
const result = await redis.zrevrange('trending', 0, 3, 'WITHSCORES');
// ['post:1002', '230', 'post:1003', '180', 'post:1001', '151', ...]

// score 포함 결과는 [member, score, member, score, ...] 형태로 반환된다
// 파싱이 필요하다
function parseWithScores(arr: string[]): { member: string; score: number }[] {
  const result = [];
  for (let i = 0; i < arr.length; i += 2) {
    result.push({ member: arr[i], score: Number(arr[i + 1]) });
  }
  return result;
}

실전 패턴: 실시간 트렌드 랭킹

게시글 조회수 기반으로 인기 글 Top N을 실시간으로 보여주는 시스템을 설계한다고 하자.

조회수 증가

사용자가 게시글을 클릭할 때마다 ZINCRBY로 score를 1 증가시킨다.

typescript
// 점수 조회
const score = await redis.zscore('trending', 'post:1001');
// '151' (문자열로 반환)

// 순위 조회 (높은 점수 기준, 0-indexed)
const rank = await redis.zrevrank('trending', 'post:1001');
// 2 (3등)

// 멤버 삭제
await redis.zrem('trending', 'post:1001');

// 전체 멤버 수
const count = await redis.zcard('trending');

Top N 조회

typescript
async function incrementViewCount(postId: number): Promise<void> {
  const key = 'trending:daily';
  await redis.zincrby(key, 1, `post:${postId}`);
}

일별 리셋

트렌드 데이터는 보통 일별로 리셋한다. TTL을 설정하거나, 스케줄러로 키를 삭제하는 방식을 사용한다.

typescript
async function getTopTrending(count: number): Promise<TrendingPost[]> {
  const results = await redis.zrevrange('trending:daily', 0, count - 1, 'WITHSCORES');
  
  const trending: TrendingPost[] = [];
  for (let i = 0; i < results.length; i += 2) {
    trending.push({
      postId: parseInt(results[i].split(':')[1]),
      viewCount: Number(results[i + 1]),
    });
  }
  return trending;
}

날짜를 키에 포함하는 방식이 더 유연하다. 어제의 트렌드, 이번 주 트렌드를 별도로 유지할 수 있고, ZUNIONSTORE로 여러 날의 데이터를 합산할 수도 있다.

순위 변동 감지 (SSE와 연동)

트렌드 데이터가 변경될 때만 클라이언트에 업데이트를 보내려면, 현재 순위와 이전 순위를 비교해야 한다.

typescript
// 방법 1: TTL 설정 (자정에 자동 만료)
function getSecondsUntilMidnight(): number {
  const now = new Date();
  const midnight = new Date(now);
  midnight.setHours(24, 0, 0, 0);
  return Math.floor((midnight.getTime() - now.getTime()) / 1000);
}

await redis.zincrby('trending:daily', 1, `post:${postId}`);
await redis.expire('trending:daily', getSecondsUntilMidnight());

// 방법 2: 날짜를 키에 포함
const today = new Date().toISOString().slice(0, 10); // '2024-11-26'
await redis.zincrby(`trending:${today}`, 1, `post:${postId}`);

이 함수를 주기적으로 호출해서 순위 변동이 있을 때만 SSE로 이벤트를 보내면, 불필요한 네트워크 트래픽을 줄일 수 있다.

ZUNIONSTORE / ZINTERSTORE — 집합 연산

여러 Sorted Set을 합치거나 교집합을 구할 수 있다. 주간 트렌드를 구할 때 유용하다.

bash
async function checkRankingChange(key: string, topN: number): Promise<boolean> {
  const current = await redis.zrevrange(key, 0, topN - 1, 'WITHSCORES');
  
  // 이전 순위와 비교
  const currentStr = JSON.stringify(current);
  const previousStr = await redis.get(`${key}:snapshot`);
  
  if (currentStr !== previousStr) {
    await redis.set(`${key}:snapshot`, currentStr);
    return true; // 순위 변동 있음
  }
  return false;
}

WEIGHTS 옵션으로 각 키에 가중치를 줄 수 있다. 최근 데이터에 높은 가중치를 주면 "최근 인기 글"에 더 가까운 결과를 얻을 수 있다.

AGGREGATE 옵션으로 합산 방식도 제어한다:

bash
# 7일간의 일별 트렌드를 합산
ZUNIONSTORE trending:weekly 7 trending:2024-11-20 trending:2024-11-21 ... trending:2024-11-26

# 가중치 적용 (최근 날짜에 더 높은 가중치)
ZUNIONSTORE trending:weekly 7 trending:day1 trending:day2 ... trending:day7 WEIGHTS 1 1 1 2 2 3 3

ZRANGEBYSCORE vs ZRANGEBYLEX

score 기반 범위 조회와 사전순(lexicographic) 범위 조회는 용도가 다르다.

bash
ZUNIONSTORE result 2 set1 set2 AGGREGATE SUM   # 점수 합산 (기본값)
ZUNIONSTORE result 2 set1 set2 AGGREGATE MAX   # 최댓값
ZUNIONSTORE result 2 set1 set2 AGGREGATE MIN   # 최솟값

ZRANGEBYLEX는 자동완성(autocomplete) 같은 기능에서 유용하다. 모든 멤버의 score를 0으로 동일하게 설정하고, 문자열 prefix로 범위 조회한다.

주의사항과 Best Practices

메모리 관리

Sorted Set은 메모리를 많이 사용한다. 멤버 수가 무한히 늘어나는 구조라면 주기적으로 정리해야 한다.

typescript
# score 기반: 조회수 100~500인 게시글
ZRANGEBYSCORE trending 100 500

# 사전순: 모든 멤버의 score가 같을 때, 문자열 범위 조회
ZADD autocomplete 0 "apple" 0 "application" 0 "apply" 0 "banana"
ZRANGEBYLEX autocomplete "[app" "[app\xff"
# "apple", "application", "apply"

Pipeline으로 배치 처리

여러 ZINCRBY를 한꺼번에 실행해야 할 때 Pipeline을 사용하면 네트워크 라운드트립을 줄일 수 있다.

typescript
// 하위 멤버 정리 (score가 낮은 것부터 삭제)
await redis.zremrangebyrank('trending:daily', 0, -101);
// 상위 100개만 남기고 나머지 삭제

// score 기준 삭제
await redis.zremrangebyscore('trending:daily', '-inf', 0);
// score가 0 이하인 멤버 삭제

key 네이밍 컨벤션

trending:daily # 오늘의 트렌드 trending:2024-11-26 # 특정 날짜의 트렌드 leaderboard:game:123 # 특정 게임의 리더보드 search:popular:weekly # 주간 인기 검색어

콜론(:)으로 계층을 구분하는 것이 Redis 커뮤니티의 관례다. 키 이름만 봐도 어떤 데이터인지 알 수 있게 짓는 것이 중요하다.

Sorted Set vs 다른 방법 비교

방법삽입/갱신순위 조회범위 조회특정 멤버 조회
RDBMS ORDER BYO(log N)O(N log N)O(N log N)O(log N)
Redis Sorted SetO(log N)O(log N + M)O(log N + M)O(1)
인메모리 배열 정렬O(N)O(N log N)O(N)O(N)

M은 반환하는 결과 수다. Sorted Set은 모든 연산에서 일관되게 빠르다. 특히 "Top N 조회"가 핵심인 랭킹 시스템에서는 경쟁 상대가 없다.

다만 Sorted Set은 인메모리 자료구조이므로 영속성이 필요하면 DB와 이중 저장 전략을 써야 한다. Redis가 재시작되면 데이터가 날아갈 수 있기 때문이다 (RDB/AOF 설정을 하더라도 마지막 스냅샷 이후 데이터는 유실 가능).

정리

  • Sorted Set은 Skip List + Hash Table 조합으로 삽입/갱신/범위 조회 모두 O(log N)이고, ZINCRBY는 싱글 스레드 덕분에 동시성 문제 없이 점수를 누적할 수 있다.
  • 날짜를 키에 포함하면 일별/주별 트렌드를 유연하게 관리할 수 있고, ZUNIONSTORE의 WEIGHTS 옵션으로 최근 데이터에 가중치를 줄 수 있다.
  • 인메모리 자료구조이므로 영속성이 필요하면 DB 이중 저장 전략이 필요하고, zremrangebyrank로 메모리를 주기적으로 정리해야 한다.

관련 문서