Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

366 changes: 366 additions & 0 deletions 05-안정 해시/정인철/01_안정_해시_설계_1차.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
# 5장. 안정 해시 설계 (Consistent Hashing)

---

## 문제 이해 및 설계 범위 확정하기

수평적 확장을 위해서는 요청 또는 데이터를 서버들에 균등하게 나누는 것이 중요하다.
이 장의 목표는 다음 두 가지를 동시에 만족하는 분배 방법을 찾는 것이다.

**요구사항**

- 데이터(키)를 서버들에 균등하게 분배
- 서버가 추가/삭제될 때 **재배치되는 키를 최소화**
- 대규모 분산 캐시/스토리지 환경에서 동작
- 특정 서버로 키가 쏠리는 핫스팟 방지

---

## 해시 키 재배치 문제

### 나머지 연산 방식 (hash % N)

N개의 서버에 부하를 나누는 가장 단순한 방법.

```
인덱스 = hash(key) % N // N = 서버의 개수
```

서버 풀의 크기가 고정되어 있고 데이터 분포가 균등하면 잘 동작한다.
문제는 **서버가 추가되거나 삭제되는 순간**이다.

### 치명적 단점: 대규모 재배치

```
서버 4대 → 3대로 축소되는 경우

key0: hash=18 → 18 % 4 = 2 (서버2) 18 % 3 = 0 (서버0) ← 이동
key1: hash=26 → 26 % 4 = 2 (서버2) 26 % 3 = 2 (서버2) ← 유지
key2: hash=35 → 35 % 4 = 3 (서버3) 35 % 3 = 2 (서버2) ← 이동
key3: hash=50 → 50 % 4 = 2 (서버2) 50 % 3 = 2 (서버2) ← 유지
...

→ % 4와 % 3의 결과가 일치할 확률은 약 25%
→ 즉 약 75%의 키가 재배치됨
```

**캐시 환경에서의 장애 시나리오**

```
서버 1대 다운
키의 대부분이 다른 서버로 재매핑
대량 캐시 미스 발생
요청이 전부 DB(원본 저장소)로 쏟아짐
cache stampede → DB 과부하 → 연쇄 장애
```

> 서버 1대가 죽었을 뿐인데 DB까지 함께 죽는 구조.
> 4장의 처리율 제한 장치가 "요청 폭주"를 막는 장치였다면,
> 이 장은 "노드 변경으로 인한 내부 폭주"를 막는 문제의식이다.

---

## 안정 해시 (Consistent Hashing)

해시 테이블의 크기가 조정될 때 평균적으로 **k/n개의 키만 재배치**하는 해시 기법.

- k: 키의 개수
- n: 슬롯의 개수 (실질적으로는 노드/서버 수로 이해)

### 해시 링 (Hash Ring)

해시 공간(0 ~ 2^160 - 1)의 양 끝을 구부려 맞닿게 하면 해시 링이 만들어진다.
링 위에 **서버의 위치**와 **데이터의 해시 키**를 함께 배치한다.

```
0 (= 2^160)
┌───────┴───────┐
│ │
k3 ○ │ │ ● S0
│ │
S3 ● │ Hash │ ○ k0
│ Ring │
│ │ ● S1
k2 ○ │ │
└───────┬───────┘ ○ k1
S2 ●

● = 서버 노드, ○ = 데이터 키
```

### 키 → 서버 조회

각 키는 자신의 위치에서 **시계 방향으로 탐색하여 처음 만나는 서버**에 저장된다.

```
k0 → 시계 방향 → S1 담당
k1 → 시계 방향 → S2 담당
k2 → 시계 방향 → S3 담당
k3 → 시계 방향 → S0 담당
```

### 서버 추가

```
S0과 S1 사이에 S4 추가

변경 전: ── S0 ──── k0 ──── S1 ── (k0은 S1 담당)
변경 후: ── S0 ─ k0 ─ S4 ─── S1 ── (k0만 S4로 이동)

→ 새 서버 위치에서 반시계 방향으로 이전 서버(S0)를 만나기 전까지의
키들만 새 서버(S4)로 재배치
→ 나머지 키는 전부 그대로
```

### 서버 제거

```
S1 제거

변경 전: ── S0 ── k0 ── S1 ── S2 ── (k0은 S1 담당)
변경 후: ── S0 ── k0 ──────── S2 ── (k0만 S2로 이동)

→ 제거된 서버가 담당하던 키들만 시계 방향의 다음 서버로 재배치
```

**핵심**: 영향 범위가 **인접 구간으로 국한**된다. % N처럼 전체가 뒤섞이지 않는다.

---

### 기본 안정 해시의 두 가지 문제점

#### 1. 파티션 크기의 불균등

서버 추가/삭제가 반복되면 각 서버가 담당하는 링 구간(파티션)의 크기가 균등하게 유지되지 않는다.

```
S1이 제거되는 경우:

── S0 ──[ 작은 구간 ]── S1 ──[ 작은 구간 ]── S2 ──
▼ S1 제거
── S0 ──[ 매우 큰 구간 ]── S2 ──

→ S2의 담당 구간이 다른 서버 대비 2배로 커짐
```

#### 2. 키의 불균등 분포 (쏠림)

서버가 링 위에 몰려 배치되면 특정 서버로 키가 집중된다.

```
S0 ● ● S1 ● S2 ← 서버 3대가 한쪽에 몰림
┌──────────────┐
│ │
│ 나머지 │ ← 이 넓은 구간의 키 전부가
│ 절반의 링 │ 시계 방향 첫 서버 하나에 쏠림
│ │
└──────────────┘

서버 3대면 링 위의 점이 3개뿐
→ 운이 나쁘면 한 서버가 링의 절반을 담당
```

---

## 가상 노드 (Virtual Node / Replica)

위 두 문제를 해결하는 기법. 하나의 물리 서버를 링 위에 **여러 개의 가상 노드**로 배치한다.

```
물리 서버 2대 (S0, S1), 서버당 가상 노드 3개

── S0_vn0 ── S1_vn0 ── S0_vn1 ── S1_vn1 ── S0_vn2 ── S1_vn2 ──

→ 각 서버의 담당 구간이 링 전체에 잘게 분산됨
→ 조회 방식은 동일: 시계 방향 탐색, 처음 만난 가상 노드의 실제 서버에 저장
```

### 왜 균등해지는가

가상 노드 수가 많아질수록 각 서버가 담당하는 구간 합이 통계적으로 평균에 수렴한다 (**대수의 법칙**, 표준편차 감소).

```
가상 노드 수 키 분포의 표준편차
──────────── ─────────────────
100개 평균 대비 약 10%
200개 평균 대비 약 5%

→ 가상 노드 ↑ = 표준편차 ↓ = 분포 균등
```

### Trade-off

| 항목 | 내용 |
|------|------|
| 균등성 | 가상 노드 수 ↑ → 분포 균등 ↑ |
| 메모리 | 가상 노드 V개 저장 필요 → O(V) |
| 조회 비용 | 정렬 자료구조에서 이진 탐색 → O(log V) |
| 노드 증감 비용 | 서버 1대 추가/제거 시 V개의 점을 넣고 빼야 함 |
| 실무 타협점 | 서버당 **100~200개**가 일반적 |

### 서버 제거 시 데이터는?

링은 **라우팅 규칙**일 뿐, 데이터 자체를 보존하지 않는다.

```
캐시 용도 → 캐시 미스 후 원본에서 재적재 (자연 회복)
스토리지 용도 → 별도 복제(replication) 없으면 데이터 유실
(Dynamo: 시계 방향 다음 N개 노드에 복제)
```

---

## 구현 (Java + TreeMap)

핵심은 `ceilingEntry()` 두 줄이다.

```java
public class ConsistentHashRouter {

// 링 위의 위치(해시값) -> 실제 서버. 정렬된 맵이라 시계 방향 탐색 가능
private final TreeMap<Long, String> ring = new TreeMap<>();
private final int virtualNodeCount; // 서버당 가상 노드 개수

public ConsistentHashRouter(List<String> servers, int virtualNodeCount) {
this.virtualNodeCount = virtualNodeCount;
servers.forEach(this::addServer);
}

public void addServer(String server) {
// 물리 서버 하나를 가상 노드 여러 개로 링 위에 배치
for (int i = 0; i < virtualNodeCount; i++) {
ring.put(hash(server + "#VN" + i), server);
}
}

public void removeServer(String server) {
for (int i = 0; i < virtualNodeCount; i++) {
ring.remove(hash(server + "#VN" + i));
}
}

public String route(String key) {
long h = hash(key);
// ceilingEntry = 시계 방향으로 처음 만나는 가상 노드
Map.Entry<Long, String> entry = ring.ceilingEntry(h);
// 없으면 링을 한 바퀴 돌아 처음(firstEntry)으로
return (entry != null ? entry : ring.firstEntry()).getValue();
}

private long hash(String input) {
// String.hashCode()는 분포가 나쁨 → MurmurHash 등 균등 분포 해시 사용
return Hashing.murmur3_128()
.hashString(input, StandardCharsets.UTF_8).asLong();
}
}
```

**구현 포인트**

```
[1] TreeMap.ceilingEntry(h) = "시계 방향 탐색"
[2] null이면 firstEntry() = "링을 한 바퀴 도는" 처리
[3] 해시 함수는 분포가 균등한 것 사용 (MurmurHash, MD5 등)
→ String.hashCode()는 분포가 나빠 부적합
```

---

## 방식 비교

| 방식 | 노드 변경 시 재배치 | 분포 균등성 | 메모리 | 구현 복잡도 |
|------|--------------------|------------|--------|------------|
| hash % N | ✗ 대부분 재배치 (~75%) | ✓ 균등 | ✓ 없음 | ✓ 매우 단순 |
| 안정 해시 (기본) | ✓ 평균 k/n | ✗ 쏠림 가능 | ✓ 노드 수만큼 | 보통 |
| 안정 해시 + 가상 노드 | ✓ 평균 k/n | ✓ 통계적 균등 | △ O(V) | 보통 |
| 고정 해시 슬롯 (Redis) | ✓ 슬롯 매핑만 이동 | ✓ 균등 | ✓ 슬롯 테이블 | 보통 |

**선택 트리**

```
노드가 동적으로 추가/제거되는가?
NO → hash % N으로 충분
YES → 노드 수를 미리 고정할 수 있는가?
YES → 고정 해시 슬롯 (Redis Cluster 방식)
NO → 안정 해시 + 가상 노드
```

---

## 실무 적용 사례

| 시스템 | 적용 방식 |
|--------|----------|
| Amazon DynamoDB | 파티션 키 분산에 안정 해시 계열 사용 (Dynamo 논문이 원조) |
| Apache Cassandra | 클러스터 내 데이터 파티셔닝 |
| Nginx | `hash ... consistent` (ketama 알고리즘) |
| Envoy | Ring Hash / Maglev 로드 밸런싱 |
| Discord, Akamai CDN | 트래픽/콘텐츠 분산 |

### Redis Cluster는 안정 해시가 아니다

```
Redis Cluster의 방식:

슬롯 = CRC16(key) % 16384 ← 슬롯 개수를 16384로 "고정"

┌─────────────┐ 슬롯 0 ~ 5460 → 노드 A
│ 16384 슬롯 │ 슬롯 5461 ~ 10922 → 노드 B
└─────────────┘ 슬롯 10923 ~ 16383 → 노드 C

노드 추가 시: 키 재해싱 없이 "슬롯 → 노드 매핑"만 이동
```

- 슬롯 개수를 고정해서 % 연산의 재배치 문제 자체를 회피한 **다른 접근**
- 안정 해시 대비 운영이 단순 (슬롯 단위 마이그레이션)

> **4장과의 연결**: 처리율 제한 장치에서 카운터 저장소로 쓴 Redis Cluster가
> 내부적으로 "카운터 키를 어느 노드에 둘 것인가"를 푸는 방식이 곧 이 장의 문제의식이다.

---

## 마무리 및 추가 논의

### 안정 해시의 이점 정리

- 서버 추가/삭제 시 재배치되는 키 최소화 (평균 k/n)
- 데이터가 균등 분포되므로 수평적 규모 확장에 유리
- 핫스팟(hotspot) 문제 완화 — 특정 키가 몰리는 서버 분산

### 스터디 논의 주제

```
[논의 1] 가상 노드 개수 산정
→ 서버당 100~200개라는 수치의 근거는? (균등성 vs 메모리의 실측 기반 타협)

[논의 2] Redis Cluster의 선택
→ 안정 해시 대신 고정 슬롯을 택한 이유는?
→ 운영 단순성(슬롯 단위 관리) vs 유연성(임의 노드 수)

[논의 3] 캐시 vs 스토리지
→ 스토리지 용도라면 복제 전략까지 필요 (Dynamo의 N-replica)
→ 어디까지가 라우팅의 책임이고 어디부터 복제의 책임인가
```

---

## 설계 요약

| 결정 항목 | 선택 | 이유 |
|-----------|------|------|
| 분배 방식 | 안정 해시 | 노드 증감 시 재배치 최소화 (평균 k/n) |
| 균등성 보완 | 가상 노드 (서버당 100~200개) | 파티션 불균등·키 쏠림 해소 |
| 자료구조 | TreeMap (정렬 맵) | ceilingEntry로 시계 방향 탐색 O(log V) |
| 해시 함수 | MurmurHash 등 | 균등 분포 (hashCode 부적합) |
| 데이터 보존 | 캐시: 자연 회복 / 스토리지: 복제 필요 | 링은 라우팅 규칙일 뿐 |
Loading