수평적 확장성(horizontal scalability)을 달성하기 위해서는 요청 또는 데이터를 서버에 균등하게 분산하는 것이 중요하다.
안정 해시(Consistent Hashing)는 서버가 동적으로 추가되거나 제거될 때도 최소한의 데이터 이동만으로 분산 균형을 유지할 수 있는 기술로, 다양한 분산 시스템에서 널리 사용된다.
서버 인덱스 = hash(key) % N (N: 서버 수)
N 값이 바뀌며 대부분의 키가 다른 서버로 재배치되어 캐시 미스 및 시스템 부하 발생.안정 해시는 서버 수(N)가 변경되어도 전체 키(k) 중 일부(k/n)만 재배치되는 해시 기법이다.
서버와 키를 같은 해시 공간에 매핑하고, 키는 시계 방향으로 가장 가까운 서버에 배정된다.
0 ~ 2³²-1의 정수 범위를 사용하는 원형 공간.평균적으로 k/n개의 키만 재배치
➡️ 대부분 키는 변경되지 않음
| 항목 | 내용 |
|---|---|
| 확장성 | 서버 추가/제거 시 재배치 최소화 |
| 균형성 | 가상 노드를 통해 균등한 키 분포 달성 |
| 유연성 | 동적 서버 관리 용이 |
| 안정성 | Hotspot 현상 완화, 장애 격리 효과 |
안정 해시는 분산 시스템에서 유연한 확장, 장애 허용성, 성능 향상을 동시에 추구할 수 있는 핵심 기법이다. 단순한 구현으로 높은 효율성을 얻을 수 있으며, 캐시, 키-값 저장소, 마이크로서비스 샤딩 등 다양한 환경에서 널리 쓰이고 있다.
import hashlib
import bisect
class ConsistentHash:
def __init__(self, replicas=100):
self.replicas = replicas # 가상 노드 수
self.ring = dict()
self.sorted_keys = []
self.nodes = set()
def _hash(self, key):
return int(hashlib.md5(key.encode('utf-8')).hexdigest(), 16)
def add_node(self, node):
self.nodes.add(node)
for i in range(self.replicas):
virtual_node_key = f"{node}#{i}"
hash_val = self._hash(virtual_node_key)
self.ring[hash_val] = node
bisect.insort(self.sorted_keys, hash_val)
def remove_node(self, node):
self.nodes.discard(node)
for i in range(self.replicas):
virtual_node_key = f"{node}#{i}"
hash_val = self._hash(virtual_node_key)
self.ring.pop(hash_val, None)
index = bisect.bisect_left(self.sorted_keys, hash_val)
if index < len(self.sorted_keys) and self.sorted_keys[index] == hash_val:
self.sorted_keys.pop(index)
def get_node(self, key):
if not self.ring:
return None
hash_val = self._hash(key)
index = bisect.bisect(self.sorted_keys, hash_val) % len(self.sorted_keys)
return self.ring[self.sorted_keys[index]]
# 예제 사용
hash_ring = ConsistentHash()
hash_ring.add_node("ServerA")
hash_ring.add_node("ServerB")
hash_ring.add_node("ServerC")
print("Key1 →", hash_ring.get_node("Key1"))
print("Key2 →", hash_ring.get_node("Key2"))
hash_ring.remove_node("ServerB")
print("After removing ServerB:")
print("Key1 →", hash_ring.get_node("Key1"))
print("Key2 →", hash_ring.get_node("Key2"))
package main
import (
"crypto/md5"
"fmt"
"sort"
"strconv"
)
type ConsistentHash struct {
replicas int
keys []int
hashMap map[int]string
nodeSet map[string]bool
}
func NewConsistentHash(replicas int) *ConsistentHash {
return &ConsistentHash{
replicas: replicas,
hashMap: make(map[int]string),
nodeSet: make(map[string]bool),
}
}
func hashKey(key string) int {
sum := md5.Sum([]byte(key))
return int((uint32(sum[0])<<24 | uint32(sum[1])<<16 | uint32(sum[2])<<8 | uint32(sum[3])))
}
func (c *ConsistentHash) AddNode(node string) {
if c.nodeSet[node] {
return
}
c.nodeSet[node] = true
for i := 0; i < c.replicas; i++ {
virtualKey := node + "#" + strconv.Itoa(i)
hash := hashKey(virtualKey)
c.keys = append(c.keys, hash)
c.hashMap[hash] = node
}
sort.Ints(c.keys)
}
func (c *ConsistentHash) RemoveNode(node string) {
if !c.nodeSet[node] {
return
}
delete(c.nodeSet, node)
for i := 0; i < c.replicas; i++ {
virtualKey := node + "#" + strconv.Itoa(i)
hash := hashKey(virtualKey)
delete(c.hashMap, hash)
for j, h := range c.keys {
if h == hash {
c.keys = append(c.keys[:j], c.keys[j+1:]...)
break
}
}
}
}
func (c *ConsistentHash) GetNode(key string) string {
if len(c.keys) == 0 {
return ""
}
hash := hashKey(key)
idx := sort.Search(len(c.keys), func(i int) bool {
return c.keys[i] >= hash
})
if idx == len(c.keys) {
idx = 0
}
return c.hashMap[c.keys[idx]]
}
// 예제 실행
func main() {
ch := NewConsistentHash(100)
ch.AddNode("ServerA")
ch.AddNode("ServerB")
ch.AddNode("ServerC")
fmt.Println("Key1 →", ch.GetNode("Key1"))
fmt.Println("Key2 →", ch.GetNode("Key2"))
ch.RemoveNode("ServerB")
fmt.Println("After removing ServerB:")
fmt.Println("Key1 →", ch.GetNode("Key1"))
fmt.Println("Key2 →", ch.GetNode("Key2"))
}
Redis는 일반적인 Consistent Hash Ring 대신, 다음과 같은 방식으로 데이터를 분산
CRC16(key) % 16384)를 통해 하나의 슬롯에 매핑된다.즉, 일반적인 해시링에서의 "서버 인덱스 = hash(key) % N" 대신,
"해시 슬롯 = CRC16(key) % 16384" → 이 슬롯을 담당하는 노드에게 저장.
[Key] → [Hash Function] → [Hash Slot] → [Redis Node]
예:
"user:1234"Redis는 Consistent Hash Ring처럼 전체 키 재해싱이 필요 없이,
슬롯 단위로 분할 및 이동하기 때문에 안정적인 확장이 가능하다.
예:
Redis Cluster는 키가 여러 슬롯에 걸쳐 분산되기 때문에, 다중 키 연산(MGET, MSET 등)은 동일한 슬롯에 있는 키에 한해서만 지원돼.
이 문제를 해결하기 위해 Hash Tag 기능이 제공돼:
"user:{1234}:profile"
"user:{1234}:posts"
{} 안의 부분만 해시 계산에 사용됨"1234"를 기반으로 같은 슬롯에 배치됨 → 연산 가능Redis는 내부적으로 CRC16 해시 함수를 사용한다.
hash_slot = CRC16(key) % 16384
이 함수는 빠르고 충돌이 적으며, 클러스터 전체에서 균형 있는 슬롯 분포를 달성하는 데 효과적이야.
| 기능 | 설명 |
|---|---|
| 슬롯 기반 샤딩 | 키 해시값이 16384 슬롯에 균등 분포 |
| 노드 확장/축소 | 슬롯 단위로 분할/이동 가능 |
| 최소 재배치 | 새로운 노드에 일부 슬롯만 이동 |
| 가상 노드 없이 슬롯으로 분산 | 복잡도 감소, 속도 향상 |
| 항목 | Redis Hash Slot 방식 | 일반적인 Consistent Hash Ring |
|---|---|---|
| 분산 단위 | 고정된 16384 슬롯 | 해시 링 전체에서 노드 간 거리 기반 |
| 데이터 이동 | 슬롯 단위로 재배치 (정밀 제어) | 해시값 기준 인접 구간 전체 재배치 |
| 구현 복잡도 | 상대적으로 단순 | 가상 노드/표준편차 조절 등 고려 필요 |
| 다중 키 연산 | 동일 슬롯 키만 가능 (해시 태그 필요) | 일반적으로 불가능, 추가 구현 필요 |
| 항목 | 설명 |
|---|---|
| 해시 방식 | CRC16(key) % 16384 → 해시 슬롯 |
| 데이터 분산 | 슬롯 단위로 분산, 각 노드가 일부 슬롯 담당 |
| 확장/축소 | 슬롯 단위 이동으로 안정성 유지 |
| 해시 태그 | 연관 키를 같은 슬롯에 그룹핑 |
| 가상 노드 없음 | 슬롯으로 역할 대체함 |
CLUSTER SETSLOT, CLUSTER ADDSLOTS) 등을 통해 리밸런싱을 수동 제어할 수 있음Key → Slot → Node를 추적하는 샘플 작성"NodeA", "NodeB", "NodeC"NodeA: 슬롯 0 ~ 5460NodeB: 슬롯 5461 ~ 10922NodeC: 슬롯 10923 ~ 16383import crcmod
# Redis의 CRC16 해시 함수와 동일한 것 사용
crc16 = crcmod.predefined.mkPredefinedCrcFun('crc-aug-ccitt')
# 슬롯 → 노드 매핑
slot_ranges = {
"NodeA": range(0, 5461),
"NodeB": range(5461, 10923),
"NodeC": range(10923, 16384)
}
def get_slot(key: str) -> int:
# Redis Cluster에서 키에 {}가 있으면 그 부분만 해싱
if '{' in key and '}' in key:
start = key.index('{') + 1
end = key.index('}')
key = key[start:end]
return crc16(key.encode()) % 16384
def get_node_for_key(key: str) -> str:
slot = get_slot(key)
for node, slots in slot_ranges.items():
if slot in slots:
return f"{key} → slot {slot} → {node}"
return f"{key} → slot {slot} → Unknown"
# 테스트
keys = ["user:1000", "order:42", "product:{123}", "product:{124}"]
for k in keys:
print(get_node_for_key(k))
출력 예시:
user:1000 → slot 5793 → NodeB
order:42 → slot 10927 → NodeC
product:{123} → slot 14113 → NodeC
product:{124} → slot 2853 → NodeA
package main
import (
"fmt"
"github.com/snksoft/crc"
)
var slotRanges = map[string][2]int{
"NodeA": {0, 5460},
"NodeB": {5461, 10922},
"NodeC": {10923, 16383},
}
func getHashSlot(key string) int {
// 해시 태그 처리: {tag}
start := -1
end := -1
for i := 0; i < len(key); i++ {
if key[i] == '{' && start == -1 {
start = i
} else if key[i] == '}' && start != -1 {
end = i
break
}
}
if start != -1 && end != -1 {
key = key[start+1 : end]
}
crc16 := crc.Calculator(crc.CRC16_XMODEM)
hash := crc16.Checksum([]byte(key))
return int(hash % 16384)
}
func getNodeForKey(key string) string {
slot := getHashSlot(key)
for node, rng := range slotRanges {
if slot >= rng[0] && slot <= rng[1] {
return fmt.Sprintf("%s → slot %d → %s", key, slot, node)
}
}
return fmt.Sprintf("%s → slot %d → Unknown", key, slot)
}
func main() {
keys := []string{"user:1000", "order:42", "product:{123}", "product:{124}"}
for _, key := range keys {
fmt.Println(getNodeForKey(key))
}
}
Go에서는 github.com/snksoft/crc 패키지를 사용해서 Redis와 동일한 CRC16-XMODEM 해시를 계산했어.
go.mod에 다음 의존성을 추가해야 해:
go get github.com/snksoft/crc