일관된 해싱 — 서버가 추가되어도 데이터가 안 깨지는 이유
서버를 3대에서 4대로 늘렸을 뿐인데, 캐시 적중률이 0%에 가까워진 적이 있나요?
개념 정의
일관된 해싱(Consistent Hashing) 은 노드(서버)가 추가되거나 제거될 때, 최소한의 키만 재배치 되도록 하는 해싱 기법입니다.
기존의 hash(key) % N 방식은 N(서버 수)이 바뀌면 거의 모든 키가 다른 서버로 이동합니다. 일관된 해싱은 이 문제를 해결합니다.
왜 필요한가
단순 해시의 문제
서버 3대: hash("user:1001") % 3 = 2 → 서버2
서버 4대: hash("user:1001") % 4 = 1 → 서버1 (이동!)
서버 1대 추가했을 뿐인데, 약 75%의 키가 다른 서버로 이동
→ 캐시 미스 폭증 → DB 부하 급증 (cache stampede)
분산 캐시, 분산 DB, 로드밸런서 등 어디서든 이 문제가 발생합니다.
동작 원리 — 해시 링
기본 아이디어
- 해시 함수의 출력 공간을 원형 링 으로 생각합니다 (0 ~ 2^32-1)
- 각 서버를 링 위의 한 점에 배치합니다 (
hash(서버 ID)) - 각 키도 링 위의 한 점에 배치합니다 (
hash(키)) - 키에서 시계 방향으로 처음 만나는 서버 가 해당 키를 담당합니다
0
┌──┬──┐
│ S1 │
S3│ │
│ │
│ S2 │
└──┴──┘
2^32
키 K가 S2와 S3 사이에 있으면 → S3가 담당
서버 추가/제거 시
서버 S4를 추가하면, S4의 바로 이전 서버가 담당하던 키 중 S4에 더 가까운 키만 S4로 이동합니다. 나머지 키는 영향 없습니다.
S3 제거 전: K → S3가 담당
S3 제거 후: K → S3 다음 서버인 S1이 담당
(K와 S3 사이의 키만 S1으로 이동)
코드 예제 — Java
public class ConsistentHash<T> {
private final TreeMap<Long, T> ring = new TreeMap<>();
private final int virtualNodes; // 가상 노드 수
private final MessageDigest md;
public ConsistentHash(int virtualNodes) {
this.virtualNodes = virtualNodes;
try {
this.md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
// 서버 추가
public void addNode(T node) {
for (int i = 0; i < virtualNodes; i++) {
long hash = hash(node.toString() + "#" + i);
ring.put(hash, node);
}
}
// 서버 제거
public void removeNode(T node) {
for (int i = 0; i < virtualNodes; i++) {
long hash = hash(node.toString() + "#" + i);
ring.remove(hash);
}
}
// 키가 어느 서버에 속하는지 결정
public T getNode(String key) {
if (ring.isEmpty()) return null;
long hash = hash(key);
// 시계 방향으로 가장 가까운 서버 찾기
Map.Entry<Long, T> entry = ring.ceilingEntry(hash);
if (entry == null) {
entry = ring.firstEntry(); // 링의 끝을 넘으면 처음으로
}
return entry.getValue();
}
private long hash(String key) {
md.reset();
byte[] digest = md.digest(key.getBytes());
// MD5의 처음 8바이트를 long으로 변환
long hash = 0;
for (int i = 0; i < 8; i++) {
hash = (hash << 8) | (digest[i] & 0xFF);
}
return hash;
}
}
가상 노드(Virtual Nodes)
문제
물리 서버가 3대뿐이면, 링 위에 점 3개만 찍히므로 데이터가 편향될 수 있습니다.
해결
각 물리 서버에 ** 여러 개의 가상 노드 **를 배치합니다.
물리 서버 S1 → 가상 노드: S1#0, S1#1, S1#2, ..., S1#149
물리 서버 S2 → 가상 노드: S2#0, S2#1, S2#2, ..., S2#149
물리 서버 S3 → 가상 노드: S3#0, S3#1, S3#2, ..., S3#149
총 450개 점이 링에 분포 → 훨씬 균등한 분배
가상 노드 수가 많을수록 균등해지지만, 메모리와 조회 시간이 증가합니다. 보통 100~200개 가 적당합니다.
가중치가 있는 서버
서버 성능이 다르면 가상 노드 수를 다르게 설정합니다.
// 고성능 서버는 가상 노드를 더 많이
addNodeWithWeight("server-large", 300); // 가상 노드 300개
addNodeWithWeight("server-small", 100); // 가상 노드 100개
// → 고성능 서버가 약 3배의 키를 담당
재배치되는 키의 수
| 방식 | 서버 N → N+1 시 재배치 비율 |
|---|---|
| hash % N | 약 (N-1)/N ≈ 거의 전부 |
| 일관된 해싱 | 약 1/N (최소한) |
서버 10대 → 11대로 늘릴 때:
hash % N: 약 90%의 키가 이동- 일관된 해싱: 약 10%의 키만 이동
백엔드/실무 연결
Redis Cluster
Redis Cluster는 일관된 해싱의 변형인 해시 슬롯 방식을 사용합니다.
총 16384개의 슬롯
키의 슬롯 = CRC16(key) % 16384
노드 A: 슬롯 0-5460
노드 B: 슬롯 5461-10922
노드 C: 슬롯 10923-16383
노드를 추가하면 일부 슬롯만 새 노드로 이전합니다. 일관된 해싱과 동일한 효과입니다.
Memcached 클라이언트
Memcached 자체는 분산 기능이 없지만, 클라이언트(libketama 등)에서 일관된 해싱으로 서버를 선택합니다.
로드밸런서
Nginx의 upstream 설정에서 hash 모드를 사용하면 일관된 해싱으로 요청을 분배합니다.
upstream backend {
hash $request_uri consistent;
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}
같은 URL은 항상 같은 서버로 라우팅되어, 서버별 캐시 효율이 높아집니다.
DynamoDB / Cassandra
Amazon DynamoDB와 Apache Cassandra는 일관된 해싱으로 데이터를 파티셔닝합니다. 노드 추가/제거 시 최소한의 데이터만 이동시켜 가용성을 유지합니다.
주의할 점
가상 노드(virtual node) 없이 배포하여 데이터 편향 발생
물리 서버가 적으면 해시 링에서 노드 간 간격이 불균등해져 특정 서버에 데이터가 몰립니다. 서버당 100~200개의 가상 노드를 배치하면 균등에 가까워집니다.
hash % N 방식에서 서버 추가/제거 시 캐시 미스 폭증
서버를 1대 추가하면 거의 모든 키의 매핑이 변경됩니다. 일관된 해싱을 사용하면 평균 K/N개의 키만 재배치됩니다.
정리
- ** 일관된 해싱 **은 서버 추가/제거 시 최소한의 키만 재배치하는 해싱 기법입니다
- ** 해시 링** 위에 서버와 키를 배치하고, 시계 방향으로 가장 가까운 서버가 키를 담당합니다
- ** 가상 노드 **로 데이터 편향을 해결합니다. 보통 100~200개가 적당합니다
- Redis Cluster(해시 슬롯), Cassandra, DynamoDB 등 분산 시스템의 핵심 기술입니다
hash % N의 재배치율은 약 (N-1)/N이지만, 일관된 해싱은 약 1/N입니다