Redis ZSET으로 실시간 랭킹 시스템 구축하기

2025. 9. 11. 16:12·Spring

TL;DR

Kafka 기반 이벤트 파이프라인 위에 Redis ZSET을 활용한 실시간 랭킹 시스템을 구축했습니다. 단순한 집계 테이블에서 벗어나 실시간 가중치 조절, 콜드 스타트 문제 해결, 그리고 배치 처리를 통한 성능 최적화까지 - 운영 환경에서 실제로 마주할 수 있는 문제들을 어떻게 해결했는지 공유합니다.


문제 정의: 단순한 집계에서 실시간 랭킹으로

지난 주차에서 Kafka 기반 이벤트 파이프라인을 구축하면서 product_metrics 테이블에 상품별 집계 데이터를 쌓고 있었습니다:

-- 기존: 단순한 집계 테이블
SELECT product_id, view_count, like_count, sales_count
FROM product_metrics 
WHERE metric_date = CURRENT_DATE
ORDER BY like_count DESC;

하지만 새로운 요구사항이 들어왔습니다:

  • 실시간 랭킹: 좋아요가 눌리는 순간 랭킹에 반영
  • 가중치 조절: 시간대별, 이벤트별로 점수 비중 변경 가능
  • 순위 조회: 특정 상품의 현재 순위 확인
  • 콜드 스타트 완화: 매일 0점에서 시작하는 문제 해결

해결책: Redis ZSET으로 실시간 랭킹 파이프라인

Redis ZSET(Sorted Set)의 특성을 활용하면 이 문제들을 우아하게 해결할 수 있습니다:

  • O(log N) 시간복잡도: 점수 업데이트와 순위 조회가 빠름
  • 자동 정렬: 점수에 따라 자동으로 순위 유지
  • 원자적 연산: 동시성 문제 없이 점수 증감 가능

1단계: 랭킹 시스템 아키텍처 설계

Kafka Event → Consumer → Score Calculator → Redis ZSET → Ranking API
     ↓              ↓            ↓             ↓            ↓
  좋아요 이벤트    배치 처리      가중치 적용     실시간 저장    순위 조회

핵심 설계 원칙:

  • Key 전략: ranking:all:{yyyyMMdd} - 날짜별 분리
  • TTL 관리: 2일로 설정하여 메모리 효율성 확보
  • 가중치 시스템: 실시간으로 조절 가능한 점수 계산

2단계: 가중치 기반 점수 계산 시스템

"진짜 인기 있는 상품"을 정의하는 것이 가장 어려운 부분이었습니다.

@Component
public class ScoreCalculator {
    private final WeightConfigService weightConfigService;

    public double calculateViewScore() {
        return weightConfigService.getCurrentWeights().getViewWeight(); // 기본: 0.1
    }

    public double calculateLikeScore(boolean isLikeEvent) {
        double score = weightConfigService.getCurrentWeights().getLikeWeight(); // 기본: 0.2
        return isLikeEvent ? score : -score; // 좋아요 취소 시 점수 차감
    }

    public double calculateOrderScore() {
        return weightConfigService.getCurrentWeights().getOrderWeight(); // 기본: 0.6
    }
}

가중치 설정의 고민:

  • 조회 (0.1점): 많은 양이지만 가벼운 액션
  • 좋아요 (0.2점): 의도적인 관심 표현
  • 주문 (0.6점): 실제 구매 의도가 있는 강한 신호

처음에는 고정값으로 시작했지만, 운영하면서 실시간 조절의 필요성을 느꼈습니다.

3단계: 배치 처리로 성능 최적화

단건 처리는 너무 많은 Redis 연산을 발생시킵니다:

// ❌ 비효율적인 단건 처리
@KafkaListener(topics = "product-like-events")
public void handleSingle(ProductLikeEvent event) {
    // 메시지 하나당 Redis 연산 한 번 = 높은 오버헤드
    rankingService.updateScore(event.getProductId(), calculateScore(event));
}

배치 리스너로 처리량을 대폭 개선했습니다:

@Component
public class ProductLikeEventConsumer extends BatchConsumerTemplate<ProductLikeEventDto> {
    
    @KafkaListener(
        topics = "${app.kafka.topics.product-like-events:product-like-events}",
        containerFactory = KafkaConfig.BATCH_LISTENER,
        groupId = "${app.kafka.consumer-groups.product-like-collector:product-like-collector}"
    )
    public void onMessage(@Payload List<ProductLikeEventDto> events, /* ... */) {
        handleBatch(topic, events, keys, partitions, offsets, ack);
    }

    @Override
    protected void process(ProductLikeEventDto dto) {
        // 배치 단위로 처리하여 Redis 연산 최적화
        processor.processEvent(dto.toCommand());
    }
}

배치 처리의 장점:

  • 네트워크 오버헤드 감소: 여러 메시지를 한 번에 처리
  • 트랜잭션 효율성: DB와 Redis 연산을 묶어서 처리
  • 에러 핸들링: 배치 단위로 실패 시 재처리

4단계: Redis ZSET 연산 구현

Redis ZSET의 핵심 연산들을 래핑했습니다:

@Component
public class RankingCacheImpl implements RankingCache {
    private final RedisZSetOperations redisZSetOperations;
    private final RankingKeyGenerator keyGenerator;
    
    private static final Duration TTL = Duration.ofDays(2);

    @Override
    public void incrementScore(Long productId, double score, LocalDate date) {
        String key = keyGenerator.generateDailyRankingKey(date); // "ranking:all:20250911"
        
        // ZINCRBY 명령어로 점수 증가 (원자적 연산)
        redisZSetOperations.incrementScore(key, productId.toString(), score);
        redisZSetOperations.expire(key, TTL);
    }
}

ZSET 연산의 장점:

  • ZINCRBY: 점수 증감이 원자적으로 처리됨
  • ZREVRANGE: 순위별 상품 목록을 빠르게 조회
  • ZRANK: 특정 상품의 순위를 O(log N)으로 조회

5단계: 실시간 가중치 조절 시스템

운영 중에 가중치를 조절해야 하는 상황이 생겼습니다:

  • 이벤트 시즌: 주문 가중치를 일시적으로 높이기
  • 브랜드 데이: 좋아요 가중치를 강화하기
  • 조정 필요: 특정 지표가 너무 지배적일 때

이벤트 기반 가중치 동기화:

// API 모듈에서 가중치 변경 요청
@PutMapping("/admin/ranking/weights")
public ApiResponse<Object> updateWeights(@RequestBody WeightUpdateRequest request) {
    weightFacade.updateWeights(request.toCommand());
    
    // Kafka로 이벤트 발행하여 Collector 모듈에 전파
    weightConfigEventPublisher.publish(weightChangedEvent);
    
    return ApiResponse.success();
}
// Collector 모듈에서 가중치 변경 이벤트 수신
@Component
public class WeightConfigEventConsumer extends BatchConsumerTemplate<WeightConfigEventDto> {
    
    @Override
    protected void process(WeightConfigEventDto dto) {
        // 새로운 가중치로 업데이트
        processor.processEvent(dto.toCommand());
    }
}

이벤트 기반 접근의 장점:

  • 모듈 간 독립성: API와 Collector가 직접 연결되지 않음
  • 신뢰성: Kafka의 At-Least-Once 보장 활용
  • 확장성: 새로운 Collector 추가 시에도 자동으로 동기화

콜드 스타트 문제 해결: Score Carry-Over

매일 자정에 모든 상품이 0점에서 시작하는 것은 사용자 경험상 문제였습니다.

문제 상황:

  • 어제까지 1위였던 상품이 오늘 아침에 순위권에서 사라짐
  • 새로운 하루가 시작될 때마다 랭킹이 리셋되어 연속성 부족

해결책: 스케줄러를 통한 점수 이월

@Scheduled(cron = "0 50 23 * * *") // 콜드 스타트 완화를 위한 점수 Carry-Over 23시 50분 내일 랭킹판을 미리 생성
@ConditionalOnProperty(name = "scheduling.tasks.ranking-carry-over.enabled", havingValue = "true", matchIfMissing = true)
public void carryOverRankingScores() {
    log.info("랭킹 점수 Carry-Over 스케줄러 시작");

    distributedLock.executeWithLock(
        RANKING_CARRY_OVER_LOCK_KEY, 
        LOCK_TIMEOUT, 
        this::doCarryOverScores
    );

    log.info("랭킹 점수 Carry-Over 스케줄러 완료");
}

private void doCarryOverScores() {
    // 오늘 점수를 내일로 감쇠 적용하여 복사 (콜드 스타트 방지)
    rankingService.carryOverTodayScoresToTomorrow(decayFactor);
       
}

 

Carry-Over 설계 원칙:

  • 감쇠 계수 (0.3): 과거 점수가 너무 지배적이지 않도록 조절
  • 분산 락: 여러 인스턴스에서 중복 실행 방지
  • 점진적 페이드아웃: 며칠 지나면 자연스럽게 새로운 인기도 반영

랭킹 API: 성능과 사용성의 균형

페이지네이션과 상품 정보 조합

단순히 상품 ID만 반환하는 것이 아니라, 사용자가 바로 사용할 수 있는 형태로 데이터를 제공해야 했습니다:

@Service
public class RankingFacade {
    
    public Page<RankingInfo> getRankings(LocalDate date, Pageable pageable) {
        // 1. Redis에서 랭킹 데이터 조회
        List<RankingItem> rankingItems = rankingService.getRankings(
            date, pageable.getOffset(), pageable.getPageSize()
        );
        
        // 2. 상품 정보 배치 조회 (N+1 문제 방지)
        List<Long> productIds = rankingItems.stream()
                .map(RankingItem::getProductId)
                .toList();
        
        List<Product> products = productService.getProductsByIds(productIds);
        
        // 3. 랭킹 + 상품 정보 조합
        Map<Long, Product> productMap = products.stream()
                .collect(Collectors.toMap(Product::getId, p -> p));

        List<RankingInfo> rankingInfos = rankingItems.stream()
                .map(item -> {
                    Product product = productMap.get(item.getProductId());
                    return new RankingInfo(
                            item.getRank(),
                            item.getProductId(),
                            product != null ? product.getName() : "상품명 없음",
                            product != null ? product.getImageUrl() : null,
                            product != null ? product.getBasicPrice() : null,
                            item.getScore()
                    );
                })
                .toList();

        return new PageImpl<>(rankingInfos, pageable, totalCount);
    }
}

상품 상세에서 랭킹 정보 포함

기존 상품 상세 API에 랭킹 정보를 자연스럽게 추가했습니다:

public class ProductInfo {
    // 기존 필드들...
    private final Integer currentRank; // 현재 순위 (순위권 밖이면 null)
}

API 응답 예시:

{
  "productId": 1,
  "name": "인기 상품",
  "price": 29900,
  "currentRank": 3
}

현실적인 고민들과 해결 과정

1. "Redis가 날아가면 어떡하지?"

가장 무서웠던 시나리오입니다. Redis는 메모리 기반이라 데이터 유실 위험이 있습니다.

현재 상황:

  • Redis 장애 시 랭킹 데이터 완전 유실
  • 별도 백업 메커니즘 미구현
  • 장애 발생 시 랭킹 서비스 전면 중단

고민 중인 대응 방안:

// TODO: 향후 구현 예정
@Component
public class RankingBackupService {
    
    @Scheduled(fixedRate = 300000) // 5분마다 혹은 23시 55분
    public void backupCurrentRanking() {
        // Redis → DB 백업 로직 구현 필요
    }
}

앞으로 개선해야 할 부분:

  • 정기적 DB 백업 메커니즘
  • Redis 장애 감지 및 알람
  • DB 기반 Fallback 서비스
  • 장애 복구 후 데이터 재구축 프로세스

현재는 Redis 가용성에 전적으로 의존하고 있어, 이 부분이 가장 큰 리스크로 남아있습니다.

2. "정말 이 가중치가 맞을까?"

가중치 설정은 비즈니스 도메인에 따라 달라지는 매우 주관적인 영역입니다.

데이터 기반 접근:

@Component
public class WeightOptimizer {
    
    public void analyzeUserEngagement(WeightConfig config) {
        // 특정 가중치 설정으로 일주일 운영 후
        // 사용자 체류시간, 클릭률, 구매전환율 분석
        EngagementMetrics metrics = analyticsService.getMetrics(config);
        
        log.info("가중치 {} 적용 결과: 체류시간={}s, 클릭률={}%, 전환율={}%", 
                config, metrics.avgStayTime, metrics.clickRate, metrics.conversionRate);
    }
}

실험을 통한 최적화:

  • 사용자 행동 분석: 어떤 랭킹이 더 높은 참여도를 보이는지 측정
  • 점진적 개선: 급격한 변경보다는 단계적 조정

3. "동시성 문제는 없을까?"

같은 상품에 대해 여러 이벤트가 동시에 발생하는 상황:

시간: 10:00:01.001 - 좋아요 이벤트 (+0.2)
시간: 10:00:01.002 - 조회 이벤트 (+0.1)  
시간: 10:00:01.003 - 주문 이벤트 (+0.6)

Redis의 원자적 연산 활용:

// Redis ZINCRBY는 원자적으로 실행됨
public void incrementScore(Long productId, double score, LocalDate date) {
    String key = keyGenerator.generateDailyRankingKey(date);
    
    // 이 연산은 원자적이므로 동시성 문제 없음
    redisZSetOperations.incrementScore(key, productId.toString(), score);
}

Redis의 싱글 스레드 특성 덕분에 별도의 락 없이도 안전합니다.


구현하면서 고민했던 잠재적 문제들

1. "Long Tail 현상이 발생할 수 있지 않을까?"

구현하면서 가장 우려했던 부분은 시간이 지날수록 상위권이 고착화되는 문제였습니다.

예상되는 문제:

  • 인기 상품은 더 많이 노출되어 더 많은 이벤트 발생
  • 신규 상품이나 틈새 상품은 기회 자체가 적어질 가능성
  • 랭킹 상위권의 순환이 어려워짐

대비책으로 구현한 부분:

  • Carry-Over 감쇠 적용: 전날 점수의 30%만 이월하여 고착화 완화

향후 개선 아이디어:

  • 신규 상품 부스팅 정책
  • 카테고리별 랭킹 분리
  • 더 강한 감쇠 정책 적용

2. "시간대별 편향 문제"

구현 과정에서 떠올린 또 다른 우려사항은 특정 시간대 활동 집중으로 인한 편향이었습니다.

예상 시나리오:

  • 저녁 시간대(19:00-23:00)에 활동이 집중
  • 해당 시간에 노출된 상품들이 상대적으로 유리
  • 새벽이나 오전 시간대 상품들의 기회 부족

현재 구현에서는 미해결: 현재는 모든 시간대의 이벤트를 동일한 가중치로 처리하고 있어, 실제 서비스 시에는 이 부분에 대한 보완이 필요할 것 같습니다.

 

개선 방안 아이디어:

// 시간대별 가중치 적용 (미구현)
public double calculateTimeBasedWeight(LocalTime eventTime) {
    // 활동이 적은 시간대의 이벤트에 더 높은 가중치
    if (eventTime.isAfter(LocalTime.of(2, 0)) && 
        eventTime.isBefore(LocalTime.of(6, 0))) {
        return 1.5; // 새벽 시간대 부스팅
    }
    return 1.0;
}

배운 것들과 앞으로의 개선 방향

기술적으로 성장한 부분

1. Redis ZSET 기본 활용법 습득

  • ZINCRBY, ZREVRANGE 등 기본 명령어 사용법
  • 키 설계와 TTL 관리 방법
  • 점수 기반 자동 정렬의 편리함 체험

2. 이벤트 기반 아키텍처 확장

  • 기존 Kafka 파이프라인에 새로운 기능 추가하는 방법
  • 모듈 간 이벤트 통신으로 실시간 설정 동기화
  • 배치 처리와 실시간 처리를 함께 사용하는 경험

3. 스케줄러를 활용한 문제 해결

  • 콜드 스타트 같은 비즈니스 문제를 기술적으로 접근
  • 분산 락을 사용한 중복 실행 방지
  • 시간 기반 데이터 이월 로직 구현

앞으로 개선하고 싶은 부분

1. 더 정교한 점수 계산

// 현재: 단순 가중치 합산
score = viewWeight * viewCount + likeWeight * likeCount + orderWeight * orderCount;

// 개선 방향: 시간 감쇠나 상품 카테고리 등을 고려
score = calculateComplexScore(events, categoryBonus, timeDecay);

2. 더 안정적인 장애 대응

  • Redis 장애 시 백업/복구 메커니즘
  • 서비스 중단 없는 Graceful Degradation

3. 성능 모니터링 강화

  • Redis 메모리 사용량 추적
  • 랭킹 업데이트 지연시간 모니터링
  • 배치 처리 성능 지표 수집

마무리: 완벽하지 않지만 현실적인 해결책

이번 랭킹 시스템 구축을 통해 "기술적 완벽함"보다는 "비즈니스 문제 해결"에 집중하는 것이 얼마나 중요한지 깨달았습니다.

 

달성한 목표들:

  • 실시간 랭킹 업데이트 구현
  • 관리자 가중치 조절 기능
  • 콜드 스타트 문제 완화
  • 배치 처리를 통한 처리량 개선

 

아직 부족한 부분들:

  • 개인화된 랭킹 부재
  • 더 정교한 점수 계산 알고리즘 필요
  • 카테고리별, 브랜드별 세분화 랭킹
  • 실시간 이상 감지 및 알람

하지만 가장 중요한 건 **"왜 이렇게 구현했는가"**에 대한 명확한 답을 갖는 것이었습니다:

  • 왜 Redis ZSET을 선택했는가? → O(log N) 성능과 자동 정렬 기능
  • 왜 배치 처리를 도입했는가? → 네트워크 오버헤드 최소화와 처리량 향상
  • 왜 이벤트 기반으로 가중치를 동기화하는가? → 모듈 간 독립성과 확장성
  • 왜 콜드 스타트 문제를 해결해야 하는가? → 사용자 경험의 연속성

코드로 보는 핵심 구현

마지막으로 이번 프로젝트의 핵심이 되는 코드들을 정리해보겠습니다:

랭킹 점수 업데이트 (핵심 로직)

@Service
public class RankingService {
    private final RankingCache rankingCache;
    private final ScoreCalculator scoreCalculator;

    public void updateProductLikeScore(Long productId, boolean isLikeEvent, LocalDate date) {
        // 실시간 가중치를 반영한 점수 계산
        double score = scoreCalculator.calculateLikeScore(isLikeEvent);
        
        // Redis ZSET에 원자적으로 점수 업데이트
        rankingCache.incrementScore(productId, score, date);

        log.info("상품 좋아요 랭킹 점수 업데이트: productId={}, isLike={}, score={}, date={}",
                productId, isLikeEvent, score, date);
    }
}

Redis ZSET 연산 (성능 핵심)

@Component
public class RankingCacheImpl implements RankingCache {
    private static final Duration TTL = Duration.ofDays(2);

    @Override
    public void incrementScore(Long productId, double score, LocalDate date) {
        String key = keyGenerator.generateDailyRankingKey(date); // "ranking:all:20250911"
        
        // ZINCRBY: O(log N) 시간복잡도로 점수 업데이트 + 자동 정렬
        redisZSetOperations.incrementScore(key, productId.toString(), score);
        redisZSetOperations.expire(key, TTL);
    }

    @Override
    public void carryOverScores(LocalDate fromDate, LocalDate toDate, double decayFactor) {
        String fromKey = keyGenerator.generateDailyRankingKey(fromDate);
        String toKey = keyGenerator.generateDailyRankingKey(toDate);

        // 전체 랭킹 데이터를 감쇠 적용하여 다음 날로 이월
        Set<ZSetOperations.TypedTuple<String>> previousScores =
                redisZSetOperations.reverseRangeWithScores(fromKey, 0, -1);

        previousScores.forEach(tuple -> {
            String productIdStr = tuple.getValue();
            Double score = tuple.getScore();

            if (productIdStr != null && score != null) {
                double newScore = score * decayFactor; // 30% 감쇠
                redisZSetOperations.add(toKey, productIdStr, newScore);
            }
        });
    }
}

배치 컨슈머 (처리량 최적화)

@Component
public class ProductLikeEventConsumer extends BatchConsumerTemplate<ProductLikeEventDto> {
    
    // 상속받은 템플릿이 멱등성 처리, 에러 핸들링, DLT 등을 자동으로 처리
    @Override
    protected void process(ProductLikeEventDto dto) {
        processor.processEvent(dto.toCommand());
    }

    @KafkaListener(
        topics = "${app.kafka.topics.product-like-events:product-like-events}",
        containerFactory = KafkaConfig.BATCH_LISTENER, // 배치 처리 설정
        groupId = "${app.kafka.consumer-groups.product-like-collector:product-like-collector}"
    )
    public void onMessage(@Payload List<ProductLikeEventDto> events, /* ... */) {
        // 한 번에 여러 메시지를 배치로 처리하여 처리량 향상
        handleBatch(topic, events, keys, partitions, offsets, ack);
    }
}

가중치 실시간 조절 (운영 편의성)

// API 모듈: 관리자가 가중치 변경
@PutMapping("/admin/ranking/weights")
public ApiResponse<Object> updateWeights(@RequestBody WeightUpdateRequest request) {
    weightFacade.updateWeights(request.toCommand());
    return ApiResponse.success();
}

// Collector 모듈: 변경된 가중치를 이벤트로 수신
@Component
public class WeightConfigEventConsumer extends BatchConsumerTemplate<WeightConfigEventDto> {
    @Override
    protected void process(WeightConfigEventDto dto) {
        // 새로운 가중치로 실시간 업데이트
        processor.processEvent(dto.toCommand());
    }
}

마무리: 아직 갈 길이 먼 랭킹 시스템

이번 랭킹 시스템 구축을 통해 Redis ZSET의 기본적인 활용법과 이벤트 기반 아키텍처 확장 경험을 쌓을 수 있었습니다.

하지만 여전히 부족한 부분들이 많습니다:

1. 개인화 부재

  • 모든 사용자에게 동일한 랭킹 제공
  • 사용자별 취향이나 구매 이력을 반영하지 못함
  • 진정한 "맞춤형 추천"과는 거리가 멀음

2. 시간 단위의 한계

  • 하루 단위 랭킹만 구현
  • 시간별, 실시간 트렌드 반영 부족
  • 급상승 상품이나 순간적 인기도 포착 어려움

3. 단순한 점수 계산

  • 기본적인 가중치 합산에 그침
  • 시간 가중치, 사용자 신뢰도, 상품 카테고리 등 고려 부족
  • 더 정교한 랭킹 알고리즘 필요

앞으로의 학습 목표:

개인화된 추천 시스템, 실시간 스트리밍 처리, 그리고 더 정교한 점수 계산 알고리즘까지 - 아직 배워야 할 것들이 산더미입니다.

이번 프로젝트는 시작점일 뿐이고, 앞으로 더 깊이 있는 학습과 경험을 통해 사용자에게 정말 의미 있는 랭킹 시스템을 만들 수 있는 개발자로 성장하고 싶습니다.

 

'Spring' 카테고리의 다른 글

Spring Batch로 대용량 데이터 처리하기: 학습 정리  (0) 2025.10.19
Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기  (0) 2025.09.05
ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택  (3) 2025.08.28
결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략  (1) 2025.08.22
DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기  (2) 2025.08.15
'Spring' 카테고리의 다른 글
  • Spring Batch로 대용량 데이터 처리하기: 학습 정리
  • Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기
  • ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택
  • 결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
Redis ZSET으로 실시간 랭킹 시스템 구축하기
상단으로

티스토리툴바