


이번 주 과제는 "상품 인기 랭킹을 실시간으로 보여줘라"였다. 처음에는 간단할 줄 알았다. 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점. 이건 가중치의 문제가 아니라 스케일의 문제였다.
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);
}
"그냥 DB에 total_score 컬럼 만들고 인덱스 걸면 되지 않나?"라는 질문에 처음에는 "실시간성이 떨어지니까"라고만 대답했다. 하지만 Q&A를 하면서 진짜 이유를 하나씩 파고들었다.
view * 0.1 + like * 0.2 + sale * 0.7)Redis ZSET은 ZINCRBY로 점수 갱신과 Skip List 재정렬이 O(log N)에 동시 처리된다. 단일 스레드 모델이라 락 자체가 없다. 8주차에 대기열로 Sorted Set을 썼을 때는 "순서 보장"에 집중했는데, 이번에는 "실시간 점수 갱신 + 정렬"이라는 전혀 다른 용도로 같은 자료구조를 쓰면서, ZSET이 정말 범용적이구나 하는 걸 느꼈다.
ranking:all 하나의 키에 계속 누적하면 6개월간 누적 50,000점인 상품이 오늘 바이럴 탄 상품(500점)을 절대 이길 수 없다. 이게 롱테일 문제다. 해결은 키를 시간 단위로 분리하는 것 — ranking:all:{yyyyMMdd}.
여기서 TTL을 24시간이 아닌 48시간으로 잡은 이유가 처음에는 와닿지 않았다. 과제를 더 읽어보니 두 가지였다:
콜드 스타트가 흥미로웠다. 자정이 지나면 새 키 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)을 따른 거라, 실험으로 검증해봐야 할 부분이다.
랭킹 점수 갱신은 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를 선택하는 판단 — 이게 이번 주 가장 큰 배움이었다.
ZSET에는 (productId, score) 쌍만 있다. 랭킹 API 응답에는 상품명, 가격, 브랜드명이 필요하다. 이걸 어떻게 조합할 것인가?
처음에는 "LinkedHashSet?"이라고 답했는데, 정리해보니 더 단순했다:
ZREVRANGE로 Top-N productId 리스트 확보 (순서 보장)Map<Long, Product>에 담기 (순서 무관, O(1) lookup)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();
이번 주 가장 선명한 교훈. 289만 × 0.1이 1건 × 0.5를 압도하는 건 가중치의 문제가 아니라 단위(scale)의 문제다. 여러 지표를 합산할 때는 먼저 스케일을 맞추고(정규화), 그 다음에 가중치를 조절해야 의미가 있다. 이건 랭킹뿐 아니라 추천, 검색 등 점수 기반 시스템 전반에 적용되는 원칙이다.
결제 → Strong, 재고 → Pessimistic Lock, 랭킹 → Eventual Consistency. 모든 데이터에 같은 수준의 정합성을 요구하면 시스템이 불필요하게 복잡해진다. "이 데이터가 잠깐 틀리면 비즈니스에 얼마나 영향이 있는가?"를 먼저 판단하고, 그에 맞는 전략을 선택하는 게 실무적 사고라는 걸 배웠다.
8주차에서는 대기열(ZADD NX + ZPOPMIN), 9주차에서는 랭킹(ZINCRBY + ZREVRANGE). 같은 Sorted Set인데 완전히 다른 용도다. "Score에 뭘 넣느냐"에 따라 대기열이 되기도 하고 랭킹이 되기도 한다. Redis를 쓸 때 자료구조 선택이 설계의 절반이라는 8주차의 교훈이 이번 주에도 그대로 적용됐다.
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) 전환을 시각적으로 비교할 수 있었으면 블로그 글의 설득력도 더 높았을 텐데.
ranking:all:{date} 하나지만, 성별 × 연령 × 카테고리로 세분화하면 키가 수백 개로 늘어난다. 키 관리, 메모리 예측, TTL 전략이 어떻게 달라지는지 실험해보고 싶다.