🔥 "랭킹 좀 만들어달라는데 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 고급 활용법을 익혔고, 실시간 시스템 설계 역량도 늘었다.
실무에서는:
- 비즈니스 우선순위 먼저 (파트너사 추천, 신상품 노출)
- 단순한 해결책으로 80% 달성
- 복잡성 추가는 명확한 ROI 검증 후
언제 이런 복잡한 시스템이 필요할까:
- 게임 리더보드 (실시간성이 핵심)
- 대규모 소셜 미디어 (수억 사용자)
- 랭킹 자체가 핵심 비즈니스
최종 교훈: 기술은 수단이지 목적이 아니다. 하지만 이런 경험을 통해 "언제 복잡하게, 언제 단순하게" 할지 판단하는 눈을 기를 수 있었다.
'Loopers' 카테고리의 다른 글
WIL Redis 자료구조 (0) | 2025.09.14 |
---|---|
"같은 주문이 두 번 결제됐습니다" 💸 - Kafka로 배운 분산 시스템의 잔혹한 현실 (1) | 2025.09.05 |
🚨 “그냥 @EventListener면 끝?” — 이벤트, 언제·왜·어떻게 사용할 것인가 ⚙️ (3) | 2025.08.29 |
Resilience와 보상 트랜잭션: 장애에 대응하는 방법 (1) | 2025.08.24 |
PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥 (3) | 2025.08.22 |