본문으로 건너뛰기
DevOpsMar 28, 2026

Deep EVM #26: 샤딩 vs 파티셔닝 — 대규모 테이블을 위한 아키텍처

OS
Open Soft Team

Engineering Team

파티셔닝과 샤딩의 차이

파티셔닝과 샤딩은 모두 대규모 데이터를 작은 조각으로 분할합니다. 핵심 차이는 분할이 어디서 일어나는가입니다:

  • 파티셔닝: 단일 데이터베이스 서버 내에서 하나의 논리적 테이블을 여러 물리적 테이블로 분할. 데이터베이스가 라우팅을 처리.
  • 샤딩: 여러 데이터베이스 서버에 걸쳐 데이터를 분산. 애플리케이션이 라우팅을 처리.
특성파티셔닝샤딩
서버 수1N
라우팅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(())
}

단계적 마이그레이션

  1. 새 샤드 구성 배포 (읽기는 여전히 이전에서)
  2. 이중 쓰기 활성화
  3. 백그라운드에서 기존 데이터 마이그레이션
  4. 읽기를 새 구성으로 전환
  5. 이전 샤드 정리

하이브리드 접근: 파티셔닝 + 샤딩

대규모 시스템에서는 두 기법을 결합합니다:

[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 트랜잭션이 필요한가?예 -> 파티셔닝최종 일관성 허용 -> 샤딩
운영 복잡도 감당 가능한가?간단전담 팀 필요
데이터 성장률은?예측 가능폭발적

결론

파티셔닝과 샤딩은 다른 규모의 문제를 해결합니다. 파티셔닝은 단일 서버 내에서 큰 테이블을 관리 가능하게 만들고, 샤딩은 단일 서버를 넘어 수평으로 확장합니다. 대부분의 시스템은 파티셔닝으로 시작해야 합니다 — 더 간단하고, 트랜잭션이 보장되며, 운영 부담이 적습니다. 단일 서버의 한계에 도달했을 때만 샤딩을 고려하세요. 두 기법을 결합하면 사실상 무한한 확장이 가능합니다.