Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
Tags
- 캐시
- 파이썬
- spring
- collapse
- thymeleaf
- interceptor
- 타임리프
- JPA
- Transactional
- 코딩테스트
- Spring Security
- EntityGraph
- 클린아키텍처
- Java
- effective java
- JVM
- TDD
- 배낭문제
- 알고리즘
- lombok
- 자바
- Garbage Collection
- 멱등성
- 이펙티브자바
- @Transactional
- cache
- 동시성처리
- AOP
- EffectiveJava
- BFS
Archives
- Today
- Total
Jinnie devlog
Redis ZSET로 만드는 “오늘의 인기상품” 본문
Redis의 ZSET은 정렬된 집합으로, "멤버"는 유일한 문자열이고 각 멤버에 붙는 "점수"로 오름차순으로 정렬된다.
ZSET(Sorted Set) 는 (member, score) 쌍을 점수 기준 정렬해 보관한다.
- ZINCRBY key delta member : 멤버 점수를 가산/감산(원자적)
- ZREVRANGE key start stop WITHSCORES : 점수 내림차순으로 페이지 조회
- ZREVRANK key member : 멤버의 0-based 순위
- ZSCORE key member : 멤버 점수 조회
- EXPIREAT key ts : 키 만료 시각 지정
랭킹은 실시간 가산, 빠른 페이지 조회, 상위 N개가 핵심인데, 이 조합이 ZSET에 딱 맞다.
- 정렬된 집합: 각 멤버에 실수 점수(score) 를 붙여 자동 정렬.
- 원자적 가산/감산: ZINCRBY 한 번으로 누적(경쟁 상황에도 안전).
- 빠른 상위 N 조회: ZREVRANGE … WITHSCORES 로 상위 랭킹 페이지 바로 뽑기.
- 개별 순위/점수 조회: ZREVRANK, ZSCORE로 단건 부가 정보 즉시 조회.
쇼핑몰 등의 인기상품 정렬을 생각했을 때, 나는 그저 단순히 "많이 팔린 상품" 정렬 정도로 생각하였다.
하지만 생각보다 "인기" 의 기준에 여러가지 지표 그리고 그 지표들의 합산이 반영되어야 한다는 것을 새롭게 알게되었다.
이 지표들을 "가중치" 라고 표현할 수 있는데, 커머스 시스템의 경우 아래와 같은 예시를 들 수 있다.
가중치 합산 (Weighted Sum)
왜 가중치가 필요한가?
(1) 좋아요/구매/매출액은 스케일이 달라 단순 합산 시 특정 지표가 지배
(2) 서비스 전략에 따라 어떤 지표를 더 중요하게 볼지 달라짐
총점식
Sum(p) = W(like)*Count(p.like) + W(order)*Count(p.order) + W(view)*Count(p.view)
* W : Weight (가중치)
* Count : 스코어를 구성하는 요소 수
기본 가중치 예시
- Weight(view) = 0.1: 조회 수는 가장 많을 것이므로 전체 스코어를 잡아먹을 수 있음
- Weight(like) = 0.2: 좋아요 수는 주문 수보다는 구매 결정 관점에서 덜 중요한 지표이므로 조금 낮게 설정
- Weight(order) = 0.7: 주문 수는 유저가 구매를 결정했으므로 가장 중요한 지표라고 보고 가중치를 높게 설정
실제 적용
1) 모델링
- 키(Key): ranking:all:{yyyyMMdd} (예: ranking:all:20250912)
- 멤버(Member): productId(문자열)
- 점수(Score): double — 조회/좋아요/주문을 가중치로 환산한 델타를 누적
2) 적재(Write) — 배치 + ZINCRBY 파이프라인
- 배치 컨슈머가 이벤트 묶음을 (productId → deltaScore)로 합산
- Redis에 파이프라인으로 다건 ZINCRBY + EXPIREAT 실행
redis.executePipelined(c -> {
byte[] k = key.getBytes(UTF_8);
deltas.forEach((member, delta) ->
c.zSetCommands().zIncrBy(k, delta, member.getBytes(UTF_8)));
c.keyCommands().expireAt(k, expireAtEpochSeconds);
return null;
});
- ZINCRBY: 동시성 상황에서도 한 줄로 원자적 누적이 된다.
- 파이프라인: 네트워크 RTT를 크게 줄여 처리량/지연을 개선할 수 있다. (특히 배치로 수백~수천 멤버 갱신 시 필수).
- 배치 합산 후 1회 적재: 이벤트마다 바로 ZSET 갱신하면 Redis 호출이 과도해지므로, 애플리케이션 측에서 먼저 집계해 호출 횟수를 줄인다.
3) 만료(TTL)
- 각 일자 키에 TTL=2일(예: EXPIREAT now+2d)
- 집계를 일정 단위 (시간, 일, 주, 월 등) 으로 나누어서 관리
- 일간 집계 - 하루 단위로 랭킹을 관리할 수 있어야 함 → 오늘 점수와 어제 점수를 분리
- TTL - 메모리 관리를 위해 시간 윈도우의 1.5배~2배 정도로 잡으면 안정적
4) 조회(Read)
- 페이지(내림차순): ZREVRANGE key start end WITHSCORES
- 개별 순위: ZREVRANK key member → 0-based라 응답은 +1
- 개별 점수: ZSCORE key member
- 랭킹은 “상위 N”이 핵심 → ZSET의 역순(range) 가 가장 빠르고 간단하다.
- 단건 순위/점수는 API 상세 응답에 부가 정보로 붙이기 좋다.
5) 점수 정책
View +0.1, Like +0.2 * delta(±1), Order +0.7 * log1p(price*amount)
- View/Like는 빈도 기반이라 작은 가중치로 신호만 반영.
- Order는 금액·수량이 크면 랭킹이 급등하지 않도록 log1p로 완만하게 정규화.
- 정책은 전략 패턴으로 분리해 가중치/공식을 설정으로 교체 가능
6) 결과 출력
요청
GET /api/v1/rankings?date=20250912&page=1&size=3
예시 응답(JSON)
{
"success": true,
"data": {
"page": 1,
"size": 3,
"total": 237,
"items": [
{
"productId": 101,
"rank": 1,
"score": 7.43,
"name": "아이폰 16",
"price": 1200000,
"stock": 15,
"brandId": 7,
"brandName": "Apple"
},
{
"productId": 202,
"rank": 2,
"score": 5.96,
"name": "갤럭시 Z Flip",
"price": 1390000,
"stock": 8,
"brandId": 12,
"brandName": "Samsung"
},
{
"productId": 303,
"rank": 3,
"score": 4.12,
"name": "에어팟 프로 2",
"price": 329000,
"stock": 42,
"brandId": 7,
"brandName": "Apple"
}
]
}
}
참고
'교육' 카테고리의 다른 글
Kafka로 이벤트 파이프라인 만들기 (0) | 2025.09.05 |
---|---|
Spring 트랜잭션 이벤트 정리와 적용 (2) | 2025.08.29 |
외부 시스템 연동 지연, 장애, 실패 대응 (Failure-Ready Systems) (0) | 2025.08.22 |
10만건 조회 성능 개선 시도 (인덱스, 캐싱) (4) | 2025.08.15 |
@Transactional 남용 줄이기 도전기 - 두 번의 삽질 (4) | 2025.08.08 |