Published on

Redis 핫키로 QPS 폭증? 키 분산·LFU 튜닝

Authors

서버가 잘 버티던 어느 날, 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는 트래픽이 높은 운영에서 매우 부담이 큽니다. 꼭 필요할 때만 짧은 시간만 켜고, 가능하면 복제본/스테이징에서 재현하는 걸 권장합니다.

latencyslowlog로 “명령이 느린지”부터 확인

핫키는 “명령 자체가 느려서”가 아니라 “몰려서” 느려지는 경우도 많지만, 큰 값/큰 해시를 잘못 읽는 경우(예: 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:viewcountINCR가 몰리면 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의 의미가 약해집니다.

권장 접근:

  1. 먼저 maxmemory를 현실적으로 설정(메모리 여유가 없으면 어떤 정책도 고통)
  2. 캐시 전용이면 allkeys-lfu로 단순화
  3. eviction이 잦다면 maxmemory-samples를 소폭 상향(기본보다 2배 정도) 후 CPU 영향 관찰
  4. 트래픽 패턴이 급변(이벤트성)하면 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가 폭증할 때의 우선순위는 명확합니다.

  1. 핫키를 찾는다: --hotkeys, slowlog, 노드별 INFO로 후보를 좁힌다
  2. 키/요청을 분산한다: 카운터 샤딩, 고정 버킷, 로컬 캐시, 싱글플라이트로 Redis 호출 자체를 줄인다
  3. LFU로 캐시 효율을 올린다: allkeys-lfu 또는 volatile-lfu, maxmemory-samples, lfu-decay-time을 트래픽 패턴에 맞게 조정한다

핫키는 “Redis가 느려서”가 아니라 “설계상 한 지점에 몰리도록 되어 있어서” 생기는 경우가 대부분입니다. 키 분산과 스탬피드 방지까지 적용하면, QPS 그래프가 올라가도 레이턴시는 안정적으로 유지되는 구조로 바뀝니다.