개요
TL;DR "Redis는 '빠른 정렬'을 해결하지, '정합성'은 개발자 몫이다."
커머스 프로젝트에서 "오늘의 인기 상품" 랭킹 시스템을 Redis ZSET으로 설계했습니다.
처음엔 ZINCRBY 한 줄이면 끝날 줄 알았는데, 실제로는 DB Ledger + ZADD + 합성 Score 인코딩 + 멱등 Carry-Over까지 발전했습니다.
이 글은 ZSET 기반 랭킹 시스템을 4개 구간으로 나누어 Redis가 해결하는 것과 그렇지 않은 것을 분석합니다.
배경: 왜 Redis ZSET인가
상품 랭킹을 DB로 구현하면 매번 이런 쿼리가 필요합니다.
SELECT product_id, SUM(score) as total
FROM product_metrics
WHERE date = '2026-04-10'
GROUP BY product_id
ORDER BY total DESC
LIMIT 20;
트래픽이 적을 땐 문제없지만, 랭킹 페이지는 자주 호출되는 API입니다. 트래픽이 몰리면 매번 GROUP BY + ORDER BY를 하는 건 DB에 부담이 됩니다.
Redis의 Sorted Set(ZSET)은 이 문제를 위해 태어난 자료구조입니다. member(상품 ID)와 score(인기 점수)를 넣으면 score 기준으로 자동 정렬해주지요.
Top-N 조회는 ZREVRANGE, 특정 상품의 순위는 ZREVRANK 한 방이면 끝입니다.
하지만 ZSET을 "잘" 쓴다는 건, ZSET이 해결하지 않는 것의 목록을 알고 있다는 뜻이라고 생각했습니다.
[1] 클라이언트가 점수를 기록할 때
Redis가 해결하는 것
점수 기록 요청
├─ RESP 프로토콜: 텍스트 기반 직렬화, 파싱 오버헤드 최소
├─ Single-threaded: 모든 명령이 순차 실행 → 동시성 제어 불필요
├─ ZINCRBY: 기존 score에 delta를 원자적으로 누적
│ └─ member가 없으면 0에서 시작
└─ Pipelining: 여러 명령을 한 번에 묶어 RTT 절약
싱글 스레드의 힘: 인스턴스가 10대여도 동시에 ZINCRBY ranking:day:20260410 0.2 101을 보내면 Redis가 하나씩 순차 실행합니다. Lock이 필요 없습니다. DB에서 조회수 UPDATE할 때 겪는 Optimistic Lock 충돌이 여기선 발생하지 않습니다.
ZINCRBY vs ZADD: ZADD는 score를 덮어씁니다. 좋아요 3번에 ZADD 0.2를 3번 넣으면 최종 score는 0.2입니다. 원하는 건 0.6이므로, 누적에는 ZINCRBY를 써야 합니다.
Redis가 해결하지 않는 것
1. 프로듀서 애플리케이션이 죽는 경우
Kafka Consumer가 이벤트를 받아서 ZINCRBY를 호출하기 전에 죽으면 어떻게 될까요? Kafka의 at-least-once 보장 덕분에 이벤트는 재배달되지만, 반대로 ZINCRBY 성공 후 offset commit 전에 죽으면 같은 이벤트가 두 번 반영됩니다.
랭킹이라서 0.1 정도의 중복은 사용자가 체감할 수 없습니다. 은행 잔고와는 다릅니다. 하지만 "괜찮다"와 "신경 안 써도 된다"는 다른 말입니다.
2. DB-Redis 간 원자성 부재
비즈니스 로직이 DB 트랜잭션과 Redis 쓰기를 모두 포함하면, 둘 사이의 atomicity는 보장되지 않습니다.
@Transactional
public void processEvent(Event event) {
ledgerRepository.upsert(event); // DB: 성공
redisTemplate.opsForZSet()
.incrementScore(key, member, delta); // Redis: 실패하면?
// DB는 이미 커밋 준비 중...
}
DB는 커밋됐는데 Redis가 실패하면 불일치가 생깁니다. 반대도 마찬가지입니다. 2PC(Two-Phase Commit)를 쓸 수도 있겠지만, 랭킹 시스템에 그 정도 복잡도를 들일 이유가 없습니다.
해결책: DB를 원장(Ledger)으로, Redis는 파생 뷰로
이벤트 → Kafka Consumer → DB Ledger upsert (dirty=true)
↓
5초 주기 SyncScheduler
↓
dirty 행 조회 → ZADD → dirty=false
DB ledger를 source of truth로 두고, 별도 스케줄러가 dirty 행만 읽어서 ZADD로 동기화합니다. Redis가 날아가도 ledger를 다시 sync하면 복구가 끝납니다. ZINCRBY 시절의 "Redis가 곧 원장" 문제가 사라집니다.
3. 합성 Score 인코딩 — ZINCRBY로는 불가능한 것
동점이면 누가 위에 올라갈까요? Redis 기본 동작은 member의 사전순(lexicographic) 정렬입니다. productId "100"이 "99"보다 앞서는데, 이건 비즈니스적으로 아무 의미가 없습니다.
정책을 정했습니다: 동점이면 더 최근에 갱신된 상품이 상위. "지금 뜨고 있는 상품"을 위로 올리는 효과입니다. 구현은 하나의 double 값에 두 가지 정보를 끼워 넣는 합성 score 인코딩입니다.
composite = basePoints + ((lastScoredEpochSec - bucketStartEpochSec) * 1e-9)
- basePoints가 정수부를 차지하고, 시각 정보가 소수 8~9자리에 들어감
- base points 차이가 1e-4 이상이면 tie-break 비트는 영향 없음
- double 정밀도(약 15~16자리) 안에서 base points가 1e6 이하면 안전
문제는 ZINCRBY로는 이 합성값을 만들 수 없다는 것입니다. ZINCRBY는 단순 덧셈만 하므로, 합성값의 소수부(시각 정보)가 누적되어 망가집니다. 결국 application에서 합성 score를 계산한 뒤 ZADD로 덮어쓰는 구조가 필요합니다.
[2] Redis 서버가 명령어를 처리할 때
Redis가 해결하는 것
Skip List 기반 O(log N) 삽입/갱신
ZSET 내부는 두 자료구조의 조합입니다.
ZSET 내부 구조
├─ Hash Table: member → score 매핑 (O(1) lookup)
└─ Skip List: score 기준 정렬 (O(log N) insert/delete/range)
Skip List는 Balanced Tree(Red-Black, AVL)보다 구현이 단순하면서 비슷한 성능을 냅니다. 10만 개 상품이 들어있어도 ZADD는 O(log 100,000) ≈ 17번의 비교로 끝납니다.
인메모리 — 디스크 IO 제로
모든 데이터가 메모리에 있으므로 디스크 탐색 시간이 없습니다. ZREVRANGE ranking:day:20260410 0 19 WITHSCORES는 마이크로초 단위로 응답합니다. DB에서 ORDER BY + LIMIT을 하면 인덱스가 있어도 디스크 IO가 개입하는 것과 대조적입니다.
자동 TTL 만료
ZADD ranking:day:20260410 ...
EXPIRE ranking:day:20260410 172800 // 2일 후 자동 삭제
일별 키에 TTL을 걸어두면 이틀 뒤에 알아서 사라집니다. 별도 정리 배치가 필요 없습니다. 시간별 키는 1일 TTL이면 충분합니다.
Redis가 해결하지 않는 것
1. 메모리는 유한하다
Redis는 인메모리 DB이므로, 모든 데이터가 RAM을 차지합니다. 10만 개 상품의 ZSET은 어떨까요?
- member당 약 100바이트 × 100,000 = ~10MB
이 정도는 Redis에 전혀 부담이 아닙니다. 하지만 일별 + 시간별 키가 매일 쌓이고, 상품이 수백만 개로 늘어나면 이야기가 달라집니다. TTL 자동 만료로 오래된 키는 정리되지만, 하나의 ZSET이 수천만 member를 가지면 ZREMRANGEBYRANK로 하위권을 주기적으로 정리하는 전략이 필요합니다.
2. 주문 금액의 스케일 문제
이벤트 타입별 가중치를 정했습니다: 조회 0.1, 좋아요 0.2, 주문 0.7. 그런데 주문의 score는 price × quantity입니다.
조회 1회: 0.1
좋아요 1회: 0.2
주문 1회(100만원 × 10개): 0.7 × 10,000,000 = 7,000,000
조회수 7천만 회와 주문 1회가 같은 score라는 건 말이 안 됩니다. Redis는 score에 숫자만 넣으면 충실히 정렬해주지만, 그 숫자의 의미는 개발자가 설계해야 합니다.
해결책: log10 스케일링
score = orderWeight × log10(Σ price × quantity)
= 0.7 × log10(10,000,000)
= 0.7 × 7.0
= 4.9
여기서 합산 시점도 중요합니다. 이벤트 단위로 log10을 적용한 뒤 합산하면(Σ log10(price_i × qty_i)), 같은 상품의 큰 주문이 작은 주문보다 지수적으로 유리해지는 부작용이 생깁니다. 먼저 raw value를 합산한 뒤 log10을 한 번만 적용해야 공정합니다.
[3] 클라이언트가 랭킹을 조회할 때
Redis가 해결하는 것
Top-N 조회: O(log N + M)
ZREVRANGE ranking:day:20260410 0 19 WITHSCORES
Skip List에서 가장 큰 score부터 M개를 뽑습니다. 인덱스는 0부터 시작하므로 페이징은 다음과 같습니다
page=1: start=0, stop=19 (1~20위)
page=2: start=20, stop=39 (21~40위)
일반화: start = (page-1) * size, stop = start + size - 1
개별 순위 조회: O(log N)\
ZREVRANK ranking:day:20260410 101
→ 0 (0-based, +1 해서 사용자에게 1위로 표시)
특정 상품의 순위를 상수 시간에 가깝게 알 수 있습니다. DB에서 이걸 하려면 COUNT(*) WHERE score > ?인데, 인덱스가 있어도 느립니다.
Redis 위에 캐시를 또 올릴 필요가 없다
Redis 자체가 인메모리 캐시입니다. ZREVRANGE가 마이크로초 단위로 응답하는데, 그 위에 로컬 캐시를 한 겹 더 올리면 캐시의 캐시가 됩니다. 초당 수만 건의 극단적 트래픽이 아니면 직접 호출로 충분합니다.
Redis가 해결하지 않는 것
1. 상품 정보 조합
ZREVRANGE가 반환하는 건 [(productId, score), ...] 뿐입니다. 사용자에게 보여줄 상품 이름, 가격, 이미지 같은 정보는 없습니다.
ZREVRANGE 결과: [(101, 15.3), (205, 14.8), (42, 12.1)]
↓
사용자가 보고 싶은 것:
1위: 나이키 에어맥스 / 189,000원 / 별점 4.5
2위: 아디다스 울트라부스트 / 219,000원 / 별점 4.3
...
productId 리스트를 받아서 RDB에서 상품 정보를 조회한 뒤 조합해야 합니다. 이 뷰 조합을 어느 계층에서 할 것인가가 중요한데, 도메인 로직이 아니라 표현(Presentation) 계층의 관심사이므로 Controller에서 처리합니다.
// Controller (Interfaces 계층)
List<ProductRanking> rankings = rankingQueryService.getDailyRanking(date, page, size);
List<Long> productIds = rankings.stream().map(ProductRanking::productId).toList();
Map<Long, Product> products = productService.getByIds(productIds); // RDB 조회
return rankings.stream()
.map(r -> RankingProductResponse.from(r, products.get(r.productId())))
.toList();
2. score를 사용자에게 노출할 것인가
합성 score 인코딩을 쓰면 score 값이 15.000000042처럼 됩니다. 이걸 사용자에게 보여주면 혼란만 줍니다. 디코딩해서 base points만 보여줄 수도 있지만, 애초에 사용자에게 필요한 건 순위이지 점수가 아닙니다. 응답 DTO에서 score 필드를 빼면 합성 score 노출 문제 자체가 사라집니다.
[4] 장애가 발생했을 때
Redis가 해결하는 것
RDB 스냅샷 + AOF 로그
Redis는 두 가지 영속성 옵션을 제공합니다.
RDB: 특정 시점의 메모리 전체를 디스크에 덤프 (fork + COW)
AOF: 모든 쓰기 명령을 로그로 기록 (append-only)
RDB AOF
┌──────────────┐ ┌──────────────┐
│ 주기적 스냅샷 │ │ 모든 쓰기 기록 │
│ 복구 빠름 │ │ 데이터 손실 최소 │
│ 데이터 손실 가능 │ │ 파일 크기 큼 │
└──────────────┘ └──────────────┘
Replication: Master-Replica 구조로 읽기 부하를 분산하고, Master 장애 시 Replica를 승격시킬 수 있습니다. 랭킹 조회(ZREVRANGE)는 Replica에서, 쓰기(ZADD)는 Master에서 처리하면 됩니다.
Redis가 해결하지 않는 것
1. Redis가 Source of Truth이면 복구가 어렵다
초기 구현처럼 Kafka Consumer가 직접 ZINCRBY로 Redis에 누적하면, Redis가 사실상 source of truth가 됩니다. Redis가 날아가면 어떻게 될까요?
- AOF가 있으면 마지막 fsync 이후의 데이터만 손실
- AOF가 없으면 마지막 RDB 스냅샷 이후의 모든 누적이 증발
- Kafka offset을 되감아서 재처리? → 멱등성 보장이 또 필요
해결책: DB ledger를 원장으로 두면 Redis는 언제든 재구축할 수 있는 파생 뷰가 됩니다. ledger를 다시 sync하면 끝입니다.
2. 콜드 스타트
매일 자정에 새로운 일별 키가 시작되면 ZSET이 비어있습니다. 새벽 2시에 랭킹 API를 호출하면 빈 결과가 반환됩니다. Redis는 "데이터가 없으면 없다"고만 답할 뿐, 어제 데이터를 알아서 이월해주지 않습니다.
Redis에 ZUNIONSTORE 명령이 있어서 어제 키의 10%를 오늘 키로 복사할 수 있습니다.
ZUNIONSTORE ranking:day:20260411 1 ranking:day:20260410 WEIGHTS 0.1
하지만 합성 score 인코딩을 쓰면 이 명령이 깨집니다. (base + tsFraction) × 0.1 ≠ base×0.1 + tsFraction. 시각 정보까지 0.1배가 되어 의미가 망가집니다.
해결책: carry-over도 ledger 기반으로 처리합니다. 23:50에 오늘 ledger를 읽어서 basePoints × 0.1만큼 내일 ledger에 추가하고, 다음 날 첫 SyncScheduler 사이클에서 ZADD로 자연스럽게 반영됩니다.
23:50 → 오늘 ledger 읽기 → 내일 ledger에 10% 추가 (dirty=true)
↓
00:00 → SyncScheduler 첫 사이클 → ZADD ranking:day:20260411 → 빈 랭킹 방지
10%인 이유는 다음과 같습니다. 50%면 어제 순위가 오늘을 지배합니다. 1%면 새벽에 조회 1회(0.1점)에도 밀립니다. 10%가 오전 중 자연스럽게 오늘 점수가 주도하게 되는 수준입니다.
3. 분산 환경에서의 중복 실행
서버가 N대면 SyncScheduler와 CarryOverScheduler가 N번 동시에 돌 수 있습니다. SyncScheduler는 합성 score가 멱등적 ZADD라서 중복 실행돼도 결과가 같습니다. 하지만 CarryOverScheduler가 두 번 돌면 10%가 20%가 됩니다.
Redis가 제공하는 분산 락(SET key value NX EX ttl)으로 동시 실행을 차단할 수 있습니다.
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent("ranking:lock:carry-over", uuid, 300, TimeUnit.SECONDS);
하지만 분산 락은 동시 실행만 막습니다. 락이 만료된 후 같은 작업이 다시 실행되면 어떻게 될까요? cron이 다음 날에 같은 날짜를 다시 처리하면요? carry-over가 두 번 누적됩니다.
해결책: DB unique 제약으로 이중 방어
CREATE TABLE ranking_carry_over_history (
carry_over_date DATE NOT NULL UNIQUE
);
existsByCarryOverDate(today)로 99% 케이스를 차단하고, 동시에 가드를 통과한 race 케이스는 UNIQUE 충돌로 트랜잭션이 통째로 롤백됩니다. 분산 락(동시 실행 차단) + DB unique(재진입 멱등성), 이중 방어입니다.
결론
| 구간 | Reedis가 해결 | 개발자 책임 |
| [1] 점수 기록 | 싱글 스레드 원자성, ZINCRBY 누적 | DB-Redis 원자성 → Ledger + ZADD 구조 |
| [2] 명령 처리 | Skip List O(log N), 인메모리, TTL | score 스케일링 → log10, 합성 score 인코딩 |
| [3] 랭킹 조회 | ZREVRANGE/ZREVRANK, 마이크로초 응답 | 상품 정보 조합, score 노출 여부 판단 |
| [4] 장애/복구 | RDB/AOF, Replication | Source of Truth 설계, 콜드 스타트, 멱등성 |
Kafka 때의 교훈과 같습니다. Redis를 "잘" 쓴다는 것은 "Redis가 해결하지 않는 것의 목록을 알고 있다"는 뜻입니다. ZSET은 O(log N) 정렬과 인메모리 속도를 공짜로 주지만, source of truth 설계, 합성 score 인코딩, 콜드 스타트 완화, 분산 환경의 멱등성은 전부 개발자의 몫입니다.
ZINCRBY 한 줄이면 될 줄 알았던 랭킹 시스템이 DB Ledger + ZADD + Tie-Break + Idempotent Carry-Over까지 발전한 건, 결국 Redis가 해결하지 않는 것들을 하나씩 메우는 과정이었습니다.
'TW' 카테고리의 다른 글
| 대기열? ZADD하면 끝 아니야? (0) | 2026.04.03 |
|---|---|
| Kafka가 해결하는 것과 여전히 남아있는 것 (0) | 2026.03.27 |
| 인덱스가 5배 느려지게 만들었다 (0) | 2026.03.11 |
| 원자적 업데이트가 그렇게 좋아? (3) | 2026.03.06 |
| JPA 엔티티와 도메인 엔티티, 분리해야 하는가? (0) | 2026.02.25 |




