TL;DR — 랭킹에 Redis를 쓰는 이유는 Top-N 조회가 아니다. 인덱스를 걸면 Top-N은 DB도 잘한다. 진짜 격차는 집계(조회 트래픽 전체가 쓰기로 전환된다), 개별 순위(세지 않고 순위를 아는 것), 폐기(어제 점수판을 치우는 것)라는 덜 보이는 세 연산의 건당 가격에 있었다.
이번 주 과제는 상품 랭킹이다. 조회/좋아요/주문 이벤트를 Kafka로 받아 Redis ZSET에 점수를 쌓고, API가 Top-N과 개별 순위를 내려준다.
"랭킹 = Redis ZSET"은 이 바닥의 공식이다. 근데 나는 공식을 그냥 받아먹으면 체하는 편이라, 설계하는 내내 질문이 꼬리를 물었다. 그 질문들을 던진 순서대로 남긴다. 앞쪽은 설계하다 만난 질문들이고, 뒤로 갈수록 "그래서 왜 Redis인데"라는 근본 질문으로 내려간다.
1. 그냥 키 하나에 계속 누적하면 안 되나?
제일 처음 한 생각. ranking:all 키 하나에 계속 ZINCRBY 하면 되는 거 아닌가. 단순하고 좋잖아.
안 된다. 랭킹의 목적을 배신하기 때문이다.
랭킹이 보여주려는 건 "오늘의 인기"다. 무한 누적은 "역대 누적 인기"다. 작년에 점수를 쌓아둔 상품이 영원히 상위권에 깔고 앉는다. 신상품은 오늘 반응이 아무리 좋아도 누적치를 못 따라잡는다. 노출이 안 되면 클릭도 구매도 안 생기니 격차는 더 벌어진다 — 악순환이다. 소수가 상위를 독식하고 나머지가 긴 꼬리를 이루는 이 분포에는 롱테일이라는 이름까지 붙어 있다.1
그래서 키에 날짜를 박았다.
ranking:all:20260717 ← 오늘의 점수판
ranking:all:20260716 ← 어제의 점수판
시간을 일 단위로 쪼개는 것. 마음에 든 건 리셋 방식이다 — 자정에 아무 배치도 안 돌린다. 새 날짜 키에 쌓이는 순간 리셋은 이미 끝나 있다.
2. TTL은 왜 정확히 24시간이면 안 되나?
하루짜리 점수판이니 TTL도 24시간. 당연하다고 생각했는데, 굴려보니 세 군데서 걸렸다.
하나, TTL 카운트는 자정부터가 아니라 키에 첫 값이 들어간 시점부터다. 만료 시각이 날짜 경계와 어긋난다. 둘, 자정 근처에 컨슈머가 밀리면 어제 발생한 이벤트를 어제 키에 넣어야 하는데, 그 키가 이미 죽어 있을 수 있다. 셋, "어제 랭킹 조회" 기능과 carry-over(12번에서 나온다)가 전날 키를 읽어야 한다.
그래서 윈도우의 2배인 2일. 여기서 하나가 정리됐다 — 어느 키에 들어가느냐(집계 경계)와 얼마나 보관하느냐(TTL)는 서로 다른 레이어다. 처음엔 이 둘을 한 덩어리로 뭉뚱그려 생각해서 헷갈렸던 거다.
3. 이 이벤트, 어느 날짜 키에 넣지?
23시 59분에 발생한 좋아요가 0시 1분에 컨슘되면 어제 키인가, 오늘 키인가.
정확히 하려면 발생 시각(occurredAt) 기준이어야 한다. 근데 우리 Kafka envelope엔 발생 시각 필드가 없었다. 넣으려면 여러 서비스가 같이 쓰는 공용 envelope을 수정해야 한다. 변경 범위가 크다.
이 비용을 낼 가치가 있나. 세 가지로 판정해봤다.
① 오차가 실제 손해로 이어지나? → 아니오. 랭킹은 전시용이다.
② 오차가 영구적인가? → 아니오. TTL 2일이면 오차의 수명도 최대 하루다.
③ 다른 시스템이 이 값에 의존하나? → 아니오.
셋 다 아니오라서 처리 시각(processedAt, LocalDate.now())으로 갔다. 자정 근처 극소수 이벤트가 하루 옆 칸에 잘못 꽂힐 수 있지만 감수한다.
이때 "감수"라는 단어를 처음 의식적으로 썼는데, 이 세 질문짜리 판정 틀을 뒤에서 계속 다시 꺼내 쓰게 될 줄은 몰랐다.
4. 조회/좋아요/주문, 다 1점씩 주면 안 되나?
발생 빈도가 다르다. 조회는 페이지를 열기만 해도 발생하니 압도적으로 많다. 전부 1점이면 랭킹은 "많이 보인 상품" 순위가 되지, "인기 있는 상품" 순위가 못 된다.
정리하다 보니 빈도와 신호 강도가 반비례한다는 게 재밌었다. 제일 많이 발생하는 조회가 제일 약한 신호고, 제일 드문 주문이 돈을 쓴 확정 신호라 제일 강하다. 그래서 보정한다 — View 0.1 / Like 0.2 / Order 0.7.
5. 주문은 금액을 그대로 점수로 쓰면 안 되나?
건수만 세면 매출 기여를 무시한다. 그래서 price × amount를 그대로 넣어봤더니 반대쪽 극단이 나왔다.
500만원 × 1건 = 5,000,000 점
5천원 × 100건 = 500,000 점
초고가 1건이 대중 인기 상품 100건을 10배로 눌러버린다. 이것도 "인기"라는 목적과 어긋난다. 건수만 세면 매출을 무시하고, 금액을 그대로 쓰면 매출이 지배한다. 양극단 사이 어딘가가 필요했다.
log를 씌웠다.
log10(5,000,000 + 1) ≈ 6.70
log10( 500,000 + 1) ≈ 5.70
곱셈으로 벌어진 10배 격차가 덧셈 수준(약 1.18배)으로 눌린다. 매출 기여는 반영하되 지배는 못 하게 하는 절충이다. +1은 금액 0일 때 log(0) = -∞로 추락하는 걸 막는 안전핀.
여기까지가 설계다. 키 전략, TTL, 날짜 귀속, 가중치, 정규화 — 하나하나 나름의 이유를 달아가며 결정했다. 근데 다 만들고 나니 발밑이 허전했다. 이 모든 게 "Redis ZSET을 쓴다"는 전제 위에 서 있는데, 정작 그 전제를 나는 검증한 적이 없다.
6. 그래서, DB로 하면 왜 안 되는데?
"DB의 ORDER BY는 데이터가 쌓일수록 느려지고, 조회 빈도가 높아 과부하로 이어진다."
랭킹에 왜 Redis를 쓰냐고 물으면 돌아오는 표준 답변이다. 이게 나한테는 안 꽂혔다. 아니, 인덱스 걸면 되잖아?
허수아비를 때리면 의미가 없으니, DB를 최대한 유리하게 설계해놓고 비교하기로 했다.
CREATE TABLE ranking_score (
ranking_date DATE NOT NULL,
product_id BIGINT NOT NULL,
score DOUBLE NOT NULL DEFAULT 0,
PRIMARY KEY (ranking_date, product_id),
INDEX idx_date_score (ranking_date, score DESC)
);
-- 이벤트마다 upsert로 점수 누적
INSERT INTO ranking_score (ranking_date, product_id, score)
VALUES (CURDATE(), 123, 0.2)
ON DUPLICATE KEY UPDATE score = score + 0.2;
-- Top-N 조회
SELECT product_id, score
FROM ranking_score
WHERE ranking_date = CURDATE()
ORDER BY score DESC
LIMIT 20;
(ranking_date, score DESC) 복합 인덱스가 있으니 저 Top-N 쿼리는 정렬을 하지 않는다. 인덱스가 이미 score 순으로 정렬돼 있어서, 리프 노드를 따라 20개만 읽으면 끝이다. "매 요청 GROUP BY + ORDER BY"가 느리다는 표준 답변은 집계를 미리 안 해둔 순진한 설계 얘기지, 이 설계엔 해당 사항이 없다.
여기서 허탈했다. Top-N만 놓고 보면 DB도 잘한다. 그럼 Redis를 쓸 이유가 대체 뭐지. 설계는 다 끝났는데 전제가 무너지는 기분이었다.
7. 조회는 읽기가 아니었다
읽기만 보고 있던 게 문제였다. 쓰기를 봐야 했다.
랭킹 점수는 언제 갱신되나? 조회, 좋아요, 주문마다. 그중 제일 흔한 게 뭔가? 조회다. 페이지를 열기만 해도 발생하니까. 그러면 —
조회 이벤트는 유저에겐 읽기지만, 랭킹 시스템에겐 쓰기다.
거의 모든 페이지뷰가 랭킹 테이블에 UPDATE 한 건을 만든다. 서비스의 읽기 트래픽 전체가 랭킹 시스템 입장에선 쓰기 트래픽으로 전환된다. 하나 더 — 읽기는 replica를 늘려 분산할 수 있지만, 쓰기는 primary 한 대로 몰린다. "쓰기 폭주"는 사고가 아니라 이 구조의 기본값이었다.
그래도 의심이 남았다. Redis도 결국 값 갱신은 똑같지 않나. UPDATE score = score + 0.2와 ZINCRBY 0.2는 논리적으로 같은 연산인데.
같은 "update"라도 한 건이 하는 일의 양이 달랐다. MySQL(InnoDB)의 UPDATE 한 건이 밟는 경로2:
1. B+트리 탐색으로 행 찾기 (버퍼 풀에 없으면 디스크행)
2. 그 행에 배타 락 (커밋까지 유지)
3. undo/redo 로그 기록
4. 커밋 시 fsync (디스크 동기화 — 가장 비쌈)
5. score 인덱스 재정렬 (옛 위치 삭제 + 새 위치 삽입)
5번에서 좀 억울해졌다. Top-N 조회를 공짜로 만들어준 그 idx_date_score가, 쓰기 때마다 재정렬 비용을 청구하고 있었다. score가 바뀌면 인덱스 안에서 그 행의 위치도 옮겨야 하니까.
읽기가 쌌던 건 공짜가 아니었다. 쓰기가 그 비용을 건건이 대신 내주고 있었다.
Redis ZINCRBY 한 건:
1. 메모리 자료구조 갱신. 끝.
(디스크는 응답 경로에 없음. 락 없음. 로그 없음. μs 단위.)
여기서 이 문장이 만들어졌다. 정렬을 쓰기 시점에 유지하는 전략 자체는 DB 인덱스(B+트리)나 ZSET(스킵리스트)이나 똑같다. 차이는 그 쓰기의 건당 가격이다. Redis는 정렬 비용을 읽기 시점에서 쓰기 시점으로 옮기고, 그 쓰기를 메모리 가격에 산다.
8. 인기 상품이 제일 위험한 행이었다
건당 가격만이 아니다. 쓰기가 어디에 몰리는지도 문제였다.
인기 상품의 정의가 뭔가. "많이 조회되는 상품"이다. 그러면 랭킹 테이블에서 UPDATE가 집중되는 행도 정확히 그 상품의 행이다. 거의 동어반복인데, 파레토까지 겹치면 상위 몇 개 행에 쓰기가 쏠린다.
InnoDB는 UPDATE 때 그 행에 배타 락을 걸고 커밋까지 유지한다. 1위 상품을 초당 500명이 조회하면 UPDATE 500건이 같은 행 하나에 몰린다. 락은 한 번에 하나만 — 500개 트랜잭션이 한 줄로 선다. 그 행의 처리량은 "락 잡고 → 갱신 → fsync → 락 놓기" 시간의 역수로 캡이 걸린다. 핫 로우(hot row)다.
이론으로만 알다가, 이게 실제로 회사를 멈춘 사건을 찾았다.
2011년 저스틴 비버가 인스타그램에 가입했다. 그가 포스팅할 때마다 좋아요가 폭주해 서버가 실제로 다운되곤 했다. 공동창업자 Mike Krieger는 비버의 DB 상 ID — 6860189 — 를 외우고 있었다고 회고한다.3 장애 원인이 하도 자주 그 계정이라, ID만 보고도 "또 걔구나" 알 수 있었다는 거다.
인스타그램의 초기 해법이 흥미롭다. 좋아요 테이블을 매번 COUNT 하는 대신, 포스트 테이블에 카운트 컬럼을 두고 증분 갱신하는 denormalized counter로 바꿨다. 읽다가 소름이 돋았는데 — 이거 우리 product_metrics 그 자체다. 컨슈머가 like_count에 +1 upsert하는 구조. 근데 인스타그램은 여기서 안 끝났다. 카운터를 한 행에 몰자 이번엔 그 행이 핫 로우가 됐고, 결국 카운터를 여러 행으로 쪼개 나눠 쓰는 샤딩까지 갔다.
핫 로우는 설계를 잘해서 피하는 게 아니었다. "인기"라는 개념 자체에 내장된 문제였다. Redis는 단일 스레드 순차 처리라 애초에 락이 없다 — 잠글 상황 자체가 없다.
9. "이 상품 오늘 7위"는 어떻게 아는 건데?
상품 상세에 순위 뱃지를 붙이는 요구가 있었다. 처음엔 이렇게 생각했다. 어차피 상품 정보 가져올 때 DB 다녀오는데, 순위도 그때 같이 조회하면 되잖아?
두 조회의 종류가 달랐다.
상품 정보는 WHERE id = ? PK 조회다. 아주 싸다. 실제로 우리도 랭킹 페이지의 상품 정보는 DB에서 가져온다 — ZSET이 없애주는 건 "매 요청 정렬"이지 모든 DB 접근이 아니니까.
상품 순위는 다르다. "나보다 점수 높은 상품이 몇 개냐"는 집계 질문이다.
SELECT COUNT(*) FROM ranking_score
WHERE ranking_date = CURDATE() AND score > (내 점수);
순위가 낮을수록 스캔 범위가 커지고, 상품마다 값이 달라 캐시도 안 먹히고, 상세 페이지를 열 때마다 실행된다. ZSET의 ZREVRANK는 세지 않고 O(logN)에 순위를 돌려준다.
구현하면서 마음에 들었던 디테일 하나. ZREVRANK는 member가 없으면 nil을 반환한다 — 0을 "없음"으로 쓸 수 없기 때문이다. 0은 "1위"라는 유효한 값이니까. nil은 자바에서 null이 되고, 응답의 rank 필드에 그대로 실려 나간다. "순위에 없으면 null"이라는 스펙이 별도 처리 없이 자연스럽게 충족됐다.
어떻게 안 세고 순위를 아는지 파봤더니 스킵리스트의 span이라는 게 나왔다 — 각 전진 포인터에 "건너뛴 노드 수"가 저장돼 있어, 탐색 경로의 span을 합하면 그게 곧 순위가 되는 구조다. 더 재밌는 건 역사다. Redis 창시자 antirez에 따르면 ZRANK의 O(logN) 구현은 원래 설계에 있던 게 아니라 외부 기여자의 패치로 들어왔고, 스킵리스트가 단순한 덕분에 코드 변경이 거의 없이 가능했다.4 단순함을 골랐더니 O(logN) 순위가 부산물로 따라온 셈이다. (스킵리스트가 왜 트리가 아닌지, 그런데도 세상의 DB는 왜 여전히 B+트리인지는 그것대로 한 편이라 다음 글로 미룬다.)
10. 어제 점수판은 누가 치우나
제일 시시해 보였는데 격차가 컸던 연산이다.
날짜별로 쪼갰으니 "새 판 시작"은 DB도 date 컬럼으로 공짜다. 격차는 지난 판 폐기에서 벌어진다. DB에서 어제치 수백만 행을 지우는 건 일이다. DELETE WHERE ranking_date < ?를 그냥 날리면 거대 트랜잭션에 undo 로그가 폭증하고 복제 지연이 따라온다. 실무에선 청크 단위 삭제 배치를 돌리거나, 파티셔닝을 해두고 DROP PARTITION으로 잘라낸다. 어느 쪽이든 만들고 운영해야 할 무언가가 생긴다.
Redis는 키 만들 때 이미 끝났다.
EXPIRE ranking:all:20260717 172800
여기까지 오니 연산별 대차대조표가 그려졌다.
| 연산 | DB (score 컬럼 + 인덱스) | Redis ZSET | 격차 |
|---|---|---|---|
| Top-N 조회 | 인덱스 리프 20개 읽기 | ZREVRANGE | 거의 없음 |
| 집계 (쓰기) | fsync + 인덱스 재정렬 + 행 락 | 메모리 연산, μs | 크다 |
| 개별 순위 | COUNT 범위 스캔, 캐시 불가 | ZREVRANK O(logN) | 크다 |
| 폐기 | 삭제 배치 / 파티션 운영 | TTL 한 줄 | 크다 |
표준 답변이 자랑하던 Top-N이 실은 격차가 제일 작은 줄이었다. Redis를 고르게 한 진짜 근거는 화려한 Top-N이 아니라, 집계·개별 순위·폐기라는 덜 보이는 세 줄이었다.
솔직히 덧붙이면 — 자릿수를 추정해서 초당 쓰기가 몇십 건 수준이면, 저 표의 DB 열도 그냥 다 버틴다. 그 규모에서 처음부터 Redis는 오버엔지니어링일 수 있다. 그리고 우리 프로젝트는 사실 둘 다 한다. 정확해야 하는 원본 집계(product_metrics)는 DB에, 전시용 정렬 뷰(ZSET)는 Redis에. "Redis냐 DB냐"가 아니라 역할 분담이었다.
11. Redis에만 있는 점수, 날아가면 어떡하지?
구현을 끝내고 뒤늦게 불안해졌다.
가중치 적용된 점수라는 숫자는 Redis에만 있다. product_metrics엔 원시 카운트만 있지, 가중 합산된 그 값은 어디에도 없다. 유일한 사본이 휘발성 저장소에 있는 건데 — 이거 Redis가 SoT(Source of Truth)가 돼버린 거 아닌가? DB에 백업해줘야 하나?
한참 고민하다가 정의를 잘못 잡고 있었다는 걸 알았다. SoT는 "유일하게 저장된 곳"이 아니라 **"틀리면 안 되고, 어긋났을 때 기준이 되는 곳"**이다. Kleppmann은 시스템의 데이터를 원본(system of record)과 파생 데이터(derived data)로 나누는데, 파생 데이터의 정의가 정확히 이거다 — 원본에서 다시 만들 수 있고, 잃어도 원본이 훼손되지 않는 데이터.5
3번에서 만든 판정 틀을 다시 꺼냈다. 잃으면 실제 손해가 나나? 아니오, 전시용이다. 오차가 영구적인가? 아니오, TTL 2일이면 자연 소멸한다. 다른 시스템이 의존하나? 아니오.
셋 다 아니오면 유일 사본이어도 SoT가 아니다. 잃어도 되는 파생 뷰다. DB 백업은 안 하기로 했다 — 조회용이면 ORDER BY 부하가 부활하고, 백업용이면 잃어도 되는 데이터에 파이프라인 비용만 얹는다.
단서 하나. 이 순위로 셀러 정산이나 보상이 나가는 순간 "잃으면 손해인가"의 답이 뒤집히고, 스냅샷·영속화·재구성 절차가 다 필요해진다. 같은 데이터라도 쓰임새가 등급을 결정한다.
12. 자정이 되자 랭킹판이 텅 비었다
일자 변경 테스트를 돌리다가 직접 봤다. 자정이 지나면 새 키가 빈 채로 시작하고, ZREVRANGE는 빈 집합을 돌려준다. API는 items: []로 정상 응답한다. 서비스는 안 죽는다. 대신 유저가 빈 랭킹판을 본다.
이게 장애가 아니라는 게 무서웠다. 매일 자정마다 정기적으로 발생한다. 전날 인기 있던 상품도, 새벽에 들어온 신상품도 전부 0점에서 다시 시작하니까 — 콜드 스타트다.
근데 잠깐. 11번에서 "잃어도 되는 데이터"라고 판정했잖아. 그런데 빈 화면은 왜 이렇게 못 참겠지? 판정이 틀렸었나?
한참 만에 정리된 건, 두 요구가 서로 다른 레이어에 있다는 거였다. "데이터는 잃어도 된다"는 정합성 레이어의 판정이고, "화면이 비면 안 된다"는 가용성 — 프로덕트 레이어의 요구다. 랭킹의 정확성은 타협할 수 있어도 존재는 타협할 수 없다. 한 줄로 누르면:
stale이 empty보다 낫다.
그래서 해법도 "어제 점수를 조금 얹어서 시작하기"가 된다. 후보를 세 개 놓고 비교했다.
A. 어제 점수 100% 복사 → 기각. 어제 순위가 그대로 고착.
"오늘의" 랭킹이라는 목적이 죽는다.
B. 오전 몇 시까지 어제 랭킹 노출 → 기각. 전환 시각을 어떻게 잡든
그 순간 Top-10이 통째로 급변한다.
C. 어제 점수의 10%만 복사 → 채택. 연속성은 생기고,
오늘의 실제 반응이 금방 추월한다.
구현은 ZUNIONSTORE 한 방이다. 23시 50분에 스케줄러가 돈다.
ZUNIONSTORE ranking:all:20260718 1 ranking:all:20260717 WEIGHTS 0.1
숫자로 보면 의도가 선명하다.
어제 (20260717) 내일 0시의 시작점 (20260718)
product:101 → 100 → product:101 → 10
product:202 → 50 → product:202 → 5
어제의 인기가 "약한 초기값"으로 깔려 연속성이 생기고, 가중치가 0.1이라 오늘 조회 몇 번, 주문 한 건이면 판이 뒤집힌다. TTL을 2일로 잡아둔 게(2번) 여기서도 일한다 — 23시 50분에 어제 키가 살아 있어야 이 연산이 가능하니까.
스케줄러 하나로 "빈 판"이라는 순간 자체가 달력에서 지워졌다.
13. 내가 만난 문제가 Redis를 만든 문제였다
글을 정리하다 Redis가 애초에 왜 태어났는지 찾아봤는데, 여기서 좀 이상한 기분이 들었다.
2007년, 이탈리아의 개발자 Salvatore Sanfilippo(antirez)는 LLOOGG라는 실시간 웹 분석 서비스를 운영하고 있었다. 방문자가 사이트에서 뭘 하는지 실시간으로 보여주는 서비스 — Google Analytics가 비슷한 기능을 내놓기 4년 전이다. 구조를 뜯어보면, 방문자의 페이지뷰마다 DB 쓰기가 발생하고 대시보드는 최신 데이터를 계속 읽어간다.
MySQL이 버티지 못했다. 트래픽이 늘수록 모든 연산이 디스크 I/O에서 막혔다. antirez는 본인 저장소에 이렇게 적어놨다.
나는 Redis를 만들고 있었다. 정확히, 우리 경험상 LLOOGG가 MySQL로는 확장이 불가능했기 때문에.6
메모리에 데이터를 두고, 애플리케이션이 실제로 쓰는 자료구조(리스트, 셋, 정렬된 셋)를 그대로 지원하는 저장소. Tcl로 짠 300줄짜리 프로토타입이 Redis가 됐다. Redis로 갈아탄 LLOOGG는 싸구려 가상머신 한 대로 5년간 20억 페이지뷰를 처리했다.
"페이지뷰마다 DB 쓰기가 발생하는 실시간 집계." 내가 이번 주에 만든 파이프라인의 워크로드와 같다. 도구를 먼저 외우고 문제를 끼워 맞춘 게 아니라, 문제를 끝까지 밀었더니 도구의 출생지에 도착한 거라 묘하게 안심이 됐다.
결국
랭킹은 "쓰기 폭주 + 정렬된 읽기 + 낮은 정확성 요구"라는 특이한 워크로드다. DB는 정확성 보장(fsync, 락, 트랜잭션)에 건건이 비용을 지불하는 시스템이라 이 워크로드와 궁합이 나쁘다 — 필요 없는 보장에 비싼 값을 치르면서 정작 필요한 처리량을 못 받는다. Redis ZSET은 그 보장을 버리고 속도를 사는 교환이고, 랭킹은 그 교환이 남는 장사인 드문 경우다.
확신의 범위도 적어둔다. UPDATE와 ZINCRBY의 건당 비용 구조는 문서와 사례로 확인했지만, 실제 부하를 걸어 수치로 재보진 못했다. 초당 몇 건부터 DB가 실제로 무너지는지 — "폭주"의 경계선은 아직 내 안에서 추정치다. 핫 로우도 원리와 비버 사건으로 이해했지, 컨슈머를 늘려 직접 재현해본 건 아니다. 여기까지가 지금 정직하게 말할 수 있는 선이다.
남은 질문
ZREVRANK가 세지 않고 순위를 아는 비밀 — 스킵리스트의 span, Pugh가 1990년에 들고나온 "확률적 균형"7이라는 아이디어, 그렇게 좋다면서 세상의 DB들은 왜 여전히 B+트리인지. 파다 보니 분량이 넘쳐 다음 글로 넘긴다.
Redis 자체가 병목이 되는 규모는? 단일 스레드가 무기이자 한계일 텐데, ZSET을 샤딩하면 ZREVRANGE와 ZREVRANK는 어떻게 합치나. 아직 감이 없다.
일간 키로 주간/월간 집계 만들기 — ZUNIONSTORE로 7일 치를 합치면 되나, 그 연산의 비용은? 다음 주차 주제라 부딪혀보고 쓴다.
참고 문헌
자기 점검 메모
- 이 글의 진짜 질문: "인덱스 걸면 DB도 되잖아?"라는 반문에 스스로 답할 수 있는가
- 오해/수정 과정: Redis는 조회가 빨라서 쓴다 → Top-N은 DB도 잘한다(허탈) → 격차는 집계·개별 순위·폐기다 / 인덱스는 읽기를 공짜로 만든다 → 쓰기가 그 비용을 건건이 대신 낸다 / Redis에만 있으면 SoT다 → SoT는 유일 사본이 아니라 "기준이 되는 곳"이다 / 잃어도 되는 데이터니 비어도 된다 → 정합성과 가용성은 다른 레이어다(stale > empty)
- 실험 또는 검증의 동기: 표준 답변("DB는 느리다")이 납득이 안 돼서 DB에 최대한 유리한 설계를 직접 세워 연산별로 대조 / 일자 변경 테스트에서 빈 랭킹판을 직접 목격한 것이 콜드 스타트 대응의 출발점
- 남겨둔 미해결 질문: 스킵리스트 내부(span·확률 균형·B+트리 트레이드오프), Redis 샤딩 시 랭킹 연산 합산, 실측 벤치마크, 주간/월간 집계
- 독자층 가정: "랭킹엔 Redis"라는 공식은 알지만 "DB로 하면 왜 안 되는데?"라는 반문에 말문이 막히는 백엔드 초중급 개발자
Footnotes
-
Chris Anderson, "The Long Tail," Wired, October 2004. 소수가 상위를 독식하고 나머지가 긴 꼬리를 이루는 분포에 이 이름을 붙인 원전. ↩
-
MySQL 8.0 Reference Manual, "InnoDB Locking" 및 "innodb_flush_log_at_trx_commit". InnoDB의 암시적 행 락과 커밋 시 로그 플러시(fsync) 동작의 공식 근거. https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html ↩
-
Cade Metz, "How Instagram Solved Its Justin Bieber Problem," Wired, November 11, 2015. 셀럽 포스트로 인한 서버 다운과 denormalized counter 전환의 원출처. Krieger가 비버의 DB ID(6860189)를 외우고 있었다는 일화 포함. 보조: Sarah Frier, No Filter: The Inside Story of Instagram, Simon & Schuster, 2020. ↩
-
Salvatore Sanfilippo(antirez), redis-db Google Groups, 2010. ZSET에 스킵리스트를 채택한 세 가지 이유(메모리 조절 가능, ZRANGE 순회 시 캐시 지역성, 구현·디버깅 단순성)와 ZRANK O(logN) 패치가 외부 기여로 들어온 경위의 직접 서술. ↩
-
Martin Kleppmann, Designing Data-Intensive Applications, O'Reilly, 2017. Part III "Derived Data" — 원본(system of record)과 파생 데이터의 구분, 파생 뷰의 재처리 가능성 논의. 같은 저자의 강연 "Turning the Database Inside-Out"(2014)도 같은 맥락. ↩
-
Salvatore Sanfilippo, LLOOGG 저장소 README, GitHub(antirez/lloogg). "I was writing Redis, exactly because LLOOGG was not scalable using MySQL in our experience" 원문. Redis 전환 후 단일 VM으로 5년간 20억 페이지뷰를 처리한 기록 포함. ↩
-
William Pugh, "Skip Lists: A Probabilistic Alternative to Balanced Trees," Communications of the ACM, vol. 33, no. 6, June 1990, pp. 668–676. DOI: 10.1145/78973.78977. 확률적 균형 개념의 원논문. ↩