본문 바로가기
카테고리 없음

Redis Hot Key라고 보기엔 부족하다 - ZINCRBY 반복 호출 구조에서 본 진짜 문제

by 글쓰는 개발자 2026. 4. 10.

들어가며

이번에 Kafka Batch Consumer에서 상품 랭킹 점수를 Redis Sorted Set에 반영하는 구조를 보면서, 단순히 “이벤트를 잘 소비하고 있다”에서 끝나는 게 아니라 Redis에 어떤 방식으로 write가 들어가고 있는지까지 같이 봐야겠다는 생각이 들었다.

처음에는 Batch Listener를 쓰고 있으니 어느 정도는 효율적인 구조라고 생각했는데, 코드를 자세히 보니 Kafka에서는 배치로 가져오더라도 실제로는 이벤트마다 Redis에 ZINCRBY를 호출하는 구조였다.

이 구조를 보면서 내가 정리하게 된 핵심은 아래였다.

  • Batch Consumer와 실제 Batch Processing은 다를 수 있다.
  • Hot Key는 “키 개수”보다 “요청 집중도”로 봐야 한다.
  • 현재 구조는 ranking:all:yyyyMMdd 하나에 write가 몰리는 형태라서, 구조적으로 병목 가능성을 안고 있다고 느꼈다.
  • 개선 포인트는 key 이름을 바꾸는 것보다, Redis로 보내는 write 횟수 자체를 줄이는 것에 더 가깝다고 생각했다.

이 내용을 이해하려면 먼저 알아야 하는 핵심 개념

1. Redis Hot Key

내가 이해한 Hot Key는 “키가 하나냐 여러 개냐”의 문제가 아니라, 특정 key에 요청이 몰리느냐의 문제다.

즉, 어떤 key 하나에 read나 write가 과도하게 집중되면 그 key를 처리하는 Redis 노드가 병목이 될 수 있다.

그래서 “키가 하나뿐이라 관리가 쉬우니 Hot Key는 아니다”라고 보기보다는,
오히려 모든 요청이 분산 없이 한 곳에 집중된다면 그 자체로 Hot Key 구조일 수 있다고 이해했다.

2. ZINCRBY

ZINCRBY는 Redis Sorted Set에서 특정 member의 score를 증가시키는 명령어다.

랭킹 점수를 다룰 때 자주 쓰이는 이유는:

  • 상품별 점수를 누적할 수 있고
  • score 기준으로 정렬이 가능하고
  • 상위 랭킹 조회도 편하기 때문이다

그래서 조회수, 좋아요, 구매수 같은 이벤트를 점수로 환산해서 Sorted Set에 계속 누적하는 방식이 자연스럽게 보였다.

다만 문제는, 이 명령 자체가 나쁘다기보다는 어떤 패턴으로 얼마나 자주 호출되느냐가 중요하다는 점이었다.

3. Batch Consumer와 Batch Processing은 다르다

이 부분이 이번에 가장 크게 다시 정리된 포인트였다. 

처음에는 Kafka Batch Listener를 쓰고 있으니 “배치로 잘 처리되는 구조겠지”라고 생각하기 쉬운데, 실제로는 그 안에서 어떻게 처리하느냐가 더 중요했다. 내가 이해한 차이는 이렇다.

  • Batch Consumer: Kafka에서 메시지를 여러 건 한 번에 가져오는 것
  • Batch Processing: 가져온 메시지를 내부에서도 묶어서 집계하거나 합쳐서 처리하는 것

즉, Kafka에서 한 번에 100건을 가져와도 내부에서 100번 Redis write를 날리면 Kafka만 배치일 뿐, Redis 입장에서는 여전히 단건 처리의 반복이다. 이걸 보면서 입력은 배치인데 출력은 단건이면 병목은 그대로 남을 수 있겠다는 생각이 들었다.

4. Throughput과 Latency

구조를 바꿀 때 항상 같이 봐야 하는 개념이라고 느낀다.

  • Throughput(처리량): 일정 시간 동안 얼마나 많은 이벤트를 안정적으로 처리할 수 있는가
  • Latency(지연시간): 이벤트가 발생한 뒤 실제 랭킹에 반영되기까지 얼마나 늦어지는가

배치 내 집계를 하면 Redis write 수가 줄어들어서 throughput에는 유리할 수 있다. 반면 이벤트를 조금 모아서 처리하게 되면 latency는 늘어날 수 있다. 그래서 “무조건 배치 집계가 좋다”라기보다, 현재 서비스에서 랭킹 반영 지연을 얼마나 허용할 수 있는가와 함께 봐야 한다고 생각했다.

 

내가 본 현재 구조

현재 구조는 Kafka에서 이벤트를 batch로 가져온다.

@KafkaListener(
    topics = {"product.like.events", "product.payment.events", "product.view.events"},
    groupId = "product-metrics-consumer",
    containerFactory = KafkaConfig.BATCH_LISTENER
)
public void consume(List<ConsumerRecord<Object, Object>> messages, Acknowledgment acknowledgment) {
    for (ConsumerRecord<Object, Object> message : messages) {
    	// 이벤트마다 1건씩 처리 → 이벤트마다 Redis write 발생
        productMetricsService.handle(kafkaMessage);
    }
    acknowledgment.acknowledge();
}

 

그런데 내부에서는 메시지를 하나씩 순회하면서 서비스 로직을 호출하고 있다.
즉, Kafka에서는 batch 단위로 가져오지만, 실제 처리 흐름은 이벤트마다 점수를 계산하고 Redis에 반영하는 방식이다.

점수 계산은 이벤트 타입별로 나뉘어 있다.

private static final double WEIGHT_VIEW = 0.1;
private static final double WEIGHT_LIKE = 0.2;
private static final double WEIGHT_SOLD = 0.6;

switch (message.eventType()) {
    case "LIKE_CREATED"  -> rankingRepository.incrementScore(payload.productId(), WEIGHT_LIKE, rankingDate);
    case "LIKE_DELETED"  -> rankingRepository.incrementScore(payload.productId(), -WEIGHT_LIKE, rankingDate);
    case "PRODUCT_SOLD"  -> rankingRepository.incrementScore(payload.productId(), WEIGHT_SOLD * Math.log1p(payload.amount()), rankingDate);
    case "PRODUCT_VIEWED"-> rankingRepository.incrementScore(payload.productId(), WEIGHT_VIEW, rankingDate);
}

 

그리고 최종적으로 Redis에는 아래 key 구조로 점수가 반영된다.

private static final String KEY_PREFIX = "ranking:all:";
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");

@Override
public void incrementScore(Long productId, double score, LocalDate date) {
    String key = KEY_PREFIX + date.format(DATE_FORMAT);
    redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
    redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS);
}
 

즉, 실제 write는 이런 형태가 된다.

ZINCRBY ranking:all:20260409 {productId} {delta}
 

이걸 보고 나는 현재 구조를 다음처럼 이해했다.

  • 날짜별 랭킹 key는 하루에 하나다
  • 상품 ID는 Sorted Set member로 들어간다
  • 조회, 좋아요, 구매 이벤트가 모두 같은 날짜 key에 누적된다

이 말은 결국 하루 동안 발생하는 전체 이벤트가 하나의 key에 write되고 있다는 뜻이라고 봤다.


왜 이 구조가 Hot Key 문제로 이어질 수 있다고 생각했는가

처음에는 member가 상품별로 다르니 어느 정도 분산되는 것처럼 느껴질 수도 있다.
하지만 Redis에서 중요한 건 Sorted Set 내부 member가 아니라, 어떤 key에 요청이 집중되느냐라고 이해했다.

 

현재 구조에서는:

  • 조회 이벤트
  • 좋아요 이벤트
  • 구매 이벤트

모두가 같은 날짜 key에 write된다. (ranking:all:yyyyMMdd) 즉, 상품 종류와 이벤트 타입이 다르더라도 최종 write 대상 key는 하나다. 그래서 나는 이 구조를 “Hot Key가 생길 수도 있는 구조”보다는, 애초에 단일 key 집중 구조에 가깝다고 봤다.

특히 이런 비교를 해보면 더 분명해진다.

  • ranking:all:yyyyMMdd
    → 하루치 전체 이벤트가 하나의 key에 집중
  • ranking:product:{id}:yyyyMMdd
    → 적어도 상품별로는 분산됨

이 비교만 보면 현재 구조를 Hot Key 관점에서 해석할 수도 있다.

하지만 멘토님께 여쭤봤을 때는, 이걸 단순히 Hot Key 문제로 보기보다는 특정 데이터에 대한 커맨드가 지속적으로 발생하면서 네트워크 I/O 비용이 커지는 문제로 보는 쪽에 더 가깝다는 의견을 주셨다.

나도 이 설명이 더 와닿았다.
현재 구조의 본질은 “Hot Key라는 이름” 자체보다,

같은 key에 대해 Redis write가 이벤트마다 반복 호출되고 있다는 점,
그리고 그로 인해 호출 횟수, 네트워크 왕복 비용, 처리 부하가 계속 누적된다는 점이라고 생각했다.

그래서 이 구조를 이해할 때도 “Hot Key인가 아닌가”를 먼저 단정하기보다,
단일 key에 대한 반복 커맨드 구조가 어떤 비용을 만드는가를 중심으로 보는 편이 더 적절하다고 느꼈다.


Batch Listener를 쓰는데도 병목 가능성이 남는 이유

이 부분이 가장 중요하게 느껴졌다.

Kafka Batch Listener를 쓰고 있기 때문에 처음에는 “이미 배치 처리되고 있다”고 생각하기 쉽다.
그런데 실제로는 Kafka가 메시지를 batch로 전달해줄 뿐이고, 내부 로직은 그 메시지들을 다시 하나씩 처리하고 있다.

결국 현재 구조는:

  1. Kafka에서 여러 메시지를 한 번에 가져온다
  2. 메시지를 하나씩 순회한다
  3. 메시지마다 Redis ZINCRBY를 호출한다

이 흐름이다.

즉, Kafka fetch 횟수는 줄었을 수 있지만, Redis write 횟수는 여전히 이벤트 수에 비례한다. 그래서 나는 이 구조를 보며
“입력만 batch이고 출력은 단건이면, 하류 저장소인 Redis 관점에서는 최적화 효과가 제한적일 수 있겠다”
라고 생각했다.


내가 생각한 개선 방향은 배치 내 집계였다

현재 구조에서 가장 먼저 떠오른 개선은, 같은 batch 안에 들어온 이벤트를 상품별로 먼저 합산한 다음 Redis에 반영하는 방식이었다. 

예를 들어 같은 배치 안에서 이런 이벤트가 들어왔다고 해보자.

이벤트 A: productId=1, +0.1
이벤트 B: productId=1, +0.1
이벤트 C: productId=1, +0.2
이벤트 D: productId=2, +0.6

 

현재 방식이라면 Redis write는 4번 발생한다.

ZINCRBY ranking:all:20260409 1 0.1
ZINCRBY ranking:all:20260409 1 0.1
ZINCRBY ranking:all:20260409 1 0.2
ZINCRBY ranking:all:20260409 2 0.6

 

반면 batch 안에서 상품별 delta를 먼저 집계하면:

productId=1 -> 0.4
productId=2 -> 0.6

 

이렇게 합산한 뒤 Redis에는 2번만 반영하면 된다.

ZINCRBY ranking:all:20260409 1 0.4
ZINCRBY ranking:all:20260409 2 0.6
 

이 구조를 보며 나는 핵심이 key 이름을 바꾸는 데 있다기보다, Redis로 향하는 write 횟수 자체를 줄이는 데 있다고 생각했다.


왜 이 방식이 의미 있다고 느꼈는가

1. Redis write 수를 줄일 수 있다

가장 직접적인 효과는 write 수 감소다. 기존 방식은 이벤트 수 기준으로 write가 발생했지만, 집계 후 반영 방식은 같은 상품에 대한 여러 이벤트를 하나로 합칠 수 있기 때문에 고유 상품 수 기준으로 줄어들 수 있다. 기존에는 이벤트 수만큼 Redis write가 발생했다면, 집계 후에는 상품 수만큼만 발생할 수 있다.

특히 조회 이벤트처럼 빈도가 높은 이벤트가 많아질수록 이 차이는 더 커질 수 있겠다고 생각했다.

2. 네트워크 round-trip도 줄어든다

Redis write가 많다는 건 단순히 명령 수(서버 연산)만 많은 게 아니라, 애플리케이션과 Redis 사이의 왕복도 많다는 뜻이다. 그래서 배치 내 집계는 단순히 Redis 부하만 줄이는 게 아니라 네트워크 round-trip을 줄여서 전체 처리량에도 도움을 줄 수 있겠다고 생각했다.

3. Hot Key 자체를 없애지는 못해도 강도는 낮출 수 있다

여기서 중요한 건 이 방식이 Hot Key를 “제거”하는 건 아니라는 점이다. 여전히 ranking:all:yyyyMMdd key는 존재하고,
write 대상도 결국 그 key다. 다만 같은 시간 동안 그 key로 날아가는 write 수를 줄일 수 있으니, 나는 이걸 Hot Key 해결보다는 Hot Key 완화 전략으로 보는 게 더 맞다고 생각했다.


운영에서 어떤 신호가 보이면 전환을 고민해야 할까

집계를 어떻게 하느냐에 고민을 하다보니, 많은 전략이 있었고 멘토링 시간에도 다양하게 생각해 볼 부분을 알게 되었다. 그러다가 생각했던 것이 그 많은 것들을 어떻게 반영을 할 것인가라는 생각이 들게 되었다. 그래서 아래 같은 신호가 보이면 구조 전환을 검토해볼 수 있지 않을까 생각했다.

1. Kafka consumer lag이 늘어나는 경우

메시지는 계속 들어오는데 소비가 따라가지 못한다면 Redis write 구간이 병목일 가능성을 의심해볼 수 있다고 본다.

2. Redis command latency가 올라가는 경우

특히 ZINCRBY 호출량이 많은 시간대에 지연이 같이 증가한다면 write 집중의 영향이 드러난 것일 수 있다고 생각했다.

3. 배치 크기를 늘려도 처리량이 잘 안 오르는 경우

Kafka fetch 단위는 커졌는데 전체 처리 속도 개선이 크지 않다면, 내부에서 여전히 이벤트마다 Redis를 호출하는 구조가 발목을 잡고 있을 수 있다고 생각했다.

4. 특정 프로모션이나 인기 상품 시점에만 급격히 흔들리는 경우

평소에는 괜찮다가 특정 프로모션이나 상품에 트래픽이 몰릴 때만 성능이 급격히 흔들린다면, 집중 write 패턴을 같이 봐야겠다고 느꼈다.  즉, “이론상 hot key가 될 수 있다”보다 실제로 Redis write 경로가 병목으로 드러나는가를 보는 게 더 중요하다고 생각했다.


점진적 전환은 왜 필요하다고 느꼈는가

랭킹은 사용자에게 노출되는 결과라서 성능만 좋아졌다고 바로 바꾸기에는 조금 조심해야 한다고 생각했다.

현재 점수 계산에는:

  • 좋아요 증가
  • 좋아요 취소
  • 구매 수량 기반 로그 계산
  • 조회 누적

같은 여러 규칙이 섞여 있다. 그래서 집계 방식으로 바꿀 때는 단순히 “합쳐서 더하면 되겠지”로 보기보다, 실제 결과가 기존과 동일한지 검증하는 과정이 필요하다고 느꼈다. 

내 생각에는 이런 전환은 한 번에 바꾸기보다:

  • 기존 방식과 신규 방식을 비교해보고
  • 점수 차이나 순위 차이를 확인하고
  • 안정성이 확인되면 전환하는 흐름

으로 가는 편이 더 안전해 보였다. 멘토님께서도 기능을 한 번에 교체하는 경우는 거의 없다고 말씀해주셨는데,
이런 점을 고려하면 A/B 테스트처럼 두 경로를 병행하면서 검증하는 방식이 적절한 접근이라고 느꼈다.


정리하며

이번 구조를 보면서 내가 가장 크게 정리한 건 두 가지였다.

첫째, Batch Consumer를 쓰고 있다고 해서 전체 경로가 자동으로 배치 최적화되는 것은 아니라는 점이다. Kafka는 batch로 읽고 있지만, Redis에는 이벤트마다 write하고 있다면 병목은 그대로 남을 수 있다.

둘째, 현재 구조의 핵심 문제는 ZINCRBY 자체라기보다 모든 이벤트가 ranking:all:yyyyMMdd라는 단일 key에 write되는 패턴에 더 가깝다고 생각했다.

그래서 내가 떠올린 개선 방향은 key 구조를 바로 바꾸는 것보다,
먼저 batch 안에서 상품별 delta를 집계해서 Redis write 횟수를 줄이는 방식이었다.

물론 이걸로 모든 문제가 끝난다고 보지는 않는다.
트래픽이 더 커지면 pipeline, key 분산, shard 구조, 실시간 랭킹과 배치 랭킹 분리 같은 대안도 함께 검토해야 할 수 있다고 생각한다.

그래도 현재 단계에서는
지금 코드가 어떤 write 패턴을 만들고 있는지 이해하고,
그 구조에서 실제 병목이 어디서 생길 수 있는지 가설을 세워보는 것 자체가 의미 있는 학습이었다고 느꼈다.

반응형