본문 바로가기

루퍼스/라이팅

[Spring/Redis] 이커머스 실시간 인기순 랭킹 시스템 구현기 (feat. ZSET)

TL;DR
- Redis ZSET으로 일별 인기 상품 랭킹을 만들면서 내린 결정들을 정리했다.
- 가중치 설계, 어뷰징 방어, DB-Redis 부분 실패 처리, 콜드 스타트 완화를 주요 주제로 다뤘다.
- 처음에 놓쳤던 부분을 코드 짜면서 다시 발견하고 고친 경험을 글의 중심에 두었다.

 

들어가며

이번주 이커머스 과제에서 "오늘의 인기 상품" 랭킹을 만들었다. Redis ZSET으로 점수를 쌓는 건 금방이었는데, 점수 기준이나 취소 처리 같은 걸 정하는 데서 고민이 많았다.

 

전체 아키텍처

[commerce-api]
     유저 행동 발생 (조회, 좋아요, 주문)
     → ApplicationEvent → Kafka

[commerce-streamer]
     Kafka 이벤트 소비
     → product_metrics DB 집계 (기존)
     → Redis ZSET 랭킹 점수 갱신 (신규)

[commerce-api]
     GET /api/v1/rankings         → ZREVRANGE (Top-N)
     GET /api/v1/products/{id}    → ZREVRANK (개별 순위)

 

commerce-api가 이벤트를 발행하고, commerce-streamer가 소비해서 ZSET에 점수를 누적한다. 같은 API 서버가 ZSET을 읽어서 랭킹을 제공한다.

 

가중치 설계 - "인기" 를 숫자로 정의하기

"인기 상품" 이란 무엇인가부터 정해야 했다.

 

조회 수가 많은 상품? 좋아요가 많은 상품? 주문이 많은 상품? 

세 지표 모두 "인기"의 일부이지만, 스케일이 완전히 다르다. 조회는 하루 수만 건이지만 주문은 수백 건일 수 있다. 

단순 합산하면 조회 수가 점수 대부분을 차지한다.

 

그래서 가중치를 도입했다.

Score(p) = W(view) * Count(view) + W(like) * Count(like) + W(order) * Count(order)

 

이벤트  가중치 근거
조회 (view) 0.1 가장 빈번. 낮은 가중치로 점수 쏠림 방지
좋아요 (like) 0.2 관심 시그널이지만 구매 결정보다 약함
주문 (order) 0.7 구매 결정 = 가장 강한 인기 시그널

 

이 총점식은 ZINCRBY로 자연스럽게 구현된다. 이벤트가 들어올 때마다 해당 가중치만큼 ZINCRBY하면, 결과적으로 총점식과 동일한 점수가 누적된다.

상품 A: 조회 3번 + 좋아요 1번 + 주문 1건
→ ZINCRBY +0.1 (x3) + ZINCRBY +0.2 (x1) + ZINCRBY +0.7 (x1)
→ 총 점수: 1.2

 

별도로 count를 저장하고 곱셈할 필요 없이, ZINCRBY 한 줄이면 된다.

 

수량을 곱하면 안 되는 이유

처음 구현에서 주문 점수를 이렇게 계산했다

case "ORDER_PLACED" -> WEIGHT_ORDER * quantity;  // 0.7 * 수량

 

합리적으로 보였다. 10개를 주문한 사람이 1개를 주문한 사람보다 더 큰 인기 시그널을 준다고 생각했다.

하지만 이 방식은 악용될 수 있다.

100원짜리 상품을 수량 10,000개로 주문하면?

0.7 * 10,000 = 7,000점

 

한 번의 주문으로 상위권에 올라갈 수 있다. 판매자가 자기 상품의 순위를 올리기 위해 저가 대량 주문을 하면 랭킹이 무력화된다.

다시 생각해보면, 인기 상품 랭킹에서 중요한 건 "1명이 100개를 산 것"이 아니라 "100명이 각각 1개씩 산 것"이다.

 

그래서 수량과 무관하게 주문 1건당 고정 점수를 주는 방향으로 수정을 했다.

case "ORDER_PLACED" -> WEIGHT_ORDER;   // 0.7 (건당 고정)
case "ORDER_CANCELLED" -> -WEIGHT_ORDER; // -0.7 (동일 가중치 차감)

만약 "매출 기여도" 기반 랭킹이었다면 금액을 반영하는 게 자연스러웠을 것 같다. 그 경우에는 log1p(price * quantity) 같은 정규화로 고가 상품 1건이 저가 상품 수백 건을 압도하지 않도록 조정이 필요할 것이다. 결국 어떤 랭킹을 만들고 싶었는지에 따라 점수 계산이 달라진다는 걸 느꼈다.

 

취소를 무시하면 생기는 일

수량 문제를 수정한 후, 취소 이벤트 처리를 고민했다.

"인기 상품 랭킹이니까, 한번 관심을 받았다는 사실 자체가 시그널 아닌가? 취소해도 점수를 깎지 말자."

 

하지만 취소 시 점수를 차감하지 않으면...

주문(+0.7) → 취소(0) → 주문(+0.7) → 취소(0) → 주문(+0.7) → ...

 

비용 없이 점수를 계속 올릴 수 있다. 주문→즉시 취소를 반복하면 취소 수수료 없이 랭킹을 조작할 수 있게 된다.

결국 동일 가중치로 차감하는 쪽으로 돌아갔다. 점수를 계속 올릴 수 있는 구조 자체가 남아 있으면, "관심 시그널"이라는 명분이 별로 의미가 없을 것 같았다.

case "ORDER_PLACED"   -> WEIGHT_ORDER;    // +0.7
case "ORDER_CANCELLED" -> -WEIGHT_ORDER;  // -0.7 (동일 가중치 차감)

 

DB는 성공했는데 Redis는 실패하면?

Kafka Consumer가 이벤트를 처리하는 흐름은 이렇다

@Transactional
public void process(String eventId, String eventType, Long productId, ...) {
    // 1. 멱등성 체크 (DB)
    if (eventHandledRepository.existsByTopicAndEventId(topic, eventId)) {
        return;  // 이미 처리됨
    }

    // 2. 처리 완료 기록 (DB)
    eventHandledRepository.save(EventHandled.create(topic, eventId));

    // 3. metrics 갱신 (DB)
    productMetricsRepository.upsertViewCount(productId, 1);

    // 4. 랭킹 점수 갱신 (Redis) ← 여기가 문제
    rankingScoreService.updateScore(eventType, productId, quantity);
}

 

@Transactional은 DB를 보호하지만, Redis는 DB 트랜잭션의 영향을 받지 않는다. 

같은 메서드 안에서 호출되더라도 Redis 호출은 DB 커밋/롤백과 무관하게 독립적으로 실행된다.

 

만약 1~3번은 성공하고 4번에서 Redis 연결 오류가 발생하면?

  • DB: 이벤트가 "처리됨"으로 기록 (커밋됨)
  • Redis: 랭킹 점수 미반영
  • 재시도: 멱등성 체크에 걸려서 영원히 재처리 불가

DB의 멱등성 체크가 오히려 Redis 재시도를 막는 셈이다.

이 문제에 대해 두 가지 선택지가 있다

선택지 장점 단점
Redis 실패 시 DB도 롤백 정합성 완벽 Redis 장애가 전체 이벤트 처리를 멈춤
Redis 실패를 무시 (best-effort) DB 처리는 항상 성공 랭킹에 일부 유실 가능

 

best-effort를 선택했다. 랭킹은 근사치로도 충분히 유의미하고, Redis 장애가 DB 집계까지 멈추는 건 과도한 결합이라고 생각했다.

이 판단을 코드에 반영하면, updateScore() 안에서 Redis 예외를 잡아서 로그만 남기고 삼키는 형태가 된다. 

예외가 밖으로 전파되지 않으므로 DB 트랜잭션은 정상 커밋되고, 랭킹 점수만 유실된다.

public void updateScore(String eventType, Long productId, int quantity) {
    double score = calculateScore(eventType, quantity);
    if (score == 0.0) return;

    try {
        String key = todayKey();
        productRankingRepository.incrementScore(key, productId, score);
        productRankingRepository.setTtlIfAbsent(key, TTL_SECONDS);
    } catch (Exception e) {
        log.warn("랭킹 점수 갱신 실패 (best-effort) - productId: {}, eventType: {}",
                 productId, eventType, e);
    }
}

 

매일 0시에 랭킹이 비어버린다면?

일별 키 전략(ranking:all:{yyyyMMdd})을 사용하면 자정에 새 키가 시작되기 때문에, 새로운 이벤트가 쌓이기 전까지는 랭킹이 비어 있게 된다. 이것이 바로 텀블링 윈도우의 '콜드 스타트' 문제다. 이를 방지하기 위해 슬라이딩 윈도우를 쓰면 콜드 스타트 자체는 없앨 수 있지만,

시간대별로 여러 개의 ZSET을 관리하고 ZUNIONSTORE로 합산해야 하는 등 구현 복잡도가 크게 높아진다.

따라서 기존의 텀블링 윈도우 방식에 'carry-over(이월)'를 더하는 것이 구현 비용 대비 효과가 가장 좋다고 판단했다.

 

Score Carry-Over

 

전날 점수의 10%를 다음 날 키에 미리 복사하는 방식이다.

ZUNIONSTORE ranking:all:20260407 1 ranking:all:20260406 WEIGHTS 0.1

 

이렇게 하면 다음 날 시작 시 전날 인기 상품이 낮은 점수로 깔려 있고, 오늘의 이벤트가 ZINCRBY로 누적되면서 자연스럽게 역전한다.

 

Carry-Over를 언제 실행해야 하는가

 

처음에는 스케줄러를 23:50에 실행했다 "오늘 키 → 내일 키"로 복사.

이 설계에 두 가지 문제가 있었다.

 

시간 경계 레이스

23:50에 carry-over가 완료된 후, 23:50~23:59:59 사이에 들어온 이벤트는 오늘 키에만 반영된다. 

내일 키에는 carry-over가 이미 끝났으므로 이 이벤트들이 누락된다.

 

멱등성 없음

서버가 재시작되면 스케줄러가 다시 실행될 수 있다. 

ZUNIONSTORE는 dest 키를 덮어쓰는 연산이라, 이미 오늘 이벤트가 쌓인 내일 키를 날릴 수 있다.

 

두 문제를 함께 해결하기 위해 00:05에 "어제 → 오늘" 방향으로 전환했다.

@Scheduled(cron = "0 5 0 * * *", zone = "Asia/Seoul")
public void carryOver() {
    String yesterdayKey = RANKING_KEY_PREFIX + yesterday.format(DATE_FORMAT);
    String todayKey = RANKING_KEY_PREFIX + today.format(DATE_FORMAT);

    if (!productRankingRepository.exists(yesterdayKey)) {
        return;  // 어제 데이터 없음
    }
    if (productRankingRepository.exists(todayKey)) {
        return;  // 이미 실행됨 (멱등성)
    }

    productRankingRepository.unionStoreWithWeight(todayKey, yesterdayKey, 0.1);
    productRankingRepository.setTtlIfAbsent(todayKey, TTL_SECONDS);
}

 

00:05 실행이므로 어제의 모든 이벤트가 반영된 후 carry-over가 동작한다. 오늘 키가 이미 존재하면 생략한다.

 

그런데 이 멱등성 체크에도 문제가 있었다.

 

00:00~00:05 사이에 이벤트가 먼저 도착하면 ZINCRBY로 오늘 키가 생성된다. 그러면 00:05에 carry-over가 실행될 때 exists(todayKey)가 true이므로 carry-over가 생략된다. 어제 인기 상품의 10% 시드 점수가 반영되지 않는 것이다.

 

원인은 exists(todayKey)가 "carry-over가 이미 실행되었는가"와 "이벤트가 먼저 도착했는가"를 구분하지 못한다는 점이다.

 

여기에 더해 ZUNIONSTORE 자체의 특성도 문제였다. ZUNIONSTORE는 dest 키를 덮어쓰는 연산이라, 단순히 멱등성 체크를 제거하면 이미 쌓인 오늘 이벤트 점수가 날아간다.

 

항목 변경 전 변경 후
멱등성 체크 exists(todayKey) 별도 플래그 키 (ranking:carryover:done:{date})
멱등성 방식 exists 체크 후 마킹 (2단계) SETNX로 체크와 마킹을 원자적으로 처리
ZUNIONSTORE dest=오늘, src=어제 dest=오늘, src=오늘(weight 1.0) + 어제(weight 0.1)

 

@Scheduled(cron = "0 5 0 * * *", zone = "Asia/Seoul")
public void carryOver() {
    String yesterdayKey = RANKING_KEY_PREFIX + yesterday.format(DATE_FORMAT);
    String todayKey = RANKING_KEY_PREFIX + today.format(DATE_FORMAT);
    String flagKey = CARRY_OVER_FLAG_PREFIX + today.format(DATE_FORMAT);

    if (!productRankingRepository.exists(yesterdayKey)) {
        return;  // 어제 데이터 없음
    }

    // SETNX로 플래그 마킹 — 이미 존재하면 false 반환 → 다른 인스턴스가 이미 처리
    if (!productRankingRepository.markIfAbsent(flagKey, FLAG_TTL_SECONDS)) {
        return;  // 이미 carry-over 실행됨
    }

    // 오늘 기존 점수(weight 1.0) + 어제 점수(weight 0.1) 합산 — 기존 이벤트 점수 보존
    productRankingRepository.unionStoreWithWeights(
        todayKey, 1.0,
        yesterdayKey, 0.1
    );
    productRankingRepository.setTtlIfAbsent(todayKey, TTL_SECONDS);
}

 

멱등성 체크는 exists + setFlag 두 단계가 아니라 markIfAbsent 한 번으로 처리한다.

SETNX는 값이 없을 때만 설정하고 true를 반환하기 때문에, 연산 과정에서 다른 인스턴스가 끼어들 여지가 없다.

이를 통해 다중 인스턴스 환경에서도 carry-over가 정확히 한 번만 실행되도록 보장한다.

결과적으로 00:05 이전에 새로운 이벤트가 도착하더라도 기존 점수가 보존되며, 이중 실행 또한 플래그를 통해 차단된다.

 

마치며

 

이번 과제로 Redis ZSET을 처음 직접 써보면서 랭킹 시스템을 만들어봤다. 처음에는 "ZINCRBY로 점수 쌓고 ZREVRANGE로 꺼내면 되는 거 아닌가?" 정도로 생각했는데, 막상 구현을 진행하다 보니 그 바깥에서 결정해야 할 것들이 훨씬 많았다.

이번에 내린 결정들을 돌아보면

  • 가중치 설계 (view 0.1 / like 0.2 / order 0.7)
  • 주문 점수를 수량 무관 건당 고정으로 변경 (어뷰징 방어)
  • 취소 시 동일 가중치 차감 (펌핑 방지)
  • DB와 Redis의 부분 실패는 best-effort로 처리
  • 콜드 스타트는 carry-over로 완화, 스케줄러 시점/멱등성까지 보강

이 중 몇 가지는 처음부터 의도한 게 아니라, 과제를 계속 진행하며 다시 들여다보다가 문제점을 발견하고 고친 것들이었다. 특히 수량 곱셈으로 랭킹 조작이 가능하다는 점이나, 취소를 무시하면 무한 펌핑이 가능해진다는 점은 직접 부딪히지 않았다면 그냥 지나쳤을 것 같다.

 

이번 랭킹 시스템 구현을 통해 한번 만들어두면 끝나는 게 아니라 어뷰징·실패·콜드 스타트 같은 시나리오를 의식하면서 계속 다듬어야 하는 시스템이라는 감각을 얻을 수 있었다. 다음에 비슷한 걸 다시 만든다면, 어떤 부분을 미리 고민하고 시작해야 할지 조금은 알 것 같다.