본문 바로가기

카테고리 없음

랭킹을 캐싱하지 않기로 했다 — 실시간 상품 랭킹을 Redis ZSET로 견디는 설계

반응형

프로젝트 커머스 백엔드 (Kotlin/Spring, MySQL·Redis·Kafka)  ·  주제 실시간 랭킹 · 저장 방식 선택 · cold start · Redis 장애 복구

TL;DR

만드는 것 — 커머스 상품 랭킹. 조회·좋아요·주문 이벤트로 상품 점수를 집계해 Top-N 랭킹상품별 현재 순위를 제공한다. 이 순위를 "언제·어떻게 계산해 두느냐"가 이 글의 주제이고, 아래가 그 결정들이다.

  • 왜 캐싱이 아니라 즉시 갱신인가 (계산 시점) — 상품 상세에 "내 순위"를 실시간으로 보여줘야 해서, 발행 주기만큼 낡는 배치 스냅샷(캐싱) 대신 이벤트가 들어올 때마다 점수를 즉시 갱신하는 방식을 골랐다.
  • 왜 DB가 아니라 Redis인가 (인프라) — 그 즉시 갱신을 MySQL ORDER BY로 먼저 만들어 실측하니 읽기 ~280rps 붕괴(p95 3.19s, CPU 10%인데 커넥션 풀 잠식) · 쓰기 랙 발산(드레인 185/s < 유입 220/s). 병목이 자료구조라 스케일업으로 안 풀려, 이미 운영 중인 Redis의 Sorted Set으로 옮겼다.
  • cold start — 일간 랭킹이라 자정마다 판이 0점으로 리셋돼 새벽 순위가 비는데, 그 빈 판을 23:50 스냅샷 + tail grace 병합(전일 점수 ×0.1)으로 미리 데운다.
  • Redis가 죽으면 (장애 대비) — Redis는 메모리라 서버가 꺼지면 랭킹 점수가 사라진다. 하지만 랭킹은 원본이 아니라 이벤트로 다시 계산해 만드는 2차 데이터라, 이벤트만 안전하게 남겨두면(발행·전송·소비 각 구간에서 유실 방지) 같은 이벤트를 두 번 세지 않고 그대로 복구할 수 있게 설계했다.

(1차 MySQL과 2차 Redis 모두 AWS 실서버에서 실측했다 — 읽기 천장이 MySQL ~280/초에서 Redis ~1,600~2,000/초로 약 6~8배 올랐고, 병목이 DB CPU에서 앱 CPU로 옮겨갔다. 자세한 부하테스트는 맨 아래 "부하테스트" 절에 있다.)

이 글에서 자주 나오는 용어 (처음이면 여기부터)
  • ZSET (Redis Sorted Set) — 값마다 점수를 매겨 자동으로 정렬해 두는 Redis 자료구조. "정렬을 미리 해 둔 집합"이라 랭킹에 딱 맞는다.
  • 멱등(idempotent) — 같은 이벤트가 실수로 두 번 처리돼도 결과가 한 번 처리한 것과 똑같은 성질. 점수가 두 번 더해지는 "이중 가산"을 막는 장치.
  • 원자적(atomic) — 여러 명령이 "전부 실행되거나 하나도 안 되거나" 둘 중 하나. "반만 반영된" 어정쩡한 중간 상태가 없다.
  • 컨슈머 랙 / 발산 — Kafka에 쌓인 이벤트를 소비 속도가 못 따라가 밀린 양이 컨슈머 랙. 이게 계속 벌어지면 "발산".
  • cold start / warm start — 랭킹을 하루 단위로 나눠서 자정에 새 판이 0점부터 시작하면, 새벽엔 순위가 텅 비어 의미가 없다(cold start). 반대로 미리 채워둔 채로 시작하면 warm start.
  • carry-over(이월) — 그 빈 판을 막으려고 어제 점수의 일부(여기선 10%)를 새 판에 미리 옮겨두는 것. 오늘 점수가 조금만 쌓여도 금방 위로 올라오게, 어제 인기 상품이 갑자기 사라지지 않게 한다.
  • rps·qps · p95 — 초당 요청 수·쿼리 수 · 응답시간을 줄 세웠을 때 느린 쪽 상위 5% 지점(체감 지연).
  • MySQL 1차 구현 / Redis 2차 구현 — 같은 기능을 MySQL로 먼저 만들어 한계를 실측하고(1차), Redis로 다시 만든(2차) 두 버전. R8은 지난 라운드 과제(주문 대기열)를 가리킨다.

1. 문제 — 랭킹판을 "언제" 만들 것인가

랭킹은 겉보기엔 하나지만, 랭킹판이 언제 만들어지느냐로 나뉜다. 이 시점 선택이 곧 아키텍처다.

  • A · 조회 시점 — 요청마다 GROUP BY + ORDER BY로 즉석 계산. 구현 비용 0·항상 정합. 데이터가 쌓일수록·조회가 몰릴수록 죽는다.
  • B · 쓰기 시점 — 이벤트가 들어올 때마다 점수를 즉시 갱신해 정렬 상태로 상주. 읽기는 계산이 아니라 조회. 게임 리더보드가 이 방식.
  • C · 윈도우 집계 — 스트림 처리기가 윈도우가 닫힐 때 갱신. 분 단위 신선도·복잡한 산식에 강하지만 처리기 운영이 붙는다.
  • D · 배치 스냅샷(=캐싱) — 이용 기록은 계속 쌓되 스케줄 시각에 전체를 재계산해 랭킹판 스냅샷을 발행하고, 다음 발행까지 그 판을 그대로 서빙한다. 멜론 TOP100이 대표 사례(매시 정각 발행).
같은 이벤트 스트림, 다른 "생성 시점" 캐싱 · 방식 D
이용 기록 적재
계속 쌓기
매시 정각 전체 재계산
스냅샷 발행
고정 판 서빙
다음 발행까지
발행 주기만큼 낡음
내 순위 지연
실시간 · 방식 B
이벤트 1건
조회·좋아요·주문
ZINCRBY 즉시 갱신
정렬 상태로 상주
조회 = 계산 아닌 조회
항상 최신 순위
내 순위 실시간
지연 없음
결정 요인: 상품 상세에 "내 상품의 지금 순위"를 실시간으로 보여줘야 한다 → 발행 주기만큼 낡는 캐싱은 이 계약을 못 지킨다 단, 실시간이 항상 좋은 건 아니다 — 어뷰징 여지가 커진다. 그래서 "하루 단위로 잘라" 캐싱의 안정성 감각을 일부 가져온다
같은 이벤트, 다른 생성 시점. 캐싱(배치 스냅샷)은 "그 시각의 차트"를 재현·감사할 수 있어 음원 차트·정산 순위엔 최적이지만, 발행 주기보다 빠른 변화는 뭉갠다. 상품 상세의 개별 순위는 그 지연을 허용하지 않는다.
DECISION
랭킹판 생성 시점 — 캐싱(D) vs 실시간 즉시 갱신(B)
  • D · 배치 스냅샷(캐싱) — 안정성·왜곡 통제·재현/감사에 강함(멜론 TOP100). 그러나 발행 주기만큼 낡아 "내 순위"가 실시간이 아님
  • B · 쓰기 시점 즉시 갱신 — 채택 — 이벤트마다 갱신, 읽기는 조회. 요구사항 4개가 이쪽을 가리켰다(아래 근거)
채택 근거는 취향이 아니라 요구사항에서 거꾸로 따진 것이다 — ① 상품 상세에 개별 순위를 실시간으로 노출해야 함(캐싱은 발행 주기만큼 낡음), ② 이벤트 수집기가 이미 이벤트를 소비 중이라 점수 갱신을 얹는 비용이 최소, ③ 스트림 처리기(C)를 새로 세울 규모가 아님, ④ 하루 단위로 잘라 관리하면(일간 판 + 자동 만료) 캐싱(D)의 안정성 감각을 일부 가져올 수 있음. 결론: B를 기본으로, D의 정책(하루 단위 분리·이월)을 섞은 형태.

2. 왜 MySQL이 아니라 Redis ZSET인가 — 실측이 지목한 병목

"ZSET이 좋다더라"로 시작하지 않았다. MySQL ORDER BY(정렬 조회)로 먼저 만든 1차 구현에 부하를 걸어 실측하고, 거기서 드러난 병목 하나하나에 어떤 Redis 연산이 대응하는지의 표에서 출발했다(AWS 실서버 부하 실측).

표를 읽기 전 두 가지만 — 커넥션 풀은 "동시에 DB에 접근할 수 있는 자리(여기선 40개)"이고, 자리가 다 차면 뒤 요청은 대기(pending)한다. O(...)는 데이터가 늘 때 비용이 어떻게 커지는지의 표기로, O(log N)은 "거의 안 늘어남", O(rank)는 "순위가 낮을수록 비례해 커짐"을 뜻한다.

1차 구현(MySQL)에서 드러난 병목실측 내용Redis ZSET이 푸는 방식
읽기가 한계초당 ~280건에서 무너짐(응답 상위 5%가 3.19초). 서버 CPU는 10%로 노는데, MySQL 응답이 느려지며 커넥션 풀 40자리가 전부 막힘랭킹 읽기를 DB에서 빼 Redis로. 순위대로 꺼내기(ZRANGE)는 꺼낸 개수에만 비례
"내 순위" 계산이 느림개별 순위 = "나보다 점수 높은 상품 수 세기". 순위가 낮을수록 셀 게 많아 초당 31건까지 떨어짐(인덱스를 강제 지정해도 521건)점수로 개수 세기(ZCOUNT)는 몇 위든 비용이 거의 일정
쓰기가 밀림이벤트 처리 속도 185건/초가 들어오는 220건/초를 못 따라가 밀린 이벤트가 계속 늘어남(1,402→2,555). 한 이벤트에 DB 2행을 갱신하는 트랜잭션 비용 탓점수 더하기(ZINCRBY)는 메모리에서 즉시 — 디스크 쓰기·트랜잭션·커넥션 확보가 사라짐
DB가 엉뚱한 길로 감MySQL이 어떤 인덱스로 조회할지 스스로 고르는데 잘못 골라 17배 느려짐(강제 지정으로만 회피)Redis는 그런 선택 자체가 없음 — 자료구조가 곧 조회 방식

핵심은 병목이 서버 성능이 아니라 자료구조라는 점이다. 무너지는 순간에도 CPU는 10%였다 — 서버를 늘려도 "DB 커넥션 풀"이라는 좁은 목은 그대로다. ZSET은 정렬을 자료구조가 이미 해 둔 상태라, "읽을 때 계산하지 않는다"는 즉시 갱신 방식의 원리를 그대로 구현한다.

1차 · MySQL 정렬 조회
랭킹 읽기
폭증
주문 쓰기
정상 부하
Hikari 풀 40/40 소진
pending 161 · 동반 지연
읽기 ~280rps에서 붕괴 (p95 3.19s) 2차 · Redis ZSET
랭킹 읽기
폭증
랭킹 쓰기
이벤트 소비
Redis 복제본(replica)
읽기 O(log N + size)
Redis 원본(master) + Lua
인메모리 ZINCRBY
MySQL 풀 = 주문 전용으로 회복
같은 풀 공유 = 랭킹이 주문을 죽인다
병목의 위치를 옮기는 것이 핵심. 1차(MySQL)는 랭킹과 주문이 하나의 DB 커넥션 풀을 공유해 랭킹 폭증이 주문까지 질식시켰다. 2차(Redis)는 랭킹 트래픽을 Redis(읽기=복제본, 쓰기=원본)로 통째로 빼내 DB 풀을 주문에 돌려준다.

3. 안정성 ① 갱신을 "한 덩어리로" — Lua 스크립트 쓰기

실시간 갱신의 함정은 "반만 반영된" 중간 상태다. "이미 처리한 이벤트인가" 판정과 "점수 더하기"가 두 명령으로 갈리면, 그 사이 장애가 이중 가산이나 유실을 만든다. Redis는 스크립트를 한 번에(원자적으로) 실행하므로, 판정·점수 반영·만료시간 설정을 Lua 스크립트 하나(Redis에 여러 명령을 한 덩어리로 실행시키는 코드)에 담아 그 틈을 없앴다. 이벤트 소비 부분은 1차 구현 그대로 두고, 저장소 호출만 MySQL 2행 갱신 → Redis 스크립트 1회로 바꾼다.

  • 중복 거르기(멱등 게이트) — "이 이벤트 처리한 적 있나"를 표시하는 키를 없을 때만 저장(SET … NX). 이미 있으면 점수 계산 없이 그냥 끝낸다. 이 "처리 장부"를 점수와 같은 Redis에 둬서 두 저장소에 걸친 어긋남 자체를 없앤다.
  • 점수 더하기 — 이벤트마다 오늘 랭킹판에 점수를 더한다(ZINCRBY). 주문처럼 상품이 여럿이면 한 스크립트 안에서 상품별로 반복해 이벤트당 스크립트 1회로 끝낸다.
  • 만료시간은 한 번만 — 랭킹판마다 자동 삭제 시각(TTL)을 두되, 매 이벤트가 아니라 아직 없을 때만 건다("해당 날짜 +2일 자정"). 전일 조회를 하루 더 살려두기 위해 "만든 지 2일"이 아니라 "날짜 기준 +2일"로 잡는다.
-- KEYS[1] 오늘 키 · KEYS[2] tail 키(자정 직전 구간) · KEYS[3] 멱등 키
-- ARGV[1] 멱등 TTL · [2] 오늘 EXPIREAT · [3] tail EXPIREAT · [4] tail 여부 · [5..] (productId, Δ)…
if not redis.call('SET', KEYS[3], '1', 'NX', 'EX', ARGV[1]) then
  return 0   -- 이미 처리 = 중복, 점수 연산 없이 스킵
end
local tail = ARGV[4] == '1'
for i = 5, #ARGV, 2 do
  redis.call('ZINCRBY', KEYS[1], ARGV[i + 1], ARGV[i])
  if tail then redis.call('ZINCRBY', KEYS[2], ARGV[i + 1], ARGV[i]) end
end
if redis.call('PTTL', KEYS[1]) == -1 then redis.call('EXPIREAT', KEYS[1], ARGV[2]) end
return 1

점수 산식은 지표마다 크기가 달라 그냥 더하면 한 지표가 전체를 지배하므로, 가중치를 곱해 하나의 점수로 합친다 — 조회 0.1 · 좋아요 0.2 · 주문 0.7×(가격×수량), 좋아요 취소는 −0.2로 되돌린다. 스크립트를 실행하는 코드는 지난 라운드(주문 대기열)에서 쓴 Redis 스크립트 실행 패턴을 그대로 재사용한다.

4. 안정성 ② cold start — 자정의 빈 판을 미리 데우기

랭킹을 하루 단위로 나누면(일간 랭킹판) 자정마다 새 판이 0점에서 시작한다. 새벽 랭킹은 대부분 0점이라 의미가 없고, 어제 인기 상품이 갑자기 사라지면 클릭·구매가 안 생겨 다시 랭킹에 못 드는 악순환에 빠진다 — 이게 cold start다. 해법은 새 판을 어제 점수의 일부(여기선 10%)로 미리 채워두는 것이다. 이렇게 넘겨주는 걸 carry-over(이월)라 부른다.

처음엔 "두 판에 동시 쓰기"로 풀었다 — 이벤트마다 오늘 판에 점수를 더할 때 내일 판에도 그 10%를 같이 더했다. 넘기는 타이밍에 빈틈이 없어 정확했지만(실측에서 내일로 넘긴 비율이 정확히 10%로 확인), 모든 이벤트가 쓰기를 두 번씩 하는 부담이 대가였다. 그래서 정해진 시각에 도는 스케줄러 방식으로 교체했다.

23:50 미리 복사 자정 — 조회 판 전환
오늘 판(D) · 이벤트마다 점수 +
전일 조회용 → 곧 자동 삭제
tail 23:50~자정 10분치만 따로 모음
내일 판(D+1) · 어제 10% 복사 + 10분치 합침
자정에 조회 전환 — 안 비어 있음
D 00:00 D+1 00:00 D+2 00:00
자정 10분 전(23:50)에 오늘 판을 10%로 줄여 내일 판에 미리 복사해 두고, 그 뒤 자정까지 들어온 점수는 별도 판(tail)에 따로 모아 자정에 10%로 합친다. 자정에 조회 대상이 내일 판으로 넘어갈 때 판은 이미 비어 있지 않다.
  • 23:50에 미리 데우기 — 자정 10분 전, 오늘 판을 10%로 줄여 내일 판에 통째로 복사한다(두 판을 합쳐 저장하는 ZUNIONSTORE). 덮어쓰기 방식이라 스케줄러가 두 번 돌아도 결과가 같다. 이 시각엔 아직 내일 판에 새로 들어오는 점수가 없어 덮어써도 잃는 게 없다.
  • 놓친 10분은 따로 모아 메우기 — 23:50 복사 이후 자정까지 들어온 점수는 복사에 안 잡힌다. 그래서 그 10분 구간 점수만 별도 판(tail)에 따로 모아 자정에 10%로 합쳐준다. 스트림 처리에서 "마감 직전 늦게 도착한 데이터를 유예 시간(grace period) 안에서 받아주는" 기법과 같은 발상이다.
  • 합치기는 딱 한 번만(중복 방지) — 10분치 합치기는 더하기라, 스케줄러가 두 번 돌면 두 번 더해진다. 그래서 "합쳤음" 표시를 없을 때만 저장(SET … NX)해 성공한 경우에만 합친다 — 이중 가산 0.
DECISION
이월 방식 — 두 판에 동시 쓰기 vs 스케줄러(미리 데우기 + 10분 보정)
  • 두 판에 동시 쓰기 (이전안) — 빈틈 0·정확·스케줄러 실패 걱정 없음. 대가는 모든 이벤트가 쓰기를 두 번 하는 부담
  • 23:50 미리 데우기 + 10분 보정 — 채택 — 평상시 쓰기는 오늘 판 1회로 복귀(추가 판은 하루 10분만). 과제 요구사항의 "23:50 스케줄러"와도 맞음
교체가 되살린 위험은 "스케줄러가 안 돌면?"이다 — 완화책을 뒀다: 미리 데우기는 덮어쓰기라 두 번 돌아도 안전, 10분 합치기는 표시 키로 한 번만. 서버가 죽어 스케줄러가 아예 안 돌면 내일 판은 잠깐 비지만, 첫 이벤트가 들어오며 채워지고 다음 스케줄에 따라잡는다. 남는 빈틈은 "자정을 한참 넘겨서야 처리된 어제 이벤트의 10% 이월분이 빠지는" 정도뿐 — 밀린 양에 비례해 작고 이월분에만 한정돼 수용한다(동시 쓰기 방식은 이 빈틈이 0이었던 게 원래 장점이었다).

5. 안정성 ③ Redis가 죽으면 — 랭킹은 "다시 만들 수 있는 데이터"

Redis는 데이터를 메모리에 두기 때문에 서버가 꺼지면 랭킹 점수가 통째로 사라진다. 그런데 여기 중요한 성질 하나 — 랭킹은 원본이 아니라, 이벤트로 다시 계산해 만드는 2차 데이터(파생 상태)다. 그래서 Redis가 날아간 걸 "돌이킬 수 없는 손실"이 아니라 "다시 만들면 되는 것"으로 다룰 수 있다. 단, 그러려면 두 가지가 필요하다 — (a) 원본 이벤트가 어딘가 안전하게 남아 있을 것, (b) 이벤트를 다시 흘려보낼 때 점수를 두 번 세지 않을 것.

정상 흐름 — 이벤트 발행에서 랭킹 적재까지
이벤트 발행
commerce-api
outbox 보관
MySQL
Kafka
재생 원본
랭킹 소비기
중복 방지
Redis 랭킹
메모리 · 휘발
✕ 서버 꺼짐 = 랭킹 사라짐 ↓ 장애 대비 — 같은 이벤트를 MySQL에도 남긴다
복구용 원천 저장
event store · MySQL
실패분 → 실패 이력
DLQ 경유 · MySQL
Redis가 죽으면 — 남겨둔 원본으로 되살린다
랭킹 처리기록만 정리
다른 집계 무영향
① Kafka 되감기 재생
보관기간 내
② event store 재생
장기 보관분 · MySQL
Redis로 재적재 ↑
휘발성 Redis 앞뒤를 MySQL과 Kafka가 감싼다 — 발행 실패는 outbox, 소비 실패는 실패 이력, 복구용 원본은 event store로 MySQL에 남는다. Redis가 죽으면 랭킹 처리기록만 지운 뒤 Kafka 되감기(최근분) + MySQL event store(장기분)로 다시 흘려보내 Redis를 재적재한다. 처리기록에 "누가 처리했는지"를 함께 적어, 랭킹만 콕 집어 되살리고 다른 집계는 건드리지 않는다.
  • 중복 세지 않기 = 처리 장부도 Redis에 — "이 이벤트 처리했나" 판정과 점수 더하기가 §3처럼 한 덩어리라 "반만 반영"이 없다. 만약 처리 장부를 MySQL에 두면, "Redis 점수는 더했는데 MySQL 장부 기록이 실패 → 다시 전달 → 이중 가산"이 구조적으로 생긴다(서로 다른 두 저장소를 한 번에 묶을 수는 없다).
  • 랭킹만 콕 집어 다시 만들기 — 처리 장부에 "누가 처리했는지(랭킹용/집계용)"를 함께 적어둔다. 그래서 랭킹용 기록만 지우고 이벤트를 다시 흘려보내면, 옆에서 같은 이벤트를 쓰는 다른 집계는 건드리지 않고 랭킹만 되살아난다.
  • 원본은 두 겹으로 보관 — 1차 복구는 Kafka에 남아 있는 이벤트를 되감아 다시 소비(replay), 보관 기간을 넘긴 오래된 건 DB에 따로 쌓아둔 이벤트 저장소(event store)에서 재생한다. 발행 단계에서 실패한 이벤트(outbox의 FAILED)와 소비 단계에서 실패한 이벤트(Kafka의 DLQ = 실패 이벤트 격리함)도 버리지 않는다.
  • 읽기와 쓰기를 다른 노드로 — 조회는 복제본(replica), 점수 쓰기는 원본(master)에서. 랭킹 조회가 폭증해도 점수 적재를 밀어내지 않는다.

지난 라운드의 주문 대기열에서 Redis 장애는 전면 차단(요청을 다 막음)이었다 — 대기열을 우회하면 방어가 곧장 무너지기 때문이다. 랭킹은 반대다. 뒤에 이벤트라는 다시 만들 원본이 있어 잠깐의 유실을 감내하고 되살릴 수 있다. 같은 Redis 장애라도 "뒤에 대체 경로가 있느냐"가 대응을 가른다.

6. 다른 선택지들 — 무엇을 왜 골랐나 (요약)

결정고른 것버린 것 · 이유
언제 계산하나이벤트마다 즉시 갱신(ZSET)배치 스냅샷 캐싱 — 발행 주기만큼 낡아 "내 순위" 실시간 불가
어디에 두나Redis ZSETMySQL 정렬 조회 — 읽기 ~280건/초 붕괴·쓰기 밀림, 병목이 자료구조
처리 장부 위치점수와 같은 Redis에MySQL에 따로 — 두 저장소 사이에 이중 가산/유실 틈
이월(carry-over)23:50 미리 데우기 + 10분 보정두 판 동시 쓰기 — 이벤트마다 쓰기 2회 부담
순위 매기는 규칙같은 점수는 같은 순위(공동 N위)다른 방식 — 같은 점수를 억지로 앞뒤로 줄 세워 규칙이 깨짐
1차(MySQL) 코드교체·삭제토글로 병행 — 안 쓰는 코드가 남음. 비교 측정은 예전 커밋으로 가능

일관된 원칙 하나가 관통한다 — "실시간이면 좋다"가 아니라 "실시간이라서 사용자 행동이 달라지는가"로 판단했고, 그렇지 않은 축(자정 경계의 안정성, 어뷰징 여지)에는 오히려 캐싱式 정책(하루 단위 분리·스냅샷)을 섞었다.

7. 부하테스트 — 같은 박스, 사라진 천장

실측했다. 조건이 중요하다 — 새 서버를 붙이지 않았다. 2 vCPU 한 대에 MySQL·Redis·Kafka가 함께 얹힌 같은 박스에서, 읽기 경로만 MySQL → (이미 그 박스에서 돌던) Redis로 바꾸고 같은 부하(초당 50→400 램프)를 걸었다.

먼저 — 커넥션 풀을 키워도 천장은 안 오른다

"자리(풀)가 부족해서 막힌 것 아닌가"를 먼저 반증했다. 1차(MySQL)에서 풀만 10 → 40 → 80으로 키워 봤다.

커넥션 풀처리량응답 상위 5%(p95)드롭MySQL CPU
10자리143/초759 ms53~1코어 포화
40자리142/초954 ms126~1.2코어
80자리140/초1,995 ms493198%(2코어 포화)

처리량 천장은 셋 다 ~140/초로 똑같고, 지연과 드롭만 나빠졌다. 자리를 늘려도 정작 MySQL이 CPU를 다 써서 못 빠지면, 대기줄만 길어질 뿐 처리량은 그대로다. 즉 병목은 풀이 아니라 이 박스가 가진 MySQL CPU — 튜닝으로 못 올리는 구조적 천장이다.

같은 박스에서 읽기를 Redis로 — 천장 자체가 사라졌다

풀은 그대로 40자리, 같은 400/초 부하. 읽기 경로만 Redis로 바꿨다.

지표 (400/초)1차 · MySQL2차 · Redis
응답 상위 5%(p95)1,464 ms16 ms
응답 상위 1%(p99)1,692 ms85 ms
드롭2790
MySQL CPU197% (2코어 포화)2~8% (거의 idle)
DB 커넥션 풀40/40 소진 + 대기 1600/40 (미사용)
랭킹 조회MySQL 스캔Redis(ZREVRANGE) · MySQL은 상품정보 1쿼리만
같은 박스 · 같은 400/초 — 자원이 얼마나 덜 드는가 1차 MySQL 2차 Redis 2코어 한계 40자리 만석 1,464 ms 16 ms 응답 p95 197% 2~8% MySQL CPU (2코어=200%) 40/40 0/40 커넥션 풀 (40자리)
새 자원을 붙인 게 아니라, 같은 박스에서 읽기를 이미 쓰던 Redis로 옮겼을 뿐이다. 세 지표 모두 MySQL 막대는 한계까지 가득 차고, Redis 막대는 바닥에 붙어 거의 보이지 않는다 — 같은 400/초를 MySQL은 2코어 포화로 겨우 버티고 Redis는 CPU 2~8%로 거의 놀면서 통과한다. 같은 리소스로 견디는 부하의 폭이 훨씬 크다.

부하를 더 올리면 — Redis의 천장은 어디이고, 무엇이 병목인가

위 400/초에서 Redis가 너무 여유로워서, 부하를 3,200/초까지 계단으로 올려 진짜 천장을 찾았다.

목표 부하앱 서버 CPU (4코어=400%)MySQL CPURedis CPU상태
~800/초~30~40%5~13%2~4%여유
~1,600/초~80~113%19~27%4~6%깔끔
~2,400/초180~393%35~47%6~8%tail 시작
~3,200/초290~393% (포화)57~66%8~12%무너짐(p99 1.8s)
  • 깔끔한 천장 ~1,600~2,000/초, 무너지는 지점 ~3,200/초 — MySQL 버전(~280/초 깔끔·~336/초 붕괴)의 약 6~8배다.
  • 이제 병목은 MySQL도 Redis도 아니라 앱 서버 CPU다 — 무너지는 순간 앱 4코어가 393%≈포화인데 MySQL은 66%, Redis는 10%로 둘 다 한참 여유였다. 병목이 DB에서 앱으로 옮겨간 것이다.
  • 이 이동이 핵심이다 — MySQL CPU 천장은 공유 DB라 수평 확장이 어렵지만, 앱 서버는 상태가 없어 인스턴스만 늘리면 더 올라간다. 즉 못 늘리는 천장을, 늘릴 수 있는 천장으로 바꾼 것이다.
측정 한계(정직하게) — 앱 CPU는 순간값(top -bn1)이라 수치가 튀고(추세는 명확), 부하 생성기가 로컬 맥이라 최상단 드롭 일부엔 생성기 병목이 섞였을 수 있다. 주 신호는 "앱 CPU 포화"다. 앱 인스턴스를 늘린 스케일아웃 재측정은 후속 과제로 남긴다.

실시간 랭킹의 어려움은 "빠르게 정렬하기"가 아니라, 실시간이라는 요구를 지키면서도 자정 경계·장애·이중 가산을 어떻게 견디느냐에 있었다. 저장소를 고르는 일은 자료구조를 고르는 일이었고, 안정성은 원자성·멱등·재구축 원천을 어디에 두느냐의 문제였다.

참고

  • Redis 공식 문서 — Sorted Set 연산(ZINCRBY · ZRANGE · ZCOUNT)의 시간 복잡도, Lua 스크립트의 원자성, Leaderboard의 윈도우별 키·ZUNIONSTORE 집계 패턴
  • Spring 공식 문서Spring Data Redis Scripting: DefaultRedisScript · RedisTemplate.execute 패턴
  • Apache Kafka 공식 문서Windows: 윈도우 종료 후 grace period 내 늦은 이벤트 수용 (tail 병합의 개념 원전)
  • Apache Flink 공식 문서Windows: allowed lateness와 side output으로 늦은 데이터 분리 처리
반응형