랭킹 시스템, 단순 정렬이 아니라 '시간의 설계'다
TL;DR
랭킹 시스템의 본질은 정렬이 아니라 “어떤 시간 범위의 이벤트를, 어떤 가중치로 합산할 것인가라는 정책 결정이다. Redis ZSET은 그 정책을 서빙하는 도구일 뿐이다. 이 글에서는 이벤트 집계의 시간축 설계, 텀블링/슬라이딩 윈도우의 트레이드오프, 콜드 스타트와 Score Carry-Over의 진짜 의미, 그리고 실무에서 ZSET만으로는 부족한 이유까지를 다룬다.
0. 랭킹의 SOT(Source of Truth)는 이벤트다
랭킹 시스템을 처음 설계할 때 흔히 “어떤 DB에 저장하지?”부터 고민한다. Redis? Elasticsearch? RDB? 하지만 이 질문은 순서가 틀렸다.
랭킹은 특정 기간 동안 누적된 값을 가지고 줄 세우는 시스템이다. 그리고 그 “누적된 값”의 원천은 유저의 행동 이벤트다. 조회, 좋아요, 주문 — 이 이벤트들이 랭킹의 SOT(System of Record)이며, 이벤트를 어떻게 집계하고 저장하느냐에 따라 랭킹의 표현력과 정확성이 완전히 달라진다.
저장소 선택은 그 다음 문제다. 먼저 물어야 할 질문은 이것이다:
“우리는 어떤 시간 범위의 이벤트를, 어떤 기준으로 합산해서 보여줄 것인가?”
1. 시간축에 대한 감각 — 집계 단위가 랭킹의 표현력을 결정한다
이벤트를 집계하는 시간 단위는 곧 랭킹이 표현할 수 있는 최소 해상도다. 이것을 이해하지 못하면 “왜 우리 랭킹은 트렌드를 못 따라가지?”라는 질문에 답할 수 없다.
집계 단위별 표현력
| 집계 단위 | 최소 표현 가능 범위 | 특징 |
|---|---|---|
| 월 단위 | 1개월 | “최근 3주” 데이터를 보여줄 수 없다 |
| 주 단위 | 1주일 | “최근 3일” 데이터를 보여줄 수 없다 |
| 일 단위 | 1일 | 30일, 60일, 100일 랭킹 모두 표현 가능 |
| 시간 단위 | 1시간 | “최근 3시간 인기 상품” 표현 가능 |
일 단위로 집계하면, 하루가 하나의 버킷이 된다. 1년이면 365개의 버킷이고, 이 버킷들을 조합하면 7일 랭킹도, 30일 랭킹도, 90일 랭킹도 만들 수 있다.
시간 단위로 집계하면, 1시간이 하나의 버킷이다. “최근 1시간 급상승 상품”이라는 표현이 가능해진다. 하지만 1분 단위 버킷을 만들면? 하루에 1,440개, 이건 현실적으로 운영이 어렵다.
결국 집계 단위는 비즈니스 요구사항(BGC)에 따라 결정되며, 대부분의 이커머스는 일 단위로 시작하는 것이 합리적이다. 이번 설계에서도 일 단위를 기본으로 가져갔다.
데이터 규모 감각 잡기
집계 단위를 정했으면, 데이터 규모를 가늠해봐야 한다. 대략적인 계산을 해보자:
- DAU 1억 명(10^8)이라 가정
- 하루 86,400초 ≈ 약 10만 초로 라운딩
- 인당 평균 10개 이벤트 발생 → QPS ≈ 10,000
- 이벤트 하나당 약 20바이트
하루 데이터량: 20bytes × 10,000 QPS × 86,400초 ≈ 약 17GB
365일이면 약 6TB. 트래픽 피크를 3~6배 여유로 잡으면 연간 수십 TB. 이 규모에서는 RDB의 GROUP BY + ORDER BY가 왜 안 되는지 체감이 온다.
2. 왜 RDB의 ORDER BY로는 안 되는가
가장 먼저 떠오르는 접근은 당연히 SQL이다.
SELECT product_id, SUM(score) as total
FROM product_metrics
GROUP BY product_id
ORDER BY total DESC
LIMIT 20;
초기에는 동작한다. 상품 1,000개, 일일 이벤트 10,000건 수준이라면 문제없다. 하지만 이 쿼리가 어떤 맥락에서 호출되는지를 생각하면 이야기가 달라진다:
- 홈 메인 진입 시마다 호출 — DAU 10만이면 하루 수십만 회
- 상품 상세 페이지에서 “이 상품은 현재 N위” — 상품 수만큼 곱연산
- 카테고리별, 시간대별 랭킹까지 확장되면 쿼리 조합이 폭발
읽기 빈도가 압도적으로 높은 랭킹이라는 도메인 특성에서, 쓰기 시점에 정렬을 완료해두는 구조가 필요하다. 이것이 Redis ZSET을 선택한 핵심 이유다.
3. Redis ZSET — “삽입이 곧 정렬”이지만, 만능은 아니다
Redis Sorted Set은 (member, score) 쌍을 score 기준으로 항상 정렬된 상태로 유지한다. 삽입/수정 O(log N), Top-N 조회 O(log N + M).
ZINCRBY ranking:all:20260410 0.7 product:101 // 점수 누적
ZREVRANGE ranking:all:20260410 0 19 WITHSCORES // Top 20 조회
ZREVRANK ranking:all:20260410 product:101 // 특정 상품 순위
| 방식 | 장점 | 단점 | 적합 시나리오 |
|---|---|---|---|
DB ORDER BY |
정합성 보장 | 조회마다 집계 비용 | 초기/소규모 |
| 캐시 Map + 정렬 | 구현 단순 | 매 요청마다 O(N log N) | 중규모 |
| Redis ZSET | 삽입 시 정렬 완료 | 필터링 불가, 메모리 상주 | 대규모 트래픽 |
ZSET의 실무적 한계 — 필터링이 안 된다
여기서 솔직하게 짚고 넘어갈 것이 있다. ZSET은 정렬된 데이터를 제공하는 데는 탁월하지만, 그 안에서 조건 필터링을 할 수 없다.
예를 들어, 100만 개 상품이 ZSET에 있는데 “여성 의류 카테고리만” 보고 싶다면? ZSET에는 그런 기능이 없다. 전체를 꺼내서 애플리케이션에서 필터링해야 한다.
실무에서 랭킹이 단순한 “전체 Top-N”을 넘어서는 순간 — 카테고리별, 성별, 연령대별 랭킹이 필요해지는 순간 — Redis ZSET만으로는 부족해진다. 실제로 현업에서는 Elasticsearch, NoSQL, 혹은 전용 검색/추천 엔진을 함께 사용하는 경우가 많다.
그럼에도 ZSET을 선택한 이유는, 이번 설계의 스코프가 “전체 상품 일간 랭킹”이라는 단일 차원이고, 이 범위에서는 ZSET이 가장 심플하면서도 성능이 보장되는 선택이기 때문이다. 가장 중요한 것은 스케일이 늘어났을 때 교체할 수 있는 구조로 만드는 것이다.
4. 아키텍처 — 이벤트가 랭킹이 되기까지
[유저 행동]
│
▼
[commerce-api] ── 조회/좋아요/주문 이벤트 발행 ──▶ [Kafka Topic]
│
▼
[commerce-collector]
│ │
▼ ▼
product_metrics Redis ZSET
(R7 구축) (이번 주차)
│
▼
[commerce-api]
GET /rankings/top
GET /products/{id}/rank
collector가 이벤트를 소비하면서 두 저장소에 동시 반영한다. product_metrics는 장기 분석용 원본, Redis ZSET은 실시간 서빙용 뷰. 이 분리가 주는 이점:
- ZSET이 날아가도 product_metrics에서 재구축 가능 (복원력)
- API 서버는 Redis만 바라보면 됨 (성능 격리)
- 각 저장소는 자신의 도메인에 최적화 (관심사 분리)
실시간 vs 준실시간 — ETL 파이프라인의 두 가지 스타일
여기서 잠깐, 데이터 처리 방식에 대해 넓은 시야로 보자.
실시간 이벤트 처리: Kafka Consumer가 이벤트를 즉시 소비하고 Redis에 반영. 우리가 이번에 구현한 방식이다.
준실시간/배치 처리: Spark 같은 도구로 S3에 쌓인 로그를 주기적으로(5분, 10분) 읽어 집계. 무신사가 이 방식이라고 한다.
어떤 방식이 맞는지는 비즈니스 요구에 달렸다. 29cm처럼 완전 초실시간이 필요한 곳도 있고, 5분 갱신으로 충분한 곳도 있다. 다만 데이터 규모가 커지면 로그성 데이터를 RDB에 직접 넣으면 터질 수 있으므로, 오프라인 파이프라인(Spark 등)을 통한 처리가 필수가 된다.
Kafka vs Redis — 쓰기 처리량의 차이
한 가지 더 짚자면, 쓰기 처리량에서 Kafka가 Redis보다 월등히 높다. Kafka는 프로듀서가 데이터를 벌크로 모아 보내는 구조이기 때문이다. 만 건의 이벤트를 Redis에 하나씩 쏘는 것과, Kafka에 벌크로 한 번에 보내는 것은 차원이 다르다.
그래서 이벤트 수집 단계에서 Kafka를 거치고, Consumer에서 Redis에 쓰는 구조가 합리적인 것이다. Consumer 쪽에서도 배치 리스너를 통해 100~200건씩 모아 처리하면 Redis에 대한 네트워크 RTT를 크게 줄일 수 있다.
5. “인기”를 수치화하기 — 정규화와 가중치의 설계
왜 단순 합산이 안 되는가
“인기 있는 상품”이란 무엇인가? 조회수가 높으면? 주문수가 높으면?
문제는 각 지표의 스케일이 다르다는 것이다. 조회수는 수만 단위, 주문수는 수십 단위. 이걸 단순히 더하면 조회수가 전체 스코어를 잡아먹는다. 클릭 100과 조회 100만을 더하면, 클릭의 의미가 완전히 사라진다.
정규화 — 서로 다른 스케일을 같은 척도로
각 지표를 0~1 사이의 값으로 변환해야 비교와 합산이 의미를 갖는다.
Min-Max 정규화가 흔히 쓰이지만, 실무에서는 함정이 있다. 대부분의 상품 가격이 10만원 이하인데 2,400만원짜리 가구가 하나 있다면? Min-Max 기준으로 나머지 상품은 전부 0에 수렴한다. 낮은 가격대의 변별력이 완전히 사라지는 것이다.
이런 경우 Saturation 방식이 더 적합하다. Max 값을 몰라도 적용 가능하고, 극단적인 아웃라이어에 의해 나머지 데이터가 뭉개지지 않는다.
가중치 — 비즈니스 중요도의 수치화
정규화된 지표에 서비스 전략에 맞는 가중치를 곱한다:
Score(product) = W(view) × Norm(view)
+ W(like) × Norm(like)
+ W(order) × Norm(order)
| 시그널 | 가중치 | 판단 근거 |
|---|---|---|
| 조회 (view) | 0.1 | 발생 빈도가 압도적으로 높아 높은 가중치 시 전체 스코어 지배 |
| 좋아요 (like) | 0.2 | 관심의 표현이지만 구매 결정과는 거리가 있음 |
| 주문 (order) | 0.7 | 유저가 지갑을 열었다는 것은 가장 강력한 신호 |
총합을 1.0으로 맞춘 것은 의도적이다. 새로운 시그널(장바구니 담기, 공유, 리뷰 등)이 추가될 때 기존 가중치를 비례적으로 재분배하기 쉬운 구조를 유지하기 위해서다.
솔직히 이 가중치가 “정답”인지는 모른다. 실제 운영이라면 A/B 테스트로 CTR, CVR을 모니터링하며 튜닝해야 한다. 다만 “왜 이 비율인가”에 대한 논리적 근거를 갖추고 시작하는 것과, 감으로 때려 넣는 것은 완전히 다르다.
랭킹 스코어의 고도화 — 실무에서는 여기서 끝이 아니다
현업의 랭킹 스코어에는 훨씬 많은 요소가 들어간다:
- 유저 이벤트 (클릭, 조회, 구매 — 우리가 이번에 구현한 것)
- LLM 판단 스코어 (상품 품질, 이미지 매력도 등의 AI 평가)
- 객단가 부스팅 (매출 기여도 가중)
- 어뷰징 방지 로직 (비정상 클릭 패턴 필터링)
- 광고 비딩 스코어 (광고 시스템과 연동 시)
특히 광고 시스템에서는 단순히 돈을 많이 낸 순서가 아니라 타겟팅 적합성까지 고려한 비딩이 이루어진다. 남성 속옷 광고를 여성에게 보여주면 ROAS(Return On Ad Spend)가 나오지 않아 광고주가 이탈하기 때문이다. 랭킹과 광고 시스템은 결국 같은 뿌리에서 나온다.
6. 시간의 양자화 — 텀블링 윈도우와 슬라이딩 윈도우
누적만 하면 왜 문제인가
처음에는 모든 점수를 하나의 ZSET에 누적했다. 며칠 지나니 문제가 보였다: 초기에 대량 이벤트가 발생한 상품이 영원히 상위를 독점한다.
이것이 롱테일(Long Tail) 문제다. 이미 상위에 있는 상품은 더 많은 노출 → 더 많은 클릭과 구매 → 격차 확대. 신상품은 아무리 좋아도 이 벽을 넘기 어렵다.
해법은 시간을 양자화하는 것이다. 두 가지 방식이 있다.
텀블링 윈도우 (Tumbling Window)
특정 시간 단위로 딱 끊어서 집계하는 방식이다. 우리가 구현한 일별 키 전략이 이것이다.
ranking:all:20260410 // 4월 10일의 이벤트만 집계
ranking:all:20260411 // 4월 11일의 이벤트만 집계
장점: 구현이 심플하고 키 관리가 명확하다.
단점: 윈도우가 넘어가는 순간 데이터가 0이 된다 → 콜드 스타트 발생.
슬라이딩 윈도우 (Sliding Window)
데이터가 들어오고 뒤에서 빠지며, 최근 N시간/N일의 데이터가 항상 유지되는 방식이다. 프로메테우스 같은 모니터링 시스템이 이 방식을 쓴다.
[현재 시점]
◀──────── 최근 24시간 ────────▶
항상 24시간분의 데이터가 유지됨
장점: 콜드 스타트가 발생하지 않는다.
단점: 구현이 복잡하다. 슬라이딩 윈도우의 데이터는 라운딩되어 있어 정확한 데이터가 아닐 수 있으며, 확대해서 보면 평탄화된 그래프가 보인다.
실무에서의 선택
29cm에서는 1시간짜리 버킷을 사용해서 일간/주간/월간 실시간 급상승 랭킹을 만들었다고 한다. 1시간 버킷 24개를 합산하면 일간, 168개면 주간이 되는 구조다. 하지만 이 계산 로직은 상당히 복잡해지며, 이런 수준의 테크닉을 사용하는 커머스 회사는 드물다.
우리는 일 단위 텀블링 윈도우 + Score Carry-Over라는 조합을 선택했다. 구현 복잡도와 표현력 사이의 합리적인 트레이드오프다.
7. 키 설계 — TTL과 네이밍 컨벤션
ranking:{scope}:{yyyyMMdd}
TTL은 왜 2일인가
일간 키이므로 이론적으로 24시간이면 충분하지만, 48시간(2일)으로 설정했다:
- Score Carry-Over 시 전날 키를 참조해야 하므로, 전날 키가 살아 있어야 한다
- 운영 중 디버깅이나 보정이 필요할 때 여유분이 필요하다
- 시간 윈도우의 1.5~2배가 안정적인 TTL 전략이라는 것은 실무에서 반복적으로 확인한 패턴이다
scope를 넣은 이유
지금은 all만 있지만, 카테고리별(ranking:electronics:20260410), 지역별 랭킹이 추가될 때 키 구조를 변경하지 않아도 된다.
8. 콜드 스타트와 Score Carry-Over — 생각보다 깊은 주제
단순한 “0점 시작” 문제가 아니다
콜드 스타트 문제를 “자정에 점수가 0이 되니까 랭킹이 비어 보인다”로만 이해하면 절반만 아는 것이다.
Score Carry-Over를 하는 이유는 두 가지다:
첫째, 콜드 스타트 완화. 텀블링 윈도우 방식에서 새 날이 시작되면 모든 상품이 0점이다. 새벽 1시에 앱을 켠 유저는 텅 빈 랭킹을 본다. 어제 1위였던 상품도, 신규 상품도 같은 출발선이다.
둘째, 랭킹의 표현력 향상. 이것이 더 중요한 이유인데, 특히 패션 커머스처럼 트렌드 반영이 핵심인 도메인에서 그렇다.
주간 랭킹을 만든다고 생각해보자. 7일치 일간 버킷을 동일한 비중으로 합산하면, 6일 전의 데이터와 오늘의 데이터가 같은 무게를 갖는다. 한국의 4계절처럼 시즌이 빠르게 바뀌는 패션 커머스에서, 일주일 전 인기였던 봄 아우터와 오늘 급부상 중인 여름 린넨 셔츠가 같은 가중치라면? 최신 트렌드가 묻힌다.
Score Carry-Over에 감쇠 계수를 적용하면, 오래된 데이터일수록 영향력이 줄어들면서 자연스럽게 최신 트렌드가 부각된다. 이것이 단순 합산 대비 Carry-Over가 갖는 진짜 가치다.
ZUNIONSTORE로 구현하기
ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1 AGGREGATE SUM
ranking:all:20260410의 모든 멤버와 스코어를 가져온다- 각 스코어에 0.1(10%)을 곱한다
- 결과를
ranking:all:20260411에 저장한다
왜 10%인가
| Carry-Over 비율 | 효과 | 문제 |
|---|---|---|
| 50% 이상 | 어제 랭킹이 오늘을 지배 | 일별 키 분리의 의미 퇴색 |
| 10% | 출발 보너스 + 당일 역전 가능 | 균형점 |
| 1% 이하 | 콜드 스타트 완화 효과 미미 | 거의 0점 시작과 동일 |
[4월 10일 최종] [4월 11일 시작 (Carry-Over)]
product:101 → 1000점 → product:101 → 100점
product:202 → 500점 → product:202 → 50점
product:303 → 200점 → product:303 → 20점
오전 중으로 실제 이벤트가 쌓이면 Carry-Over 점수의 영향은 자연스럽게 희석된다.
만약 슬라이딩 윈도우였다면?
시간 단위 버킷을 24개 유지하는 슬라이딩 윈도우 방식이었다면 Carry-Over가 필요 없었을 수 있다. 항상 최근 24시간의 데이터가 존재하니까.
하지만 그 방식은 계산 로직이 복잡해지고, 정확히 24시간이 아닌 23시간 10분 같은 어중간한 데이터가 될 수 있다. 1분짜리 버킷을 만들면 하루에 1,440개 — 현실적이지 않다. 결국 라운딩이 들어가고, 이 라운딩이 랭킹의 대수 법칙에 큰 영향을 주지 않는다는 합의 하에 적절한 단위를 선택하게 된다.
개발에 “정답”이란 없다. 항상 다른 선택지가 있고, 트레이드오프가 있다.
실행 시점 — 스케줄러
Carry-Over는 매일 23:50에 실행하는 것이 이상적이다:
@Scheduled(cron = "0 50 23 * * *")
public void prepareNextDayRanking() {
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String tomorrow = LocalDate.now().plusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE);
String srcKey = "ranking:all:" + today;
String destKey = "ranking:all:" + tomorrow;
// ZUNIONSTORE를 통해 전날 점수의 10%를 시드
// TTL 2일 설정
redisTemplate.expire(destKey, Duration.ofDays(2));
}
9. 상품 삭제와 랭킹 정합성 — 이벤트 드리븐의 현실
실무에서 반드시 마주치는 문제가 있다: “랭킹에 있는 상품이 삭제되면?”
상품팀에서 상품을 삭제하거나 품절 처리했는데, 랭킹에는 여전히 노출된다. 유저가 클릭하면 404. 이건 심각한 UX 문제다.
이 문제를 해결하려면 상품 삭제 이벤트가 전사적으로 전파되어야 한다. 이벤트 드리븐 아키텍처가 필수적인 이유 중 하나다. 하지만 각 팀(랭킹, 검색, 추천, 전시)의 데이터 처리 타이밍이 다를 수 있어 일관성 유지가 어렵다.
규모가 커지면 이 문제를 전담하는 “전시팀”이 생긴다. 모든 데이터가 전시 레이어를 통해 노출되면 일관성을 유지할 수 있다.
한 가지 더 — 품절/삭제된 상품을 랭킹에서 즉시 제거할지, “판매 종료”로 표시할지는 개발자가 아니라 PO(Product Owner)의 결정이다. 개발자가 할 일은 어떤 결정이 내려와도 쉽게 변경할 수 있는 구조를 미리 만들어두는 것이다. 이런 변경 가능성을 예측하고 준비해두면 높은 평가를 받는다.
10. API 설계 — 랭킹을 어떻게 서빙할 것인가
Top-N 조회
GET /api/v1/rankings?date=20260410&size=20&page=1
ZREVRANGE로 Top-N을 가져온 뒤, 상품 ID 목록으로 상세 정보를 조회해서 합쳐 응답한다.
개별 상품 순위 조회
GET /api/v1/products/{id}/rank
ZREVRANK로 O(log N)에 조회. 랭킹 미진입 상품은 null 반환.
View와 Impression의 구분 — 이벤트 로그 설계의 디테일
랭킹에 쓰이는 “조회 이벤트”를 설계할 때 한 가지 더 고민해야 할 것이 있다.
상품 목록이 3열로 나올 때, 핸드폰 기종에 따라 마지막 열이 화면에 잘릴 수 있다. 이걸 “조회”로 칠 것인가? 이미지의 몇 퍼센트가 노출되어야 “봤다”고 칠 것인가?
이 기준을 Impression 비율이라 하며, 이 기준에 따라 조회 이벤트의 정의 자체가 달라진다. 단순해 보이지만, 이 기준이 랭킹 스코어의 정확도에 직접적으로 영향을 미친다.
11. 돌아보며 — 기술적 판단은 항상 트레이드오프다
이번에 내린 판단들의 기록
| 판단 | 선택 | 대안 | 근거 |
|---|---|---|---|
| 저장소 | Redis ZSET | ES, NoSQL | 단일 차원 전체 랭킹에서 가장 심플 |
| 시간 윈도우 | 텀블링 (일 단위) | 슬라이딩 (시간 단위) | 구현 복잡도와 표현력의 균형 |
| 콜드 스타트 | Carry-Over 10% | 슬라이딩 윈도우 | 텀블링 선택의 논리적 귀결 |
| 가중치 | view 0.1 / like 0.2 / order 0.7 | 균등 배분 | 비즈니스 임팩트 기반 차등 |
| 키 TTL | 2일 | 1일 | Carry-Over 참조 + 운영 여유 |
로직보다 중요한 것은 “로직을 쉽게 바꿀 수 있는 구조”
멘토링에서 가장 인상 깊었던 말이 있다:
“로직 질문이 많을수록, 로직 자체보다 로직을 쉽게 변경할 수 있는 테스트 가능한 구조로 아키텍처를 짜야 한다.”
가중치가 바뀔 수 있다. 집계 단위가 바뀔 수 있다. Carry-Over 비율이 바뀔 수 있다. 랭킹은 “정답이 없기에 계속 튜닝해 나가는” 시스템이다. 그래서 각 정책 값이 외부에서 주입 가능하고, 변경 시 테스트로 검증할 수 있는 구조가 코드의 정교함보다 중요하다.
랭킹은 “정적 vs 동적”의 줄다리기
랭킹은 정적이어야 한다는 관점이 있다 — 유저가 볼 때마다 순위가 바뀌면 신뢰를 잃는다. 반대로, 유저가 원하는 것에 따라 실시간으로 변해야 한다는 관점도 있다 — 지금 이 순간 핫한 것을 보여줘야 구매가 일어난다.
정답은 없다. 서비스의 성격, 유저의 기대, 비즈니스 목표에 따라 그 줄다리기의 균형점이 달라질 뿐이다.
이번 글은 “어떻게 만들었는가”보다 “왜 그렇게 판단했는가”에 집중했다. 코드는 6개월이면 레거시가 되지만, 판단의 근거는 다음 시스템을 설계할 때도 유효하다. 그래서 기록한다.