DevOpsMar 28, 2026
Deep EVM #26: 샤딩 vs 파티셔닝 — 대규모 테이블을 위한 아키텍처
OS
Open Soft Team
Engineering Team
파티셔닝과 샤딩의 차이
파티셔닝과 샤딩은 모두 대규모 데이터를 작은 조각으로 분할합니다. 핵심 차이는 분할이 어디서 일어나는가입니다:
- 파티셔닝: 단일 데이터베이스 서버 내에서 하나의 논리적 테이블을 여러 물리적 테이블로 분할. 데이터베이스가 라우팅을 처리.
- 샤딩: 여러 데이터베이스 서버에 걸쳐 데이터를 분산. 애플리케이션이 라우팅을 처리.
| 특성 | 파티셔닝 | 샤딩 |
|---|---|---|
| 서버 수 | 1 | N |
| 라우팅 | DB 엔진 | 애플리케이션 |
| 트랜잭션 | 완전 ACID | 분산 트랜잭션 필요 |
| 조인 | 네이티브 | 크로스 샤드 조인 복잡 |
| 확장 한계 | 단일 서버 | 수평 무한 |
| 복잡도 | 낮음 | 높음 |
언제 파티셔닝을 사용할 것인가
파티셔닝은 단일 서버가 데이터를 처리할 수 있지만, 개별 쿼리와 유지보수 작업이 테이블 크기로 인해 느려지는 경우에 사용합니다:
-- 3,400만 행 테이블을 월별 파티션으로
CREATE TABLE transactions (
id BIGINT,
block_number BIGINT NOT NULL,
data JSONB
) PARTITION BY RANGE (block_number);
파티셔닝의 이점:
- 쿼리 플래너가 자동으로 파티션 프루닝
- 오래된 데이터를 파티션 단위로 빠르게 삭제
- VACUUM이 파티션별로 병렬 실행
- 인덱스가 파티션별로 작아 빌드/리빌드가 빠름
언제 샤딩을 사용할 것인가
샤딩은 단일 서버의 용량(스토리지, CPU, 메모리, IOPS)을 초과하거나, 지리적으로 분산된 읽기/쓰기가 필요한 경우에 사용합니다:
// 애플리케이션 수준 샤딩
struct ShardRouter {
shards: Vec<PgPool>,
}
impl ShardRouter {
fn get_shard(&self, key: &[u8]) -> &PgPool {
let hash = xxhash_rust::xxh3::xxh3_64(key);
let shard_idx = (hash as usize) % self.shards.len();
&self.shards[shard_idx]
}
async fn get_balance(&self, address: &[u8]) -> Result<Decimal> {
let pool = self.get_shard(address);
let row = sqlx::query_scalar(
"SELECT balance FROM accounts WHERE address = $1"
)
.bind(address)
.fetch_one(pool)
.await?;
Ok(row)
}
}
일관된 해싱
단순한 모듈로 해싱(hash % N)의 문제는 샤드를 추가하면 거의 모든 키가 재할당된다는 것입니다. 일관된 해싱은 이 문제를 해결합니다:
use std::collections::BTreeMap;
struct ConsistentHash {
ring: BTreeMap<u64, usize>, // 해시 -> 샤드 인덱스
virtual_nodes: usize,
}
impl ConsistentHash {
fn new(shard_count: usize, virtual_nodes: usize) -> Self {
let mut ring = BTreeMap::new();
for shard in 0..shard_count {
for vn in 0..virtual_nodes {
let key = format!("shard-{}-vn-{}", shard, vn);
let hash = xxhash_rust::xxh3::xxh3_64(key.as_bytes());
ring.insert(hash, shard);
}
}
Self { ring, virtual_nodes }
}
fn get_shard(&self, key: &[u8]) -> usize {
let hash = xxhash_rust::xxh3::xxh3_64(key);
// 링에서 다음 노드 찾기
self.ring
.range(hash..)
.next()
.or_else(|| self.ring.iter().next())
.map(|(_, &shard)| shard)
.unwrap()
}
}
일관된 해싱에서 새 샤드를 추가하면 전체 키의 1/N만 이동합니다(N = 새 샤드 수).
크로스 샤드 쿼리
샤딩의 가장 큰 도전은 여러 샤드에 걸친 쿼리입니다:
impl ShardRouter {
// 모든 샤드에서 병렬 쿼리
async fn total_supply(&self) -> Result<Decimal> {
let mut futures = Vec::new();
for pool in &self.shards {
futures.push(async move {
sqlx::query_scalar::<_, Decimal>(
"SELECT COALESCE(SUM(balance), 0) FROM accounts"
)
.fetch_one(pool)
.await
});
}
let results = futures::future::join_all(futures).await;
let mut total = Decimal::ZERO;
for result in results {
total += result?;
}
Ok(total)
}
}
크로스 샤드 쿼리 전략:
- 스캐터-개더: 모든 샤드에 쿼리를 보내고 결과를 합치기
- 글로벌 인덱스: 별도의 서비스에 전역 인덱스 유지
- 비정규화: 자주 조인되는 데이터를 같은 샤드에 배치
리샤딩
샤드 수를 변경해야 할 때의 전략:
이중 쓰기
async fn resharding_write(&self, key: &[u8], value: &Value) -> Result<()> {
let old_shard = self.old_router.get_shard(key);
let new_shard = self.new_router.get_shard(key);
// 두 샤드에 모두 쓰기
tokio::try_join!(
self.write_to_shard(old_shard, key, value),
self.write_to_shard(new_shard, key, value),
)?;
Ok(())
}
단계적 마이그레이션
- 새 샤드 구성 배포 (읽기는 여전히 이전에서)
- 이중 쓰기 활성화
- 백그라운드에서 기존 데이터 마이그레이션
- 읽기를 새 구성으로 전환
- 이전 샤드 정리
하이브리드 접근: 파티셔닝 + 샤딩
대규모 시스템에서는 두 기법을 결합합니다:
[Application]
|
v
[Shard Router] -- 주소별 샤딩
|
+-- Shard 0 (PostgreSQL)
| +-- transactions (PARTITION BY RANGE block_number)
| +-- accounts (단일 테이블)
|
+-- Shard 1 (PostgreSQL)
| +-- transactions (PARTITION BY RANGE block_number)
| +-- accounts (단일 테이블)
|
+-- Shard 2 (PostgreSQL)
+-- transactions (PARTITION BY RANGE block_number)
+-- accounts (단일 테이블)
각 샤드 내에서는 파티셔닝으로 테이블 크기를 관리하고, 샤드 간에는 샤딩으로 서버 부하를 분산합니다.
의사 결정 프레임워크
| 질문 | 파티셔닝 | 샤딩 |
|---|---|---|
| 단일 서버가 데이터를 보유할 수 있는가? | 예 -> 파티셔닝 | 아니오 -> 샤딩 |
| ACID 트랜잭션이 필요한가? | 예 -> 파티셔닝 | 최종 일관성 허용 -> 샤딩 |
| 운영 복잡도 감당 가능한가? | 간단 | 전담 팀 필요 |
| 데이터 성장률은? | 예측 가능 | 폭발적 |
결론
파티셔닝과 샤딩은 다른 규모의 문제를 해결합니다. 파티셔닝은 단일 서버 내에서 큰 테이블을 관리 가능하게 만들고, 샤딩은 단일 서버를 넘어 수평으로 확장합니다. 대부분의 시스템은 파티셔닝으로 시작해야 합니다 — 더 간단하고, 트랜잭션이 보장되며, 운영 부담이 적습니다. 단일 서버의 한계에 도달했을 때만 샤딩을 고려하세요. 두 기법을 결합하면 사실상 무한한 확장이 가능합니다.