Kafka Consumer → Redis ZSET → Ranking API 파이프라인 구축 과정에서의 설계 결정들
TL;DR
Redis ZSET + ZINCRBY로 이벤트를 실시간 누적하고, 일별 키 분리·가중치 합산·콜드 스타트 Carry-Over까지 "단순하게 시작하되 확장 경로를 열어두는" 방향으로 랭킹 시스템을 설계한다.
커머스 서비스에서 "오늘의 인기 상품"은 유저에게 가장 많이 노출되는 지면 중 하나다. 홈 메인의 Top 10, 인기순 정렬, 카테고리별 베스트 — 이 모든 것의 뒤에는 랭킹 시스템이 있다.
이번 글에서는 기존 Kafka Consumer 기반 이벤트 파이프라인 위에 Redis ZSET을 얹어 실시간 랭킹 시스템을 구축한 과정을 정리한다. 각 설계 결정에서 어떤 대안을 고려했고, 왜 그 선택을 했는지에 초점을 맞춘다.
전체 아키텍처
흐름은 간단하다. commerce-api가 유저 행동 이벤트(조회, 좋아요, 주문)를 Kafka에 발행하고, commerce-collector가 이를 소비한다. collector는 기존에 product_metrics 테이블을 upsert하던 로직에 더해, 이번에 Redis ZSET에 랭킹 점수를 실시간으로 갱신하는 로직을 추가했다. commerce-api는 이 ZSET을 조회해서 랭킹 API를 제공한다.
1. 왜 Redis ZSET인가
문제부터 짚자. RDB에서 GROUP BY + ORDER BY로 랭킹을 계산하면, 데이터가 쌓일수록 쿼리가 느려진다. 랭킹은 조회 빈도가 매우 높은 API인데, 매 요청마다 집계 쿼리를 날리면 DB 과부하로 이어진다.
대안으로 세 가지를 고려했다.
A안: RDB COUNT/SUM 집계 쿼리다. 정합성은 높지만 실시간 처리에 부적합하다.
B안: Redis Hash에 점수를 저장하고 조회 시 애플리케이션에서 정렬하는 방식이다. 매 요청마다 전체 데이터를 꺼내 O(N log N)으로 정렬해야 한다.
C안: Redis ZSET이다. score 기반 정렬이 내장되어 있어 Top-N 조회가 O(log N + M)으로 빠르고, ZINCRBY로 원자적 점수 누적이 가능하다.
ZSET을 선택했다. 단점은 Redis 메모리 사용량 증가와 인메모리 특성상 장애 시 데이터 유실 가능성이다. 추후 AOF/RDB 영속화 설정으로 보완할 수 있다.
2. 점수 누적 방식 — ZADD vs ZINCRBY
ZSET에 점수를 어떻게 쌓을 것인가. 겉보기엔 단순하지만 선택에 따라 구조가 크게 달라진다.
A안은 ZADD 덮어쓰기다. 이벤트가 발생할 때마다 해당 상품의 전체 이벤트를 DB에서 재집계해서 SET한다. 이벤트 하나에 SELECT 쿼리가 동반되므로 트래픽이 몰리면 DB 부하가 랭킹 시스템으로 전이된다.
B안은 ZINCRBY 증분 누적이다. 현재 score에 delta만 더하므로 DB 조회 없이 Redis 단독으로 처리 가능하다. 원자적 연산이라 동시성 문제도 없다.
C안은 애플리케이션 메모리에 집계한 뒤 주기적으로 ZADD하는 방식이다. 스루풋은 가장 높지만 서버가 죽으면 미반영 점수가 유실되고, 멀티 인스턴스 환경에서 반영 시점에 따라 순위가 일시적으로 왜곡될 수 있다.
ZINCRBY를 선택했다. 단점은 이벤트 1건당 Redis 호출 1회가 필수라는 점이다. 초당 수만 건이면 커넥션 부하가 생길 수 있는데, 이건 Kafka 배치 리스너로 N건을 앱에서 먼저 합산한 뒤 ZINCRBY 호출 횟수를 줄이는 방식으로 개선할 수 있다.
3. 키 전략 — 시간의 양자화
ZSET 키를 어떻게 설계하느냐가 랭킹의 의미를 결정한다. 단일 키에 무한 누적하면 오래 전 점수를 쌓은 상품이 상위를 독점하는 롱테일 문제가 발생한다. 신상품은 노출 기회가 사라지고, 랭킹의 "오늘" 이라는 시의성이 퇴색된다.
A안은 단일 키 누적(ranking:all)이다. 위에서 말한 롱테일 문제가 그대로 발생한다. B안은 일별 키 분리(ranking:all:{yyyyMMdd})에 TTL 2일이다. C안은 시간별 키 분리(ranking:all:{yyyyMMddHH})다. 세밀한 집계가 가능하지만 키 수가 24배로 증가하고 관리 복잡도가 높아진다.
일별 키 분리를 선택했다. TTL 2일이면 오늘과 어제 랭킹을 커버하면서 메모리가 자동 정리된다. 단점은 일 단위보다 세밀한 트렌드 파악이 불가능하다는 것과 자정 직후 콜드 스타트 문제다. 시간별 키는 Nice-to-Have로 별도 구현하기로 했다.
4. 가중치 합산 — 왜 단순 카운트가 아닌가
조회, 좋아요, 주문은 스케일이 완전히 다르다. 조회는 하루 수만 건이 쌓이지만 주문은 수백 건일 수 있다. 단순 합산하면 랭킹이 사실상 "많이 본 상품 순위"가 되어 좋아요와 주문 시그널이 묻힌다.
A안은 단순 이벤트 카운트 합산이다. 조회 1건 = 좋아요 1건 = 주문 1건으로 취급하면 위 문제가 그대로 발생한다. B안은 이벤트별 가중치를 곱해 단일 score로 합산하는 Weighted Sum이다. C안은 이벤트 타입별로 ZSET을 분리한 뒤 ZUNIONSTORE로 합산하는 방식이다. 가중치 변경 시 재합산이 용이하지만 키 3개 + 합산 키까지 4배 메모리를 사용하고, ZUNIONSTORE 실행 시 블로킹이 발생할 수 있다.
가중치 합산(view=0.1, like=0.2, order=0.7, 총합 1)을 선택했다. ZINCRBY 한 번으로 처리되어 단순하다. 단점은 가중치를 변경할 때 이미 누적된 점수에 소급 적용이 불가능하다는 점이다. 이를 위해 weight 값은 application.yml의 @ConfigurationProperties로 관리해서 배포 시 조정 가능하도록 했고, 추후 원본 이벤트 카운트를 별도 저장해 재계산 구조를 검토할 수 있다.
5. 랭킹 API 페이징
랭킹 조회 API는 GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 형태로 설계했다. 페이징 방식에 세 가지 대안이 있었다.
A안은 ZREVRANGE offset 기반이다. (page-1)×size부터 size개를 조회한다. B안은 ZREVRANGEBYSCORE cursor 기반으로, 이전 페이지 마지막 score를 기준으로 조회한다. 동일 score 상품이 페이지 경계에 걸칠 때 누락/중복 처리가 복잡하다. C안은 전체 조회 후 애플리케이션에서 subList하는 방식이다. 상품 수만 개 시 매 요청마다 전체를 전송해야 한다.
offset 기반을 선택했다. 랭킹 특성상 대부분의 유저가 1~3페이지만 보기 때문에 offset의 단점(뒤쪽 페이지에서 skip 비용 증가)이 실질적으로 문제가 되지 않는다. 추후 API 레벨에서 maxPage 제한(예: Top 100)을 두면 완전히 해소된다.
상품 상세 조회에는 ZREVRANK로 해당 상품의 순위를, ZSCORE로 점수를 함께 반환하도록 확장했다. 랭킹에 없는 상품은 rank=null, score=null로 처리한다.
6. 콜드 스타트 — 자정의 빈 랭킹 문제
일별 키 전략의 부작용이다. 자정이 되면 새 키가 생성되는데, 아직 이벤트가 쌓이지 않아 랭킹이 비어 있다. 새벽 2시에 접속한 유저가 보는 "오늘의 인기 상품"이 조회 1건짜리 상품이 되는 문제다.
A안은 아무것도 안 하는 것이다. 이벤트가 충분히 쌓일 때까지 빈 랭킹 또는 의미 없는 순위가 노출된다. B안은 ZUNIONSTORE로 전일 점수의 10%를 새 키에 복사하는 Score Carry-Over다. C안은 DB에서 전일 상위 N개를 조회해 ZADD하는 방식이다. SELECT + ZADD N회로 구현 복잡도와 DB 부하가 증가한다.
ZUNIONSTORE 기반 Carry-Over를 선택했다. 매일 23:50에 Scheduler가 실행되어 ZUNIONSTORE ranking:all:{내일날짜} 1 ranking:all:{오늘날짜} WEIGHTS 0.1 명령으로 오늘 점수의 10%를 내일 키에 미리 복사한다. Redis 서버 사이드에서 한 번에 처리되어 가장 효율적이다.
단점은 Scheduler가 실행되지 않으면(배포, 장애) 콜드 스타트가 그대로 발생한다는 점이다. carry-over 비율 10%가 적절한지도 서비스 데이터를 보고 튜닝해야 한다. 추후 Scheduler 실패 모니터링과 자정에 키가 비어 있으면 on-demand로 carry-over를 실행하는 fallback 로직을 추가할 수 있다.
정리
이번 설계에서 반복적으로 나타난 패턴이 있다. "가장 단순한 구조를 선택하되, 단점을 명확히 인지하고 추후 개선 경로를 열어두는 것"이다. ZINCRBY 단건 호출의 한계는 배치 리스너로, 일별 키의 콜드 스타트는 Carry-Over Scheduler로, 가중치 소급 적용 불가는 원본 카운트 별도 저장으로 — 각각 확장 포인트가 존재한다.
결국 설계는 현재 요구사항에 맞는 최적을 고르면서도, 다음 문제가 발생했을 때 구조를 갈아엎지 않고 확장할 수 있는 여지를 남기는 작업이라는 걸 다시 한번 느꼈다.