- Published on
Redis 핫키로 QPS 급락? 탐지·분산·완화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡한데도 Redis QPS가 갑자기 꺾이고 p99 지연이 치솟는 경우가 있습니다. CPU 사용률은 높아지거나(혹은 단일 코어만 뜨겁게) 네트워크는 포화 직전인데, 애플리케이션은 타임아웃을 내고 재시도를 반복합니다. 이런 증상은 핫키(hot key) 하나가 트래픽을 독점하면서 Redis 단일 스레드 이벤트 루프를 막아버릴 때 자주 발생합니다.
핫키는 “자주 조회되는 키”를 넘어, 특정 키에 대한 요청이 짧은 시간에 집중되어 Redis 처리량을 구조적으로 제한하는 상태를 말합니다. 특히 다음 조건이 겹치면 QPS 급락이 빠르게 나타납니다.
- 한 키에 GET/INCR/LPOP 같은 연산이 폭주
- 큰 value(수십 KB~MB) 또는 큰 hash/list/set에 대한 연산
- 캐시 미스가 동시에 발생하는 캐시 스탬피드(stampede)
- 클러스터 환경에서 특정 슬롯/노드에 키가 치우침
- 애플리케이션 재시도 정책이 공격적으로 설정됨
이 글은 탐지 → 원인 분류 → 분산 설계 → 완화(미스/스탬피드/재시도 제어) 순서로, 운영에서 바로 적용 가능한 방법을 정리합니다. 장애 대응 관점에서의 “재시도·폴백” 패턴은 Claude API 529 Overloaded 재시도·폴백 설계도 함께 참고하면 좋습니다.
1) 핫키가 QPS를 ‘급락’시키는 메커니즘
Redis는 대부분의 명령을 단일 스레드로 처리합니다(네트워크 I/O 스레드 옵션이 있어도 명령 실행 자체는 기본적으로 단일 실행 흐름). 그래서 한 키에 대한 요청이 몰리면 다음이 연쇄적으로 발생합니다.
- 해당 키를 처리하느라 이벤트 루프가 바빠짐
- 다른 키 요청까지 큐에 쌓이며 전체 지연 증가
- 클라이언트 타임아웃 → 재시도 → 트래픽 증폭
- 결과적으로 “처리량(QPS)은 떨어지고 지연은 올라가는” 전형적 붕괴 패턴
특히 value가 크거나, Lua 스크립트/트랜잭션/블로킹 명령이 섞이면 한 번의 요청이 오래 걸려 전체가 흔들립니다.
2) 탐지: ‘어떤 키가’ 핫한지 빠르게 찾는 방법
핫키 대응의 핵심은 “감”이 아니라 키 단위 관측입니다. 아래는 운영에서 자주 쓰는 탐지 루트입니다.
2.1 redis-cli --hotkeys로 1차 스캔
Redis에는 샘플링 기반 핫키 탐지 명령이 있습니다.
redis-cli -h $REDIS_HOST -p 6379 --hotkeys
- 장점: 즉시 실행 가능, 상위 빈도 키를 빠르게 확인
- 단점: 정확한 실시간 top-k가 아니라 샘플링이며, 클러스터에서는 노드별로 봐야 함
클러스터라면 각 노드에 대해 반복 실행하거나, 운영 도구(예: redis-trib/관리 콘솔)로 노드별 상위 키를 모읍니다.
2.2 MONITOR는 최후의 수단(짧게)
MONITOR는 모든 명령을 스트리밍하므로 부하가 큽니다. 정말 급한 장애 상황에서 몇 초만 켜고 바로 끄는 방식으로 사용합니다.
redis-cli -h $REDIS_HOST MONITOR
- 특정 키가 초당 수천 번 이상 찍히면 거의 확정
- 단, 출력 자체가 병목이 될 수 있으니 “짧게”가 원칙
2.3 SLOWLOG로 “느린 핫키” 찾기
핫키는 “빈도” 뿐 아니라 “비용”도 중요합니다. 큰 value를 매번 가져오면 빈도가 낮아도 병목이 됩니다.
redis-cli SLOWLOG GET 20
redis-cli CONFIG GET slowlog-log-slower-than
GET big:key같은 패턴이 반복되면 value 크기/명령 비용을 의심HGETALL/LRANGE 0 -1같은 전체 스캔성 명령이 반복되면 구조 개선이 필요
2.4 클라이언트에서 키별 QPS를 계측
서버에서만 보면 “어떤 서비스가 때렸는지”가 불명확합니다. 애플리케이션에서 다음을 태깅해 메트릭을 남기면 장애 시 추적이 빨라집니다.
- Redis 명령명
- 키 프리픽스(예:
user:feed:) - (가능하면) 샤드 키 파생 정보
Prometheus를 쓴다면 “키 전체 문자열”은 카디널리티 폭발 위험이 있어, 프리픽스/버킷으로만 집계하는 것이 안전합니다.
3) 원인 분류 체크리스트: 핫키는 왜 생겼나
탐지 후에는 “핫키를 없앨지, 분산할지, 완화할지”를 결정해야 합니다. 아래 분류가 유용합니다.
3.1 자연 핫키(인기 콘텐츠/랭킹/공지)
- 예:
article:123:viewcount,ranking:today,notice:top - 트래픽이 몰리는 게 정상인 도메인
- 해결은 “분산/캐싱 계층화”가 중심
3.2 설계 핫키(키 설계/샤딩 실수)
- 예: 모든 카운터를
counter하나에INCR - 예: 사용자별로 나눠야 할 것을
session:all같은 단일 키에 저장 - 해결은 “키 스키마 수정”이 정답
3.3 캐시 스탬피드(동시 미스 폭주)
- TTL 만료 순간에 다 같이 미스 → DB/외부 API 폭발
- Redis는 핫키 + 백엔드 과부하까지 동반
- 해결은 “락/확률적 TTL/스테일 서빙”
3.4 재시도 증폭(타임아웃-재시도 루프)
- 클라이언트 타임아웃이 짧고 재시도가 공격적이면, Redis가 느려질수록 더 느려지는 악순환
- 해결은 “재시도 예산, 지수 백오프, 서킷 브레이커”
운영 자동화/배포 파이프라인에서 이런 병목이 터질 때는 관측과 디버깅 루틴이 중요합니다. 배포 단계에서 멈춤/지연을 추적하는 관점은 GitHub Actions Kubernetes 배포 stuck in Progress 디버깅도 유사한 문제 해결 흐름을 제공합니다.
4) 분산(Distribute): 핫키를 여러 키로 쪼개는 설계
핫키를 근본적으로 해결하는 가장 강력한 방법은 키를 분할(sharding) 하는 것입니다.
4.1 카운터 핫키: 스트라이프 카운터(Sharded Counter)
단일 INCR 키가 폭주하면, N개 스트라이프로 분산하고 읽을 때 합산합니다.
- 쓰기: 임의 스트라이프에
INCR - 읽기:
MGET후 합산(또는 파이프라인)
// Node.js (ioredis)
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
const STRIPES = 32
export async function incrView(articleId) {
const stripe = Math.floor(Math.random() * STRIPES)
const key = `article:${articleId}:views:${stripe}`
return redis.incr(key)
}
export async function getViews(articleId) {
const keys = Array.from({ length: STRIPES }, (_, i) => `article:${articleId}:views:${i}`)
const values = await redis.mget(keys)
return values.reduce((sum, v) => sum + (v ? Number(v) : 0), 0)
}
- 장점: 단일 키 병목 제거
- 단점: 읽기 비용 증가(하지만 읽기는 덜 빈번하게 설계 가능)
- 팁: “실시간 정확도”가 필요 없다면, 합산 결과를 짧은 TTL로 캐시해 읽기 비용을 줄입니다.
4.2 큰 value 핫키: value를 조각내기
큰 JSON/Blob을 매번 GET 하면 네트워크와 CPU가 같이 뜁니다. 다음을 고려합니다.
- 필요한 필드만 분리해서 hash로 저장(
HGET) - 리스트/피드라면 페이지 단위로 키를 나누기
- 압축을 쓰되, 압축/해제 CPU가 병목인지도 함께 측정
예: 피드를 페이지로 분할
feed:user:42:page:0
feed:user:42:page:1
4.3 Redis Cluster에서 슬롯 쏠림 피하기
Redis Cluster는 키 해시 슬롯으로 분배합니다. 그런데 해시 태그를 잘못 쓰면 특정 슬롯으로 쏠릴 수 있습니다.
{tag}형태 해시 태그는 같은 태그면 같은 슬롯으로 고정됩니다.- 멀티키 연산을 위해 의도적으로 같은 슬롯에 묶는 경우가 있는데, 그 태그가 “핫”하면 노드 단위 병목이 됩니다.
예: 모든 사용자를 {global}로 묶으면 최악
# 위험한 예시(같은 슬롯으로 고정)
session:{global}:user:1
session:{global}:user:2
멀티키 원자성이 꼭 필요하지 않다면 태그를 제거하거나, 태그를 더 세분화하세요.
5) 완화(Mitigate): 당장 서비스가 죽지 않게 만드는 방어
분산 설계는 시간이 걸립니다. 장애 중/직후에는 “완화책”이 필요합니다.
5.1 캐시 스탬피드 방지: 싱글플라이트 + 락 + 스테일 서빙
가장 흔한 패턴은 “TTL 만료 순간 동시 미스”입니다. 해결 전략은 3단계 조합이 실전에서 강합니다.
- 싱글플라이트(singleflight): 같은 키에 대한 동시 요청을 애플리케이션 내부에서 1개로 합치기
- 분산 락: 프로세스가 여러 대일 때, 1대만 백엔드 조회
- 스테일 서빙: 새 값 갱신 중이면 이전 캐시를 잠깐 더 제공
아래는 Redis SET NX PX로 간단 락을 잡고, 갱신 중에는 오래된 값을 반환하는 예시입니다.
// TypeScript pseudo-code
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function getWithRevalidate(key: string, ttlMs: number, fetcher: () => Promise<string>) {
const cacheKey = `cache:${key}`
const staleKey = `stale:${key}`
const lockKey = `lock:${key}`
const cached = await redis.get(cacheKey)
if (cached) return cached
// stale이 있으면 우선 반환하고 백그라운드 갱신 시도
const stale = await redis.get(staleKey)
if (stale) {
void refreshInBackground()
return stale
}
// 아무것도 없으면 락을 잡고 직접 생성
const gotLock = await redis.set(lockKey, '1', 'PX', 3000, 'NX')
if (!gotLock) {
// 다른 워커가 만들고 있을 확률이 높으니 짧게 대기 후 재조회
await new Promise(r => setTimeout(r, 50))
const retry = await redis.get(cacheKey)
if (retry) return retry
// 최후: fetcher 직접 호출(백엔드 보호 위해 제한 필요)
}
const value = await fetcher()
// cache와 stale을 같이 저장(스테일은 더 길게)
await redis.multi()
.set(cacheKey, value, 'PX', ttlMs)
.set(staleKey, value, 'PX', ttlMs * 10)
.del(lockKey)
.exec()
return value
async function refreshInBackground() {
const got = await redis.set(lockKey, '1', 'PX', 3000, 'NX')
if (!got) return
try {
const v = await fetcher()
await redis.multi()
.set(cacheKey, v, 'PX', ttlMs)
.set(staleKey, v, 'PX', ttlMs * 10)
.del(lockKey)
.exec()
} catch {
await redis.del(lockKey)
}
}
}
핵심은 “백엔드 보호”입니다. 캐시가 깨졌을 때도 DB나 외부 API가 같이 죽지 않게 해야 전체가 산다—이건 과부하 대응 전반의 공통 원리입니다. 재시도/폴백을 설계할 때의 사고방식은 Claude API 529 Overloaded 재시도·폴백 설계와 맞닿아 있습니다.
5.2 TTL 지터(Jitter)로 동시 만료를 흩트리기
많은 키가 동일 TTL로 세팅되면 특정 시각에 만료가 몰립니다. TTL에 랜덤 지터를 추가하세요.
# Python 예시
import random
def ttl_with_jitter(base_sec: int, jitter_sec: int = 30) -> int:
return base_sec + random.randint(0, jitter_sec)
# redis.set(key, value, ex=ttl_with_jitter(300))
5.3 클라이언트 타임아웃/재시도 정책 조정
Redis가 느려질 때 재시도가 트래픽을 증폭시키지 않게 해야 합니다.
- 타임아웃: 너무 짧으면 불필요 재시도 증가, 너무 길면 워커 스레드 고갈
- 재시도: 지수 백오프, 최대 재시도 횟수 제한
- “재시도 예산”: 일정 시간 내 재시도 총량 제한
- 서킷 브레이커: Redis 지연이 임계치면 캐시를 우회하거나 로컬 캐시로 폴백
Node.js(ioredis)라면 무한 재시도를 피하도록 설정을 점검합니다.
import Redis from 'ioredis'
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
// 과도한 재시도 방지(예시)
retryStrategy(times) {
if (times >= 10) return null
return Math.min(times * 50, 500)
},
maxRetriesPerRequest: 2,
connectTimeout: 1000,
})
5.4 로컬 캐시(프로세스 메모리)로 1차 완충
핫키가 “모두가 같은 값”을 읽는 형태라면, 각 애플리케이션 인스턴스에 짧은 TTL 로컬 캐시를 두는 것만으로 Redis 부하가 크게 줄 수 있습니다.
- 장점: Redis 왕복 제거
- 단점: 인스턴스 수만큼 중복 저장, 일관성 약화
실무에서는 TTL 100ms~1s 정도의 초단기 로컬 캐시가 핫키 완충에 매우 효과적입니다.
6) 운영 튜닝: Redis 쪽에서 확인할 것들
핫키 자체를 해결하지 못해도, Redis의 현재 상태를 정확히 진단하면 “어디가 병목인지”가 보입니다.
6.1 INFO에서 보는 신호
redis-cli INFO stats
redis-cli INFO clients
redis-cli INFO memory
instantaneous_ops_per_sec: 순간 처리량 추이connected_clients: 커넥션 폭증 여부rejected_connections: 백로그/리소스 부족evicted_keys: 메모리 압박으로 eviction 발생 여부
6.2 큰 키(big key)도 같이 점검
핫키는 종종 big key와 함께 옵니다. big key는 단일 요청 비용을 키웁니다.
redis-cli --bigkeys
결과에서 특정 hash/list/set이 과도하게 크다면, 자료구조를 분해하거나 상한을 두는 설계가 필요합니다.
6.3 Lua 스크립트/트랜잭션 사용 시 주의
Lua는 원자성이 좋지만, 오래 걸리면 그 시간 동안 Redis가 다른 요청을 처리하지 못합니다. 핫키 상황에서는 특히 위험합니다.
- 스크립트는 짧게
- 반복/대량 처리 로직은 애플리케이션으로 옮기거나 배치화
- 필요하면 스크립트 실행 시간과 호출 빈도를 별도 계측
7) 실전 대응 플로우(장애 시나리오 기준)
핫키로 QPS가 급락했을 때 “순서”가 중요합니다.
- 증상 확인: Redis p99 지연, 타임아웃, 커넥션 수, 네트워크/CPU
- 핫키 후보 추출:
--hotkeys+ 애플리케이션 메트릭 + (짧게)MONITOR - 완화책 즉시 적용
- 로컬 캐시(가능하면 핫키만)
- 스테일 서빙 + 락으로 스탬피드 차단
- 재시도/타임아웃 완화(증폭 방지)
- 근본 해결 설계
- 스트라이프 카운터
- 키 분할(페이지/필드 분리)
- 클러스터 슬롯 쏠림 제거
- 재발 방지
- 키 프리픽스 단위 QPS/지연 대시보드
- 핫키 자동 감지 알람(상위 N개 키 패턴)
- TTL 지터 기본 적용
이 흐름은 Redis뿐 아니라 “병목 지점이 단일화되는 시스템” 전반에 통합니다. 예를 들어 Netty 메모리 릭처럼 원인이 숨은 병목을 추적하는 과정은 Spring Boot 3 간헐적 500? Netty 메모리릭 추적 같은 글의 디버깅 접근과도 유사합니다.
8) 결론: 핫키는 ‘키’가 아니라 ‘패턴’ 문제다
핫키는 Redis 성능 문제가 아니라, 트래픽이 특정 상태 공유 지점에 집중되는 패턴에서 시작합니다. 그래서 해결도 Redis 설정 몇 개로 끝나기보다, 다음 3가지를 함께 해야 안정적으로 끝납니다.
- 탐지: 키/프리픽스 단위로 “누가 무엇을 얼마나” 쓰는지 보이게 만들기
- 분산: 단일 키를 여러 키로 쪼개고(스트라이프/페이지/필드), 클러스터 슬롯 쏠림을 피하기
- 완화: 스탬피드/재시도 증폭을 막고, 스테일 서빙과 로컬 캐시로 완충층 만들기
핫키는 언젠가 다시 생깁니다. 중요한 것은 “다음번에는 QPS가 급락하기 전에” 감지하고, 자동으로 완화가 작동하도록 시스템을 설계하는 것입니다.