- Published on
Redis 핫키로 QPS 폭증? 키 분산·LFU 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 잘 버티던 어느 날, Redis QPS가 갑자기 치솟고(그래프는 좋아 보이는데) 애플리케이션 타임아웃이 늘어납니다. CPU는 100%에 가까워지고 latency spikes가 생기며, 특정 API만 느려지는 현상이 반복되면 가장 먼저 의심할 게 Redis 핫키(hot key) 입니다.
핫키는 “값이 큰 키”가 아니라 접근이 비정상적으로 많이 몰리는 키입니다. 단일 키가 이벤트 루프/단일 스레드 특성상 Redis 인스턴스의 처리량을 사실상 잠가버리기도 하고, 클러스터 환경에서는 특정 슬롯/노드에 부하가 집중되어 전체 성능을 무너뜨릴 수 있습니다.
이 글에서는
- 핫키를 어떻게 발견하고
- 키를 어떻게 분산(샤딩) 설계하며
- eviction 정책에서 LFU를 어떻게 튜닝해 “자주 쓰는 키가 쫓겨나는” 상황을 줄이는지
를 실전 기준으로 정리합니다.
관련해서 트래픽 방어 관점은 Spring Boot 대용량 트래픽 대비 API Rate Limiting 설계도 같이 읽으면 설계 연결이 잘 됩니다.
핫키가 왜 QPS 폭증으로 보이나
핫키 상황에서 흔히 관측되는 패턴은 다음과 같습니다.
- Redis
instantaneous_ops_per_sec(QPS)는 증가 - 애플리케이션 p95/p99 레이턴시 증가, 타임아웃 증가
- Redis
used_cpu_sys/used_cpu_user상승 LATENCY DOCTOR에서 스파이크 원인으로 AOF rewrite, fork, big key, networking 등이 언급되기도 하지만, 실제로는 특정 키에 GET/INCR/HGETALL 등이 몰려 이벤트 루프가 바빠지는 경우가 많음
Redis는 기본적으로 단일 스레드로 명령을 처리합니다(네트워크 I/O는 멀티플렉싱이지만, 명령 실행은 단일 스레드가 핵심). 그래서 한 키로 몰리는 요청 자체가 병목이 됩니다. 클러스터여도 “키가 한 슬롯이면 한 노드”에 몰립니다.
핫키가 생기는 전형적인 원인:
- “오늘의 공지”, “메인 배너”, “전역 설정” 같은 전역 단일 키
- 로그인/세션 관련에서 특정 사용자(봇/공격/인플루언서)로 쏠림
- 랭킹/카운터를 단일 키로
INCR하는 구조 - 캐시 키 설계가 거칠어(예:
product:list) 파라미터 다양성이 사라짐
1단계: 핫키 진단(운영에서 바로 쓰는 방법)
--hotkeys로 빠르게 후보 찾기
Redis는 키스페이스 히트가 많은 키를 샘플링으로 추정할 수 있습니다.
redis-cli -h REDIS_HOST -p 6379 --hotkeys
주의할 점:
- 샘플링 기반이라 “항상” 정확하진 않음
- 클러스터면 노드별로 실행해야 함
- 키가 너무 많거나, 접근 패턴이 짧은 순간에 몰리면 놓칠 수 있음
MONITOR는 최후의 수단(짧게, 조심해서)
redis-cli -h REDIS_HOST -p 6379 MONITOR
MONITOR는 트래픽이 높은 운영에서 매우 부담이 큽니다. 꼭 필요할 때만 짧은 시간만 켜고, 가능하면 복제본/스테이징에서 재현하는 걸 권장합니다.
latency와 slowlog로 “명령이 느린지”부터 확인
핫키는 “명령 자체가 느려서”가 아니라 “몰려서” 느려지는 경우도 많지만, 큰 값/큰 해시를 잘못 읽는 경우(예: HGETALL로 수천 필드)도 섞여 나옵니다.
redis-cli -h REDIS_HOST -p 6379 LATENCY DOCTOR
redis-cli -h REDIS_HOST -p 6379 SLOWLOG GET 20
SLOWLOG에 특정 키가 반복 등장하면 빅키(big key) 가능성도 큼- 빅키는 핫키와 결합되면 최악입니다(느린데 많이 호출)
클러스터라면 “슬롯 쏠림”도 같이 보자
클러스터에서 특정 키 패턴이 특정 해시태그를 공유하면(예: {global}:config) 특정 슬롯에 쏠립니다. 노드별 INFO를 비교해 특정 노드의 QPS/CPU만 튀는지 확인하세요.
redis-cli -c -h REDIS_HOST -p 6379 CLUSTER INFO
redis-cli -c -h REDIS_HOST -p 6379 INFO stats
2단계: 키 분산(샤딩) 패턴 4가지
핫키를 해결하는 가장 강력한 방법은 키 자체를 여러 개로 쪼개서 부하를 분산하는 것입니다. 다만 “정확성”과 “읽기 비용”이 트레이드오프라서 케이스별로 패턴을 선택해야 합니다.
패턴 A: 카운터 샤딩(랜덤 버킷 + 합산)
단일 키 page:viewcount에 INCR가 몰리면 Redis가 사실상 그 키의 원자 연산 큐가 됩니다. 이를 N개 버킷으로 쪼갭니다.
- 쓰기: 랜덤 버킷에
INCR - 읽기: N개 합산(
MGET또는pipeline)
// Node.js 예시
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
const SHARDS = 32
export async function incrPageView(pageId) {
const shard = Math.floor(Math.random() * SHARDS)
const key = `page:${pageId}:views:${shard}`
await redis.incr(key)
}
export async function getPageViews(pageId) {
const keys = Array.from({ length: SHARDS }, (_, i) => `page:${pageId}:views:${i}`)
const values = await redis.mGet(keys)
return values.reduce((sum, v) => sum + (v ? Number(v) : 0), 0)
}
운영 팁:
- 읽기가 잦으면 합산 비용이 커집니다. 이 경우 “주기적 집계(rollup)”로 합산 결과를 별도 키에 캐시하는 전략이 좋습니다.
- TTL이 필요하면 버킷 키에 TTL을 걸되, 매번
INCR할 때 TTL을 갱신하면 비용이 증가합니다. “생성 시 1회만 TTL 설정” 패턴을 고려하세요.
패턴 B: 고정 버킷(사용자 기반 해시)으로 읽기 비용 줄이기
랜덤 대신 “사용자 ID 해시 mod N”처럼 고정 버킷을 쓰면, 특정 사용자의 접근이 항상 같은 샤드로 가서 읽기/쓰기 동작을 단순화할 수 있습니다.
// Java 예시 (샤드 선택)
static int shardOf(String userId, int shards) {
return Math.floorMod(userId.hashCode(), shards);
}
String key = "user:" + userId + ":profile:" + shardOf(userId, 64);
이 방식은 “전역 단일 키”에는 효과가 제한적이지만, 특정 사용자/테넌트에 쏠리는 핫키에는 유효합니다.
패턴 C: 로컬 캐시(near-cache)로 Redis 호출 자체를 줄이기
전역 설정/공지처럼 모두가 같은 값을 읽는 키는 Redis에서 분산해도 결국 “읽기”가 계속 발생합니다. 이때는 애플리케이션에 짧은 TTL의 로컬 캐시를 두는 게 가장 싸게 먹힙니다.
// Kotlin + Caffeine 예시
val cache = com.github.benmanes.caffeine.cache.Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(java.time.Duration.ofSeconds(1))
.build<String, String>()
fun getGlobalConfig(redis: (String) -> String?): String {
return cache.get("global:config") {
redis("global:config") ?: "{}"
}
}
주의할 점:
- TTL을 너무 길게 잡으면 설정 반영이 늦습니다.
- 여러 인스턴스가 동시에 만료되면 Redis로 동시 재요청이 몰리는 캐시 스탬피드가 생깁니다. 아래 패턴 D와 같이 묶어 해결하는 게 좋습니다.
패턴 D: 싱글플라이트(요청 합치기) + 소프트 TTL
같은 키를 동시에 갱신/미스 처리할 때, 애플리케이션 레벨에서 “한 번만” Redis/DB를 치고 나머지는 기다리게 만드는 패턴입니다.
Go에서는 singleflight가 대표적입니다.
import (
"context"
"time"
"golang.org/x/sync/singleflight"
)
type Cache struct {
g singleflight.Group
}
func (c *Cache) GetOrLoad(ctx context.Context, key string, loader func() (string, error)) (string, error) {
v, err, _ := c.g.Do(key, func() (any, error) {
// 여기서 Redis GET 후 miss면 loader 실행
return loader()
})
if err != nil {
return "", err
}
return v.(string), nil
}
핫키는 “성능 문제”이기도 하지만 “트래픽 제어 문제”이기도 합니다. API 단에서 폭주를 막는 설계는 Spring Boot 대용량 트래픽 대비 API Rate Limiting 설계와 함께 보면 전체 방어선이 완성됩니다.
3단계: LFU eviction 튜닝으로 ‘자주 쓰는 키’ 보호하기
핫키 상황에서 메모리가 빡빡하면 eviction이 발생하고, 이때 정책이 LRU 계열이면 “최근에 쓴 키” 위주로 남습니다. 하지만 핫키/웜키(warm key)를 보호하고 싶다면 LFU(Least Frequently Used) 가 더 적합한 경우가 많습니다.
eviction 정책 확인/설정
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
# 예: TTL이 있는 키만 날리고 싶다면
redis-cli CONFIG SET maxmemory-policy volatile-lfu
# 예: 모든 키를 대상(캐시 전용 Redis라면)
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
정책 선택 가이드:
volatile-lfu: TTL이 설정된 키만 eviction 대상(세션/영속 키를 보호)allkeys-lfu: 모든 키 대상(캐시 전용이면 단순)
LFU 핵심 파라미터 2개
Redis LFU는 완전한 LFU가 아니라 근사(approximate) 입니다. 아래 설정으로 “빈도 카운터의 감소 속도”와 “샘플링 정확도”를 조절합니다.
redis-cli CONFIG GET maxmemory-samples
redis-cli CONFIG GET lfu-decay-time
# 예시 튜닝
redis-cli CONFIG SET maxmemory-samples 10
redis-cli CONFIG SET lfu-decay-time 5
maxmemory-samples: eviction 후보를 고를 때 샘플링 개수. 올리면 정확도는 좋아지나 CPU 비용이 늘 수 있음lfu-decay-time: 빈도 카운터가 시간에 따라 감소하는 속도(분 단위). 너무 크면 “옛날에 인기 많던 키”가 계속 살아남고, 너무 작으면 LFU의 의미가 약해집니다.
권장 접근:
- 먼저
maxmemory를 현실적으로 설정(메모리 여유가 없으면 어떤 정책도 고통) - 캐시 전용이면
allkeys-lfu로 단순화 - eviction이 잦다면
maxmemory-samples를 소폭 상향(기본보다 2배 정도) 후 CPU 영향 관찰 - 트래픽 패턴이 급변(이벤트성)하면
lfu-decay-time을 낮춰 “최근 인기”를 더 반영
LFU만으로 핫키가 해결되진 않는다
중요한 결론: LFU는 “무엇을 버릴지”를 개선할 뿐, 핫키로 인한 단일 키 병목을 직접 해소하지 못합니다. 핫키 해결의 본체는 키 분산/요청 합치기/로컬 캐시입니다. LFU는 그 다음으로 “캐시 효율”을 끌어올리는 역할입니다.
운영 체크리스트: 핫키를 ‘재발 방지’까지
1) 키 네이밍에 샤딩 여지를 남겨라
처음부터 feature:flag:global 같은 단일 키를 박아두면, 나중에 샤딩하려면 코드/데이터 마이그레이션이 큽니다. feature:flag:global:{bucket} 같은 확장 가능 구조를 고려하세요(단, 클러스터 해시태그 {} 사용 시 슬롯 고정 효과가 있으니 의도적으로만 사용).
2) 캐시 미스 폭주(스탬피드) 방지
- 소프트 TTL(만료 후에도 잠깐 stale 허용)
- 백그라운드 리프레시
- 싱글플라이트
- 지터(jitter)로 TTL 분산
예: TTL에 지터 주기
function ttlWithJitter(baseSec) {
const jitter = Math.floor(Math.random() * Math.min(10, baseSec * 0.1))
return baseSec + jitter
}
3) Redis 자체를 보호하는 제한도 고려
- 클라이언트 타임아웃/재시도 정책 점검(무한 재시도는 QPS를 더 올림)
- 커넥션 풀 상한
- 애플리케이션 레벨 rate limit
Kubernetes 환경이라면 Redis가 밀리면서 애플리케이션이 메모리/스레드가 쌓여 CrashLoopBackOff로 번질 수 있습니다. 장애 전파 관점은 Kubernetes CrashLoopBackOff와 OOMKilled(ExitCode 137) 해결도 참고할 만합니다.
4) 관측 지표를 “키 단위”로 가져가라
가능하면 다음을 준비하세요.
- 상위 N개 키 prefix별 QPS
- 미스율, TTL 분포
- 명령어별 비율(
GET/MGET/INCR/EVAL) - 노드별 CPU/네트워크/latency
키 단위 관측이 없으면 매번 “감”으로 핫키를 찾게 되고, 그 사이 장애는 반복됩니다.
결론: 우선순위는 ‘분산’ 다음 ‘LFU’다
핫키로 QPS가 폭증할 때의 우선순위는 명확합니다.
- 핫키를 찾는다:
--hotkeys,slowlog, 노드별INFO로 후보를 좁힌다 - 키/요청을 분산한다: 카운터 샤딩, 고정 버킷, 로컬 캐시, 싱글플라이트로 Redis 호출 자체를 줄인다
- LFU로 캐시 효율을 올린다:
allkeys-lfu또는volatile-lfu,maxmemory-samples,lfu-decay-time을 트래픽 패턴에 맞게 조정한다
핫키는 “Redis가 느려서”가 아니라 “설계상 한 지점에 몰리도록 되어 있어서” 생기는 경우가 대부분입니다. 키 분산과 스탬피드 방지까지 적용하면, QPS 그래프가 올라가도 레이턴시는 안정적으로 유지되는 구조로 바뀝니다.