Skip to content
Published on

Consistent Hashing 완전 가이드 2025: 가상 노드, Jump Hash, Rendezvous, 분산 시스템의 핵심 빌딩 블록

Authors

들어가며: 왜 Consistent Hashing이 필요한가?

문제 상황: 해시 기반 샤딩의 함정

캐시 서버 4대를 운영 중이라고 가정하자. 단순한 해싱으로 키를 분산시킨다면:

def get_server(key, num_servers):
    return hash(key) % num_servers

# 4대 서버에 분산
get_server("user:1234", 4)  # 서버 2
get_server("user:5678", 4)  # 서버 0
get_server("user:9012", 4)  # 서버 3

평상시엔 잘 동작한다. 그런데 트래픽이 늘어 서버를 5대로 증설하면?

get_server("user:1234", 5)  # 서버 4 (기존 2번 → 4번)
get_server("user:5678", 5)  # 서버 3 (기존 0번 → 3번)
get_server("user:9012", 5)  # 서버 2 (기존 3번 → 2번)

거의 모든 키의 위치가 바뀐다. 캐시 미스가 폭발하고, 데이터베이스에 트래픽이 쏟아지며, 서비스 장애로 이어진다. 이를 리해싱 폭풍(Rehashing Storm) 이라 부른다.

Consistent Hashing의 약속

1997년 MIT의 David Karger 등이 발표한 Consistent Hashing은 이 문제를 우아하게 해결한다:

노드가 N개에서 N+1개로 바뀌어도, 이동해야 할 키는 평균 K/N개뿐이다. (K는 전체 키 개수)

즉, 서버 4대 → 5대로 늘어나면 전체 키의 약 1/5만 이동한다. 나머지 4/5는 그대로다. 이 원리가 Amazon DynamoDB, Apache Cassandra, Riak, Memcached, Akamai CDN 등 수많은 분산 시스템의 기초가 되었다.

graph LR
    A[Key] --> B{해싱 방식}
    B -->|modulo| C[80% 키 이동<br/>❌ 재앙]
    B -->|Consistent Hash| D[20% 키 이동<br/>✅ 정상]

1. 기본 아이디어: 해시 링(Hash Ring)

원형 공간에 노드와 키 배치

Consistent Hashing의 핵심 아이디어는 의외로 간단하다:

  1. 해시 함수의 출력 공간을 원형(0 ~ 2^32-1)으로 생각한다.
  2. 노드(서버)를 해싱해서 링 위의 한 점에 놓는다.
  3. 키(데이터)도 해싱해서 링 위의 한 점에 놓는다.
  4. 키는 시계 방향으로 가장 가까운 노드에 저장된다.
        0 / 2^32
            |
     NodeA  |
    ---+----+----+---
    |       |       |
    |       |       |
    NodeD      NodeB
    |       |       |
    |       |       |
    ---+----+----+---
     NodeC  |
            |

파이썬 기본 구현

import hashlib
from bisect import bisect_right, insort

class ConsistentHashBasic:
    def __init__(self):
        self.ring = {}       # hash → node
        self.sorted_keys = []

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node: str):
        h = self._hash(node)
        self.ring[h] = node
        insort(self.sorted_keys, h)

    def remove_node(self, node: str):
        h = self._hash(node)
        del self.ring[h]
        self.sorted_keys.remove(h)

    def get_node(self, key: str) -> str:
        if not self.ring:
            return None
        h = self._hash(key)
        # 시계 방향으로 가장 가까운 노드 찾기
        idx = bisect_right(self.sorted_keys, h)
        if idx == len(self.sorted_keys):
            idx = 0  # 링을 한 바퀴 돌아 처음으로
        return self.ring[self.sorted_keys[idx]]

# 사용 예시
ch = ConsistentHashBasic()
for node in ["server-A", "server-B", "server-C", "server-D"]:
    ch.add_node(node)

print(ch.get_node("user:1234"))  # server-C
print(ch.get_node("user:5678"))  # server-A

노드 추가/제거 시 영향

노드 E를 추가하면:

  1. E의 해시값이 기존 노드들 사이 어딘가에 들어간다.
  2. E와 직전 노드 사이의 키들만 E로 이동한다.
  3. 나머지 키들은 그대로다.

평균적으로 K/N개(1/5)의 키만 이동한다. 이게 마법이다.


2. 기본 방식의 문제점: 불균형

기본 Consistent Hashing은 완벽하지 않다. 가장 큰 문제는 키 분포의 불균형이다.

문제 1: 노드 소수일 때 편중

4개 노드만으로 링을 구성하면, 해시값이 균등하게 분포한다고 가정해도 각 노드가 맡는 "구간"의 크기가 천차만별이다:

NodeA: 0 ~ 5 (5%)
NodeB: 5 ~ 40 (35%)  ← 많이 할당
NodeC: 40 ~ 50 (10%)
NodeD: 50 ~ 100 (50%) ← 절반을 차지

결과: NodeD가 전체 트래픽의 절반을 받고, NodeA는 거의 놀고 있다.

문제 2: 노드 삭제 시 편중 악화

NodeB가 다운되면, B의 키는 전부 NodeC로 몰린다. 핫스팟(Hotspot)이 발생한다.

문제 3: 이질적인 서버 용량 반영 어려움

어떤 서버는 CPU 16코어 메모리 64GB이고, 어떤 서버는 4코어 16GB일 수 있다. 기본 방식으로는 서버 용량 비율을 반영할 수 없다.


3. 해결책: 가상 노드 (Virtual Nodes, vnodes)

아이디어

각 물리 노드를 여러 개의 가상 노드로 표현해서 링에 뿌린다. 예를 들어 노드당 150개 가상 노드를 만들면:

  • 물리 노드 10개 × 150 = 1,500개의 가상 노드가 링에 분산된다.
  • 대수의 법칙에 의해 각 물리 노드가 담당하는 구간이 균등해진다.
물리 노드 4 (150 vnode)

링 위 분포:
A-1, B-7, C-3, A-99, D-42, B-15, C-88, D-1, A-15, ...
(거의 균등하게 섞임)

가상 노드 구현

class ConsistentHashWithVNodes:
    def __init__(self, vnodes_per_node: int = 150):
        self.vnodes_per_node = vnodes_per_node
        self.ring = {}
        self.sorted_keys = []

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node: str, weight: int = 1):
        # weight가 높을수록 더 많은 vnode 생성
        count = self.vnodes_per_node * weight
        for i in range(count):
            vnode_key = f"{node}#{i}"
            h = self._hash(vnode_key)
            self.ring[h] = node
            insort(self.sorted_keys, h)

    def remove_node(self, node: str, weight: int = 1):
        count = self.vnodes_per_node * weight
        for i in range(count):
            vnode_key = f"{node}#{i}"
            h = self._hash(vnode_key)
            del self.ring[h]
            self.sorted_keys.remove(h)

    def get_node(self, key: str) -> str:
        if not self.ring:
            return None
        h = self._hash(key)
        idx = bisect_right(self.sorted_keys, h)
        if idx == len(self.sorted_keys):
            idx = 0
        return self.ring[self.sorted_keys[idx]]

# 사용 예시: 용량이 다른 서버들
ch = ConsistentHashWithVNodes(vnodes_per_node=150)
ch.add_node("server-A", weight=2)  # 성능 2배 → 300 vnode
ch.add_node("server-B", weight=1)  # 표준 → 150 vnode
ch.add_node("server-C", weight=1)
ch.add_node("server-D", weight=1)

# 이제 A는 전체 키의 ~40%, 나머지는 각 ~20%를 받는다

가상 노드 개수 선택 가이드

가상 노드 수표준편차메모리 오버헤드
10개±30%낮음
100개±10%중간
160개±6%적정 (Ketama 기본값)
1000개±2%높음

실전에서 100~200개가 스위트스팟이다. Memcached의 libketama는 160개를 사용하며, Cassandra는 기본 256개(num_tokens)를 사용한다.


4. 벤치마크: 실제 분산은 얼마나 균등한가?

import random
import statistics

def benchmark(vnodes_per_node: int, num_nodes: int, num_keys: int):
    ch = ConsistentHashWithVNodes(vnodes_per_node)
    for i in range(num_nodes):
        ch.add_node(f"node-{i}")

    counts = {f"node-{i}": 0 for i in range(num_nodes)}
    for k in range(num_keys):
        key = f"key-{random.random()}"
        node = ch.get_node(key)
        counts[node] += 1

    values = list(counts.values())
    avg = statistics.mean(values)
    stddev = statistics.stdev(values)
    max_val = max(values)
    min_val = min(values)

    print(f"vnodes={vnodes_per_node}: "
          f"min={min_val}, max={max_val}, "
          f"stddev={stddev:.0f} ({stddev/avg*100:.1f}% of avg)")

# 1,000,000 키를 10개 노드에 분산
benchmark(vnodes_per_node=1,   num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=10,  num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=100, num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=500, num_nodes=10, num_keys=1_000_000)

예상 출력:

vnodes=1:   min=30000, max=250000, stddev=70000 (70.0% of avg)
vnodes=10:  min=60000, max=180000, stddev=35000 (35.0% of avg)
vnodes=100: min=92000, max=110000, stddev=5800  (5.8% of avg)
vnodes=500: min=97000, max=103000, stddev=2000  (2.0% of avg)

100개 가상 노드로도 ±6% 수준의 균형을 달성할 수 있다.


5. Jump Consistent Hash: 가상 노드 없이도 균등하게

2014년 Google의 John Lamping과 Eric Veach가 발표한 Jump Consistent Hash는 가상 노드 없이 O(1) 공간거의 완벽한 균등 분포를 달성한다.

놀라운 특성

  • 메모리 사용량: O(1) (노드 목록 불필요!)
  • 시간 복잡도: O(log N) (N은 노드 수)
  • 분포 균등도: 거의 완벽
  • 단점: 노드 제거가 불편 (끝에 있는 노드만 제거 가능)

Jump Hash 알고리즘

def jump_consistent_hash(key: int, num_buckets: int) -> int:
    """
    Google Jump Consistent Hash
    key: 해시된 정수
    num_buckets: 버킷(노드) 개수
    """
    b = -1
    j = 0
    while j < num_buckets:
        b = j
        key = (key * 2862933555777941757 + 1) & 0xFFFFFFFFFFFFFFFF
        j = int((b + 1) * (1 << 31) / ((key >> 33) + 1))
    return b

# 사용 예시
import hashlib

def hash_key(key: str) -> int:
    return int(hashlib.md5(key.encode()).hexdigest()[:16], 16)

num_nodes = 10
key = hash_key("user:1234")
bucket = jump_consistent_hash(key, num_nodes)
print(f"user:1234 → bucket {bucket}")

어떻게 이렇게 작동하는가?

Jump Hash의 핵심 원리는 의사난수 기반 확률적 스킵이다. 각 반복마다 "다음에 점프할 위치"를 확률적으로 결정한다. 수학적으로 증명된 속성:

  • 노드 수가 N → N+1로 바뀌면, 정확히 K/(N+1)개의 키만 새 노드로 이동한다.
  • 분포는 완벽하게 균등하다 (가상 노드가 전혀 필요 없다).

한계: 노드 제거 문제

Jump Hash는 오직 마지막 노드(가장 높은 인덱스)만 제거 가능하다. 중간 노드를 제거하면 뒷 노드들의 인덱스가 바뀌어 모든 키 매핑이 깨진다.

이를 해결하려면 Lookup Table 을 추가로 관리하거나, 다음에 설명할 Rendezvous Hashing을 사용한다.


6. Rendezvous Hashing (HRW: Highest Random Weight)

1997년 David Thaler와 Chinya Ravishankar가 제안한 Rendezvous Hashing은 또 다른 우아한 해결책이다.

아이디어

각 키에 대해 모든 노드와의 해시값을 계산하고, 그중 가장 높은(또는 낮은) 값을 가진 노드를 선택한다.

def rendezvous_hash(key: str, nodes: list) -> str:
    """
    각 키를 가장 높은 가중치를 가진 노드에 할당
    """
    if not nodes:
        return None

    max_weight = -1
    selected_node = None

    for node in nodes:
        combined = f"{key}:{node}"
        h = int(hashlib.md5(combined.encode()).hexdigest(), 16)
        if h > max_weight:
            max_weight = h
            selected_node = node

    return selected_node

# 사용 예시
nodes = ["server-A", "server-B", "server-C", "server-D"]
print(rendezvous_hash("user:1234", nodes))  # 예: "server-C"

장단점

장점:

  • 구현이 매우 간단 (해시 함수만 있으면 됨)
  • 완벽하게 균등한 분포
  • 노드 추가/제거 시 K/N개 키만 이동
  • Jump Hash와 달리 임의의 노드 제거 가능
  • Replica 선택에 유용: Top-K 노드를 얻으면 K개의 복제본 위치를 즉시 얻을 수 있다.

단점:

  • O(N) 시간 복잡도: 노드가 많으면 느리다.
  • 1000개 이상의 노드에서는 부적절.

복제(Replica) 선택

Rendezvous Hashing이 빛나는 순간은 Top-K 노드 선택이다:

def rendezvous_hash_topk(key: str, nodes: list, k: int) -> list:
    weights = []
    for node in nodes:
        h = int(hashlib.md5(f"{key}:{node}".encode()).hexdigest(), 16)
        weights.append((h, node))
    weights.sort(reverse=True)
    return [node for _, node in weights[:k]]

# 3개 복제본을 위한 3개 노드 선택
replicas = rendezvous_hash_topk("user:1234", nodes, k=3)
print(replicas)  # ["server-C", "server-A", "server-D"]

이는 Cassandra의 replica 배치 전략에서 사용되는 아이디어와 비슷하다.


7. Anchor Hashing: 최신 알고리즘 (2018)

Anchor Hashing은 2018년 Gal Mendelson 등이 발표한 최신 알고리즘으로, Jump Hash의 속도Rendezvous의 유연한 노드 제거를 모두 제공한다.

주요 특성:

  • O(log N) 시간 복잡도
  • O(N) 공간 (각 노드당 상수 메모리)
  • 임의 노드 제거 가능
  • 완벽한 균등 분포

실제 구현은 복잡하니, 이 글에서는 존재만 언급하고 넘어간다. Maglev Hash(Google)도 비슷한 범주에 속한다.


8. 실전 시스템에서 어떻게 쓰이나?

DynamoDB: 기본 Consistent Hashing + vnode

Amazon DynamoDB는 Amazon Dynamo 논문(2007)의 설계를 따른다:

  • 128비트 해시 공간을 원형으로 구성.
  • 각 물리 노드를 **여러 가상 노드(토큰)**로 분산.
  • 각 키는 시계 방향 가장 가까운 N개 노드에 복제(Preference List).
  • 노드 추가/제거 시 인접 노드에서만 데이터 이동.

Cassandra: 토큰 링

Cassandra도 유사한 구조지만 용어가 조금 다르다:

  • 토큰(Token) = 가상 노드 (Murmur3 64비트 해시)
  • 기본 num_tokens: 256 → 각 물리 노드가 256개 토큰 소유.
  • 데이터 복제는 Replication Strategy (SimpleStrategy, NetworkTopologyStrategy)로 설정.
# cassandra.yaml
num_tokens: 256
partitioner: org.apache.cassandra.dht.Murmur3Partitioner

Memcached (libketama): 가상 노드 표준

libketama는 Last.fm이 만든 Memcached 클라이언트 라이브러리로, Consistent Hashing의 사실상 표준이 되었다:

  • 노드당 160개 가상 노드.
  • MD5 해시 (128비트 → 32비트로 절단).
  • 서버 정보 문자열 → MD5 → 4개의 32비트 정수 → 4개의 가상 노드 생성.

Nginx: upstream consistent hash

Nginx도 upstream 로드 밸런싱에 Consistent Hashing을 지원한다:

upstream backend {
    hash $request_uri consistent;
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com;
}

consistent 키워드가 Ketama 기반 Consistent Hashing을 활성화한다.

Envoy / HAProxy / Istio

Envoy의 Ring Hash LB PolicyMaglev LB Policy는 각각 Ketama 스타일과 Google Maglev 스타일의 Consistent Hashing을 구현한다.

# Envoy config
load_balancing_policy:
  policies:
  - typed_extension_config:
      name: envoy.load_balancing_policies.ring_hash
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.load_balancing_policies.ring_hash.v3.RingHash
        minimum_ring_size: 1024

9. 흔한 실수와 함정

함정 1: 해시 함수 선택

나쁜 선택:

def bad_hash(key):
    return hash(key)  # 파이썬 내장 hash()는 재시작 시 바뀜!

파이썬 내장 hash()보안상 이유로 프로세스 시작마다 다른 시드를 쓴다. 분산 시스템에서 재시작 시 매핑이 완전히 바뀌면 재앙이다.

좋은 선택:

  • MD5, SHA-1: 느리지만 분포 좋음.
  • MurmurHash3: 빠르고 분포 좋음 (Cassandra 사용).
  • xxHash: 매우 빠름 (최근 선호됨).

함정 2: 가상 노드 수 부족

노드 4개 × 가상 노드 1개 = 4개 → 분포가 매우 불균형. 항상 노드당 최소 100개 이상의 가상 노드를 사용하자.

함정 3: 노드 ID의 문자열 포맷

가상 노드를 만들 때 포맷이 일관되지 않으면 재현 불가:

# 나쁨
f"{node}-{i}"   # node1-0, node1-1, ...
f"{node}_{i}"   # node1_0, node1_1, ... (다른 시스템에서 사용)

# 좋음: 팀/조직 표준 문서화
f"{node}#{i}"   # 문서에 명시

함정 4: 핫키(Hot Key)

Consistent Hashing은 키 분포가 균등할 때만 잘 작동한다. 특정 키(예: user:celebrity_account)가 엄청난 트래픽을 받으면 한 노드에 부하가 집중된다.

해결책:

  • Key Salting: user:celebrity_account:{random_suffix} 로 여러 샤드에 분산.
  • Local Cache: 앱 레벨 캐싱으로 분산 캐시 호출 자체를 줄임.
  • Read Replica: 읽기 전용 복제본 추가.

함정 5: 노드 클러스터의 급격한 변동

쿠버네티스 같은 환경에서 노드가 빈번하게 생성/삭제되면, 매번 데이터 이동이 발생한다. Rebalancing Throttling이 필요하다:

  • Warm-up 기간: 새 노드는 점진적으로 트래픽을 받는다.
  • Cooldown: 노드 제거 후 데이터가 완전히 이동할 때까지 대기.

10. 한 걸음 더: Weighted Consistent Hashing

Maglev (Google)

Google의 Maglev는 다음과 같은 요구사항으로 설계됐다:

  1. 완벽한 균등성 (비슷한 가중치 서버들 간)
  2. 최소 disruption (노드 변경 시)
  3. O(1) lookup (런타임에 조회가 빨라야 함)

Maglev는 각 서버에 대해 **선호 순열(preference permutation)**을 계산하고, 이를 통해 크기 M의 lookup table을 만든다. 조회는 lookup_table[hash(key) % M]로 O(1)이다.

# 개념 수준 의사코드
def build_maglev_table(servers, table_size=65537):
    # table_size는 소수(prime) 선택
    permutations = {
        s: generate_permutation(s, table_size) for s in servers
    }
    table = [None] * table_size
    next_idx = {s: 0 for s in servers}

    filled = 0
    while filled < table_size:
        for s in servers:
            while table[permutations[s][next_idx[s]]] is not None:
                next_idx[s] += 1
            table[permutations[s][next_idx[s]]] = s
            next_idx[s] += 1
            filled += 1
            if filled == table_size:
                break
    return table

Maglev는 Envoy의 maglev 로드 밸런서 정책으로 사용할 수 있으며, Google Cloud Load Balancer 내부에서도 사용된다.

가중치(Weight) 부여

이질적인 서버가 있을 때(예: 16코어와 4코어) 가중치를 반영하려면:

  1. 가상 노드 방식: vnodes = base * weight
  2. Maglev 방식: permutation을 weight 배만큼 사용.
  3. Rendezvous 가중치: 해시값에 log(weight)를 곱해 bias.

11. 성능 비교: 어떤 알고리즘을 선택할까?

알고리즘시간 복잡도공간 복잡도분포 균등도노드 제거주 사용처
Modulo HashO(1)O(1)완벽❌ 전체 재분배단순 샤딩
Consistent + vnodeO(log V)O(V)양호 (V↑일수록)✅ 임의Cassandra, DynamoDB
Jump HashO(log N)O(1)완벽❌ 마지막만Google 내부
Rendezvous HashO(N)O(N)완벽✅ 임의소규모 클러스터
MaglevO(1) 조회O(M)거의 완벽✅ 임의Google Cloud LB
Anchor HashingO(log N)O(N)완벽✅ 임의최신 시스템

선택 가이드

  • 노드 수 < 100, 잦은 변동: Rendezvous Hashing (간단, 유연)
  • 대규모 캐시/DB 클러스터: Consistent Hashing + vnode (검증됨)
  • 정적 노드 집합, 극한 성능: Jump Hash (O(log N))
  • L4/L7 로드밸런서: Maglev (O(1) 조회)
  • 이질 용량 서버: vnode 가중치 또는 Maglev weighted

12. 실전 팁: 운영하면서 배운 것들

Tip 1: 가상 노드 수는 절대 바꾸지 마라

운영 중에 num_tokens를 150 → 200으로 바꾸면 거의 모든 데이터가 재분배된다. 클러스터 초기 설계 단계에서 신중히 선택하자.

Tip 2: 로그로 분포 모니터링

실제 키 분포가 얼마나 균등한지 주기적으로 측정하자:

# Prometheus 메트릭
consistent_hash_node_keys{node="server-A"} 12050
consistent_hash_node_keys{node="server-B"} 10800
consistent_hash_node_keys{node="server-C"} 11200
consistent_hash_node_keys{node="server-D"} 12500

표준편차가 갑자기 커지면 핫키 의심.

Tip 3: Graceful Shutdown

노드를 제거할 땐 갑자기 죽이지 말고:

  1. 해당 노드를 "draining" 상태로 표시 (링에서 제거).
  2. 기존 연결 처리 완료 대기.
  3. 데이터 이동 확인.
  4. 프로세스 종료.

Tip 4: 해시 함수 속도 vs 품질

초당 수십만 건의 조회가 있다면, MD5는 느릴 수 있다. 벤치마크:

해시 함수속도 (MB/s)분포 품질
xxHash3~30,000양호
MurmurHash3~6,000양호
CityHash~10,000양호
MD5~500우수
SHA-256~400우수

캐시 샤딩 같은 용도엔 xxHashMurmur가 적합하다.


퀴즈로 복습하기

Q1. 서버 4대에서 5대로 증설할 때, 일반적인 모듈로 해싱(hash % N)은 몇 퍼센트의 키가 이동하는가?

A. 약 **80%**의 키가 이동한다. 기존 hash % 4hash % 5의 결과는 거의 무관하기 때문이다. 반면 Consistent Hashing은 약 20%(K/N = K/5)만 이동한다.

Q2. 기본 Consistent Hashing에서 가상 노드가 왜 필요한가?

A. 노드가 적을 때 링 위 노드 분포가 불균등해져서 일부 노드에 키가 편중된다. 각 물리 노드를 100~200개의 가상 노드로 흩뿌리면 대수의 법칙에 의해 거의 균등한 분포를 얻을 수 있다. 또한 서버 용량에 따라 가상 노드 수를 조절해 가중치를 부여할 수도 있다.

Q3. Jump Consistent Hash의 가장 큰 장점과 단점은?

A. 장점: O(1) 공간과 완벽한 균등 분포. 가상 노드 없이도 잘 작동하며 매우 빠르다. 단점: 오직 가장 끝(높은 인덱스) 노드만 제거 가능. 중간 노드를 제거하려면 전체 매핑이 깨진다. 임의 노드 제거가 필요하면 Rendezvous Hashing이나 Anchor Hashing을 써야 한다.

Q4. Rendezvous Hashing이 N개 복제본 선택에 유용한 이유는?

A. Rendezvous Hashing은 각 키에 대해 모든 노드의 가중치(해시값)를 계산한다. 이를 정렬해서 상위 K개 노드를 선택하면 자연스럽게 K개 복제본의 배치가 결정된다. 이는 Consistent Hashing 링에서 "시계 방향으로 다음 K개 노드"를 찾는 것과 유사한 효과지만, 더 균등하게 분포된다.

Q5. Memcached의 libketama는 노드당 몇 개의 가상 노드를 기본으로 사용하며, 그 선택 이유는?

A. Ketama는 노드당 160개의 가상 노드를 사용한다. 이는 균형성(±5% 표준편차)메모리 오버헤드 사이의 타협점이다. 실제로는 MD5 해시의 결과를 4개의 4바이트 정수로 쪼개 각 "replica"당 4개 가상 노드를 만들므로, 40 replicas × 4 = 160이다.


마치며: Consistent Hashing의 교훈

Consistent Hashing은 1997년 MIT에서 CDN의 웹 캐시 문제를 풀기 위해 고안되었다. Akamai를 탄생시켰고, 이후 20년 넘게 분산 시스템의 핵심 빌딩 블록이 되었다.

핵심 원리 5가지

  1. 원형 해시 공간: 노드와 키를 같은 공간에 배치해 이웃 관계를 정의.
  2. 시계 방향 할당: 키는 시계 방향 가장 가까운 노드로.
  3. 가상 노드: 대수의 법칙으로 균등 분포 달성.
  4. 최소 disruption: 노드 추가/제거 시 K/N개 키만 이동.
  5. 가중치 유연성: vnode 수로 용량 차이 반영.

현대의 진화

  • Jump Hash: O(1) 공간으로 극도의 효율.
  • Rendezvous Hashing: 간단함의 극치.
  • Maglev: O(1) 조회 + weighted LB.
  • Anchor Hashing: 최신 개선판.

기억할 것

"분산 시스템에서 데이터를 어디에 둘지 결정하는 문제" 의 답은 거의 항상 Consistent Hashing의 어떤 변종이다.

당신이 지금 사용하는 Redis Cluster, DynamoDB, Cassandra, Memcached, CDN — 모두 이 30년 된 아이디어 위에서 돌아간다. 기본을 알면 디버깅과 설계의 깊이가 달라진다.

다음번에 "왜 이 키가 저 서버로 가지?"라는 질문이 생기면, 해시 링을 머릿속에 그려보자. 답이 거기에 있다.


참고 자료