Hot Key

· 8분 읽기

분산 시스템은 데이터를 여러 노드에 분산하여 부하를 나눈다. 그런데 특정 키에 요청이 집중되면 분산의 이점이 사라진다. 하나의 노드에만 부하가 몰리고, 나머지 노드는 유휴 상태가 된다. 이것이 Hot Key(Hot Spot) 문제이다.

Hot Key란

파티셔닝과 데이터 편중

분산 시스템은 데이터를 파티셔닝(partitioning) 하여 여러 노드에 나눈다. 대부분 hash(key) % node_count 같은 해시 기반 분배를 사용한다. 해시 함수가 균일하게 분배하므로, 키가 다양하면 노드 간 부하가 고르게 나뉜다.

문제는 접근 빈도가 균일하지 않다는 것이다. 현실 세계의 데이터 접근은 멱법칙(power law) 분포를 따르는 경우가 많다. 소수의 키에 다수의 요청이 집중된다. 인기 상품, 유명인 계정, 기업 계좌 등이 그렇다.

파티셔닝은 키의 수를 균등하게 나누지만, 키별 접근 빈도까지 균등하게 만들지는 못한다. 100만 개의 키가 10개 노드에 고르게 분배되어 있어도, 전체 트래픽의 30%가 단 하나의 키에 몰리면 해당 노드에 부하가 집중된다. 이것이 Hot Key이다.

어디서든 발생한다

Hot Key는 해시 기반 파티셔닝을 사용하는 모든 시스템에서 발생할 수 있다.

  • KV Store: Redis Cluster, Memcached, DynamoDB — 특정 키에 읽기/쓰기 집중
  • Message Queue: Kafka, Pulsar — 특정 Partition Key의 메시지 집중
  • Sharded DB: MySQL 샤딩, MongoDB, CockroachDB — 특정 행에 UPDATE 집중
  • CDN: 특정 리소스(바이럴 이미지, 인기 동영상)에 요청 집중
  • Load Balancer: sticky session 사용 시 특정 서버에 세션 집중

공통점은 분산했는데 분산이 안 되는 상황이라는 것이다. 아래에서는 서버 엔지니어링에서 가장 자주 마주치는 Redis, Kafka, DB에서의 구체적인 양상과 대응을 다룬다.

Redis에서의 Hot Key

읽기 Hot Key

가장 흔한 경우다. 인기 상품 페이지의 캐시, 실시간 검색어 순위, 유명인의 프로필 정보 등 특정 키에 읽기 요청이 몰린다.

Redis Cluster에서 하나의 키는 하나의 노드에만 존재한다. 초당 수만~수십만 건의 GET이 단일 노드에 집중되면, 해당 노드의 CPU와 네트워크 대역폭이 포화된다. Redis가 단일 스레드로 명령을 처리하므로, 다른 키의 요청까지 영향을 받는다.

대응 방법:

  • 로컬 캐시: 애플리케이션 서버의 메모리에 짧은 TTL(수 초)로 캐싱한다. Redis까지 가지 않으므로 Redis 부하가 줄어든다. 다만 인스턴스 간 일관성이 깨질 수 있으므로, 약간의 불일치가 허용되는 데이터에 적합하다
  • 키 복제: product:123product:123:0, product:123:1, …, product:123:N으로 복제한다. 클라이언트가 랜덤으로 suffix를 붙여 읽으면 여러 노드에 분산된다. 쓰기 시에는 모든 복제본을 갱신해야 하므로, 읽기가 압도적으로 많은 경우에 적합하다
  • Redis Read Replica: Redis Cluster의 Replica 노드에서 읽기를 허용한다(READONLY 명령). 다만 비동기 복제이므로 약간의 stale 데이터를 허용해야 한다

쓰기 Hot Key

분산 락에서 특정 계좌에 대한 락 경합이 대표적이다. 기업 계좌나 이벤트 계좌처럼 수신이 집중되는 경우, lock:user:123:account 키에 SET NX 요청이 몰린다.

Redis의 쓰기는 Primary 노드에서만 처리되므로, 읽기 Hot Key보다 대응이 어렵다. Replica로 분산할 수 없다.

대응 방법:

  • 버퍼링: 매 건마다 잔고를 바로 갱신하지 않고, 수신 금액을 별도의 pending 테이블에 쌓아둔다. 주기적으로 배치가 pending을 합산하여 잔고에 반영한다. 락 경합이 건수/배치 주기로 줄어든다
  • 락 세분화: 하나의 계좌에 대해 서브 키를 두어 경합을 줄인다. 예를 들어 lock:user:123:account:0 ~ lock:user:123:account:3으로 4개의 서브 락을 두고, 각각 잔고의 1/4을 관리한다. 다만 전체 잔고 조회 시 합산이 필요하고, 구현 복잡도가 높아진다

Kafka에서의 Hot Partition

Kafka Producer는 메시지를 보낼 때 Partition Key를 지정한다. Kafka는 hash(partition_key) % partition_count로 메시지가 들어갈 Partition을 결정한다. 같은 키의 메시지는 항상 같은 Partition으로 간다.

왜 Partition Key를 쓰는가

Kafka는 Partition 내에서만 순서를 보장한다. 같은 사용자의 이벤트를 순서대로 처리하려면, 해당 사용자의 메시지가 모두 같은 Partition에 있어야 한다. 그래서 user_id를 Partition Key로 쓰는 것이 일반적이다.

문제

Partition이 6개이고 사용자가 100만 명이면, 대부분의 경우 메시지가 Partition에 고르게 분산된다. 하지만 특정 사용자(기업 계좌, 이벤트 계정 등)의 메시지가 압도적으로 많으면, 해당 Partition을 담당하는 Consumer 하나에 부하가 집중된다.

flowchart LR
    subgraph "Producer"
        M1["일반 유저 메시지<br/>(고르게 분산)"]
        M2["기업 계좌 메시지<br/>(대량)"]
    end
    subgraph "Kafka Partitions"
        P0["P0 ■■"]
        P1["P1 ■■"]
        P2["P2 ■■■■■■■■■■"]
        P3["P3 ■■"]
    end
    subgraph "Consumers"
        C0["Consumer 0 (여유)"]
        C1["Consumer 1 (여유)"]
        C2["Consumer 2 (과부하)"]
        C3["Consumer 3 (여유)"]
    end
    M1 --> P0
    M1 --> P1
    M2 --> P2
    M1 --> P3
    P0 --> C0
    P1 --> C1
    P2 --> C2
    P3 --> C3

Consumer 2만 과부하 상태이고, 나머지 3대는 한가하다. Partition 수를 늘려도 기업 계좌의 메시지는 여전히 하나의 Partition에 몰린다.

대응 방법

  • Partition Key에 랜덤 suffix 추가: user_iduser_id + "_" + random(0,3)으로 바꾸면 같은 사용자의 메시지가 여러 Partition에 분산된다. 대신 같은 사용자의 순서 보장이 깨진다. 멱등성 키로 중복을 방지하고 있다면, 순서가 바뀌어도 최종 결과는 동일하다
  • Custom Partitioner: 특정 키만 별도 로직으로 분배한다. 예를 들어 “알려진 hot key 목록”을 두고, 해당 키들만 랜덤 분배하고 나머지는 기본 해시를 쓴다
  • 별도 토픽 분리: 기업 계좌 전용 토픽을 만들어 일반 유저와 아예 분리한다. Consumer 그룹도 별도로 둔다

DB에서의 Hot Row

특정 행에 UPDATE가 집중되면 행 레벨 락(row-level lock) 경합이 발생한다.

사례

  • 카운터: 좋아요 수, 조회수 같은 카운터를 하나의 행에서 UPDATE SET count = count + 1로 갱신하면, 인기 콘텐츠에서 경합이 심해진다
  • 재고: 인기 상품의 재고를 하나의 행에서 차감할 때, 타임 세일 등으로 동시 요청이 몰리면 대기 시간이 길어진다
  • 잔고: 기업 계좌의 잔고를 하나의 행에서 갱신할 때

대응 방법

  • 카운터 분산: 하나의 행 대신 N개의 서브 행을 두고 랜덤으로 UPDATE한다. 조회 시 SUM으로 합산한다. 이를 counter sharding이라 한다
-- 기존: 하나의 행에 경합
UPDATE counters SET value = value + 1 WHERE id = 123

-- 분산: N개의 서브 행
UPDATE counters SET value = value + 1
WHERE id = 123 AND shard = (RANDOM() % 8)

-- 조회: 합산
SELECT SUM(value) FROM counters WHERE id = 123
  • 버퍼링 + 배치: Redis에 증분값을 INCR로 쌓아두고, 주기적으로 DB에 반영한다. Redis INCR은 원자적이고 빠르므로 경합 없이 처리된다. 다만 Redis 장애 시 아직 반영되지 않은 증분값이 유실될 수 있다
  • 낙관적 락: UPDATE ... WHERE version = ?으로 충돌을 감지하고, 실패 시 재시도한다. 경합이 낮을 때 효과적이지만, 경합이 심하면 재시도가 폭증한다

Hot Key 감지

문제가 발생하기 전에 감지하는 것이 중요하다.

Redis

  • redis-cli --hotkeys: Redis 4.0+에서 지원한다. maxmemory-policy가 LFU 계열이어야 동작한다
  • MONITOR 명령으로 실시간 명령을 관찰할 수 있지만, 프로덕션에서 사용하면 성능이 급격히 저하된다. 디버깅 용도로만 사용해야 한다
  • 애플리케이션 레벨에서 키별 접근 빈도를 샘플링하여 로그로 남기는 방법이 가장 안전하다

Kafka

  • Consumer lag per partition: 특정 Partition의 lag만 증가하면 해당 Partition이 hot이다
  • Partition별 메시지 수 불균형: Kafka Exporter나 kafka-consumer-groups.sh로 Partition별 offset 증가율을 비교한다
  • Producer 측에서 Partition Key별 메시지 수를 메트릭으로 수집하면 사전에 감지할 수 있다

DB

  • slow query log: 특정 테이블/행에 대한 쿼리 지연이 증가하면 경합을 의심한다
  • lock wait timeout: InnoDB의 innodb_lock_wait_timeout에 걸리는 빈도를 모니터링한다
  • SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK 섹션을 확인한다

현실적 판단

Hot Key 문제는 트래픽 규모와 분포에 따라 실제로 발생하지 않을 수도 있다.

예를 들어 하루 500만 건 송금이면 평균 ~60 TPS이다. 피크 시간에 10배가 되어도 600 TPS이다. 이 정도에서 특정 계좌에 몰리는 경우가 있어도, Redis나 Kafka가 처리하지 못할 수준이 되려면 상당히 극단적인 상황이어야 한다.

하지만 다음 상황에서는 실제로 발생한다:

  • 이벤트 프로모션: “선착순 입금하면 포인트 지급” 같은 캠페인에서 특정 계좌에 수만 건이 동시에 몰리는 경우
  • 플랫폼 계좌: 쇼핑몰 정산 계좌, 급여 지급 계좌처럼 다수의 트랜잭션이 하나의 계좌로 수렴하는 경우
  • 바이럴 콘텐츠: 특정 게시물에 좋아요/조회수가 급증하는 경우

설계 면접에서 Hot Key를 언급할 때는, “현재 규모에서는 극단적이지만, 이런 시나리오에서 발생할 수 있고, 이렇게 대응합니다”라고 규모 감각과 함께 답하는 것이 좋다.

References