Software Architecture/Distributed Systems
Distributed Systems - 05. 파티셔닝과 샤딩
파티셔닝과 샤딩 — 데이터가 한 노드에 안 들 때
2019년 한 SaaS 회사의 주문 테이블이 2TB를 넘겼다. 단일 PostgreSQL 인스턴스에 적재했더니 디스크 I/O가 병목이 돼 쿼리가 10배 느려졌다. 가장 비싼 인스턴스로 올려도 한계가 드러났다 — RAM에 인덱스가 다 안 들어가서 디스크 읽기가 폭증했다. "더 비싼 서버"라는 해결책이 다했던 시점이다. 다음 단계는 — 데이터를 여러 노드로 나누는 것, 즉 파티셔닝(partitioning) 또는 샤딩(sharding)이다. 둘은 같은 개념의 다른 이름이다 (샤딩은 주로 데이터베이스 맥락에서, 파티셔닝은 더 일반적으로 쓰인다). 이 글은 데이터를 어떻게 나눌까, 나누면서 생기는 새로운 문제를 어떻게 다룰까를 다룬다.
비유로 감 잡기 — 도서관 책을 층별로 배치
도서관에 책이 100만 권 있다. 한 층에 다 안 들어간다. 세 층으로 나눠야 한다. 어떻게 나눌까. 세 가지 자연스러운 방법이 있다.
- 주제별 (범위 분할) — 1층에 소설, 2층에 역사, 3층에 과학. 찾기 쉽지만, 어느 주제가 인기 있으면 그 층만 붐빈다 (핫스팟).
- 제목 해시 (해시 분할) — 제목의 첫 글자를 해시해 층을 정함. "해리포터"는 2층, "호밀밭의 파수꾼"은 1층 식. 균등하게 분산되지만, 한 책을 찾으려면 어느 층인지 계산해야 한다.
- 원형 테이블 (일관적 해시) — 층을 원형으로 배치하고, 책을 원형 위의 한 점에 놓는다. 층이 하나 추가돼도 전체를 다시 배치하지 않는다. 10편에서 깊이 다룬다.
파티셔닝의 기본 전략이 이 세 가지다. 각각의 장단이 있고, 데이터의 패턴에 따라 적합한 전략이 다르다.
비유의 한계 — 도서관 비유는 '물리적 공간'을 강조하지만, 데이터 파티셔닝은 성능·일관성·확장이라는 다축 목표를 동시에 다룬다. 또한 층이 고정되지만, 데이터베이스 파티션은 동적으로 추가·제거·리밸런싱된다. 비유는 '동적 재구성'에서 무너진다.
왜 파티셔닝을 하는가
단일 노드의 한계는 세 가지 형태로 나타난다.
- 데이터 볼륨 — 디스크 한계. 1TB를 넘기면 단일 인스턴스에 부담.
- 쓰기 처리량 — 단일 마스터는 쓰기 처리량의 상한이다. 병렬 쓰기를 위해 데이터를 나누면, 각 파티션이 독립적으로 쓰기를 받을 수 있다.
- 읽기 처리량 — 읽기는 복제(06편)로도 늘릴 수 있지만, 인덱스 크기가 RAM을 넘기면 결국 데이터를 나눠야 한다.
Martin Kleppmann이
파티셔닝의 세 가지 기본 전략
1. 범위 분할 (Range Partitioning)
데이터를 정렬된 순서대로 연속적인 구간으로 나눈다. 가장 단순하고 직관적이다.
// 주문 ID로 파티션 결정 (범위)
class RangePartitioner {
List<Range> ranges = List.of(
new Range(0, 1_000_000, "partition-0"),
new Range(1_000_000, 2_000_000, "partition-1"),
new Range(2_000_000, 3_000_000, "partition-2")
);
String partitionFor(long orderId) {
return ranges.stream()
.filter(r -> r.contains(orderId))
.findFirst()
.map(Range::name)
.orElseThrow(() -> new OutOfRangeException(orderId));
}
}
장점 — 범위 쿼리가 효율적이다 ("1번~100번 주문 조회"는 한 파티션만 보면 됨). 타임스탬프로 나누면 시계열 데이터에 자연스럽다.
단점 — 핫스팟 위험이 크다. 가장 최근 파티션에 쓰기가 집중된다 (시간이 갈수록 새 데이터는 마지막 파티션에만 쌓임). 인기 있는 사용자 ID 대역이 한 파티션에 몰리면 그 파티션만 과부하.
2. 해시 분할 (Hash Partitioning)
파티션 키를 해시 함수에 넣어 파티션을 결정한다. 데이터가 균등하게 분산된다.
// 사용자 ID 해시로 파티션 결정
class HashPartitioner {
private final int partitionCount;
String partitionFor(UserId userId) {
int hash = userId.hashCode();
int partition = Math.abs(hash) % partitionCount;
return "partition-" + partition;
}
}
장점 — 데이터가 균등하게 분산된다 (해시 함수가 좋으면). 핫스팟 위험이 적다.
단점 — 범위 쿼리가 비효율적이다. "1번~100번 주문"이 여러 파티션에 흩어지므로, 모든 파티션에 쿼리해야 한다 (scatter-gather). 노드 추가·제거 시 데이터 재배치 비용이 크다 — 파티션 수가 바뀌면 거의 모든 데이터가 다른 파티션으로 이동한다 (10편의 일관적 해싱이 이를 해결).
3. 일관적 해시 (Consistent Hashing)
해시 분할의 "노드 추가 시 대규모 이동" 문제를 해결한 형태. 10편에서 깊이 다룬다. 여기선 — "노드 추가 시 해당 노드가 담당하는 구간의 데이터만 이동하고, 나머지는 그대로"라는 속성만 짚고 넘어간다.
전략 비교
| 전략 | 분산 균등 | 범위 쿼리 | 핫스팟 위험 | 노드 추가 |
|---|---|---|---|---|
| 범위 | 낮음 (편중 가능) | 빠름 | 높음 | 복잡 (경계 재조정) |
| 해시 | 높음 | 느림 (scatter-gather) | 낮음 | 매우 비싼 대규모 이동 |
| 일관적 해시 | 높음 (가상 노드 시) | 느림 | 낮음 | 최소 이동 |
핵심 딜레마 — 핫스팟
파티셔닝의 가장 흔한 장애 패턴이 핫스팟이다. 한 파티션에 부하가 집중돼 그 파티션만 과부하에 걸리는 현상. 전체 시스템의 처리량은 "가장 바쁜 파티션의 처리량"으로 제한된다.
핫스팟의 원인:
- 시간적 편중 — 최신 데이터가 자주 읽힘 (뉴스 피드, 소셜 미디어).
- 인기 편중 — 특정 사용자·상품이 압도적 트래픽 (유명인 계정, 베스트셀러).
- 파티션 키 설계 실패 — 예: 타임스탬프를 키로 쓰면 "현재 시간"이 한 파티션에 몰림.
핫스팟 완화 패턴
// 안티패턴 — 타임스탬프만 키로 쓰면 모든 쓰기가 마지막 파티션에
class BadPartitionKey {
String key(Instant timestamp) {
return timestamp.toString(); // 핫스팟 발생
}
}
// 개선 — 복합 키로 분산
class CompoundKey {
String key(UserId userId, Instant timestamp) {
// 사용자 ID 해시로 분산, 그 안에서 타임스탬프 정렬
return userId.value() + "#" + timestamp.toString();
}
}
// 개선 — 의도적 분해 (salt)
class SaltKey {
String key(UserId userId, int saltBuckets) {
int salt = ThreadLocalRandom.current().nextInt(saltBuckets);
return salt + "#" + userId.value(); // 한 사용자 데이터도 N 버킷으로 분산
}
}
salt 기법은 — 한 사용자의 데이터를 여러 파티션에 흩어놓는 대신, 한 사용자의 전체 조회가 N배 비싸지는 trade-off를 감수한다. 사용 패턴(단일 사용자 조회 vs 전체 스캔)에 따라 선택이 다르다.
리밸런싱 — 파티션을 다시 나눌 때
시간이 지나면 파티션의 크기가 불균형해진다. 한 파티션만 500GB, 다른 파티션은 100GB. 그러면 큰 파티션을 쪼개야 한다. 이를 리밸런싱(rebalancing)이라 부른다.
flowchart LR
subgraph Before["리밸런싱 전"]
P1["partition-1<br/>500GB"]
P2["partition-2<br/>100GB"]
P3["partition-3<br/>100GB"]
end
subgraph After["리밸런싱 후"]
P1a["partition-1a<br/>250GB"]
P1b["partition-1b<br/>250GB"]
P2a["partition-2<br/>100GB"]
P3a["partition-3<br/>100GB"]
end
Before -->|P1을 반으로 쪼갬, 데이터 이동| After
리밸런싱의 핵심 문제 — 데이터 이동 중에도 서비스는 계속돼야 한다. 한 파티션의 데이터를 다른 노드로 옮기는 동안, 그 데이터에 대한 쓰기는 어디로 가나. 이를 다루는 전략:
- 일시 정지 — 그 파티션의 쓰기를 잠시 막고 옮긴 뒤 재개. 단순하지만 가용성 손실.
- 더블 라이트 — 옮기는 동안새와 구 파티션에 동시 쓰기, 완료 후 구 파티션 폐기. 가용성 유지 but 일시적 일관성 부담.
- 일관적 해싱 기반 — 최소 데이터 이동. 10편에서 다룸.
Cassandra, MongoDB, Kafka 같은 시스템은 자동 리밸런싱을 제공하지만, 운영자가 개입해야 하는 경우도 많다 (특히 대규모 클러스터에서 리밸런싱 자체가 부하가 될 때).
보조 인덱스 문제 — 파티셔닝의 숨은 비용
단일 DB에선 보조 인덱스가 단순하 — 인덱스로 행을 찾아 본체를 읽는다. 파티셔닝된 DB에선 — 인덱스가 파티션마다 따로 있을 수도 있고, 전역 인덱스가 따로 있을 수도 있다.
- 로컬 인덱스 (partition-local) — 각 파티션이 자기 데이터만 인덱싱. "사용자 이름으로 조회" 쿼리는 모든 파티션에 보내야 함 (scatter-gather). 느리지만 각 파티션이 독립적.
- 전역 인덱스 (global index) — 모든 파티션의 데이터를 아우르는 인덱스. 한 쿼리로 끝나지만, 인덱스 자체가 파티셔닝돼야 하고 갱신 비용이 크다.
Kleppmann이 DDIA에서 강조하듯, 보조 인덱스를 다루는 방식이 파티셔닝된 DB 설계의 핵심 결정 중 하나다. 사용 쿼리 패턴에 따라 선택이 갈린다.
설계 사례 — 주문 시스템 파티셔닝
주문 테이블을 어떻게 파티셔닝할까. 사용 패턴을 먼저 분석한다.
// 사용 패턴 분석
class OrderAccessPattern {
// 1. 고객별 주문 조회 — 가장 빈번 (90% 쿼리)
List<Order> findByCustomer(CustomerId id);
// 2. 기간별 주문 조회 — 분석용 (8% 쿼리)
List<Order> findByDateRange(LocalDate from, LocalDate to);
// 3. 주문 ID 직접 조회 (2% 쿼리)
Order findById(OrderId id);
}
고객별 조회가 90%이므로 — customerId를 파티션 키로 쓰는 게 자연스럽다. 한 고객의 주문이 한 파티션에 모여 있어 조회가 한 번에 끝난다.
// 해시 분할 — customerId 기준
class OrderPartitioner {
private final int partitionCount;
String partitionFor(CustomerId customerId) {
int hash = Murmur3Hash.hash(customerId.value());
return "orders-" + (Math.abs(hash) % partitionCount);
}
// 고객별 조회 — 한 파티션만 조회
List<Order> findByCustomer(CustomerId customerId) {
String partition = partitionFor(customerId);
return partition.query("SELECT * FROM orders WHERE customer_id = ?", customerId);
}
// 기간별 조회 — 모든 파티션에 쿼리 (scatter-gather)
List<Order> findByDateRange(LocalDate from, LocalDate to) {
return allPartitions.parallelStream()
.flatMap(p -> p.query("SELECT * FROM orders WHERE date BETWEEN ? AND ?", from, to).stream())
.sorted(Comparator.comparing(Order::date).reversed())
.toList();
}
}
기간별 조회는 모든 파티션에 쿼리해야 하지만 — 그게 8% 쿼리라면 감내 가능. 90% 쿼리(고객별)가 빠른 게 더 중요하다.
복합 파티셔닝 — 두 전략 결합
// 1차: customerId 해시 분할, 2차: date 범위 분할
class CompoundPartitioner {
String partitionFor(CustomerId customerId, LocalDate date) {
int hashPartition = Math.abs(customerId.hashCode()) % 16;
int rangePartition = date.getYear() - 2020; // 연도별 하위 파티션
return "orders-" + hashPartition + "-" + rangePartition;
}
}
이 패턴은 — 1차로 사용자를 16개 해시 파티션에 분산시키고, 각 파티션 안에서 연도별로 하위 파티션을 둔다. PostgreSQL의 선언적 파티셔닝, MySQL의 파티셔닝, MongoDB의 샤딩이 이런 복합 전략을 지원한다.
파티셔닝 위반/오용 감지
| 신호 | 의미 |
|---|---|
| 한 파티션의 CPU/디스크가 다른 파티션의 3배 이상 | 핫스팟 — 키 설계 재검토 |
| 노드 추가 후 전체 데이터 이동 | 일관적 해싱 미사용 |
| 모든 쿼리가 모든 파티션에 가야 함 (scatter-gather 100%) | 파티션 키가 쿼리 패턴과 안 맞음 |
| 리밸런싱 중 가용성 손실 | 더블 라이트 미구현 |
| 보조 인덱스가 갱신 안 됨 | 글로벌 인덱스 동기화 실패 |
| 쿼리가 데이터의 실제 위치를 안다 | 애플리케이션이 파티셔닝을 앎 — 결합 |
파티셔닝과 복제의 관계
파티셔닝은 데이터를 나누고, 복제(06편)는 데이터를 복사한다. 둘은 직교하는 개념이지만 함께 쓰인다. 실제 분산 데이터베이스는 — 각 파티션을 여러 노드에 복제한다 (예: 3-way 복제면 한 파티션의 데이터가 3개 노드에 복사돼 있음). 이 조합이 — 확장성(파티셔닝)과 가용성(복제)을 동시에 잡는 일반적 패턴이다.
flowchart TD
subgraph Cluster["3노드 클러스터, 파티션 3개, 복제 3-way"]
N1["Node 1<br/>P1 leader<br/>P2 follower<br/>P3 follower"]
N2["Node 2<br/>P1 follower<br/>P2 leader<br/>P3 follower"]
N3["Node 3<br/>P1 follower<br/>P2 follower<br/>P3 leader"]
end
이 패턴에서 — 각 파티션의 리더가 다른 노드에 분산돼 있어, 쓰기 부하가 골고루 나뉜다. 한 노드가 죽어도 각 파티션의 남은 복제본이 서비스를 유지한다. Kafka, Cassandra, MongoDB 샤딩, CockroachDB가 이 형태를 쓴다. 06편(복제)이 이 조합을 깊이 다룬다.
다음으로 — 복제
파티셔닝이 데이터를 나눴다면, 복제(06편)는 나눈 각 파티션을 어떻게 복사하고 일관성을 유지할까를 다룬다. 단일 리더·다중 리더·리더 없는 복제, 동기·비동기의 trade-off, 복제 지연이 일관성 모델(03편)에 미치는 영향. 파티셔닝과 복제는 거의 항상 함께 쓰이므로, 두 개념을 합쳐 봐야 실제 분산 데이터베이스의 형태가 보인다.
파티셔닝의 핵심 통찰 — "어떻게 나눌까"라는 질문에 정답이 없다. 데이터의 패턴(균등 vs 편중), 쿼리 패턴(범위 vs 개별), 확장 패턴(노드 추가 빈도)에 따라 적합한 전략이 다르다. 핵심은 — 한 가지 전략을 고정하지 말고, 사용 패턴에 맞춰 선택하고, 핫스팟을 감시하며, 리밸런싱을 대비하는 것이다.
참고
- Kleppmann —
(O'Reilly, 2017), Ch.6 (파티셔닝) — 접근 2026-07-20 - DeCandia et al. — "Dynamo: Amazon's Highly Available Key-value Store" (SOSP 2007) — 접근 2026-07-20
- Richards, Ford —
(O'Reilly, 2020), 데이터 아키텍처 장 - PostgreSQL Documentation — "Table Partitioning" (선언적 파티셔닝, 범위/해시/리스트) — 접근 2026-07-20
- MongoDB Documentation — "Sharding" (샤드 키, 청크 마이그레이션) — 접근 2026-07-20
'Software Architecture > Distributed Systems' 카테고리의 다른 글
| Distributed Systems - 07. 분산 트랜잭션 (0) | 2026.07.20 |
|---|---|
| Distributed Systems - 06. 복제 패턴 (0) | 2026.07.20 |
| Distributed Systems - 04. 합의 알고리즘 (0) | 2026.07.20 |
| Distributed Systems - 03. 일관성 모델 (0) | 2026.07.20 |
| Distributed Systems - 02. CAP PACELC (0) | 2026.07.20 |