상품 랭킹 시스템을 일간 / 주간 / 월간 세 가지 주기로 나눠 구현했다.
핵심은 "조회 주기에 따라 적절한 저장소를 고르는 것" 이었다.
| 주기 | 저장소 | 갱신 방식 |
|---|---|---|
| 일간(실시간) | Redis ZSET | Kafka 이벤트 기반 ZINCRBY |
| 주간/월간 | RDB Materialized View | Spring Batch 집계 |
랭킹은 결국 "점수 기반 정렬 + 순위 조회" 문제다.
ZSET은 내부적으로 skip list + hash를 써서 score 정렬 삽입/조회가 O(log N)으로 보장되고,
ZINCRBY는 원자적이라 동시성 고민이 크게 줄어든다.
이벤트마다 중요도가 다르기 때문에 단순 카운트가 아니라 가중치를 뒀다.
0.10.20.7 × log10(quantity + 1)주문에 log10을 씌운 건, 대량 주문 1건이 모든 지표를 압도하는 것을 막기 위함이다.
이걸 안 넣으면 특정 상품이 랭킹을 독점해서 "랭킹"의 의미가 사라진다.
ranking:20260418 ← 날짜별 ZSET
일별로 키를 쪼개면 "오늘의 랭킹"만 계산/조회하면 되고,
TTL만 걸어두면 과거 데이터 정리도 자동화된다.
OrderPlacedEvent → Kafka → OrderEventConsumer → ZINCRBY
CatalogViewEvent → Kafka → CatalogEventConsumer → ZINCRBY
주문/조회 API 응답 속도가 랭킹 계산에 영향받지 않도록 Kafka로 분리했다.
API는 이벤트만 던지고 끝. 랭킹은 streamer 모듈이 따로 받아서 반영한다.
처음엔 "ZSET 키를 ranking:week:2026W16처럼 주 단위로 만들면 되지 않나?" 생각했다.
근데 몇 가지 문제가 있다.
그래서 일간은 Redis, 주간/월간은 MV(Materialized View) + Spring Batch 조합으로 갔다.
ProductMetricsDaily (원천)
↓ Reader (chunk 단위로 읽기)
↓ Processor (점수 계산)
↓ Writer (MvProductRankWeekly에 bulk insert)
Row-by-row가 아니라 청크 단위로 처리해서 처리량 확보.
이번에 가장 크게 배운 패턴.
1. 새 버전으로 MV 테이블에 write (version = N+1)
2. ActivateVersionTasklet 실행 (active_version = N+1)
3. ClearOldVersionTasklet 실행 (version = N 삭제)
배치 도는 중에도 API는 이전 버전(N)을 읽기 때문에 조회 중단이 없다.
활성 버전을 원자적으로 스왑하는 순간에만 새 데이터가 노출된다.
"blue-green 배포를 데이터에 적용한 느낌" 이 딱 맞는 비유였다.
"저장소는 접근 패턴이 결정한다."
실시간 증분 + 빠른 순위 조회 → Redis ZSET.
집계/히스토리/복잡 쿼리 → RDB + 배치.
둘을 하나로 통일하려고 하지 말고, 역할에 맞는 도구를 각자 쓰게 두는 것이 이번 주의 가장 큰 교훈이었다.
