이커머스 프로젝트를 진행하면서 실시간 상품 랭킹을 설계하게 되었다.
사실 어지간한 이커머스에는 다 있는 기능이라 쉽게 끝날 거라 생각했지만… 역시나 고민할 부분이 정말 많았다.
왜 실시간 랭킹인가?
사용자가 상품을 조회하고, 좋아요를 누르고, 구매한다.
이때마다 바로바로 갱신되는 랭킹을 보여주고 싶었다.
기존에는 단순히 DB 테이블에 데이터를 쌓고 조회하는 방식이었다.
하지만 트래픽이 늘어난다고 생각하면 단순 집계 쿼리는 곧 심각한 병목으로 이어질 것 같았다.
따라서 실시간 반영 + 빠른 조회를 만족시킬 수 있는 Redis를 선택했다.
Redis ZSET
Redis의 Sorted Set (ZSET) 은 score 기반으로 자동 정렬되는 자료구조다.
랭킹도 점수를 기반으로 정렬하기 때문에 아주 적합하다고 생각했다.
ZSET 구조 특징
- (member, score) 쌍을 score 기준으로 정렬된 상태로 유지
- 삽입/수정: O(logN)
- Top-N 조회: O(N)
주요 연산
- ZADD key score member : score와 함께 member 저장 (중복 시 갱신)
- ZREVRANGE key 0 N WITHSCORES : Top-N 조회
- ZREVRANK key member : 특정 멤버의 순위 조회
- ZSCORE key member : 특정 멤버의 점수 조회
ZSET 사용 시 이벤트 발생 시 점수 누적은 이렇게 할 수 있었다.
# 조회, 좋아요, 구매 이벤트
ZINCRBY ranking:20250912 1 product:1
조회는 다음처럼 할 수 있었다.
ZRANGE ranking:20250912 0 9 REV WITHSCORES
이렇게 작성할 경우 TOP 10 랭킹 목록을 조회할 수 있었다.
여기까지만 보면 끝난 것 같았지만..?
시간의 양자화: 며칠 전에 본 상품이 왜 오늘도 1등이지?
실시간이라고 해서 무작정 데이터를 누적하면 오래된 상품만 상위에 고정되는 롱테일 현상이 발생한다.
이를 막기 위해 날짜별로 key를 분리했다.
ranking:product:all:{yyyyMMdd}
- ranking:20250911 → 2025년 9월 11일 랭킹
- ranking:20250912 → 2025년 9월 12일 랭킹
또한 TTL은 2일로 설정했다.
너무 길게 가져가면 메모리 낭비가 심해지고, 너무 짧으면 비교/추세 분석이 어려워지기 때문에 2일 정도가 균형점이라고 생각해 설정했다.
📌 롱테일 현상
특정 상품이 한번 상위에 오르면 데이터가 무한히 누적되면서 계속 상위에 남는 문제.
가중치 설계: 조회만 많은데 1등이라고요?
처음엔 단순히 좋아요=1, 조회=1, 주문=1 로 점수를 올렸다.
그랬더니 조회수만 많은 상품들이 상위 랭킹을 차지했다.
좋아요나 조회도 중요하지만, 진짜 인기 상품은 결국 실제로 주문된 상품이라고 생각했다.
그래서 이벤트별로 가중치를 조정했다.
조회 = 1
좋아요 = 3
구매 = 6
하지만 점수가 지나치게 커지면 문제가 또 발생했다.
Redis ZSET의 score는 double 타입이기 때문에 값이 과도하게 커지면 정확성 손실 문제가 생길 수 있고 작은 변화가 무의미해질 수 있었다.
최종적으로는 좋아요:조회:주문 = 0.3 : 0.1 : 0.6 비율을 설정해 단순 노출이 아니라 실제 반응이 좋은 상품이 상위 랭킹에 오르도록 조정했다.
대량 데이터 처리: 초당 수만 건 이걸 Redis가 다요?
초기에는 이벤트가 들어올 때마다 곧바로 ZINCRBY를 호출했다.
하지만 운영을 가정하면 초당 수천~수만 건이 몰릴 수 있고, 그럴 경우 Redis CPU와 네트워크 모두에 심각한 부하가 생긴다.
그래서 Consumer 로직을 개편했다.
@KafkaListener(...)
public void consume(List<ConsumerRecord<String, KafkaMessage<?>>> records, Acknowledgment ack) {
Map<Long, ProductMetricsCount> aggregate = new HashMap<>();
for (ConsumerRecord<String, KafkaMessage<?>> record : records) {
KafkaMessage<?> message = record.value();
Map<String, Object> payload = (Map<String, Object>) message.payload();
Long productId = getValue(payload, "productId", Long.class);
Integer quantity = getValue(payload, "quantity", Integer.class);
aggregate.computeIfAbsent(productId, id -> new ProductMetricsCount())
.apply(message.type(), quantity != null ? quantity : 0);
}
if (!aggregate.isEmpty()) {
productMetricsService.bulkUpdate(aggregate);
productRankingCacheService.updateRanking(aggregate);
}
ack.acknowledge();
}
개선 포인트
- Consumer가 batch 메시지를 읽고
- productId 단위로 점수 집계
- 최종 집계 결과만 Redis에 반영
결과적으로 Redis 호출 횟수가 크게 줄어 부하도 완화될 수 있었다.
DB upsert 처리도 같은 전략을 적용했다.
콜드스타트: 랭킹 화면이 비어있다구요?
코드를 다 짜고 테스트를 돌렸는데 조회 API가 아무것도 반환하지 않았다.
당연함. 초기 데이터가 없었기 때문.
더미 데이터를 넣고 끝내려 했지만..?
문득 운영 환경에서는 날짜가 바뀌는 순간에도 같은 문제가 발생할 수 있다는 걸 깨달았다.
자정 이후 첫 사용자가 빈 화면을 마주한다면, 단순히 기능이 안 돌아가는 게 아니라 랭킹 자체에 대한 신뢰를 잃을 수 있다고 생각했다.
일단 대체해서 보여줄 데이터가 있으면 좋겠지? 그리고 아예 미리 데이터를 채워둘 수 있다면 더 좋을 것 같다.
이런 고민을 통해 두 가지 방법을 생각했다.
Fallback
전날 데이터도 유저의 실제 행동이므로 기본 신뢰도는 확보할 수 있다.
오늘 데이터가 비어 있으면 전날 데이터를 대신 보여준다.
Warm-up
전날 23:30에 전일 TOP100을 뽑아 다음날 key에 미리 적재한다.
단, 그대로 적재하면 또 롱테일 문제가 생기므로 일정 비율로 희석해 반영했다.
덕분에 날짜가 바뀌어도 유저는 빈 화면이 아니라 어느 정도 신뢰성 있는 순위를 보게 되었다.
마무리
단순히 상품을 정렬해서 보여주기만 하면 끝날 줄 알았던 랭킹 기능이 이렇게 많은 고민을 안겨줄 줄은 몰랐다.
Redis ZSET이라는 딱 맞는 도구가 있었어도 누적 데이터 관리, 대용량 처리, 초기 데이터 문제까지 신경 써야 할 게 정말 많았다.
기술은 쓰면 쓸수록 고려해야 할 변수가 늘어난다는 말을 매주 체감하고 있는 것 같다.
이번 작업에서 특히 많이 고민한 건 진짜 실시간이란 무엇인가? 였다.
어디까지를 실시간이라고 할 수 있을까?
몇 초, 몇 분 단위의 지연은 허용해도 될까?
생각보다 그 경계는 명확하지 않았고 사실 아직 나만의 답도 찾진 못한 것 같다.
결국 사용자가 사용할 서비스니까 사용자가 느끼기에 충분히 빠르고 자연스러운 수준에서 답을 찾아야 하지 않을까..
좋은 기능이란 단순히 더 최신 기술, 더 좋은 기술을 쓰는 것보단 사용자 입장에서 자연스럽고 신뢰할 수 있는 기능이지 않을까 생각해 보게 되었다.
어쩌면 기능과 기술을 설계하는 건 사용자 경험을 설계한다고 봐도 되지 않을까?
아직 답은 다 못 찾았지만 이런 시행착오들이 쌓여서 내가 만드는 서비스를 조금은 더 좋아지게 만들 수 있을 것 같다.
'Study > Architecture' 카테고리의 다른 글
| Spring Batch로 랭킹 집계 확장하기 (0) | 2025.09.19 |
|---|---|
| 내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로 (0) | 2025.09.05 |
| 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까? (2) | 2025.08.29 |
| 장애 대응 시스템 구축하기 (0) | 2025.08.22 |
| 캐시 구조 개선 (0) | 2025.08.22 |