WIL (Redis ZSET 기반 실시간 랭킹 시스템)

KwonMoYang·2026년 4월 12일



새로 배운 것

이번 주 과제는 "상품 인기 랭킹을 실시간으로 보여줘라"였다. 처음에는 간단할 줄 알았다. product_metrics 테이블에 조회수, 좋아요, 주문수가 이미 쌓이고 있으니까 ORDER BY 한 방이면 되지 않나? 하지만 "인기"를 정의하려는 순간부터 생각보다 복잡한 설계 판단이 시작됐다.

"인기"는 하나가 아니었다

29CM의 BEST 탭을 F12로 열어봤다. 인기순(POPULARITY)과 판매순(SALES)이 분리되어 있었다. 처음에는 "왜 하나로 안 합치지?"라고 생각했는데, 직접 실험해보니 이유를 알겠더라.

20개 더미 상품에 4,069건의 이벤트를 넣고, 동일한 가중치(View 0.2, Like 0.3, Order 0.5)로 Score 계산 방식만 바꿔봤다.

Score 방식1위의미
LINEAR (price × quantity × weight)르메르 크로와상 백 (289만원)매출 기여도 순위
LOG (log(price × quantity) × weight)뉴발란스 993 (34건 주문)구매 인기 순위
COUNT (1 × weight)탬버린즈 퍼퓸 (조회 636건)관심도/트렌드 순위

같은 데이터, 같은 가중치인데 1위가 세 번 바뀌었다. 특히 LINEAR 방식에서 289만원짜리 르메르 백이 주문 점수 비중 99.99%로 모든 걸 압도하는 걸 보고, 가중치를 아무리 조정해도 스케일 자체가 다르면 의미가 없다는 걸 체감했다. 289만 × 0.1 = 289,000점 vs 조회 1건 × 0.5 = 0.5점. 이건 가중치의 문제가 아니라 스케일의 문제였다.

log 정규화 — 금액 시그널은 살리되 스케일은 눌러주기

log10(price × amount)를 적용하면 289만원은 log10(2,890,000) = 14.9, 19.8만원은 log10(198,000) = 12.2가 된다. 15배 차이가 1.2배로 압축된다. 이러면 금액의 시그널은 남아있되, 주문 건수가 순위를 결정하게 된다.

학습 Q&A에서 "1000원짜리 100개 산 거랑 100만원짜리 1개 산 거면 1000원짜리가 더 인기있다고 볼 수 있잖아"라고 대답했는데, 이게 정확히 log 정규화가 해주는 일이었다. 금액을 아예 무시하진 않지만, 건수가 더 중요한 시그널이 되도록 스케일을 맞춰준다.

public double orderScore(long price, int amount) {
    long totalAmount = price * amount;
    if (totalAmount <= 0) return 0.0;
    return ORDER_WEIGHT * Math.log10(totalAmount);
}

RDB ORDER BY가 안 되는 진짜 이유

"그냥 DB에 total_score 컬럼 만들고 인덱스 걸면 되지 않나?"라는 질문에 처음에는 "실시간성이 떨어지니까"라고만 대답했다. 하지만 Q&A를 하면서 진짜 이유를 하나씩 파고들었다.

  1. 가중치 합산은 계산된 값 → 인덱스 불가 (view * 0.1 + like * 0.2 + sale * 0.7)
  2. 별도 컬럼 저장 → 매 이벤트마다 UPDATE → 행 락 경쟁
  3. 읽기/쓰기 동시 발생 — 홈 메인급 트래픽에서 UPDATE + SELECT가 동시에 일어나면 DB 병목

Redis ZSET은 ZINCRBY로 점수 갱신과 Skip List 재정렬이 O(log N)에 동시 처리된다. 단일 스레드 모델이라 락 자체가 없다. 8주차에 대기열로 Sorted Set을 썼을 때는 "순서 보장"에 집중했는데, 이번에는 "실시간 점수 갱신 + 정렬"이라는 전혀 다른 용도로 같은 자료구조를 쓰면서, ZSET이 정말 범용적이구나 하는 걸 느꼈다.

내가 했던 고민들

"시간의 양자화" — 어제의 인기 상품이 영원히 1위인 문제

ranking:all 하나의 키에 계속 누적하면 6개월간 누적 50,000점인 상품이 오늘 바이럴 탄 상품(500점)을 절대 이길 수 없다. 이게 롱테일 문제다. 해결은 키를 시간 단위로 분리하는 것 — ranking:all:{yyyyMMdd}.

여기서 TTL을 24시간이 아닌 48시간으로 잡은 이유가 처음에는 와닿지 않았다. 과제를 더 읽어보니 두 가지였다:

  • 어제 랭킹을 조회할 수 있어야 한다
  • 콜드 스타트 시 전날 키를 carry-over 원본으로 써야 한다

콜드 스타트가 흥미로웠다. 자정이 지나면 새 키 ranking:all:20260411은 비어있다. 새벽 1시에 "오늘의 인기상품"을 요청하면 아무것도 안 뜬다. 그래서 23:50에 스케줄러로 전날 점수의 10%만 새 키에 복사해둔다.

ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1

왜 자정이 아니라 23:50인가? ZUNIONSTORE는 목적지 키를 통째로 덮어쓰기 때문이다. 만약 0:00:00에 실행하면, 자정 직후 유입된 이벤트 점수가 덮어씌워진다. 23:50에는 아직 내일 키에 이벤트가 없으니까 안전하게 carry-over만 넣을 수 있다.

carry-over 비율도 고민했다. 90%면 어제랑 거의 같은 랭킹이 나오고(롱테일 재발), 0%면 새벽에 빈 화면이다. 10%는 새벽 화면을 채워주면서도 오전 중으로 오늘 활동이 역전 가능한 수준이라는 판단이었다. 솔직히 이건 정밀한 계산이 아니라 발제 자료의 권장(0.05~0.1)을 따른 거라, 실험으로 검증해봐야 할 부분이다.

DB-Redis 정합성 — 의도적으로 포기하는 정확도

랭킹 점수 갱신은 Kafka Consumer 안에서 이뤄진다. processRecord()에 @Transactional이 걸려있는데, Redis ZINCRBY는 이 트랜잭션에 포함되지 않는다. DB 저장은 성공했는데 Redis가 실패하면?

학습 Q&A에서 처음에는 "치명적이지"라고 답했다. 하지만 "결제 금액은 1원이라도 틀리면 문제인데, 랭킹도 그 수준인가?"라는 질문에 생각이 바뀌었다.

데이터정합성 요구전략
결제 금액1원도 틀리면 안 됨Strong Consistency
재고 수량초과 판매 방지Pessimistic Lock
랭킹 순위3위가 잠깐 4위여도 모름Eventual Consistency

2PC(Two-Phase Commit)를 쓰면 매 이벤트마다 prepare → commit 2번 왕복, 느린 쪽에 맞춰지는 성능 저하, Coordinator 장애 시 전체 블로킹. 이 비용을 "3위가 잠깐 4위인 것"을 막기 위해 치를 이유가 없다.

public void incrementScore(String key, Long productId, double score) {
    try {
        redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
        redisTemplate.expire(key, Duration.ofDays(TTL_DAYS));
    } catch (Exception e) {
        log.warn("랭킹 점수 업데이트 실패: key={}, productId={}", key, productId, e);
    }
}

Fire-and-Forget. Redis 실패해도 로그만 남기고 넘어간다. 다음 이벤트에서 자연스럽게 보정된다. "대략 맞으면 되는 데이터"에서 Eventual Consistency를 선택하는 판단 — 이게 이번 주 가장 큰 배움이었다.

N+1을 HashMap으로 해결한 조합 패턴

ZSET에는 (productId, score) 쌍만 있다. 랭킹 API 응답에는 상품명, 가격, 브랜드명이 필요하다. 이걸 어떻게 조합할 것인가?

처음에는 "LinkedHashSet?"이라고 답했는데, 정리해보니 더 단순했다:

  1. ZSET에서 ZREVRANGE로 Top-N productId 리스트 확보 (순서 보장)
  2. IN절로 상품/브랜드를 한 번에 조회 → Map<Long, Product>에 담기 (순서 무관, O(1) lookup)
  3. ZSET 순서대로 순회하며 map.get(productId)로 매핑

LinkedHashMap이 아니라 일반 HashMap이면 된다. 순서는 ZSET 결과 리스트가 보장하고, Map은 lookup 전용이니까. 2개 도메인(랭킹 + 상품)을 조합하는 건 Application 레이어(Facade)의 역할이다.

Map<Long, ProductModel> productMap = productService.getByIds(productIds);
return rankings.stream()
    .filter(r -> productMap.containsKey(r.productId()))
    .map(r -> {
        ProductModel product = productMap.get(r.productId());
        BrandModel brand = brandMap.get(product.getBrandId());
        return new RankingWithProduct(r.rank(), r.score(), ...);
    })
    .toList();

배운 주요 포인트

1. 스케일이 다르면 가중치는 의미가 없다

이번 주 가장 선명한 교훈. 289만 × 0.1이 1건 × 0.5를 압도하는 건 가중치의 문제가 아니라 단위(scale)의 문제다. 여러 지표를 합산할 때는 먼저 스케일을 맞추고(정규화), 그 다음에 가중치를 조절해야 의미가 있다. 이건 랭킹뿐 아니라 추천, 검색 등 점수 기반 시스템 전반에 적용되는 원칙이다.

2. 데이터의 성격이 정합성 전략을 결정한다

결제 → Strong, 재고 → Pessimistic Lock, 랭킹 → Eventual Consistency. 모든 데이터에 같은 수준의 정합성을 요구하면 시스템이 불필요하게 복잡해진다. "이 데이터가 잠깐 틀리면 비즈니스에 얼마나 영향이 있는가?"를 먼저 판단하고, 그에 맞는 전략을 선택하는 게 실무적 사고라는 걸 배웠다.

3. ZSET은 진짜 만능이다

8주차에서는 대기열(ZADD NX + ZPOPMIN), 9주차에서는 랭킹(ZINCRBY + ZREVRANGE). 같은 Sorted Set인데 완전히 다른 용도다. "Score에 뭘 넣느냐"에 따라 대기열이 되기도 하고 랭킹이 되기도 한다. Redis를 쓸 때 자료구조 선택이 설계의 절반이라는 8주차의 교훈이 이번 주에도 그대로 적용됐다.

4. Read/Write 경로 분리의 자연스러움

commerce-streamer가 Kafka Consumer로 ZSET에 쓰고(Master), commerce-api가 API 조회로 ZSET을 읽는다(Replica). 이게 별도의 설계 결정이 아니라, 멀티 모듈 구조에서 자연스럽게 나온 분리라는 점이 인상적이었다. CQRS를 의도하지 않았는데 구조가 그렇게 된 셈이다.

아쉬웠던 점 & 다음에 해보고 싶은 것

아쉬운 점

carry-over 비율을 "감"으로 정한 것이 가장 아쉽다. 10%라는 숫자에 "발제 자료 권장" 이상의 근거가 없다. 8주차에서 배치 크기를 HikariCP 풀에서 도출했던 것처럼, carry-over 비율도 "오전 몇 시까지 오늘 활동이 전날 carry-over를 역전하려면 이벤트 몇 건이 필요한가?"같은 계산으로 뒷받침했어야 했다.

UNLIKE 시 점수 차감 여부를 결정하지 못한 것도 아쉽다. 현재는 "한번 발생한 관심 시그널은 유효하다"고 판단해서 차감하지 않았는데, 이게 정말 맞는지는 도메인에 따라 다를 것 같다. 좋아요를 눌렀다 취소하는 유저가 많은 서비스라면 차감이 맞을 수 있다.

Ranking Lab을 다 못 만든 것도 아쉽다. 가중치 슬라이더를 움직이면서 순위 변동을 눈으로 보는 실험 환경을 설계까지 해놓고 구현을 마무리하지 못했다. Score 방식(LINEAR/LOG/COUNT) 전환을 시각적으로 비교할 수 있었으면 블로그 글의 설득력도 더 높았을 텐데.

다음에 해보고 싶은 것

  • 29CM식 다차원 랭킹: 현재는 ranking:all:{date} 하나지만, 성별 × 연령 × 카테고리로 세분화하면 키가 수백 개로 늘어난다. 키 관리, 메모리 예측, TTL 전략이 어떻게 달라지는지 실험해보고 싶다.
  • Caffeine 로컬 캐시: 랭킹 목록은 30초 정도 캐싱해도 문제없다. Redis 호출을 줄이면서 응답 속도를 어디까지 끌어올릴 수 있는지 측정해보고 싶다.
  • Feature Flag 기반 A/B 테스트: 29CM이 Unleash로 랭킹 알고리즘을 실험하는 구조가 인상적이었다. 유저 세그먼트별로 다른 Score 공식을 적용하고, 클릭률/전환율로 어느 공식이 나은지 비교하는 파이프라인을 만들어보고 싶다.
profile
Dot Your moment.

0개의 댓글