티스토리 뷰
product_metrics의 누적 카운터 구조를 일별 집계로 변경하고, 스케줄러 기반으로 Redis Sorted Set에 랭킹을 적재했다. 동시성 문제는 UPSERT로 해결하고, 자정 콜드스타트는 Score Carry-Over로 완화했다. 그 과정에서 "오늘의 랭킹"이라는 단어가 생각보다 복잡한 개념이라는 걸 알게 됐다.
커머스 서비스에서 상품의 조회 수, 좋아요 수, 판매 수를 기반으로 인기 상품 랭킹을 제공해달라는 요구사항이 들어왔다. 랭킹 기능을 개발하면서 겪은 시행착오들을 정리했다.
사용자가 상품을 조회하거나, 좋아요를 누르거나, 주문 시 이벤트가 발생하고 이벤트들은 Kafka에 적재된다. 해당 이벤트 정보를 기반으로 랭킹 점수를 산출하고 상품의 일간 랭킹을 제공해야했다.
랭킹 점수는 상품 조회 수, 좋아요 수, 판매 수에 가중치를 곱해 산출한다. 가장 먼저 고민해야하는 건 계산된 랭킹 점수를 어느 저장소에서 관리할지가 주요 포인트다. RDB, Redis 등 다양한 저장소를 생각해볼 수 있다. 랭킹 점수는 실시간 랭킹이(일간, 주간, 월간 랭킹의 경우) 아니라면 자주 변경되는 데이터가 아니고 메인 페이지, 랭킹 페이지 등 다수 페이지에서 자주 조회되는 데이터이다. 요구사항이 단순 상품의 일간 랭킹이었기 때문에 Redis를 저장소로 선택했다. Redis의 Sorted Set을 활용해 랭킹 기능을 구현했다.
1. 랭킹 점수 산출 흐름
먼저 떠오른 방식은 고객 행위로부터 발행된 Kafka 이벤트를 실시간으로 소비하면서 랭킹 점수를 계산하고 Redis에 점수를 누적하여 반영하는 것이다. 이벤트를 처리할 때 마다 ZINCRBY로 Redis Sorted Set에 누적하면 랭킹 정보가 산출된다. 동일한 날에 소비된 이벤트는 ranking:all:yyyyMMdd 동일한 키에 저장된다.
그런데 한 가지가 마음에 걸렸다. 이 구조에서 Redis에 장애가 생기면 어떻게 될까? 메시지는 소비되었고 계산된 랭킹 정보가 Redis에만 있으니 복구할 방법이 없다.
그래서 product_metrics에 사용자의 이벤트들을 일별로 집계하고 스케줄러로 집계 데이터를 읽어 랭킹 점수를 계산하는 방식을 선택했다. Redis에 문제가 생겨 랭킹 정보가 날아가도 DB 데이터로 복원이 가능하다. 항상 장애가 발생한 경우 복원이 가능한지 고려해야한다.
2. 누적 카운터로는 일간 랭킹을 만들 수 없다
스케줄러 기반으로 방향을 잡고, 이벤트를 집계하는 product_metrics 테이블을 살펴봤다.
| product_id | like_count | view_count | sales_count |
|---|---|---|---|
| 1 | 150 | 2000 | 80 |
| 2 | 85 | 1500 | 42 |
상품당 하나의 row에 전체 기간의 누적값이 들어 있었다. 여기서 "오늘 얼마나 팔렸는지"를 알 수 있을까? 어제 80개였던 판매 수가 오늘 83개가 됐다면 오늘 3개 팔린 건데, 어제 시점의 값을 어디에도 저장해둔 게 없다. 일간 랭킹을 만들 수가 없는 구조였다.
metric_date 컬럼을 추가하고, unique 제약조건을 (product_id, metric_date)로 변경했다.
| product_id | metric_date | like_count | view_count | sales_count |
|---|---|---|---|---|
| 1 | 2026-04-08 | 8 | 95 | 2 |
| 1 | 2026-04-09 | 5 | 120 | 3 |
| 2 | 2026-04-08 | 3 | 60 | 1 |
데이터는 상품 수만큼 매일 늘어나지만, 이제 일간/주간/월간 어떤 기간이든 집계할 수 있다.
3. 3개 토픽이 하나의 테이블을 동시에 건드린다
사용자의 이벤트들은 like, view, sales 3개의 토픽 저장된다. 테이블 구조를 위와 같이 설계하고 실제 Kafka 메시지를 보내봤는데, 3개 토픽의 이벤트를 동시에 보내면 일부 이벤트 정보가 영속화 되지 않는 문제가 발생했다. 판매는 들어왔는데 조회와 좋아요가 빠지는 식이었다.
원인을 찾아보니 동시성 문제였다. Consumer가 이벤트를 하나의 테이블에 집계하는 구조다. 조회/좋아요/주문 3개 토픽을 모두 같은 테이블(product_metrics)에 저장한다.
public void incrementViewCount(Long productId) {
ProductMetrics metrics = getOrCreate(productId); // 조회 → 없으면 엔티티 생성 후 반환
metrics.incrementViewCount(); // 도메인 메서드로 값 갱신
productMetricsRepository.save(metrics); // 영속화
}
해당 날짜의 데이터를 조회하고, 없으면 엔티티 객체를 생성한 뒤 도메인 메서드를 호출하여 값을 갱신하고 영속화하는 구조였다.
날짜가 바뀌면 해당 날짜의 데이터가 아직 없으니 새로운 엔티티를 생성해야 한다. 동일한 시점에 들어온 3개 토픽의 이벤트를 Consumer가 동시에 처리하게 되면 같은 날짜의 엔티를 생성하고 영속화를 시도한다. product_id, metric_date unique 제약조건으로 데이터 저장이 실패한다.
이미 데이터가 있는 경우에도 동시에 값을 갱신하면 갱신 유실(lost update)이 발생할 수 있다. 조회와 저장사이에 간극이 있어 발생하는 전형적인 동시성 문제다.
MySQL의 INSERT ... ON DUPLICATE KEY UPDATE로 해결했다. row가 없으면 INSERT, 있으면 기존 값에 더하는 것을 DB가 원자적으로 처리한다.
INSERT INTO product_metrics (product_id, metric_date, view_count, ...)
VALUES (1, '2026-04-09', 1, ...)
ON DUPLICATE KEY UPDATE view_count = view_count + 1
도메인 로직이 SQL에 묻히는 느낌이라 선호하는 방식은 아니지만, 동시성 문제를 DB 레벨에서 확실하게 해결할 수 있는 선택이었다.
4. 자정이 되면 랭킹이 비어버린다
동시성 문제를 해결하고, 스케줄러로 매일 00시에 어제 metric을 집계해서 Redis에 넣도록 구현했다. 가중치는 조회 × 0.1 + 좋아요 × 0.2 + 판매 × 0.6이다. Redis 키는 날짜별로 분리하여 ranking:all:yyyyMMdd 형식으로 저장한다.
00시 00분 00초에 스케쥴러가 실행된다면 해당 시점에 고객이 랭킹 정보를 조회한다면 오늘 날짜 기준 랭킹 점수가 Redis에 있을까? 없다. 스케줄러가 아직 계산 중일 것이다. 그 사이에 랭킹을 조회하면 데이터가 노출되지 않는 문제가 발생할 수 있다.
위 문제에서 끝이 아니다. 어제까지 1위였던 상품이 오늘 자정에 갑자기 0점이 될 수 있다. 집계 데이터가 날짜 별로 구분되어 있기 때문에 갑자기 인기 상품에서 사라져 버리는 문제가 발생할 수 있다. 해당 문제는 데이터 상으로는 이상이 없지만, 사용자의 좋아요 등 행위가 하루에 집중된다고 해서 다음날 인기 상품에서 제외되버리는건 기능의 의도와는 다르게 동작할 수 있다.
23시 50분에 스케줄러를 하나 더 돌리기로 했다. 오늘의 랭킹 점수를 읽어서, 각 점수의 30%를 내일 날짜 키에 미리 넣어둔다. "Carry-Over", 즉 이월이다.
23:50 carry-over → 오늘 score × 30%를 내일 키에 ZADD
00:00 aggregate → 어제 metric 기반 score를 오늘 키에 ZINCRBY
carry-over가 먼저 실행되어 기본 점수를 깔아두고, 00시 집계가 그 위에 누적한다. 여기서 주의할 점은 carry-over는 ZADD(덮어쓰기)로, 집계는 ZINCRBY(증분)로 동작한다는 것이다. 순서가 바뀌면 carry-over가 집계 결과를 덮어쓰게 된다.
30%라는 비율이 적절한지도 고민이었다. 너무 높으면 어제 랭킹이 오늘을 지배하고, 너무 낮으면 콜드스타트 완화 효과가 미미하다. 정답은 없지만, 점진적으로 전환되는 느낌을 주기에 30%가 나쁘지 않다고 판단했다.
5. 메시지는 언제 발생한 것인가
구현이 어느 정도 마무리됐을 때 한 가지 의문이 들었다.
metric 데이터를 저장할 때 날짜를 LocalDate.now()로 지정하고 있었다.
productMetricsRepository.upsertViewCount(productId, LocalDate.now(), 1);
고객이 4월 1일 23시 59분 59초에 상품을 조회했는데, Consumer가 이 메시지를 4월 2일 00시 00분 01초에 처리한다면? LocalDate.now()를 쓰고 있었으니 4월 2일 데이터로 집계된다. 고객의 행위는 분명 4월 1일인데.
고객의 행위를 집계하는 건데 처리 시점이 아닌 행위 시점이 기준이 되는 게 맞다고 생각했다. Kafka의 ConsumerRecord에는 Producer가 메시지를 보낸 시점이 timestamp()에 자동으로 기록되어 있다.
LocalDate eventDate = LocalDate.ofInstant(
Instant.ofEpochMilli(record.timestamp()),
ZoneId.of("Asia/Seoul")
);
LocalDate.now()를 제거하고, 이벤트 발행 시점 기준으로 변경했다. 이제 고객의 행위가 일어난 시점에 데이터가 적재된다.
6. 결국 이건 "어제의 랭킹"이다
여기까지 만들고 나서 한 가지를 인정해야 했다. 현재 구조에서 사용자가 보는 랭킹은, 결국 어제 데이터로 계산된 점수다. carry-over로 콜드스타트는 완화했지만, 오늘 발생하는 이벤트는 내일 00시에야 반영된다.
어제의 인기 상품이다. 커머스에서 인기 상품은 하루 만에 급변하지 않으니 큰 문제는 아닐 수 있다. 하지만 타임세일이나 급격한 트렌드 변화가 있다면 한계가 드러난다.
오늘의 랭킹을 반영하려면 이벤트 발생 시 Redis에도 바로 ZINCRBY하거나, metric 적재 단위를 시간별로 세분화하고 매시간 스케줄러를 돌리는 방법이 있다. 전자는 구현이 단순하지만 Redis 의존도가 높아지고, 후자는 데이터가 상품 수 × 24배로 늘어난다.
이번에는 스케줄러 기반으로 마무리했지만, 이 한계는 인식하고 있다.
이번에 체감한 건, 랭킹에서 가장 중요한 건 점수 계산 공식이 아니라 어떤 시간 범위의 데이터를 집계할 것인가라는 점이다. 일간인지, 시간별인지, 실시간인지에 따라 테이블 구조, 스케줄러 주기, 저장소 전략이 전부 달라진다. "랭킹"이라는 단어가 단순해 보이지만, 시간이라는 축을 끼얹는 순간 설계가 복잡해진다.
'데이터베이스' 카테고리의 다른 글
| 대기열, 시스템을 보호하라 (1) (0) | 2026.04.12 |
|---|---|
| Redis Sorted Set 명령어 정리 (0) | 2026.04.11 |
| 캐싱, 간단할 줄 알았다. 그런데... (0) | 2026.03.29 |
| 서브쿼리 이해하기: 인라인뷰, 스칼라 서브쿼리, 상관·비상관 쿼리 (1) | 2025.12.01 |
| SQL을 입력하면, DB 안에서는 무슨 일이 벌어질까? (0) | 2025.08.17 |
