# Redis

Valkey 핫 키, 키 하나에 요청이 몰리면 어떻게 될까?

@Hong2026-10-14

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Valkey 클러스터에서 키 하나에 요청이 몰리면 노드를 늘려서 해결할 수 있을까?
답
안 된다. 요청의 약 88%가 hot:product:1로 가자 그 키를 가진 노드가 GET의 89.3~89.8%를 처리하며 CPU 할당을 다 썼고, 나머지 두 노드는 할당의 20~24%만 썼다.
조건
2026-10-10, valkey/valkey:9.1.2 마스터 3개 클러스터, 노드마다 CPU 0.25개, 처리량 실험은 조건마다 3번

환경

측정일
2026년 10월 10일
Valkey
valkey/valkey:9.1.2 이미지(서버, valkey-benchmark, valkey-cli)
클러스터
마스터 3개, 복제본 없음, 노드마다 CPU 0.25개와 메모리 256MB
부하 도구
bench 컨테이너 CPU 1.5개
장비
다른 실습 컨테이너와 함께 쓰는 Docker VM(CPU 8개, 메모리 9.7GiB)
반복
처리량 실험은 같은 조건을 번갈아 3번씩
목차
  1. 실습 준비
  2. 실습 1: 키 하나가 한 노드를 채웁니다
  3. 실습 2: 어느 키인지 찾기, --hotkeys와 LFU
  4. 실습 3: 키를 나누기
  5. 정리

이번 시간에는 Valkey로 노드 세 개짜리 클러스터를 만들고, 키 하나에 요청이 몰리는 장면을 재현해 측정하며 배워가는 시간입니다.

예를 들어서 우리가 상품 상세 캐시를 Valkey 클러스터에 두고 쓰는데, 이벤트 상품 하나에 조회가 몰리는 날이 왔다고 해볼게요. 노드를 세 대로 늘려 두었는데 대시보드를 보면 한 대만 CPU가 꽉 차 있고 나머지 두 대는 한가합니다.

키 하나에 요청이 몰리면 노드별 명령 수와 CPU가 어떻게 갈리는지 보고, 몰린 키를 valkey-cli --hotkeys로 찾은 다음, 키를 여러 개로 나누면 무엇이 바뀌는지 재 보겠습니다.

실습 준비

이 글의 명령은 모두 실습 코드의 valkey-hot-key/ 디렉터리에서 실행합니다.

  • compose.yaml: 클러스터 노드 n1~n3, 부하 도구 bench(단일 노드 solo와 Python 컨테이너 app도 들어 있지만 이 글에서는 쓰지 않아요)
  • load.sh: bench 컨테이너 안에서 부하를 걸고 노드별 GET 수·CPU 시간과 처리량·지연을 출력하는 스크립트

그 밖의 파일은 데이터·보조 스크립트입니다.

컨테이너 구성(compose.yaml)은 다음과 같아요.

  • n1, n2, n3: 클러스터 노드. 마스터 3개, 복제본 없음. 노드마다 CPU 0.25개, 메모리 256MB
  • bench: valkey-benchmark를 돌리는 컨테이너. CPU 1.5개

노드 CPU를 0.25개로 묶은 건 작은 부하로도 노드 하나가 포화되는 모습을 보기 위해서예요. 그래서 이 글의 숫자는 절대 처리량이 아니라 노드끼리, 조건끼리의 비교로 읽어 주셔야 해요.

클러스터 클라이언트는 모두 같은 Docker 네트워크 안에서 돌려요. 호스트의 7730~7732 포트로 붙으면 노드가 돌려주는 MOVED 응답에 컨테이너 IP가 들어 있어서, 호스트에서는 그 주소로 다시 접속할 수 없거든요.

compose 프로젝트 이름을 정하고 셸 함수 두 개를 만들어 놓을게요. vcli는 n1 컨테이너 안의 valkey-cli를 실행하고, -h로 다른 노드를 고릅니다. bench는 bench 컨테이너를 한 번 띄워 명령을 실행하고 지운다고 보시면 됩니다.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=vhot
vcli() { docker compose exec -T n1 valkey-cli "$@"; }

bench() { docker compose run --rm bench "$@"; }
docker compose up -d

세 노드로 클러스터를 만들고, 클러스터 상태가 ok가 될 때까지 기다릴게요. 출력이 길어서 슬롯 배정과 확인 결과만 남겼어요.

vcli --cluster create n1:6379 n2:6379 n3:6379 --cluster-replicas 0 --cluster-yes | grep -E '^(Primary|\[OK\])'
until vcli CLUSTER INFO | grep -q 'cluster_state:ok'; do sleep 1; done
Primary[0] -> Slots 0 - 5460
Primary[1] -> Slots 5461 - 10922
Primary[2] -> Slots 10923 - 16383
[OK] All nodes agree about slots configuration.
[OK] All 16384 slots covered.

Valkey 클러스터는 키 공간을 슬롯 16384개로 나누고, 노드마다 슬롯 구간을 맡겨요. 여기서는 n1이 0~5460, n2가 5461~10922, n3이 10923~16383을 맡았습니다.

이제 데이터를 채울게요. 클러스터 모드의 valkey-benchmark는 키 이름의 {tag} 자리를 요청마다 그 노드가 맡은 슬롯 하나로 가는 태그로 바꿔 줘요. 그래서 item:{tag} 하나를 20만 번 SET하면 슬롯마다 키가 하나씩 생깁니다. 값은 모두 1KB예요. 그다음 몰릴 키 hot:product:1에도 1KB 값을 넣습니다.

bench valkey-benchmark --cluster -h n1 -n 200000 -d 1024 -q SET 'item:{tag}' __data__ | tr '\r' '\n' | tail -1
head -c 1024 /dev/zero | tr '\0' v | vcli -h n1 -x SET hot:product:1
for n in n1 n2 n3; do echo "$n $(vcli -h $n DBSIZE)"; done
SET item:{tag} __data__: 87796.30 requests per second, p50=0.071 msec
OK
n1 5462
n2 5462
n3 5461

hot:product:1이 어느 노드에 있는지는 CLUSTER KEYSLOT으로 슬롯 번호를 구해 확인합니다.

vcli CLUSTER KEYSLOT hot:product:1
vcli -h n2 GET hot:product:1
vcli CLUSTER NODES
3950
MOVED 3950 172.28.0.3:6379
a8f2eaf83364bfc81d16bf02fa9c1741d9811ab3 172.28.0.3:6379@16379 myself,master - 0 0 1 connected 0-5460
a8f927807bf898c97e71b3cd8c784b199746c71e 172.28.0.4:6379@16379 master - 0 1791638083872 2 connected 5461-10922
eccf0756c73fc366fa7bdd82539b00b743514d92 172.28.0.2:6379@16379 master - 0 1791638083000 3 connected 10923-16383

슬롯 3950은 n1(172.28.0.3)이 맡고 있어요. n2에 물으면 MOVED로 n1에 가라고 돌려보냅니다.

클러스터 클라이언트는 이 응답을 보고 다음부터 이 키의 요청을 n1으로만 보내요. 이 글에서는 n1을 핫 노드라고 부를게요.

실습 1: 키 하나가 한 노드를 채웁니다

부하를 걸고 재는 일은 load.sh 하나로 해요. bench 컨테이너 안에서 실행되며, 부하 전후에 각 노드의 INFO를 읽어 차이를 계산하고, bench 컨테이너 자신이 쓴 CPU 시간도 cgroup(컨테이너의 CPU·메모리 사용을 제한하고 세는 리눅스 기능)에서 읽습니다. 처리량과 지연은 valkey-benchmark의 CSV 출력에서 가져와요.

sh load.sh <모드> <초> [N]으로 실행해요. 모드는 부하 모양을 고르는 이름이고, N은 키를 나누는 모드(split)에서 복사본 수입니다. 출력의 각 줄은 이렇게 읽으시면 됩니다.

  • GET: 측정 구간 동안 그 노드가 실행한 GET 수(cmdstat_get의 calls 차이)와 세 노드 합에서 차지한 비율
  • CPU: 그 노드 프로세스가 쓴 CPU 시간(used_cpu_user + used_cpu_sys 차이)과, 구간 길이 × 할당 CPU(노드는 0.25) 대비 비율. 100%면 할당을 다 쓴 것입니다.
  • bench CPU: 부하 도구가 쓴 CPU 시간과 할당 대비 비율. 100%에 가까우면 서버가 아니라 부하 도구가 한계라는 뜻입니다.

메인 스레드, I/O 스레드, miss 칸은 이 글에서 다루지 않아요.

요청 대부분이 hot:product:1 하나로 가는 hot90 모드를 15초 걸어 볼게요. 연결 45개가 핫 노드에 직접 붙어 핫 키만 읽고, 연결 6개가 초당 6000건으로 묶인 고른 읽기를 함께 보내요.

valkey-benchmark 하나로는 키별 비율을 정할 수 없어서 두 벤치마크를 동시에 돌려 근사했어요. 고른 읽기 쪽을 묶지 않으면 연결 몇 개만으로도 n2와 n3이 할당을 다 써 버려요. 그래서 아래의 n2, n3 CPU와 88%라는 비율은 이 상한이 정한 값입니다.

bench sh load.sh hot90 15
# mode=hot90 sec=15 n=1
구간 15.0초
n1   GET   685937 ( 89.3%)  CPU  3.76초 (할당의 100%)  메인 스레드  3.76초  I/O 스레드 0  miss 0
n2   GET    41128 (  5.4%)  CPU  0.90초 (할당의  24%)  메인 스레드  0.90초  I/O 스레드 0  miss 0
n3   GET    41102 (  5.4%)  CPU  0.90초 (할당의  24%)  메인 스레드  0.90초  I/O 스레드 0  miss 0
bench CPU 7.89초 (할당의 35%)
GET hot:product:1          rps     45208  p50  0.207 ms  p99   4.095 ms
GET item:{tag}             rps      5994  p50  0.079 ms  p99   0.527 ms

클라이언트 요청으로 보면 초당 45,208건과 5,994건이니 약 88%가 핫 키였어요.

노드로 보면 n1이 GET의 89.3%를 처리하며 할당 CPU를 다 썼고, n2와 n3은 5.4%씩 처리하며 할당의 24%만 썼습니다. 세 번 돌려도 n1 비중은 89.3~89.8%, n2와 n3의 CPU는 할당의 20~24%였고 bench는 33~35%였어요. 핫 노드가 한계이고, 부하 도구와 다른 두 노드에는 여유가 있었던 거죠.

클라이언트요청의 88%가 hot:product:1n1슬롯 0~5460n2슬롯 5461~n3슬롯 10923~GET 89.3%CPU 100%GET 5.4%CPU 24%GET 5.4%CPU 24%
hot90 부하 1회차의 노드별 GET 비중과 CPU 사용률(할당 0.25코어 대비)입니다. 키 하나의 요청은 그 키의 슬롯을 가진 노드로만 갑니다.

왜 노드를 늘려도 안 될까요? 클러스터는 키 단위로 샤딩해요. 키 하나에 몰린 요청은 노드를 몇 대로 늘려도 그 키의 슬롯을 가진 노드 한 곳으로만 갑니다. 복제본에서 읽도록 설정하지 않았다면 그 노드의 메인 스레드가 이 요청을 하나씩 차례로 실행해요.

실습 2: 어느 키인지 찾기, --hotkeys와 LFU

노드를 찾았으면 그 노드에서 어떤 키가 많이 읽히는지 볼 차례예요. valkey-cli에는 --hotkeys 옵션이 있습니다. 기본 설정에서 실행해 볼게요.

vcli -h n1 CONFIG GET maxmemory-policy
vcli -h n1 --hotkeys
maxmemory-policy
noeviction
Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.

# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

exit status 1

기본 정책 noeviction에서는 키마다 접근 빈도를 기록하지 않아서 실패해요. 정책 이름이 lfu로 끝나야(allkeys-lfu, volatile-lfu) 키마다 접근 빈도 카운터를 둬요. LFU는 접근 빈도가 낮은 키부터 지우는 정책이라 이 카운터가 필요하거든요.

그래서 세 노드를 모두 allkeys-lfu로 바꾸고 hot90 부하를 30초 건 뒤, 부하가 끝나자마자 노드마다 --hotkeys를 실행해 볼게요. --hotkeys-count 3은 상위 3개만 보여 달라는 뜻입니다(기본 16개).

for n in n1 n2 n3; do vcli -h $n CONFIG SET maxmemory-policy allkeys-lfu; done
bench sh load.sh hot90 30 | grep -E '^n[123]'
for n in n1 n2 n3; do echo "== $n"; vcli -h $n --hotkeys --hotkeys-count 3 | tail -4; done
OK
OK
OK
n1   GET  1450108 ( 89.8%)  CPU  7.53초 (할당의 100%)  메인 스레드  7.53초  I/O 스레드 0  miss 0
n2   GET    82344 (  5.1%)  CPU  1.58초 (할당의  21%)  메인 스레드  1.58초  I/O 스레드 0  miss 0
n3   GET    82819 (  5.1%)  CPU  1.58초 (할당의  21%)  메인 스레드  1.58초  I/O 스레드 0  miss 0
== n1
Sampled 5462 keys in the keyspace!
hot key found with counter: 255	keyname: "hot:product:1"
hot key found with counter: 7	keyname: "item:{4Bx}"
hot key found with counter: 6	keyname: "item:{I8}}"
== n2
Sampled 5462 keys in the keyspace!
hot key found with counter: 9	keyname: "item:{5Fz}"
hot key found with counter: 9	keyname: "item:{0Eu}"
hot key found with counter: 9	keyname: "item:{B8}}"
== n3
Sampled 5461 keys in the keyspace!
hot key found with counter: 9	keyname: "item:{4D0}"
hot key found with counter: 9	keyname: "item:{4gV}"
hot key found with counter: 9	keyname: "item:{4MP}"

n1의 첫 줄이 hot:product:1이고 카운터는 최댓값 255예요. 나머지 키는 모든 노드에서 9 이하였습니다.

이 카운터는 지금 이 순간의 요청 수가 아니에요. 접근할 때 확률적으로 오르고 시간이 지나면 줄어드는 접근 빈도 추정치입니다. 기본값(lfu-log-factor 10, lfu-decay-time 1)에서는 100만 번 정도 읽으면 255에 닿고, 접근이 없으면 1분에 1씩 줄어들어요. --hotkeys는 SCAN으로 키를 훑으면서 키마다 OBJECT FREQ를 보내니, 클러스터에서는 노드마다 따로 실행해 주셔야 합니다.

실습 3: 키를 나누기

요청이 키 하나에 몰리는 게 문제라면, 키를 여러 개로 만들어 요청을 흩을 수 있어요.

같은 값을 hot:product:1:0, hot:product:1:1, hot:product:1:2처럼 복사본 N개에 넣고, 읽을 때는 그중 하나를 골라 읽는 방식이에요. 키 이름이 다르면 슬롯이 달라지고, 슬롯이 다르면 다른 노드에 놓일 수 있습니다.

실습 2에서 CONFIG SET으로 바꾼 정책을 기본값으로 돌리려고 노드를 멈췄다 다시 켤게요. 영속화를 꺼 두었으니 노드를 다시 켜면 데이터가 비어 있어서 다시 채웁니다. 클러스터 구성은 노드의 nodes.conf에 남아 있어서 다시 만들 필요가 없어요.

docker compose stop n1 n2 n3
docker compose start n1 n2 n3
until vcli CLUSTER INFO | grep -q 'cluster_state:ok'; do sleep 1; done
bench valkey-benchmark --cluster -h n1 -n 200000 -d 1024 -q SET 'item:{tag}' __data__ > /dev/null
head -c 1024 /dev/zero | tr '\0' v | vcli -h n1 -x SET hot:product:1

이 실습에서는 cluster_state:ok가 되기까지 38초 걸렸어요. 복사본 6개의 슬롯을 먼저 볼게요. valkey-cli의 -c 옵션은 MOVED 응답을 따라가니 키가 어느 노드에 있든 쓸 수 있습니다.

for i in 0 1 2 3 4 5; do echo "hot:product:1:$i slot $(vcli CLUSTER KEYSLOT hot:product:1:$i)"; done
for i in 0 1 2 3 4 5; do head -c 1024 /dev/zero | tr '\0' v | vcli -c -x SET hot:product:1:$i; done
vcli -c MEMORY USAGE hot:product:1
vcli -c MEMORY USAGE hot:product:1:2
hot:product:1:0 slot 5903
hot:product:1:1 slot 1838
hot:product:1:2 slot 14157
hot:product:1:3 slot 10092
hot:product:1:4 slot 6027
hot:product:1:5 slot 1962
OK
OK
OK
OK
OK
OK
1312
1320

슬롯 구간에 맞춰 보면 :0은 n2, :1은 n1, :2는 n3이에요. 처음 세 개는 운 좋게 노드마다 하나씩 놓였습니다.

6개로 늘리면 :3, :4가 n2, :5가 n1이라 n2에 셋, n1에 둘, n3에 하나가 돼요. 쓰기는 복사본 수만큼 SET을 보내야 하고, 키 하나가 1,312~1,320바이트를 쓰니 메모리도 복사본 수만큼 늘어납니다.

load.sh split은 복사본마다 그 키를 가진 노드에 직접 붙는 벤치마크를 하나씩 띄우고, 연결 50개를 복사본 수로 나눠 줘요. 클라이언트가 복사본 중 하나를 고르는 동작을 이렇게 근사했습니다. 복사본 3개일 때를 볼게요.

bench sh load.sh split 15 3
hot:product:1:0 -> n2
hot:product:1:1 -> n1
hot:product:1:2 -> n3
# mode=split sec=15 n=3
구간 15.1초
n1   GET   611843 ( 34.1%)  CPU  3.77초 (할당의 100%)  메인 스레드  3.77초  I/O 스레드 0  miss 0
n2   GET   588665 ( 32.8%)  CPU  3.75초 (할당의  99%)  메인 스레드  3.75초  I/O 스레드 0  miss 0
n3   GET   592138 ( 33.0%)  CPU  3.77초 (할당의 100%)  메인 스레드  3.77초  I/O 스레드 0  miss 0
bench CPU 12.29초 (할당의 54%)
GET hot:product:1:0        rps     39236  p50  0.063 ms  p99   0.231 ms
GET hot:product:1:1        rps     40610  p50  0.063 ms  p99   0.207 ms
GET hot:product:1:2        rps     39475  p50  0.063 ms  p99   0.231 ms

같은 값을 읽는 요청이 세 노드에 거의 3분의 1씩 나뉘었고, 세 노드 모두 할당을 다 썼어요. 합계는 초당 119,321건입니다.

명령과 출력은 싣지 않았지만 같은 날 같은 클러스터에서 연결 50개가 모두 키 하나만 읽게 했을 때는 초당 42,082~48,426건이었으니, 회차끼리 비교하면 약 2.5~2.8배예요. 같은 방식으로 복사본 6개로 늘렸을 때는 초당 118,455~122,411건으로 3개보다 나아지지 않았어요. 노드가 셋뿐이라 세 노드가 모두 포화되면 더 나눌 곳이 없거든요.

그러니 복사본 이름을 정할 때는 CLUSTER KEYSLOT으로 노드마다 같은 수만큼 놓이는지 확인하고, 노드가 바뀌면 다시 확인해 주셔야 해요. 값이 바뀔 때마다 복사본 N개에 모두 써야 하고, 클러스터에서는 키마다 노드가 달라 한 트랜잭션으로 묶을 수 없으므로, 쓰는 도중에는 복사본마다 값이 잠시 다를 수 있습니다.

정리

범위: valkey/valkey:9.1.2 마스터 3개 클러스터, 노드마다 CPU 0.25개, 처리량 실험은 조건마다 3번, 측정일 2026년 10월 10일.

  • 요청의 약 88%가 hot:product:1 하나로 가자 n1이 GET의 89.3~89.8%를 처리하며 CPU 할당을 다 썼고, 나머지 두 노드는 할당의 20~24%만 썼어요. 핫 키 문제를 노드 추가로 풀려고 하지 마세요.
  • 기본 정책 noeviction에서 --hotkeys는 실패했고, allkeys-lfu로 바꾸자 n1에서 hot:product:1만 카운터 255, 나머지 키는 9 이하로 나왔어요. 핫 키를 찾을 때는 LFU 정책에서 노드마다 --hotkeys나 OBJECT FREQ를 쓰되, 카운터를 지금의 요청 수로 읽지는 마세요.
  • 키를 복사본 3개로 나누자 세 노드가 GET을 거의 3분의 1씩 나눠 받으며 모두 할당을 다 썼고, 처리량은 초당 119,321건이었어요. 이 글에 싣지 않은 추가 측정에서는 이 처리량이 키 하나일 때의 약 2.5~2.8배였고, 복사본을 6개로 늘려도 더 늘지 않았어요. 6개면 n2에 복사본 셋이 몰립니다. 복사본 수는 노드 수에 맞추고 CLUSTER KEYSLOT으로 노드마다 같은 수가 놓이는지 확인하세요.
내 링크 만들기