Redis의 핫키와 빅키: 탐지 및 대응
1. 개요
Redis는 단일 메인 스레드에서 명령을 처리합니다. 따라서 크기가 큰 키(big key) 하나나 접근이 과도하게 집중된 키(hot key) 하나만으로도 전체 인스턴스를 느리게 만들 수 있습니다. 이 글에서는 Redis의 내장 도구와 Java 코드를 이용해 이 두 가지 키를 탐지하는 방법을 배우고, Spring Boot 3.x 애플리케이션에서 키 분할, 안전한 삭제, 로컬 캐시, 키 복제 같은 기법으로 대응하는 방법을 알아봅니다.
2. 핫키와 빅키란?
빅키는 많은 메모리를 차지하거나 매우 많은 요소를 담은 키입니다. 정해진 기준은 없지만, 많은 팀이 다음 기준을 사용합니다.
- 크기 약 10 KB 이상인 String
- 약 5,000개 이상의 요소를 가진 Hash, List, Set, ZSet, 또는 총 용량이 몇 MB를 초과하는 경우
핫키는 다른 키들에 비해 비정상적으로 높은 비율의 요청을 받는 키입니다. 예를 들어 플래시 세일 정보, 홈페이지, 공유 설정을 저장하는 키들입니다.
이 두 가지 키 유형은 다음과 같은 문제를 야기합니다.
- 메인 스레드 차단: 100만 개 필드를 가진 hash에서
HGETALL실행, 또는 거대한 ZSet을DEL하면 Redis가 수백 밀리초 동안 차단될 수 있습니다. 다른 모든 클라이언트는 대기해야 합니다. - 네트워크 대역폭 병목: 2 MB 값을 초당 5,000번 읽으면 초당 약 10 GB에 해당합니다.
- Redis Cluster에서의 부하 불균형: 하나의 키는 항상 하나의 슬롯에 위치하고, 그 슬롯은 하나의 노드에만 속합니다. 핫키든 빅키든 그 노드의 부하를 집중시키며, 클러스터에 몇 개 노드가 있든 상관없습니다.
- 복제 및 장애 조치 지연: 빅키는 동기화 작업과 RDB 로딩 시간을 더 오래 걸리게 합니다.
3. 빅키 탐지
3.1. redis-cli --bigkeys와 --memkeys 사용
가장 빠른 방법은 redis-cli의 내장 옵션을 사용하는 것입니다.
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
이 명령은 SCAN으로 전체 keyspace를 훑으므로 KEYS *처럼 서버를 차단하지 않습니다. -i 0.01 파라미터는 100개의 SCAN 명령마다 10ms 대기하여 프로덕션 서버의 부하를 줄입니다. 결과는 다음과 같습니다.
[00.00%] Biggest hash found so far '"cart:10023"' with 48211 fields
[12.35%] Biggest string found so far '"product:detail:88"' with 1048576 bytes
[45.10%] Biggest zset found so far '"leaderboard:global"' with 920144 members
-------- summary -------
Biggest hash found '"cart:10023"' has 48211 fields
Biggest string found '"product:detail:88"' has 1048576 bytes
Biggest zset found '"leaderboard:global"' has 920144 members
--bigkeys는 요소 개수로 측정합니다. 각 데이터 타입별로 가장 큰 키만 보고합니다. 실제 메모리 사용량에 관심이 있다면 --memkeys를 사용하세요.
redis-cli --memkeys --memkeys-samples 0
--memkeys-samples 0은 Redis가 모든 요소를 측정하도록 요청합니다(샘플링하지 않음). 더 정확하지만 느립니다.
3.2. Java에서 MEMORY USAGE로 스캔
redis-cli는 각 타입별 가장 큰 키만 반환합니다. 임계값을 초과하는 모든 키를 나열하려면 직접 스캔할 수 있습니다. 먼저 의존성을 추가하세요.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
다음으로 keyspace를 스캔하는 컴포넌트를 작성합니다.
@Component
public class BigKeyScanner {
private final StringRedisTemplate redis;
public BigKeyScanner(StringRedisTemplate redis) {
this.redis = redis;
}
public Map<String, Long> findKeysLargerThan(long thresholdBytes) {
Map<String, Long> result = new LinkedHashMap<>();
ScanOptions options = ScanOptions.scanOptions().match("*").count(500).build();
try (Cursor<String> cursor = redis.scan(options)) {
while (cursor.hasNext()) {
String key = cursor.next();
Long bytes = redis.execute((RedisCallback<Long>) conn ->
(Long) conn.execute("MEMORY",
"USAGE".getBytes(), key.getBytes(), "SAMPLES".getBytes(), "0".getBytes()));
if (bytes != null && bytes > thresholdBytes) {
result.put(key, bytes);
}
}
}
return result;
}
}
코드의 주요 포인트:
redis.scan(options)은 500개 키씩 일괄 처리로 keyspace를 훑습니다. 메인 스레드가 오래 차단되지 않습니다.Cursor는 반드시 닫아야 하므로 try-with-resources를 사용합니다.MEMORY USAGE key SAMPLES 0은 오버헤드를 포함한 키의 바이트 수를 반환합니다. Spring Data Redis에는 이 명령용 전용 API가 없으므로conn.execute를 통해 호출합니다.
findKeysLargerThan(1_000_000) 호출 예시:
{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}
주의: 스캐너는 마스터가 아닌 복제본에서 실행하고 사용량이 낮은 시간대에 실행하세요. 다른 방법은 RDB 읽기 도구를 사용해 오프라인으로 RDB 파일을 분석하는 것입니다. 이 방법은 실행 중인 서버에 영향을 주지 않습니다.
4. 핫키 탐지
4.1. redis-cli --hotkeys 사용
Redis는 LFU 제거 정책을 활성화했을 때만 접근 빈도를 추적합니다.
# redis.conf
maxmemory-policy allkeys-lfu
LFU를 활성화한 후 다음을 실행합니다.
redis-cli --hotkeys
[00.00%] Hot key '"config:homepage"' found so far with counter 255
[37.21%] Hot key '"product:stock:5001"' found so far with counter 201
-------- summary -------
hot key found with counter: 255 keyname: "config:homepage"
hot key found with counter: 201 keyname: "product:stock:5001"
LFU 카운터는 로그 값이며 최댓값은 255입니다. 실제 요청 개수가 아닙니다. 키 간에 상대적으로 비교할 때만 사용해야 합니다.
특정 키가 의심된다면 직접 확인하세요.
redis-cli OBJECT FREQ config:homepage
(integer) 255
주의: 핫키를 찾기 위해 프로덕션에서
MONITOR를 사용하지 마세요.MONITOR는 모든 명령을 클라이언트로 전송하며 처리량이 50% 이상 감소할 수 있습니다.
4.2. 애플리케이션 측에서 접근 횟수 카운팅
maxmemory-policy를 변경할 수 없다면 애플리케이션에서 직접 카운팅할 수 있습니다. 아래 코드는 시간 창 내에서 각 키의 읽기 횟수를 카운팅합니다.
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
public void record(String key) {
counters.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public List<Map.Entry<String, Long>> topAndReset(int n) {
Map<String, Long> snapshot = new HashMap<>();
counters.forEach((k, v) -> snapshot.put(k, v.sumThenReset()));
counters.clear();
return snapshot.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(n)
.toList();
}
}
Redis에서 읽을 때마다 record(key)를 호출합니다. 그 다음 10초마다 topAndReset(10)을 호출하는 @Scheduled 작업으로 결과를 로그나 메트릭에 기록합니다. LongAdder는 여러 스레드가 같은 카운터를 증가시킬 때 경합을 줄입니다. 따라서 이 상황에서는 AtomicLong보다 더 적합합니다.
5. 빅키 대응
5.1. 키 분할 (Sharding)
모든 상품의 조회 수를 product:views라는 단일 hash에 저장한다고 가정합니다. 이 hash는 수백만 개 필드로 커질 것입니다. 해결책은 여러 개의 작은 hash로 분할하는 것입니다.
public final class ShardRouter {
private ShardRouter() {}
public static String shardKey(String baseKey, String field, int shards) {
int shard = Math.floorMod(field.hashCode(), shards);
return baseKey + ":" + shard;
}
}
@Service
public class ProductViewService {
private static final int SHARDS = 64;
private final StringRedisTemplate redis;
public ProductViewService(StringRedisTemplate redis) {
this.redis = redis;
}
public long increment(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
return redis.opsForHash().increment(key, productId, 1);
}
public long get(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
Object value = redis.opsForHash().get(key, productId);
return value == null ? 0 : Long.parseLong(value.toString());
}
}
동작 원리:
Math.floorMod는hashCode()가 음수여도 항상 음이 아닌 수를 반환합니다. 많은 사람이%를 사용해 음의 인덱스 오류를 겪는 부분입니다.- 상품
"P-1001"은 항상 같은 shard(예:product:views:17)로 매핑됩니다. 이렇게 하면 읽기와 쓰기가 항상 일관성 있게 처리됩니다. - 각 hash는 이제 약 1/64만큼의 필드를 가집니다. Redis Cluster에서 이 64개 키는 다른 슬롯에 속하므로 부하가 여러 노드에 분산됩니다.
주의: 부하 분산이 목표라면
product:views:{all}:17같은 해시 태그를 추가하지 마세요. 해시 태그는 모든 shard를 같은 슬롯으로 강제합니다. 각 키의 크기는 감소하지만 부하는 여전히 한 노드에 집중됩니다.
1 MB 상품 상세정보 JSON 같은 큰 String의 경우, 거의 변하지 않는 부분과 자주 변하는 부분을 별개의 키로 나누세요. 값을 압축하거나 실제로 필요한 필드만 캐시할 수도 있습니다.
5.2. 빅키 안전하게 삭제
DEL은 메모리를 동기적으로 해제합니다. 100만 개 멤버를 가진 ZSet을 삭제하면 Redis가 수백 밀리초 동안 차단될 수 있습니다. 대신 UNLINK를 사용하세요.
public void removeLeaderboard() {
redis.unlink("leaderboard:global");
}
UNLINK는 키를 keyspace에서 즉시 제거합니다. 메모리 해제는 백그라운드 스레드로 미룹니다. 설정에서 lazy free를 활성화하여 Redis의 암시적 삭제 작업도 메인 스레드를 차단하지 않게 하세요.
# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
lazyfree-lazy-user-del yes 설정으로 DEL 명령도 UNLINK처럼 작동합니다. 이 설정은 여전히 delete()를 호출하는 오래된 코드로부터 보호합니다.
더 오래된 Redis에서 실행하거나 부하를 더 세밀하게 제어하려면 배치로 삭제하세요.
public void deleteHashGradually(String key) {
ScanOptions options = ScanOptions.scanOptions().count(500).build();
try (Cursor<Map.Entry<Object, Object>> cursor = redis.opsForHash().scan(key, options)) {
List<Object> batch = new ArrayList<>(500);
while (cursor.hasNext()) {
batch.add(cursor.next().getKey());
if (batch.size() == 500) {
redis.opsForHash().delete(key, batch.toArray());
batch.clear();
}
}
if (!batch.isEmpty()) {
redis.opsForHash().delete(key, batch.toArray());
}
}
redis.unlink(key);
}
각 HDEL은 500개 필드만 삭제하므로 각 명령의 차단 시간이 매우 짧습니다. Redis는 HSCAN 진행 중 hash를 수정할 수 있습니다. 아직 삭제되지 않은 필드는 여전히 반환됩니다. 마지막 unlink 명령이 남은 필드를 정리합니다.
6. 핫키 대응
6.1. Redis 앞의 로컬 캐시
가장 효과적인 방법은 대부분의 요청이 Redis에 도달하지 않도록 하는 것입니다. Caffeine을 추가하세요.
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
그 다음 Redis 앞에 단기 로컬 캐시를 배치합니다.
@Service
public class HomepageConfigService {
private final StringRedisTemplate redis;
private final LoadingCache<String, String> localCache;
public HomepageConfigService(StringRedisTemplate redis) {
this.redis = redis;
this.localCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(2))
.build(key -> redis.opsForValue().get(key));
}
public String getConfig() {
return localCache.get("config:homepage");
}
}
20개의 애플리케이션 인스턴스가 있다고 가정합니다. 각 인스턴스는 이 키에 대해 최대 2초마다 한 번씩 Redis를 호출합니다. 즉, Redis는 수만 개가 아닌 초당 약 10개 요청을 받습니다. LoadingCache는 같은 키에 대한 동시 로드도 일괄 처리합니다. 덕분에 캐시가 만료될 때 수백 개 스레드가 동시에 Redis로 몰려드는 일이 없습니다.
대가는 데이터가 최대 2초 오래될 수 있다는 것입니다. 홈페이지 설정이나 상품 카테고리의 경우 이 정도 지연은 보통 수용 가능합니다.
6.2. 키 복제
데이터가 더 최신이어야 하거나, Redis Cluster에서 한 노드가 과부하 상태일 때, 여러 복제본에 값을 작성하고 무작위로 하나에서 읽을 수 있습니다.
@Service
public class FlashSaleService {
private static final int REPLICAS = 8;
private final StringRedisTemplate redis;
public FlashSaleService(StringRedisTemplate redis) {
this.redis = redis;
}
public void publish(String saleId, String json) {
for (int i = 0; i < REPLICAS; i++) {
redis.opsForValue().set("flashsale:" + saleId + ":r" + i, json, Duration.ofMinutes(10));
}
}
public String read(String saleId) {
int i = ThreadLocalRandom.current().nextInt(REPLICAS);
return redis.opsForValue().get("flashsale:" + saleId + ":r" + i);
}
}
8개 키 flashsale:42:r0부터 flashsale:42:r7은 보통 다른 슬롯에 속합니다. 따라서 읽기 부하가 여러 노드에 분산됩니다. 이 방법은 읽기 위주, 쓰기 적음 데이터에 적합합니다.
주의: 여러 복제본에 쓰는 작업은 원자적이지 않습니다. 짧은 시간에 복제본이 달라질 수 있습니다. 계좌 잔액처럼 엄격한 일관성을 요구하는 데이터에는 사용하지 마세요. 핫한 재고 카운터의 경우 재고를 여러 독립적인 “버킷”으로 나누는 것을 고려하세요. 각 버킷이 같은 값의 복제본이 아니라 수량의 일부를 보유합니다.
이 두 방법 외에도 복제본에서 읽을 수 있습니다. Lettuce를 사용할 때 ReadFrom.REPLICA_PREFERRED로 설정하면 master-replica 토폴로지에서 읽기 부하를 분산합니다.
7. 키 분할 로직 테스트
shard 라우팅 로직은 가장 실수하기 쉬운 부분이므로 테스트를 작성하세요.
class ShardRouterTest {
@Test
void sameFieldAlwaysMapsToSameShard() {
String first = ShardRouter.shardKey("product:views", "P-1001", 64);
String second = ShardRouter.shardKey("product:views", "P-1001", 64);
assertThat(first).isEqualTo(second).startsWith("product:views:");
}
@Test
void shardIndexIsNeverNegative() {
// "polygenelubricants"의 hashCode() == Integer.MIN_VALUE
String key = ShardRouter.shardKey("product:views", "polygenelubricants", 64);
int index = Integer.parseInt(key.substring(key.lastIndexOf(':') + 1));
assertThat(index).isBetween(0, 63);
}
@Test
void fieldsAreSpreadAcrossShards() {
Map<String, Long> distribution = IntStream.range(0, 100_000)
.mapToObj(i -> ShardRouter.shardKey("product:views", "P-" + i, 64))
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
assertThat(distribution).hasSize(64);
assertThat(distribution.values()).allSatisfy(count ->
assertThat(count).isBetween(1_000L, 2_200L));
}
}
두 번째 테스트는 Integer.MIN_VALUE인 엣지 케이스를 확인합니다. Math.abs(hash) % n을 사용하면 Math.abs(Integer.MIN_VALUE)가 음수이므로 이 테스트가 실패합니다. 세 번째 테스트는 100,000개 필드가 비교적 균등하게 분산되는지 확인합니다. 평균적으로 각 shard마다 약 1,560개 필드입니다.
8. 흔한 실수와 사용하면 안 될 상황
- 빅키를 찾기 위해
KEYS *실행: 이 명령은 스캔을 완료할 때까지 Redis를 차단합니다. 항상SCAN을 사용하세요. - 키를 분할했지만 여전히 모두 읽어야 하는 경우: 비즈니스 로직이 자주 모든 필드를
HGETALL해야 한다면 1개의 느린 명령이 64개의 명령으로 바뀔 뿐입니다. 먼저 비즈니스 로직을 검토하세요. 예를 들어 페이지네이션이나 사전 계산된 집계 결과가 도움이 될 수 있습니다. - 실행 중에 shard 개수 변경:
SHARDS를 64에서 128로 바꾸면 대부분의 필드가 다른 위치로 이동합니다. 마이그레이션 계획이 필요하거나, 처음부터 충분히 큰 shard 개수를 선택해야 합니다. - 자주 바뀌는 데이터에 로컬 캐시 사용: 재고, 경매 가격 등은 몇 초 TTL에 맞지 않습니다. 꼭 사용하려면 Pub/Sub이나 Redis 6+ client-side caching(
CLIENT TRACKING)과 조합해 캐시를 무효화하세요. - 너무 일찍 최적화: 2,000개 필드를 가진 hash는 아직 문제가 아닙니다.
--bigkeys,--hotkeys, slowlog(SLOWLOG GET)로 측정한 후 결정하세요.
9. 결론
이 글에서는 Redis의 단일 스레드 명령 처리로 핫키와 빅키가 왜 위험한지 알았습니다. 탐지하려면 redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ를 사용하거나 애플리케이션 측에서 카운팅할 수 있습니다. 빅키를 대응하려면 Math.floorMod로 분할하고, UNLINK로 삭제하고, lazy free를 활성화하세요. 핫키의 부하를 줄이려면 Caffeine 로컬 캐시나 키 복제를 사용하세요. 정기 스캔을 운영 프로세스에 포함시키세요. 조기 탐지가 Redis가 병목이 된 후 대응하는 것보다 항상 저렴합니다.