게임 점수 랭킹, 이커머스 인기 상품, 포털 실시간 검색어.
이런 기능을 구현해야 한다면 어떻게 접근할까? 관계형 데이터베이스(RDB)로도 물론 구현할 수 있다. 그런데 막상 짜보면, 꽤 불편하다는 걸 금방 느끼게 된다.
이번 챕터에서는 Redis의 Sorted Set(ZSet) 자료구조를 이용해 리더보드를 구현하는 방법을 정리한다. RDB 방식과 비교하면서 왜 Redis가 이 용도에 잘 맞는지도 함께 살펴보겠다.
리더보드(Leaderboard) 는 점수, 횟수, 활동량 등을 기준으로 순위를 실시간으로 보여주는 기능이다.
| 도메인 | 리더보드 예시 |
|---|---|
| 게임 | 스테이지 클리어 시간, 최고 점수 랭킹 |
| 이커머스 | 인기 상품 Top 10 |
| 포털 | 실시간 급상승 검색어 |
이커머스 인기 상품 기준으로 생각해보자. "가장 많이 팔린 상품 Top 10"을 조회하려면 아래와 같은 SQL이 필요하다.
-- orders 테이블과 item 테이블을 JOIN하고
-- 각 상품별 주문 수량 합계를 집계한 뒤 내림차순 정렬
SELECT i.id, SUM(o.count) AS total_sold
FROM item i
INNER JOIN orders o
ON i.id = o.item_id
GROUP BY i.id
ORDER BY SUM(o.count) DESC
LIMIT 10;
이 쿼리 자체가 틀린 건 아니다. 그런데 생각해보면, 이 쿼리는 조회 때마다 전체 데이터를 집계하고 정렬해야 한다. 트래픽이 몰릴수록 DB 부담은 커진다.
"그럼 item 테이블에 purchase_count 컬럼을 추가하면 어떨까?" 맞는 접근이긴 하지만, 구매가 발생할 때마다 해당 컬럼을 UPDATE해야 하고, 조회 시 여전히 정렬 연산이 필요하다. 빈번한 쓰기와 읽기가 동시에 발생하는 구조인 셈이다. 성능 문제를 완전히 피하기 어렵다.
Redis의 Sorted Set은 각 원소(member)에 점수(score) 를 붙여서 저장하는 자료구조이다. Redis가 자동으로 score를 기준으로 정렬된 상태를 항상 유지해준다.
| 연산 | 명령어 | 시간복잡도 |
|---|---|---|
| 추가 / score 갱신 | ZADD | O(log N) |
| score 증가 | ZINCRBY | O(log N) |
| 범위 조회 (M개) | ZRANGE | O(log N + M) |
| 특정 member 순위 조회 | ZRANK / ZREVRANK | O(log N) |
| 특정 member score 조회 | ZSCORE | O(1) |
RDB의 ORDER BY + SUM() 집계 방식은 데이터가 많아질수록 전체 스캔에 가까워지는 반면, Sorted Set은 항상 이 복잡도를 보장한다.
# 1. ZADD — 원소 추가 (이미 존재하면 score 갱신 = upsert)
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
# 2. ZINCRBY — score 증가 (구매 1회 발생 시 해당 상품 score +1)
ZINCRBY soldRanks 1 "item:42"
# 3. ZRANGE — 오름차순 조회 (score 낮은 순부터)
ZRANGE leaderboard 0 9 WITHSCORES
# 4. 내림차순 조회 (score 높은 순 = 리더보드에서 가장 많이 쓰는 형태)
# ✅ Redis 6.2.0 이상 권장 방식
ZRANGE leaderboard 0 9 WITHSCORES REV
# 5. ZREVRANK — 특정 member의 순위 조회 (높은 score = 0위, 리더보드에 적합)
ZREVRANK leaderboard "player:alice"
# 6. ZSCORE — 특정 member의 score 조회
ZSCORE leaderboard "player:alice"
# 7. ZCARD — Sorted Set의 원소 개수 확인
ZCARD leaderboard
ZREVRANGE는 deprecatedRedis 6.2.0부터
ZREVRANGE,ZRANGEBYSCORE,ZREVRANGEBYSCORE등은 공식적으로 deprecated 처리되었다.
이전에는 내림차순 조회를 위해 ZREVRANGE를 별도로 사용해야 했는데, 6.2.0부터 ZRANGE 명령어에 REV, BYSCORE, BYLEX, LIMIT 옵션이 추가되면서 하나의 명령어로 통합되었다.
# ❌ 예전 방식 (deprecated, 사용은 가능하지만 권장하지 않음)
ZREVRANGE leaderboard 0 9
# ✅ 현재 권장 방식 (Redis 6.2.0+)
ZRANGE leaderboard 0 9 REV
다만, 레거시(legacy) 환경에서는 여전히 ZREVRANGE를 사용하는 코드가 많다. 기존 코드베이스를 유지보수할 때 이 점을 인지하고 있으면 혼란을 줄일 수 있다.
@Bean
public RedisTemplate<String, ItemDto> rankTemplate(
RedisConnectionFactory redisConnectionFactory
) {
RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
// Redis 연결 팩토리 설정
template.setConnectionFactory(redisConnectionFactory);
// key는 String으로, value(ItemDto)는 JSON으로 직렬화
template.setKeySerializer(RedisSerializer.string());
template.setValueSerializer(RedisSerializer.json());
return template;
}
직렬화(Serialization) 란, Java 객체를 Redis에 저장 가능한 형태(바이트 스트림 또는 JSON 문자열)로 변환하는 과정이다.
직렬화 설정을 명시적으로 하지 않으면 기본 JDK 직렬화가 적용되어 Redis에 저장된 key 값이 알아보기 힘든 형태로 저장된다. 명시적으로 설정하는 것이 좋다.
RedisTemplate에서 Sorted Set 관련 연산을 사용하려면 ZSetOperations를 꺼내야 한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
// Sorted Set 연산 객체 (RedisTemplate에서 추출)
private final ZSetOperations<String, ItemDto> rankOps;
public ItemService(
ItemRepository itemRepository,
OrderRepository orderRepository,
RedisTemplate<String, ItemDto> rankTemplate
) {
this.itemRepository = itemRepository;
this.orderRepository = orderRepository;
// opsForZSet()으로 ZSetOperations 인스턴스를 꺼낸다
this.rankOps = rankTemplate.opsForZSet();
}
}
incrementScorepublic void purchase(Long id) {
// DB에서 상품 조회 (없으면 404)
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// 주문 이력을 RDB에 저장
orderRepository.save(ItemOrder.builder()
.item(item)
.count(1)
.build());
// Redis Sorted Set에서 해당 상품의 score를 1 증가
// "soldRanks" : Sorted Set의 key 이름
// ItemDto... : member (어떤 상품인지)
// 1 : 증가시킬 score 값
// 💡 해당 member가 아직 없으면 자동으로 추가하고 score를 설정해준다
rankOps.incrementScore(
"soldRanks",
ItemDto.fromEntity(item),
1
);
}
incrementScore는 Redis의 ZINCRBY 명령과 동일하게 동작한다. 중요한 점은, 해당 member가 Sorted Set에 없어도 자동으로 추가되고 score가 설정된다는 것이다. 별도로 초기화 로직을 작성할 필요가 없다.
reverseRangepublic List<ItemDto> getMostSold() {
// score가 높은 순 (판매량 높은 순)으로 상위 10개 조회
// reverseRange("key", start, end) → 0부터 9 = 10개
Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
// 데이터가 없으면 빈 리스트 반환
if (ranks == null) return Collections.emptyList();
return ranks.stream().toList();
}
reverseRange()는 Redis의 ZREVRANGE에 대응하는 Spring Data Redis 메서드이다. score가 높은 것부터 낮은 순으로 데이터를 반환한다.
구매 요청 발생
│
▼
RDB에 주문 이력 저장 (영속성)
│
▼
Redis Sorted Set에 score 증가 (성능)
│
▼
인기 상품 조회 시 reverseRange()로 상위 N개 바로 반환
score가 같은 member가 있으면 Redis는 member 이름을 사전 순으로 정렬한다. 별도의 타이브레이킹(tie-breaking) 로직 없이도 결과가 일관되게 나온다는 의미이다.
Sorted Set은 데이터를 계속 추가하면 무한히 커진다. 실제 개발 환경에서는 다음과 같이 오래된 데이터를 정리하는 전략도 함께 고려하는 것이 좋다.
# 하위 N개 제거 (score 기준 하위 100개 제거)
ZREMRANGEBYRANK soldRanks 0 99
# 특정 score 이하 제거
ZREMRANGEBYSCORE soldRanks -inf 0
주기적인 정리 작업을 스케줄러로 등록하거나, key에 TTL(Time To Live, 만료 시간)을 설정해 일별/주별 리더보드를 자동으로 초기화하는 패턴도 자주 사용된다.
// 예: 일별 리더보드 key에 24시간 TTL 설정
redisTemplate.expire("leaderboard:daily", Duration.ofHours(24));
| 타입 | 설명 | 활용 예 |
|---|---|---|
| HyperLogLog | 대용량 데이터의 중복 없는 카운팅 (근사값) | UV(순방문자) 집계 |
| Geospatial | 좌표 기반 데이터 저장 및 거리 조회 | 주변 매장 검색 |
| Stream | 메시지 스트림 처리 | 이벤트 로그, 알림 큐 |
Redis는 다양한 자료구조를 제공하므로, 구현이 애매하게 느껴질 때는 공식 문서를 한 번 훑어보는 것도 좋은 방법이다.
| 항목 | RDB 방식 | Redis Sorted Set 방식 |
|---|---|---|
| 구현 복잡도 | 복잡한 JOIN + 집계 함수 필요 | ZINCRBY 한 줄로 처리 |
| 조회 성능 | 데이터 증가 시 저하 우려 | O(log N + M) 보장 |
| 정렬 | 조회 시 매번 정렬 필요 | 항상 정렬된 상태 유지 |
| 영속성 | 기본 보장 | 별도 설정 필요 (RDB/AOF) |
리더보드처럼 자주 쓰고, 순위가 중요하고, 빠른 응답이 필요한 기능이라면 Redis Sorted Set은 매우 잘 맞는 선택지이다.
다만, Redis는 인메모리(In-Memory) 저장소이므로 영속성(persistence) 은 별도로 고려해야 한다. 중요한 데이터라면 RDB와 병행해서 사용하는 구조를 취하는 것이 일반적이다.