-
Redis ZSet으로 실시간 랭킹 시스템 구축하기Develop 2025. 9. 12. 14:48
TL;DR
Redis Sorted Set(ZSET)을 활용하여 대규모 트래픽에서도 빠르게 동작하는 실시간 상품 랭킹 시스템을 구축한 경험을 공유합니다.
🚨 문제 인식: 왜 실시간 랭킹 시스템이 필요했나?
우리는 더 좋은 상품을 검색하기 위해 실시간 Ranking 시스템을 구축할 필요성을 느끼고 있었습니다.
🐳 Ranking 시스템의 특성
- 랭킹 정보가 많이 요청됨
- 주기적인 갱신이 필요함
- 조회 빈도가 매우 높아 DB 과부하
❓ 기존 방식의 문제점
초기에는 단순한 DB 쿼리로 해결하려 했습니다:
SELECT product_id, view_count, like_count, sale_count, (view_count * 0.3 + like_count * 0.3 + sale_count * 0.4) AS score FROM product_metrics WHERE created_at >= CURDATE() ORDER BY score DESC LIMIT 10;
하지만 이 방식은 여러 한계가 있었습니다:
1. 다양한 랭킹 정보가 많이 요청됨
- 일간 랭킹: 매일 자정 리셋
- 주간 랭킹: 매주 월요일 리셋
- 월간 랭킹: 매월 1일 리셋
2. 콜드 스타트 문제
새벽 0시, 새로운 일간 랭킹이 시작되면 모든 상품의 점수가 0이 됩니다.
- 자정 이후 새로운 일간 랭킹 시작 시 데이터 부족
- 신규 상품의 경우 누적 데이터가 없어 랭킹 진입 어려움
- 사용자는 "랭킹이 텅 비어있음" 경험
- 악순환: 랭킹에 없는 상품은 클릭도, 구매도 발생하지 않음
3. 성능 문제
RDB로 해결하면 안 되는 이유- DB 쿼리 (GROUP BY + ORDER BY) 는 데이터가 쌓일수록 느려짐
- 조회 빈도가 매우 높아 DB 과부하로 이어질 수 있음
💡 해결 방안: Redis ZSet 선택 이유
Why Redis ZSet인가?
여러 대안을 검토한 결과, Redis Sorted Set(ZSet)을 사용하는게 최적의 해결책이었습니다.
- 정렬 기능이 내장되어 있어 별도 인덱스 등에 대한 설정 불필요
- 실시간으로 랭킹 반영 가능
- 다양한 조회 지원 : Top-N, 특정 member 의 순위, score 범위 검색 등
🏗️ 랭킹 시스템 아키텍처 설계
[사용자 행동] ↓ [이벤트 발행] → [Kafka Topic] ↓ [랭킹 Consumer] → [점수 계산] → [Redis ZSet 업데이트] ↓ [랭킹 API] → [ZSet 조회] → [사용자에게 응답]아키텍쳐 설계 전략
🔑 Key 전략: 시간의 양자화
문제: 무한 누적의 함정
// 잘못된 접근: 계속 누적만 하는 경우 rank:all → 오래된 상품이 계속 1위, 신상품은 기회 없음해결: 일별 Key 분리
// 올바른 접근: 시간 단위 분리 rank:all:20240912 // 9월 12일 랭킹 rank:all:20240913 // 9월 13일 랭킹 (새로운 시작)✅ TTL 관리
해결: 2일로 설정하여 메모리 효율성 확보
@Component public class RankingKeyManger { private static final String KEY_PREFIX = "ranking"; private static final String DATE_FORMAT = "yyyyMMdd"; private static final long DAILY_TTL = 172800L; private final DateTimeFormatter dailyFormatter = DateTimeFormatter.ofPattern(DATE_FORMAT); public String getDailyRankingKey(final LocalDate date) { return String.format("%s:rank:all:%s", KEY_PREFIX, date.format(dailyFormatter)); } public long getTTL(final RankingType type) { return switch (type) { case DAILY -> DAILY_TTL; }; }⚖️ 가중치 기반 점수 계산:
단순히 이벤트 수만 세면 조회수가 압도적으로 많아 의미있는 랭킹이 만들어지지 않습니다.
public class RankingScoreCalculator { // 비즈니스 가중치 정의 private static final double VIEW_WEIGHT = 0.1; // 조회는 가장 쉬운 행동 private static final double LIKE_WEIGHT = 0.2; // 좋아요는 관심 표현 private static final double ORDER_WEIGHT = 0.6; // 구매는 가장 강한 신호 public double calculateScore(RankingEventType type) { return switch (type) { case VIEW -> VIEW_WEIGHT; case LIKE -> LIKE_WEIGHT; case ORDER -> ORDER_WEIGHT; }; } public double applyTimeDecay(double baseScore, LocalDateTime eventTime) { long hoursAgo = ChronoUnit.HOURS.between(eventTime, LocalDateTime.now()); // 시간 감쇠 적용 (최근일수록 높은 점수) double decayFactor = Math.exp(-0.1 * hoursAgo); return baseScore * decayFactor; } }가중치 설정 의도:
- 구매 (0.6): 실제 매출과 직결되는 가장 중요한 지표
- 좋아요 (0.2): 구매 의도를 나타내는 중간 단계 지표
- 조회 (0.1): 양이 많아 전체 점수를 지배할 수 있어 낮게 설정
시간 가중치:
- 최근 이벤트일수록 높은 점수 부여
⚠️ 액션 배수 설정:
- 좋아요 +1.0, 싫어요 -1.0로 배수 설정
public double getScoreMultiplier() { return switch (action) { case "LIKE" -> 1.0; // 양수 case "UNLIKE" -> -1.0; // 음수 default -> 0.0; }; }💡타이브레이커 설정:
- productId 역순으로 동점 처리
public double applyTieBreaker(double baseScore, Long productId) { // productId 역순으로 아주 작은 가중치 추가 (동점 시 큰 ID가 높은 순위) double tieBreaker = (Long.MAX_VALUE - productId) * 0.000000001; return baseScore + tieBreaker; }❄️ 콜드 스타트 문제 해결
- Score Carry-Over 전략
@Scheduled(cron = "0 50 23 * * *") // 매일 23:50에 실행 public void carryOverScores() { String todayKey = rankingKeyManger.getDailyRankingKey(LocalDate.now()); String tomorrowKey = rankingKeyManger.getDailyRankingKey(LocalDate.now().plusDays(1)); redisTemplate.opsForZSet().unionAndStore(todayKey, tomorrowKey, tomorrowKey); Duration ttl = Duration.ofSeconds(rankingKeyManger.getTTL(DAILY)); redisTemplate.expire(tomorrowKey, ttl); }이월 전략의 효과:
- ✅ 어제 상품 데이터를 가져와서 적재: unionAndStore
- ✅ 새벽에도 의미있는 랭킹 제공
🚀 구현 세부사항
1. 이벤트 기반 점수 업데이트
배치 리스너로 처리량을 대폭 개선
public void updateDailyRanking(LocalDate date, Map<Long, List<ProductAggregationEvent>> productEvents) { String rankingKey = rankingKeyManger.getDailyRankingKey(date); // Redis 파이프라인을 사용한 배치 처리 redisTemplate.executePipelined((RedisCallback<?>)(connection) -> { for (Map.Entry<Long, List<ProductAggregationEvent>> productEntry : productEvents.entrySet()) { Long productId = productEntry.getKey(); List<ProductAggregationEvent> events = productEntry.getValue(); // 이벤트별 점수 계산 double totalScore = calculateBatchEventScore(events); if (totalScore != 0.0) { // 타이브레이커 적용 double finalScore = applyTieBreaker(totalScore, productId); // Redis ZSet에 점수 누적 (ZINCRBY) connection.zIncrBy(rankingKey.getBytes(), finalScore, productId.toString().getBytes()); log.trace("랭킹 점수 업데이트 - productId: {}, score: {}, finalScore: {}", productId, totalScore, finalScore); } } // TTL 설정 Duration ttl = Duration.ofSeconds(rankingKeyManger.getTTL(DAILY)); connection.expire(rankingKey.getBytes(), ttl.getSeconds()); return null; // 파이프라인 콜백은 null 반환 }); log.debug("파이프라인 배치 업데이트 완료 - rankingKey: {}, products: {}", rankingKey, productEvents.size()); }배치 처리의 장점:
- 네트워크 오버헤드 감소: 여러 메시지를 한 번에 처리
- 트랜잭션 효율성: DB와 Redis 연산을 묶어서 처리
- 에러 핸들링: 배치 단위로 실패 시 재처리
2. 랭킹 조회 API
- 랭킹 데이터 조회: ZCARD + ZREVRANGE
- 상품 정보 배치 조회 (N+1 문제 방지)
public RankingPageResult getRankings(final GetRankingQuery query) { // 랭킹 데이터 조회 Page<RankingItem> page = rankingReadService.getRanking(query.date(), query.pageable()); if (page.isEmpty()) { return RankingPageResult.from(page, List.of()); } // 상품 ID 추출 List<Long> ids = page.getContent().stream().map(RankingItem::getProductId).toList(); // 상품 정보 배치 조회 Map<Long, ProductInfo> productMap = productService.getProductByIds(ids); List<RankingProductResult> items = page.getContent().stream() .filter(item -> productMap.containsKey(item.getProductId())) .map(item -> RankingProductResult.from(item, productMap.get(item.getProductId()))) .toList(); return RankingPageResult.from(page, items); }3. 개별 상품 랭킹 조회
상품 상세 페이지에서 특정 순위 정보를 보여주기 위한 기능:
public Long getProductRanking(Long productId) { String key = rankingKeyManager.getDailyRankingKey(LocalDate.now()); // 특정 상품의 순위 조회 (ZREVRANK) Long rank = redisTemplate.opsForZSet() .reverseRank(key, productId.toString()); return rank != null ? rank + 1 : null; // 0-based → 1-based }4. 상품 상세 조회 통합
상품 상세 조회 시 해당 상품의 랭킹 정보 추가
@Component public class ProductFacade { public ProductDetailResult getProductDetail(Long productId) { // 기존 상품 정보 조회 ProductInfo productInfo = productService.getProductDetail(productId); // 랭킹 정보 추가 조회 (실패해도 상품 조회는 성공해야 함) Long rank = null; try { rank = rankingReadService.getProductRanking(productId); } catch (Exception e) { log.warn("랭킹 조회 실패 - productId: {}", productId, e); } return ProductDetailResult.from(productInfo, rank); } }
🤔 트레이드오프와 고민점
선택한 것들 (장점)
- ✅ 뛰어난 성능: 대규모 트래픽에서도 안정적인 응답시간
- ✅ 실시간성: 사용자 행동이 즉시 랭킹에 반영
- ✅ 확장성: 주간/월간 랭킹으로 쉽게 확장 가능
- ✅ 운영 편의성: 시간 단위 키 분리로 데이터 관리 용이
포기한 것들 (단점)
- ❌ 메모리 비용: Redis 클러스터 운영 비용 증가
- ❌ 완벽한 정합성: Redis와 DB 간 동기화 지연 가능성
- ❌ 복잡도: 기존 단순 쿼리 대비 시스템 복잡도 증가
- ❌ 의존성: Redis 장애시 랭킹 서비스 전체 영향
💡 핵심 교훈
1. 적절한 도구 선택의 중요성
- DB는 정합성, Redis는 성능. 각각의 강점을 조합해 활용
2. 시간 차원의 설계 중요성
- 무한 누적 vs 시간 윈도우 분리의 차이가 서비스 품질을 좌우
3. 콜드 스타트 해결책의 필요성
- 기술적 완성도뿐 아니라 사용자 경험까지 고려한 설계 필요
4. 가중치 설계의 중요성
- 단순 카운트가 아닌 비즈니스 가치를 반영한 점수 체계 구축