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이 (2017) Ch.6에서 정리하듯, 파티셔닝의 목표는 — "데이터를 여러 노드에 분산시켜 각 노드가 전체의 일부만 담당하게 함으로써, 처리량과 저장 용량을 수평으로 확장하는 것"이다.

파티셔닝의 세 가지 기본 전략

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