ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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. 가중치 설계의 중요성

    • 단순 카운트가 아닌 비즈니스 가치를 반영한 점수 체계 구축

     

    댓글

Designed by Tistory.