상품 랭킹 시스템을 만들면서, 누적 점수만 쌓으면 오래된 상품이 영원히 상위권을 차지한다는 걸 깨달았다. "시간 양자화(Time Quantization)"로 일간/시간별 랭킹을 분리하니 최근 인기 상품이 즉시 반영됐다. 그런데 콜드 스타트 문제로 인해 새벽 0시, 매시간 정각에는 랭킹이 텅 비는 상황 이 발생했다. 콜드 스타트 문제 해결을 위해 이전 랭킹의 10%를 미리 복사하는 Score Carry-Over를 구현했다. 또한 주문 금액 차이가 10배여도 점수는 1.2배만 차이 나도록 로그 정규화를 적용하니, 드디어 인기 상품 랭킹이 쓸만해졌다.
Kafka 이벤트 파이프라인이 완성되고 나서, 지금 가장 핫한 상품을 사용자들이 볼 수 있게끔 실시간 인기 상품 랭킹 기능을 구현했다.
Redis ZSET으로 점수 누적을 하면 쉬울 것 같았다.
첫 번째 구현:
// 좋아요 이벤트 수신 → 점수 증가
@KafkaListener(topics = ["catalog-events"])
fun handleLikeAdded(event: LikeAddedEvent) {
val score = 1.0 // 좋아요 1점
redis.zIncr("ranking:all", event.productId, score)
}
// 랭킹 조회
fun getTopRankings(size: Int): List<Product> {
val productIds = redis.zRevRange("ranking:all", 0, size - 1)
return productRepository.findAllById(productIds)
}
배포하고 문제 분석과 시뮬레이션을 해보았다.
시뮬레이션을 해보니 새로 출시한 상품일수록 인기 순위를 얻기 어려웠다.
예시
| 상품 | 출시일 | 오늘 조회수 | 오늘 주문수 | 누적 점수 | 순위 |
|---|---|---|---|---|---|
| 100 | 3년 전 | 50 | 5 | 1520 | 1위 |
| 201 | 이번 주 | 500 | 100 | 450 | 10위 |
"누적 점수로는 최근 인기 상품을 반영할 수 없구나..."
이게 바로 Long Tail Problem이었다. 시간이 지날수록 과거 데이터가 쌓여서, 최근 인기 상품이 순위에 반영되지 않는다.
시간이 지날수록 누적 점수 격차가 벌어짐:
Day 1:
상품A: 10점 (1위)
상품B: 0점
Day 10:
상품A: 100점 (1위) ← 이제 하루 0점이어도
상품B: 15점 ← 상품B가 따라잡기 어려움
Day 100:
상품A: 500점 (1위) ← 인기가 식어도 계속 1위
상품B: 200점
상품C: 50점 (신상) ← 지금 핫해도 하위권
"시간이 쌓일수록 오래된 상품이 유리해지는 구조다..."
핵심 아이디어:
일간 랭킹 설계:
// 기존: 하나의 키에 모든 점수 누적
ranking:all → 영원히 누적
// 개선: 날짜별로 키 분리
ranking:all:daily:20251220 → 2025년 12월 20일 랭킹
ranking:all:daily:20251221 → 2025년 12월 21일 랭킹
ranking:all:daily:20251222 → 2025년 12월 22일 랭킹
시간별 랭킹 설계:
ranking:all:hourly:2025122014 → 2025년 12월 20일 14시 랭킹
ranking:all:hourly:2025122015 → 2025년 12월 20일 15시 랭킹
시간 양자화로 최근 트렌드를 반영하는 데는 성공했다. 그런데 새로운 문제가 생겼다.
새벽 0시에 랭킹 조회:
# 12월 20일 23:59
ZREVRANGE ranking:all:daily:20251220 0 9 WITHSCORES
1) "201" (score: 55.0)
2) "105" (score: 48.0)
...
# 12월 21일 00:00 (1분 후)
ZREVRANGE ranking:all:daily:20251221 0 9 WITHSCORES
(empty list) # 😱 텅 빔!
"새 윈도우가 시작되면 랭킹이 없네..."
사용자가 0시에 랭킹 페이지를 열면 아무것도 안 보인다. "인기 상품이 없나요?" 같은 문의가 들어올 것이다.
문제:
타임라인:
23:59 (어제):
ranking:all:daily:20251220
├── 상품A: 100점
├── 상품B: 80점
└── 상품C: 60점
00:00 (오늘):
ranking:all:daily:20251221
└── (empty) ❌ 사용자에게 빈 페이지 노출
00:10 (이벤트 10개 발생 후):
ranking:all:daily:20251221
├── 상품D: 2점
├── 상품A: 1점
└── 상품B: 1점
"어제의 인기 상품이 오늘도 어느 정도 인기 있을 텐데..."
핵심 아이디어:
왜 10%인가?
| 가중치 | 효과 | 문제 |
|---|---|---|
| 0% | 완전히 새로운 랭킹 | 빈 랭킹 노출 ❌ |
| 10% | 이전 10% + 새 90% | 균형적 ✅ |
| 50% | 이전 50% + 새 50% | 과거 의존도 너무 높음 |
| 100% | 이전 데이터 그대로 | Long Tail 재발 ❌ |
시뮬레이션:
23시 랭킹 (어제):
1위: 상품A (100점)
2위: 상품B (80점)
3위: 상품C (60점)
00시 랭킹 (오늘) - Carry-Over 10%:
1위: 상품A (10점) ← 100 * 0.1
2위: 상품B (8점) ← 80 * 0.1
3위: 상품C (6점) ← 60 * 0.1
00시 10분 - 새 이벤트 10개 발생:
1위: 상품D (15점) ← 새로운 인기 상품 즉시 1위
2위: 상품A (12점) ← 10 + 2 (기존 인기도 + 새 점수)
3위: 상품B (10점) ← 8 + 2
이제 0시에도 랭킹이 보인다.
# 12월 21일 00:00 (스케줄러 실행 직후)
ZREVRANGE ranking:all:daily:20251221 0 9 WITHSCORES
1) "201" (score: 5.5) # 어제 55점 * 0.1
2) "105" (score: 4.8) # 어제 48점 * 0.1
3) "100" (score: 1.2) # 어제 12점 * 0.1
# 12월 21일 00:10 (새 이벤트 발생 후)
1) "207" (score: 12.0) # 신상품이 즉시 1위로
2) "201" (score: 7.5) # 5.5 + 2.0 (계속 인기)
3) "105" (score: 5.8) # 4.8 + 1.0
"빈 랭킹도 없고, 새 인기 상품도 즉시 반영된다!"
이제 랭킹 점수를 어떻게 계산할지 고민이었다.
어떤 이벤트에 얼마의 점수를 줄 것인가?
처음 생각:
// ❌ 단순한 방법
조회: 1점
좋아요: 1점
주문: 1점
"이건 너무 단순한데... 주문이 훨씬 중요하지 않나?"
의사결정:
| 이벤트 | 가중치 | 이유 |
|---|---|---|
| 조회 | 0.1 | 관심도는 있지만 가벼운 행동 |
| 좋아요 | 0.2 | 더 강한 관심 표현 |
| 주문 | 0.7 | 실제 구매로 가장 중요 |
그런데 주문 점수에 문제가 있었다.
시나리오:
상품A: 1,000원 × 100개 주문 = 100,000원
상품B: 100,000원 × 1개 주문 = 100,000원
만약 주문 금액을 그대로 점수로 쓰면:
테스트 케이스:
| 상품 | 가격 | 주문 수 | 총 금액 | 가중치 0.7 | 점수 |
|---|---|---|---|---|---|
| 저가 | 10,000원 | 10개 | 100,000원 | 0.7 | 70,000점 |
| 고가 | 1,000,000원 | 1개 | 1,000,000원 | 0.7 | 700,000점 |
"금액 차이 10배 → 점수 차이 10배... 이건 공정하지 않다"
핵심 아이디어:
수식:
score = 0.7 * (1 + ln(totalAmount))
효과:
| 총 금액 | ln(금액) | 1 + ln(금액) | 0.7 * 점수 | 차이 |
|---|---|---|---|---|
| 100,000원 | 11.5 | 12.5 | 8.75점 | - |
| 1,000,000원 | 13.8 | 14.8 | 10.36점 | 1.18배 |
금액 차이 10배 → 점수 차이 1.18배
구현:
fun fromOrder(priceAtOrder: Long, quantity: Int): RankingScore {
val totalAmount = priceAtOrder * quantity
val normalizedScore = 1.0 + ln(totalAmount.toDouble())
return RankingScore(WEIGHT_ORDER * normalizedScore)
}
"이제 가격이 아니라 실제 인기도를 반영한다!"
Kafka Consumer가 배치로 메시지를 받으니까, 같은 상품에 대한 이벤트가 여러 개 올 수 있다.
잘못된 방법:
// ❌ 덮어쓰기
records.forEach { record ->
dailyScoreMap[productId] = RankingScore.fromView() // 마지막 것만 남음
}
// 결과: 같은 상품 조회 10번 → 0.1점만 반영
올바른 방법:
// ✅ 누적
records.forEach { record ->
val score = RankingScore.fromView()
dailyScoreMap.merge(productId, score) { old, new ->
RankingScore(old.value + new.value) // 0.1 + 0.1 + ... = 1.0
}
}
// 결과: 같은 상품 조회 10번 → 1.0점 반영 ✅
"배치 안에서도 모든 이벤트를 빠짐없이 반영해야 한다"
새로운 우려가 생겼다.
시나리오:
23:59:59 - 이벤트 발생
↓
Kafka Consumer가 메시지 수신
↓
00:00:01 - RankingKey 생성
↓
❌ 내일(20251221) 키로 업데이트됨
"어제 이벤트가 오늘 랭킹에 들어가버리네..."
해결:
배치 처리 시작 시점에 RankingKey를 생성해서 고정한다.
@KafkaListener(topics = ["catalog-events"])
fun consumeCatalogEvents(records: List<ConsumerRecord<String, String>>) {
// ✅ 배치 시작 시점에 키 생성 (이 순간 시간 고정)
val dailyKey = RankingKey.currentDaily(RankingScope.ALL)
val hourlyKey = RankingKey.currentHourly(RankingScope.ALL)
// 배치 처리하는 동안 키는 변하지 않음
records.forEach { record ->
// ... 점수 누적
}
// 같은 키로 업데이트
rankingRepository.incrementScoreBatch(dailyKey, dailyScoreMap)
rankingRepository.incrementScoreBatch(hourlyKey, hourlyScoreMap)
}
타임라인:
23:59:59.500 - 배치 시작
↓
23:59:59.500 - RankingKey 생성 (20251220 고정)
↓
00:00:00.100 - 배치 처리 중...
↓
00:00:00.500 - Redis 업데이트 (여전히 20251220 키 사용)
↓
✅ 올바르게 어제 랭킹에 반영됨
"배치 시작 시점 기준으로 윈도우를 정하면 일관성이 보장된다"
처음엔 "Redis ZSET만 쓰면 끝"이라고 생각했다. 하지만:
"랭킹의 핵심은 '시간'을 어떻게 다루느냐다"
금액, 조회수 같은 지표는 편차가 크다. 로그 정규화로:
"선형 스케일이 항상 답은 아니다"
배치 안에서 같은 키가 여러 번 나오면:
"Map.merge()로 간단히 해결된다"
Spring Data Redis API가 자꾸 바뀌니까:
"때로는 직접 구현이 더 나을 수 있다"
콜드 스타트 방지를 위해:
"10분 버퍼면 충분하다"
Redis ZSET으로 랭킹 시스템을 만드는 건 쉽다. 하지만 "진짜 인기 상품"을 보여주는 랭킹을 만드는 건 어렵다.
이 모든 질문에 답하면서, "랭킹의 목적은 순위를 매기는 게 아니라, 사용자가 원하는 상품을 빠르게 찾게 하는 것" 이라는 걸 배웠다.