Loopers

🔥 "랭킹 좀 만들어달라는데 Redis 메모리가..." - ZSET 랭킹 시스템 구축기

그zi운아이 2025. 9. 11. 21:29

🔥 "랭킹 좀 만들어달라는데 Redis 메모리가..." - ZSET 랭킹 시스템 구축기

TL;DR: "간단한 랭킹 좀 만들어주세요"라는 요청이 어떻게 다중 ZSET vs 단일 ZSET 설계 고민 → 가중치 실시간 변경 지옥 → 콜드 스타트 딜레마까지 이어졌는지. 실제 커밋 로그와 함께 보는 7일간의 현실.


🚀 Day 1: 기존 구조에 한 줄만 추가하면 되겠네

기획팀: "오늘의 인기상품 랭킹 보여주실 수 있나요?"

: "MetricsHandler에 랭킹 서비스 한 줄만 추가하면 될 것 같은데요?"

프로젝트를 살펴보니 이미 완성된 이벤트 처리 구조가 있었다:

// apps/commerce-streamer/src/main/java/com/loopers/handler/impl/MetricsHandler.java
@Component
public class MetricsHandler implements EventHandler {
    private final MetricsService metricsService;
    private final RankingService rankingService; // 이 한 줄이 핵심

    @Override
    public void handle(GeneralEnvelopeEvent envelope) {
        metricsService.recordMetric(envelope);
        rankingService.updateRanking(envelope); // 딱 한 줄 추가
    }
}

첫 번째 설계 결정: 새로운 Consumer를 만들지 말고 기존 MetricsHandler에 한 줄만 추가하자.

근거:

  • 이미 METRIC_EVENTS 필터링이 되어 있음
  • 배치 처리도 handleBatch() 메서드로 구현되어 있음
  • 중복 Consumer 방지

🤔 설계 결정의 딜레마

🔴 첫 번째 고민: 다중 ZSET vs 단일 ZSET

// 🤔 처음 생각한 구조 (복잡함)
ranking:view:20250911  → 조회 점수만
ranking:like:20250911  → 좋아요 점수만  
ranking:order:20250911 → 주문 점수만

// 조회할 때마다 ZUNIONSTORE로 합산
ZUNIONSTORE ranking:final:20250911 3 
    ranking:view:20250911 ranking:like:20250911 ranking:order:20250911 
    WEIGHTS 0.1 0.2 0.7

// ✅ 결국 선택한 구조 (단순함)
ranking:all:20250911 → 가중치 적용된 최종 점수

💡 단일 ZSET을 선택한 이유:

  • ZUNIONSTORE 연산 제거 → 성능 향상
  • 키 관리 복잡도 감소 (3개 → 1개)
  • 실시간 조회 최적화

🟡 두 번째 고민: 단건 처리 vs 배치 처리

// 😭 처음 구현 (단건 처리)
@Override
public void handle(GeneralEnvelopeEvent envelope) {
    metricsService.recordMetric(envelope);
    rankingService.updateRanking(envelope); // 하나씩 Redis 호출
}

// 🚀 개선 후 (배치 처리)
public void handleBatch(List<GeneralEnvelopeEvent> envelopes) {
    for (GeneralEnvelopeEvent envelope : envelopes) {
        metricsService.recordMetric(envelope);
    }
    rankingService.updateRankingBatch(envelopes); // Pipeline으로 일괄 처리
}

💥 성능 차이:

  • 단건: 100개 이벤트 = 100번 Redis 호출
  • 배치: 100개 이벤트 = 1번 Pipeline 호출 (네트워크 비용 대폭 절약!)

🟠 세 번째 고민: 하드코딩 vs WeightManager

기획팀: "가중치를 실시간으로 바꿀 수 있어야 해요!"

// 😅 처음엔 하드코딩
private Double calculateScore(String eventType, JsonNode payload) {
    return switch (eventType) {
        case EventTypes.PRODUCT_VIEWED -> 0.1;
        case EventTypes.PRODUCT_LIKED -> 0.2;
        case EventTypes.ORDER_CREATED -> 0.7;
        default -> null;
    };
}

// 🎯 WeightManager 도입
private Double calculateScore(String eventType, JsonNode payload) {
    return switch (eventType) {
        case EventTypes.PRODUCT_VIEWED -> weightManager.getWeight(RankingEventType.PRODUCT_VIEWED);
        case EventTypes.PRODUCT_LIKED -> weightManager.getWeight(RankingEventType.PRODUCT_LIKED);
        case EventTypes.ORDER_CREATED -> {
            double orderAmount = extractOrderAmount(payload);
            double weight = weightManager.getWeight(RankingEventType.ORDER_CREATED);
            yield weight * Math.log(1 + orderAmount / 1000.0); // 로그 스케일링!
        }
        default -> null;
    };
}

🤔 로그 스케일링을 도입한 이유:

  • 1만원 주문 vs 100만원 주문이 100배 차이는 너무 극단적
  • Math.log(1 + amount/1000.0)로 완만한 증가 곡선 적용
  • 공정한 점수 분배 달성

⚡ Day 4-5: 실시간 가중치 변경의 지옥

🔥 기획팀과의 폭탄 발언

기획팀: "내일 블랙프라이데이인데, 주문 가중치를 2배로 올려주세요! 바로 반영되어야 해요!"

: "기존 점수들은 어떻게 하죠? 어제는 옛날 가중치고 오늘은 새 가중치인데..."

기획팀: "그냥 다 새로운 가중치로 맞춰주시면 안 되나요?"

: "..." 😭

🎯 절충안: 비율 계산으로 재계산

public void updateWeight(RankingEventType eventType, double newWeight) {
    double oldWeight = getWeight(eventType);
    
    // Redis 업데이트
    redisTemplate.opsForHash().put(WEIGHTS_KEY, eventType.name(), String.valueOf(newWeight));
    
    // 🔥 핵심: 기존 랭킹 데이터 재계산
    recalculateExistingRankings(eventType, oldWeight, newWeight);
}

private void recalculateExistingRankings(RankingEventType eventType, double oldWeight, double newWeight) {
    if (oldWeight == 0.0) {
        log.warn("기존 가중치가 0이므로 재계산 건너뜀: {}", eventType);
        return;
    }
    
    double ratio = newWeight / oldWeight; // 🎯 비율로 해결!
    
    // 오늘과 어제만 재계산 (성능 vs 정확성 트레이드오프)
    for (int i = 0; i < 2; i++) {
        LocalDate targetDate = LocalDate.now().minusDays(i);
        recalculateRankingForDate(targetDate, ratio, eventType);
    }
}

⚖️ 트레이드오프 테이블:

재계산 범위 장점   단점  선택
전체 재계산 완전 정확성 ✨ 성능 폭사 💀
오늘만 빠름 ⚡ 어제 데이터 불일치 😵
오늘+어제 적절한 균형 🎯 부분 불일치 😐

🌅 Day 5-6: 콜드 스타트 딜레마 

😱 자정이 되면서 랭킹이 텅 비었다...

timeline
    title 하루의 랭킹 변화
    section 23:59
        랭킹 풍성 : ranking:all:20250911
                   : product:1 → 1000점
                   : product:2 → 800점
                   : product:3 → 600점
    section 00:00
        키 변경됨 : ranking:all:20250912
                  : (텅 비어있음)
                  : 신상품과 기존상품 모두 0점
    section 00:01
        사용자 접속 : "인기상품이 없네요?"
                    : 이탈률 증가 😭

🔄 Carry-over 스케줄러 도입

@Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul") // 매일 23:50
public void performCarryOver() {
    // 상위 100개의 10%를 다음날 시드로 복사
    Set<TypedTuple<String>> top100 = redisTemplate.opsForZSet()
        .reverseRangeWithScores(todayKey, 0, 99);
    
    for (TypedTuple<String> tuple : top100) {
        Double score = tuple.getScore();
        if (score != null) {
            redisTemplate.opsForZSet().add(tomorrowKey, 
                tuple.getValue(), score * 0.1); // 10% 가중치
        }
    }
}

🤔 하지만 새로운 고민이...

📊 Carry-over 시뮬레이션:

어제 1위 상품: 1000점 → 오늘 시작 시 100점 (10%)
신상품: 0점 → 오늘 시작 시 0점

결과: 신상품이 1위 되려면 100점 이상 필요 😱

💭 딜레마:

  • Carry-over 없음: 새벽에 빈 랭킹 → 사용자 경험 최악 😭
  • Carry-over 있음: 신상품 차별 → 공정성 문제 😟

💥 Day 6-7: TTL 없이 무한 증가하는 메모리

🚨 "어? 메모리 사용량이 계속 늘어나네?"

# 실제 메모리 사용량 측정
$ redis-cli MEMORY USAGE ranking:all:20250911
(integer) 15728640  # 랭킹 하나당 15MB

# 😱 계산해보니...
# 365일 × 15MB = 5.4GB (랭킹만!)
# TTL 없으면 계속 누적...

⚡ TTL 관리와 Pipeline 최적화

// 💾 TTL로 메모리 관리
private void setTtlIfNeeded(String key) {
    Long ttl = redisTemplate.getExpire(key);
    if (ttl == null || ttl == -1) {
        redisTemplate.expire(key, Duration.ofDays(2)); // 2일 후 자동 삭제
    }
}

// 🔥 Pipeline으로 네트워크 비용 절약
public void updateRankingBatch(List<GeneralEnvelopeEvent> envelopes) {
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (GeneralEnvelopeEvent envelope : envelopes) {
            Double score = calculateScore(envelope.type(), envelope.payload());
            Long productId = extractProductId(envelope.payload());
            
            if (score != null && productId != null) {
                String member = "product:" + productId;
                redisTemplate.opsForZSet().incrementScore(todayKey, member, score);
            }
        }
        return null;
    });
}

📊 최적화 효과:

항목  개선 전  개선 후  효과
네트워크 호출 100번 개별 호출 1번 Pipeline 네트워크 지연 대폭 감소
메모리 관리 TTL 없음 (무한 증가) TTL 2일 메모리 안정화
처리 속도 단건 처리 배치 처리 처리량 증가

🎯 Day 7: Fallback 전략과 상품 정보 연동

🛡️ 3단계 Fallback 전략

private RankingResult getRankingWithFallback(LocalDate targetDate, int start, int end) {
    for (int i = 0; i < 3; i++) {
        LocalDate fallbackDate = targetDate.minusDays(i);
        String key = generateRankingKey(fallbackDate);
        
        Set<TypedTuple<String>> results = redisTemplate.opsForZSet()
            .reverseRangeWithScores(key, start, end);
            
        if (results != null && !results.isEmpty()) {
            String source = i == 0 ? "today" : 
                           i == 1 ? "yesterday" : "day-before-yesterday";
            return RankingResult.of(fallbackDate, parseEntries(results), source);
        }
    }
    
    return RankingResult.empty(targetDate); // 최종 안전장치
}

🔗 상품 정보 N+1 문제 해결

// ❌ 처음 시도 (N+1 문제)
entries.stream()
    .map(entry -> {
        ProductInfo info = productService.getProduct(entry.productId()); // N번 호출!
        return new RankingEntry(entry.productId(), entry.score(), info);
    })

// ✅ 배치 조회로 해결
private List<RankingEntry> enrichWithProductInfo(List<RankingEntry> entries) {
    List<Long> productIds = entries.stream()
        .map(RankingEntry::productId)
        .collect(Collectors.toList());
    
    // 1번의 배치 호출로 모든 상품 정보 조회
    Map<Long, ProductInfo> productInfoMap = productFacade.getProductInfoMap(productIds);
    
    return entries.stream()
        .map(entry -> new RankingEntry(entry.productId(), entry.score(), 
                                     productInfoMap.get(entry.productId())))
        .filter(entry -> entry.productInfo() != null)
        .collect(Collectors.toList());
}

📊 최종 성과와 아직 남은 과제

🏆 달성한 것들

기능  상태  성과
Redis ZSET 랭킹 ✅ 완료 실시간 랭킹 업데이트
가중치 실시간 변경 ✅ 완료 기획팀 요구사항 충족
콜드 스타트 해결 ✅ 완료 빈 랭킹 방지
Fallback 전략 ✅ 완료 서비스 안정성 확보
성능 최적화 ✅ 완료 Pipeline + TTL 적용

🤔 아직 남은 과제들

1. Hot Key 문제 🔥

# 인기 상품에 이벤트 집중 시
ZINCRBY ranking:all:20250911 0.7 product:12345
# 특정 상품 → 특정 파티션 과부하

2. 가중치 변경의 원자성 ⚛️

// Thread 1: 재계산 중
for (TypedTuple<String> entry : allEntries) {
    double newScore = entry.getScore() * ratio;
    redis.add(key, entry.getValue(), newScore);
    // ← 이 시점에 Thread 2에서 ZINCRBY 발생?
}

3. 메모리 사용량 모니터링 📊

# 1년 누적 예상치
15MB × 365일 = 5.4GB (랭킹만)
+ 가중치 캐시 + 메트릭 = 8GB+

💭 현실 체크: 과연 이 모든 복잡성이 필요했을까?

😊 모두가 행복한 결과...?

  • 기획팀 😊: "실시간 가중치 변경 최고예요!"
  • 사용자 😍: "인기상품 랭킹 보기 편해졌어요!"
  • 운영팀 😌: "메모리 사용량도 안정적이고..."
  • 개발자 😭: "어우 복잡성 높고 유지보수 하기 힘들꺼 같은데'

🧮 냉정한 비용 계산

투입 비용:

  • 개발 시간: 약 40시간 (1주)
  • Redis 메모리 증가: 5.4GB/년
  • 운영 복잡도: 가중치 관리, 콜드 스타트 해결, 원자성 문제...
  • 유지보수 비용: Hot Key 모니터링, TTL 관리, 성능 튜닝...

측정 가능한 가치:

  • ??? (사실 측정하기 어려움)

🤔 실무에서 진짜 선택한다면?

랭킹이 핵심 도메인이 아니라면:

 
// 솔직히 이렇게 했을 것 같다
@Service
public class SimpleRankingService {
    
    // 그냥 조회수 기반 단순 정렬
    public List<Product> getPopularProducts() {
        return productRepository.findTop20ByOrderByViewCountDesc();
    }
    
    // 또는 파트너사 추천 상품 먼저 노출
    public List<Product> getRecommendedProducts() {
        List<Product> partnerProducts = getPartnerRecommendations(); // 비즈니스 우선
        List<Product> popularProducts = getPopularByViews();
        
        return Stream.concat(partnerProducts.stream(), popularProducts.stream())
                    .limit(20)
                    .collect(Collectors.toList());
    }
}

이 방식의 장점:

  • 개발 시간: 2시간
  • 운영 복잡도: 거의 없음
  • Redis 메모리: 0원
  • 유지보수: DB 인덱스 관리만

📊 복잡성 vs 가치 매트릭스

 

접근법 개발비용 운영비용 비즈니스 가치 현실적 선택
Redis ZSET 풀스택 높음 💸 높음 🔧 불명확 ❓ 학습용 🤓
단순 DB 정렬 낮음 ✨ 낮음 😌 80% 달성 🎯 실무용 😊
파트너사 큐레이션 낮음 ✨ 낮음 😌 높음 💰 비즈니스 우선 🥳

🎯 결론

"기술적으로 할 수 있다"와 "해야 한다"는 다르다.

하지만 이번 경험으로 Redis 고급 활용법을 익혔고, 실시간 시스템 설계 역량도 늘었다.

실무에서는:

  1. 비즈니스 우선순위 먼저 (파트너사 추천, 신상품 노출)
  2. 단순한 해결책으로 80% 달성
  3. 복잡성 추가는 명확한 ROI 검증 후

언제 이런 복잡한 시스템이 필요할까:

  • 게임 리더보드 (실시간성이 핵심)
  • 대규모 소셜 미디어 (수억 사용자)
  • 랭킹 자체가 핵심 비즈니스

최종 교훈: 기술은 수단이지 목적이 아니다. 하지만 이런 경험을 통해 "언제 복잡하게, 언제 단순하게" 할지 판단하는 눈을 기를 수 있었다.