들어가며
이커머스 서비스에서 상품 랭킹은 사용자에게 인기 상품을 추천하고, 판매까지 이어질 수 있게 만드는 중요한 기능이다.
하지만 실시간으로 쏟아지는 많은 요청을 매번 RDB에서 대량 집계해서 보여주는 것은 성능상 한계가 있을 수 있다.
이번 글에서는 Redis ZSET을 활용해 일별 랭킹 시스템을 구현한 경험을 공유하고자 한다. 특히 키 설계 전략과 콜드 스타트 문제 해결을 중점으로 글을 쓰려고 한다.
어떤 랭킹 시스템인가
구현하고자 하는 랭킹 시스템은 단순히 누적 판매량순 또는 누적 조회수순으로 정렬하는 것이 아닌, 다음과 같은 요구사항을 가진 랭킹 시스템이다.
일별 랭킹
가장 먼저 고려한 것은 "시간의 범위"이다.
전체 기간의 누적 점수만 사용하면 이미 인기가 많은 상품이 계속 상단을 차지하는 고착화 문제가 발생한다.
이를 방지하기 위해 매일 자정을 기준으로 새로운 랭킹이 시작되도록 했다.
즉, 사용자에게는 매일 아침 '지금 뜨고 있는 새로운 상품'을 보여준다.
가중치 기반 점수
단순 조회와 좋아요 등록, 그리고 실제 구매는 서비스에 주는 가치가 다르다.
그래서 각 이벤트에 비즈니스적인 가중치를 부여해서 점수를 계산한다.
가중치는 다음과 같이 설정했다.
- 조회수 : 0.1점
- 좋아요 등록 : 0.2점
- 주문 생성 : 0.7점
실시간 업데이트
배치를 돌려 몇 시간 단위로 갱신하는 방식이 아니라, 사용자 액션(좋아요 등록, 상품 조회, 주문 생성)이 발생하고 이벤트가 발행되었을 때 점수가 반영되도록 구현했다.
카프카를 통해 전달된 이벤트가 컨슈머에서 레디스의 점수를 바로 업데이트하도록 했다.
왜 Redis ZSET을 사용했나
RDB에서 실시간 랭킹을 구현하려면 사용자가 조회할 때마다 ORDER BY 쿼리를 실행해야 한다.
데이터가 늘어나고, 업데이트 요청이 많아지면 매번 정렬 쿼리를 날리는 것은 DB에 부하를 줄 수 있다.
인덱스를 사용하더라도 실시간으로 변하는 점수를 인덱싱하는 비용을 무시할 수 없다.
Redis ZSet은 데이터를 삽입하는 시점에 이미 정렬이 된다.
내부적으로 Skip List와 Hash Table 구조를 사용해서, 점수를 업데이트하거나 특정 순위 범위를 조회하는 작업을 O(log N) 성능을 가진다.
Skip List란
여러 레벨의 연결 리스트를 사용해서 정렬된 데이터를 빠르게 탐색할 수 있도록 해주는 자료구조이다.
가장 아래 레벨은 모든 요소를 순서대로 연결한 연결 리스트이고, 그 위의 상위 레벨들은 일부 요소만 선택해서 연결한 리스트이다.
그리고 동작 방식은 아래와 같다.
예를 들어, 값이 1 -> 3 -> 7 -> 12 -> 19 -> 25 -> 31 처럼 정렬되어 있고, 25를 찾는 게 목표라고 하자.
Level 0 : 1 -> 3 -> 7 -> 12 -> 19 -> 25 -> 31
Level 1 : 1 -> 7 -> 19 -> 31
Level 2 : 1 -> 19 -> 31
각 레벨의 연결 리스트가 위와 같다면 ,
탐색은 가장 위 레벨(Level 2)의 첫 노드에서 시작한다.
- 현재 위치 : 1, 다음 노드 : 19
- 19 < 25 이므로 오른쪽으로 이동
- 현재 위치 : 19, 다음 노드 : 31
- 31 > 25 이므로 아래 레벨로 이동
- Level 1에서 탐색 계속 (Level 1에서도 같은 값 19에서 시작 )
- 현재 위치 : 19, 다음 노드 : 31
- 31 > 25 이므로 아래 레벨로 이동
- Level 0에서 최종 탐색 (19에서 시작)
- 현재 위치 : 19, 다음 노드 : 25 ====> 25 찾음!
이렇게 상위 레벨의 연결 리스트는 노드 수가 적어 한 번에 여러 노드를 건너뛸 수 있고,
값이 커져서 더 이상 이동할 수 없을 때만 아래 레벨로 내려가면서 탐색 범위를 점점 좁힌다.
키 설계
데이터가 계속 쌓이는 것을 방지하고, 특정 시점의 순위를 빠르게 조회하기 위해서 날짜별로 키를 분리했다.
ranking:all:{yyyyMMdd}
// 예시 ranking:all:20251226
private String getRankingKey(LocalDate date) {
return RANKING_KEY_PREFIX + date.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
}
TTL 관리
불필요한 과거 데이터가 자동으로 삭제될 수 있도록 일별 랭킹 키에 2일의 TTL을 설정했다.
오늘 랭킹을 위해 어제의 데이터가 필요(콜드스타트 문제 해결)하기 때문에 1일이 아닌 2일로 설정했다.
만약 시간별 랭킹이라면?
키 구조는 시간 단위를 추가해서 ranking:all:{yyyyMMddHH} 이런 식으로 설정할 것이다.
그리고 시간별 키를 생성할 때 주의할 점은 레디스 메모리 사용량이다.
시간별 키에도 일별 키랑 동일하게 2일의 TTL을 설정한다면 24시간 * 2일 * 상품 개수 만큼의 데이터가 쌓이게 된다.
그래서 2시간 정도로 TTL을 짧게 가져가면서 레디스에는 항상 2 ~ 3개의 시간대 데이터만 유지되도로 관리할 수 있다.
콜드스타트 문제 해결
콜드스타트 문제란?
콜드스타트 문제는 새로운 날의 랭킹이 시작될 때 랭킹이 비어버리는 문제이다.
만약 매일 자정에 모든 점수를 0으로 초기화한다면
- 새벽 시간대에는 모든 상품의 점수가 0점
- 인기 상품도 낮은 순위게 머물게 될 수 있음
이렇게 되면 사용자 경험이 좋지 않을 수 있다.
해결 방식 : 점수 Carry-Over
전날의 랭킹 점수를 적은 가중치를 곱해 다음 날에 이월하는 방식을 적용했다.
오늘 랭킹 점수 = 어제 랭킹 점수 * 0.1 (가중치)
이렇게 하면 인기 상품이 새로운 날에도 상위권을 유지할 수 있고, 새로운 상품도 시간이 지날수록 점수를 축적할 수 있다.
ZUNIONSTORE를 활용한 구현
Redis의 ZUNIONSTORE 명령을 사용하면 두 ZSET을 합치되, 가중치를 적용할 수 있다.
ZUNIONSTORE ranking:all:20251226 1 ranking:all:20251225 WEIGHTS 0.1 AGGREGATE SUM
즉, "12월 25일의 랭킹 점수에 0.1을 곱해 12월 26일의 새로운 랭킹 키로 더해서 복사해라"라는 의미이다.
코드로 적용하면 다음과 같다.
public void carryOverScore(LocalDate fromDate, LocalDate toDate) {
String fromKey = getRankingKey(fromDate); // ranking:all:20251226
String toKey = getRankingKey(toDate); // ranking:all:20251227
double weight = RankingWeight.CARRY_OVER.getWeight(); // 0.1
// ZUNIONSTORE를 사용하여 어제 점수에 0.1 가중치 적용
zSetOps.unionAndStore(fromKey, Collections.emptyList(), toKey,
Aggregate.SUM, Weights.of(weight));
redisTemplateMaster.expire(toKey, Duration.ofSeconds(TTL_SECONDS));
}
Carry-Over 스케줄러
매일 23시 50분에 자동으로 점수를 carry-over하도록 스케줄러를 구현했다.
@Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul")
public void carryOverRankingScore() {
try {
LocalDate today = LocalDate.now();
LocalDate targetDate = today.plusDays(1); // 내일로 carry-over
rankingService.carryOverScore(today, targetDate);
} catch (Exception e) {
log.error("랭킹 점수 carry-over 중 오류 발생", e);
}
}
마치며
시간을 양자화해서 일별 키로 분리함으로써 데이터를 관리했고, ZUNIONSTORE를 활용해서 점수 carry-over로 콜드 스타트 문제도 해결했다.
이후에는 레디스 장애와 fallback 로직에 대해 고민해보면 좋을 것 같다.