Round9 - Ranking System

Pyro·2025년 12월 21일

Loopers

목록 보기
9/10

"우리 쇼핑몰 1위 상품이 그대로예요"

상품 랭킹 시스템을 만들면서, 누적 점수만 쌓으면 오래된 상품이 영원히 상위권을 차지한다는 걸 깨달았다. "시간 양자화(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)
}

배포하고 문제 분석과 시뮬레이션을 해보았다.

원치않는 결과

시뮬레이션을 해보니 새로 출시한 상품일수록 인기 순위를 얻기 어려웠다.

예시

상품출시일오늘 조회수오늘 주문수누적 점수순위
1003년 전50515201위
201이번 주50010045010위

"누적 점수로는 최근 인기 상품을 반영할 수 없구나..."

이게 바로 Long Tail Problem이었다. 시간이 지날수록 과거 데이터가 쌓여서, 최근 인기 상품이 순위에 반영되지 않는다.

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점 (신상)  ← 지금 핫해도 하위권

"시간이 쌓일수록 오래된 상품이 유리해지는 구조다..."

"시간을 잘라내야 최근 트렌드가 보인다"

시간 양자화(Time Quantization)

핵심 아이디어:

  • 누적 랭킹이 아니라 시간 윈도우별 랭킹
  • 매일, 매시간 새로운 랭킹 키를 사용
  • 과거 데이터는 자동으로 만료(TTL)

일간 랭킹 설계:

// 기존: 하나의 키에 모든 점수 누적
ranking:all → 영원히 누적

// 개선: 날짜별로 키 분리
ranking:all:daily:2025122020251220일 랭킹
ranking:all:daily:2025122120251221일 랭킹
ranking:all:daily:2025122220251222일 랭킹

시간별 랭킹 설계:

ranking:all:hourly:20251220142025122014시 랭킹
ranking:all:hourly:20251220152025122015시 랭킹

"0시가 되니 랭킹이 텅 비었어요"

새로운 문제

시간 양자화로 최근 트렌드를 반영하는 데는 성공했다. 그런데 새로운 문제가 생겼다.

새벽 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시에 랭킹 페이지를 열면 아무것도 안 보인다. "인기 상품이 없나요?" 같은 문의가 들어올 것이다.

콜드 스타트(Cold Start) 문제

문제:

  • 새 시간 윈도우가 시작하면 랭킹 데이터가 없음
  • 이벤트가 쌓이기 전까지는 빈 랭킹

타임라인:

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점

"어제의 인기 상품이 오늘도 어느 정도 인기 있을 텐데..."

Score Carry-Over 솔루션

핵심 아이디어:

  • 이전 윈도우 랭킹을 미리 복사
  • 점수에 10% 가중치를 곱해서 복사
  • 새 윈도우 시작 시에도 랭킹 존재

왜 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

"빈 랭킹도 없고, 새 인기 상품도 즉시 반영된다!"

"100만원짜리 상품 1개 vs 1만원짜리 상품 100개"

가중치 설계의 고민

이제 랭킹 점수를 어떻게 계산할지 고민이었다.

어떤 이벤트에 얼마의 점수를 줄 것인가?

처음 생각:

// ❌ 단순한 방법
조회: 1점
좋아요: 1점
주문: 1

"이건 너무 단순한데... 주문이 훨씬 중요하지 않나?"

가중치 기반 점수 계산

의사결정:

이벤트가중치이유
조회0.1관심도는 있지만 가벼운 행동
좋아요0.2더 강한 관심 표현
주문0.7실제 구매로 가장 중요

주문 금액의 딜레마

그런데 주문 점수에 문제가 있었다.

시나리오:

상품A: 1,000원 × 100개 주문 = 100,000원
상품B: 100,000원 × 1개 주문 = 100,000원

만약 주문 금액을 그대로 점수로 쓰면:

  • 고액 상품 1개 주문 = 저렴한 상품 100개 주문
  • "인기 상품"이 아니라 "비싼 상품" 랭킹이 됨

테스트 케이스:

상품가격주문 수총 금액가중치 0.7점수
저가10,000원10개100,000원0.770,000점
고가1,000,000원1개1,000,000원0.7700,000점

"금액 차이 10배 → 점수 차이 10배... 이건 공정하지 않다"

로그 정규화(Log Normalization)

핵심 아이디어:

  • 금액을 그대로 쓰지 말고 로그 스케일로 변환
  • 금액 차이가 커도 점수 차이는 작게

수식:

score = 0.7 * (1 + ln(totalAmount))

효과:

총 금액ln(금액)1 + ln(금액)0.7 * 점수차이
100,000원11.512.58.75점-
1,000,000원13.814.810.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에 발생한 이벤트가 내일 랭킹에 들어가면?"

윈도우 경계 문제

새로운 우려가 생겼다.

시나리오:

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 키 사용)
   ↓
✅ 올바르게 어제 랭킹에 반영됨

"배치 시작 시점 기준으로 윈도우를 정하면 일관성이 보장된다"

배운 것들

1. 랭킹은 데이터 구조가 아니라 시간 관리 문제

처음엔 "Redis ZSET만 쓰면 끝"이라고 생각했다. 하지만:

  • 누적 랭킹 → Long Tail Problem
  • 시간 양자화 → 최근 트렌드 반영
  • 콜드 스타트 방지 → 사용자 경험 개선

"랭킹의 핵심은 '시간'을 어떻게 다루느냐다"

2. 로그 스케일의 힘

금액, 조회수 같은 지표는 편차가 크다. 로그 정규화로:

  • 편차를 줄이고
  • 공정성을 높이고
  • 실제 인기도를 반영

"선형 스케일이 항상 답은 아니다"

3. 배치 처리의 함정

배치 안에서 같은 키가 여러 번 나오면:

  • 덮어쓰기 → 데이터 손실
  • 누적 → 정확한 반영

"Map.merge()로 간단히 해결된다"

4. API 버전 의존성 줄이기

Spring Data Redis API가 자꾸 바뀌니까:

  • 복잡한 API → 버전 문제
  • 수동 구현 → 명확하고 안정적

"때로는 직접 구현이 더 나을 수 있다"

5. 스케줄러 타이밍

콜드 스타트 방지를 위해:

  • 23:50에 내일 데이터 미리 준비
  • :50분에 다음 시간 데이터 준비

"10분 버퍼면 충분하다"

마치며

Redis ZSET으로 랭킹 시스템을 만드는 건 쉽다. 하지만 "진짜 인기 상품"을 보여주는 랭킹을 만드는 건 어렵다.

  • 시간을 어떻게 다룰 것인가 (Time Quantization)
  • 빈 랭킹을 어떻게 방지할 것인가 (Cold Start Prevention)
  • 공정한 점수를 어떻게 계산할 것인가 (Log Normalization)

이 모든 질문에 답하면서, "랭킹의 목적은 순위를 매기는 게 아니라, 사용자가 원하는 상품을 빠르게 찾게 하는 것" 이라는 걸 배웠다.

profile
dreams of chronic and sustained passion

0개의 댓글