랭킹은 로그가 아니다: Redis로 안정적인 상품 랭킹 만들기

zion·2025년 12월 26일

TL;DR

  • Redis ZSET을 활용해 상품 일간 랭킹 시스템을 구현했다
  • 조회수·좋아요·판매량을 10분 단위 time-bucket으로 집계해 노이즈를 줄이고 실시간성을 확보했다
  • 캘린더 기준(00:00 시작) 일간 랭킹의 콜드 스타트 문제를 Carry Over 전략으로 완화했다
  • 랭킹은 정확한 로그가 아니라, 상품 노출을 제어하는 시스템이라는 관점으로 설계했다

Redis의 ZSET 자료 구조를 활용해 실시간 상품 일간 랭킹 시스템을 구현하며 고민했던 설계 포인트를 정리합니다.
랭킹은 단순한 정렬 문제가 아니라, 시간 기준·집계 단위·안정성에 대한 선택의 연속이었습니다.


1️⃣ 일간 랭킹의 시간 기준

캘린더 기준 (00:00 ~ 23:59, KST)

vs Sliding Window 기준 (최근 24시간)

일간 랭킹의 기준은 크게 두 가지 방식으로 나눌 수 있습니다.

  • 캘린더 기준

    • 날짜 단위로 데이터 관리가 명확
    • 일자별 랭킹 비교 및 이력 관리에 유리
    • 단점: 00시 시점에 랭킹 데이터가 거의 없는 콜드 스타트 문제 발생
  • Sliding Window 기준 (최근 24시간)

    • 항상 충분한 데이터가 존재
    • 시간 경계에서의 데이터 왜곡이 적음
    • 단점: “오늘의 랭킹”이라는 개념이 모호해짐

처음에는 데이터 왜곡이 적고, 최근 24시간 기준이 더 합리적으로 보였습니다.
하지만 콜드 스타트 문제를 실제로 고민하고 일간 랭킹의 범위를 명확히 구분하기 위해,
이번 구현에서는 캘린더 기준(00:00 시작)의 일간 랭킹을 선택했습니다.


2️⃣ 시간의 양자화와 이벤트 집계 단위

랭킹 시스템에서 중요한 질문 중 하나는 다음과 같습니다.

연속적으로 발생하는 이벤트를 어떤 시간 단위로 끊어 집계할 것인가?

  • 1분 단위 집계

    • 세밀한 계산 가능
    • 하지만 작은 변동이나 노이즈 이벤트까지 모두 반영되어 랭킹이 불안정해질 수 있음
  • 너무 큰 단위 집계

    • 랭킹은 안정적
    • 하지만 실제 의미 있는 급등·급락이 늦게 반영됨

이 두 극단 사이에서,
노이즈는 완만하게 흡수하면서도 의미 있는 변화는 반영할 수 있는 단위
10분 단위(time-bucket) 집계를 선택했습니다.

추후 10분 단위 데이터를 기반으로 시간 감쇠(Time Decay) 모델을 적용해
급상승 상품·브랜드 랭킹으로 확장할 수도 있습니다.

product_views:{productId}::{yyyyMMddHHmm}
product_likes:{productId}::{yyyyMMddHHmm}
product_sales:{productId}::{yyyyMMddHHmm}
  • TTL: 2일
  • 이벤트는 commerce-api에서 Kafka로 발행된 시각(event time) 기준으로 bucket에 귀속
  • 실시간 계산에 필요한 최소한의 데이터만 Redis에 유지

3️⃣ Redis → DB 영속화 전략

이벤트는 eventTime 기준으로 처리되기 때문에,

“12:00에 배치가 실행되었다고 해서,
12:00까지 발생한 모든 이벤트가 이미 집계되었다고 보장할 수는 없다.”

이 문제를 해결하기 위해 변화량(delta) 기반 영속화 방식을 선택했습니다.

처리 흐름

  1. 이벤트 발생 시

    • 조회수 / 좋아요 / 판매량을 Redis bucket에 증가
  2. 배치 실행 시

    • "product_likes:*" 와 같은 패턴으로 Redis SCAN 조회

    • redisTemplate.getAndDelete(key) 사용

      • 읽기와 삭제를 동시에 처리
      • 중복 집계 방지
  3. DB에는 delta update 방식으로 누적 저장

이 방식으로,

  • 집계 시점과 이벤트 발생 시점의 불일치 문제를 완화하고
  • 절대적인 시각 일치보다는 일관된 증가량 반영에 집중했습니다.

4️⃣ 실시간 상품별 일간 랭킹 Redis 집계

집계된 지표를 기반으로, 상품별 인기도 점수를 계산해
일자 단위 ZSET에 저장합니다.

ranking:all:{yyyyMMdd}
VIEW_WEIGHT  = 0.1
LIKE_WEIGHT  = 0.2
ORDER_WEIGHT = 0.6
TTL_DAYS     = 2

점수는 가중치 기반으로 계산되며,
ZSET의 score를 증가시키는 방식으로 반영합니다.

redisTemplate.opsForZSet()
    .incrementScore(rankingKey, productId.toString(), score);
  • ZSET 특성을 활용한 자동 정렬
  • 날짜별 키 분리로 랭킹 범위 명확화
  • TTL을 통한 자연스러운 데이터 정리

5️⃣ Ranking API 구성

  • 랭킹 목록 조회
reverseRange(rankingKey, start, end)
  • 상품별 랭크 조회
reverseRank(rankingKey, productId)
  • 상품별 점수 조회
score(rankingKey, productId)
  • 랭킹 멤버 수 조회
zCard(rankingKey)

6️⃣ 콜드 스타트 완화를 위한 Carry Over 전략

캘린더 기준 일간 랭킹의 가장 큰 문제는
00시 직후 랭킹 데이터가 거의 없다는 점입니다.

이를 완화하기 위해,
매일 23:50, 오늘의 랭킹 점수를 가중치 0.2로 감쇠하여
다음 날 랭킹 키에 미리 반영합니다.

  • 전날 인기 상품의 자연스러운 노출 유지
  • 00시 직후 랭킹 공백 완화
  • 일간 랭킹의 연속성 확보

7️⃣ 정리하며

랭킹 시스템을 구현하다 보면,
로그처럼 모든 데이터를 정확히 기록해야 할 것 같은 유혹을 받게 됩니다.

하지만 랭킹은 로그 시스템이 아니라,

“인기 측정기”가 아닌
“노출을 제어하는 시스템”

입니다.

  • 일부 누락은 허용될 수 있고
  • 절대적인 정확성보다
  • 일관성, 안정성, 예측 가능성이 더 중요합니다.

추가로,
10분 단위 집계 데이터를 시간 감쇠(Time Decay) 모델에 적용해
‘지금 뜨는 상품(Trending)’ 랭킹을 계산하는 방식으로 확장하여 롱테일 문제를 해결해 볼 수 있을 것입니다.

이번 설계는
“완벽한 집계”보다는
사용자가 신뢰할 수 있는 랭킹 경험을 목표로 한 선택들의 결과였습니다.

참고
https://dan.naver.com/25/sessions/681

profile
be_zion

0개의 댓글