왜 샤딩 키가 문제가 되는가

단일 테이블이 수억 행을 넘어가면 인덱스가 메모리에 다 올라가지 않고, 쓰기 경합과 백업 시간이 급격히 늘어난다. 이때 데이터를 여러 파티션/샤드로 나누는데, 어떤 컬럼을 기준으로 나눌지가 시스템 수명을 좌우한다. 잘못 고른 샤딩 키는 되돌리기 어렵다. 이미 분산된 수 테라바이트를 재분배하려면 오랜 마이그레이션과 다운타임을 감수해야 하기 때문이다.

핵심 실패 모드는 두 가지다. 하나는 핫스팟으로, 특정 샤드에만 트래픽이 몰리는 것이다. 다른 하나는 크로스 샤드 쿼리로, 하나의 요청이 모든 샤드를 훑어야 하는 상황이다. 좋은 샤딩 키는 이 둘을 동시에 피한다.

파티셔닝과 샤딩은 다르다

파티셔닝은 한 DB 인스턴스 안에서 테이블을 물리적으로 쪼개는 것이고, 샤딩은 여러 인스턴스로 데이터를 흩뿌리는 것이다. 둘은 계층이 다르며 함께 쓰인다.

구분파티셔닝샤딩
범위단일 인스턴스 내부여러 인스턴스
목적스캔·유지보수 효율수평 확장
트랜잭션단일 노드 보장 쉬움분산 트랜잭션 필요

키 후보 평가 기준

샤딩 키는 세 가지를 만족해야 한다. 첫째 카디널리티가 충분히 높아 값이 고르게 퍼져야 한다. 둘째 쿼리 지역성이 있어, 자주 함께 조회되는 데이터가 같은 샤드에 모여야 한다. 셋째 불변성으로, 값이 자주 바뀌면 행이 샤드 사이를 이동해야 한다. 예를 들어 이커머스에서 user_id는 대체로 좋은 키다. 한 사용자의 주문·장바구니가 같은 샤드에 모이고 값도 바뀌지 않는다. 반면 status나 created_at은 카디널리티가 낮거나 시간에 편향돼 핫스팟을 만든다.

해시 vs 레인지 샤딩

레인지 샤딩은 값의 구간으로 나눈다. 범위 조회에 강하지만 최근 데이터에 쓰기가 몰려 마지막 샤드가 뜨거워진다. 해시 샤딩은 키를 해싱해 고르게 흩뿌린다. 핫스팟은 줄지만 범위 조회가 전 샤드로 퍼진다. 실무에서는 해시로 균등 분산을 얻고, 시계열 데이터는 별도 시간 파티셔닝을 얹는 조합을 자주 쓴다.

-- PostgreSQL: user_id 기준 해시 파티셔닝
CREATE TABLE orders (
    order_id   bigint,
    user_id    bigint NOT NULL,
    status     text,
    created_at timestamptz
) PARTITION BY HASH (user_id);

CREATE TABLE orders_p0 PARTITION OF orders
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE orders_p1 PARTITION OF orders
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- p2, p3 동일하게 생성

애플리케이션 레벨 라우팅

여러 인스턴스로 샤딩할 때는 애플리케이션이 어느 샤드로 갈지 결정한다. 단순 모듈러는 샤드 수를 늘릴 때 대부분의 키가 재배치되는 문제가 있어, 재분배량을 줄이려면 일관 해싱을 쓴다.

import hashlib
import bisect

class ConsistentHashRouter:
    def __init__(self, shards, vnodes=150):
        self.ring = {}
        self.keys = []
        for shard in shards:
            for i in range(vnodes):
                h = self._hash(f"{shard}#{i}")
                self.ring[h] = shard
                self.keys.append(h)
        self.keys.sort()

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def route(self, shard_key) -> str:
        h = self._hash(str(shard_key))
        idx = bisect.bisect(self.keys, h) % len(self.keys)
        return self.ring[self.keys[idx]]

router = ConsistentHashRouter(["shard-a", "shard-b", "shard-c"])
print(router.route(user_id := 480215))  # 항상 같은 샤드로 라우팅

크로스 샤드 쿼리와 팬아웃

샤딩 키가 없는 조회, 예컨대 "상태가 pending인 전체 주문"은 모든 샤드에 질의를 뿌리고 결과를 합쳐야 한다. 이런 팬아웃은 지연시간이 가장 느린 샤드에 묶이고, 페이지네이션·정렬·집계가 애플리케이션 책임이 된다. 관리자 검색이나 분석 쿼리가 잦다면 원본을 샤딩하되, 조회 전용 뷰를 검색엔진이나 컬럼 스토어로 별도 구성해 팬아웃을 애초에 피하는 편이 안전하다.

운영상 주의점

첫째, 리샤딩을 처음부터 설계에 넣어라. 물리 샤드 수를 곧바로 노출하지 말고 논리 슬롯(예: 1024개)을 물리 샤드에 매핑하면, 샤드 증설 때 슬롯 단위로만 옮기면 된다. 둘째, 마이그레이션은 이중 쓰기와 백필로 무중단 전환한다. 신규 경로에 먼저 쓰고, 과거 데이터를 배치로 복사한 뒤, 검증이 끝나면 읽기를 전환한다. 셋째, 샤드별 지표를 반드시 분리해 관측하라. 전체 평균 QPS는 정상이어도 한 샤드만 포화 상태일 수 있다. 넷째, 유니크 제약과 자동 증가 ID는 샤드 경계를 넘지 못하므로, 전역 유일성이 필요하면 스노플레이크류 ID 생성기를 쓴다. 마지막으로 분산 트랜잭션은 비싸다. 자주 함께 갱신되는 데이터가 같은 샤드에 오도록 키를 고르면 대부분의 트랜잭션을 단일 노드로 유지할 수 있고, 이것이 샤딩 키 선택의 실질적 목표다.