데이터베이스 샤딩 완전 정복 — 여러 노드로 나눌 때 무엇이 달라지나요?

스터디·12분 읽기

샤딩, 왜 알아야 하나요?

파티셔닝까지 적용했는데도 한 노드가 버티지 못하는 순간이 옵니다.

  • 단일 DB 서버의 디스크/IOPS/버퍼풀 한계에 닿았습니다
  • 쓰기 QPS가 수직 스케일로도 충분하지 않습니다
  • 테넌트나 지역별로 데이터/지연 요건이 완전히 다릅니다

이 지점에서 검토하는 것이 샤딩(Sharding) 입니다. 샤딩은 같은 스키마를 가진 데이터를 여러 DB 노드에 분산시키는 전략입니다. 파티셔닝이 한 노드 안에서 테이블을 쪼개는 이야기였다면, 샤딩은 노드 자체가 여러 개로 나뉘고, 애플리케이션이나 프록시가 매 요청마다 어느 샤드로 가야 하는지를 결정합니다.

기준: 이 글은 MySQL 8.4 + InnoDB 기준으로 작성합니다. 다만 MySQL InnoDB는 여러 노드에 데이터를 자동으로 분산시키는 네이티브 샤딩 기능이 없습니다. 따라서 실제 샤딩은 애플리케이션 레벨 라우팅이나 미들웨어/프록시가 담당합니다. 전략과 제약의 일반 개념은 AWS Prescriptive Guidance의 Sharding patterns, 분산 ID는 RFC 9562 — Universally Unique IDentifiers (UUIDv7), MySQL 샤딩 구현의 실질 레퍼런스인 Vitess 문서를 함께 참고합니다. 이 글은 수평 샤딩에 초점을 맞추며, 파티셔닝 자체는 앞 글에서 이미 다뤘습니다.

Phase 1. 파티셔닝과 샤딩은 무엇이 다른가요?

이름이 비슷해서 혼동되지만, 해결하는 문제의 축이 다릅니다.

관점 파티셔닝 샤딩
나누는 단위 한 DB 서버 안의 테이블 서로 다른 DB 노드
라우팅 주체 MySQL 옵티마이저 애플리케이션 또는 프록시
제공 기능 단일 SQL로 조회, partition pruning 샤드 선택, 크로스 샤드 결과 조립
주된 이득 삭제/탐색 단위 축소 저장 용량과 쓰기 처리량의 수평 확장
주된 비용 PRIMARY KEY에 파티션 키 포함, FOREIGN KEY 불가 라우팅, 리샤딩, 글로벌 유일성, 분산 트랜잭션
되돌리기 난이도 비교적 쉬움 매우 어려움

한 줄로 줄이면 이렇습니다.

  • 파티셔닝은 한 노드가 내부를 정돈하는 일입니다
  • 샤딩은 여러 노드를 하나의 논리 DB처럼 조립하는 일입니다

그래서 샤딩은 도입 자체가 큰 결정이고, 한 번 샤딩하고 나면 거의 모든 CRUD 경로에 라우팅 로직이 섞입니다. 이 글 뒷부분에서 "왜 샤딩은 되도록 미루고 싶은지"가 반복해서 나오는 이유입니다.

Phase 2. 전략보다 샤딩 키가 먼저입니다

샤딩의 품질은 대부분 샤딩 키 하나로 결정됩니다. 라우팅 방식, 리샤딩 비용, 크로스 샤드 조회 빈도까지 전부 샤딩 키에 달려 있기 때문에, 다른 결정보다 앞서 정해야 합니다.

좋은 샤딩 키의 조건

  • 주요 쿼리의 WHERE 절에 자연스럽게 들어갑니다 — 없으면 매 조회가 모든 샤드에 fan-out 됩니다
  • 분포가 충분히 균등합니다 — 편향되면 특정 샤드만 뜨거워집니다
  • 엔티티 수명 동안 바뀌지 않습니다 — 값이 바뀌면 행이 다른 샤드로 이사 가야 합니다
  • 같이 읽히는 데이터가 같은 샤드로 모입니다co-location이 유지돼야 JOIN이 단일 샤드 안에서 끝납니다

서비스 유형별로 자주 쓰이는 후보는 이렇습니다.

서비스 유형 자주 쓰는 샤딩 키 이유
B2C 일반 user_id 대부분의 쿼리가 특정 사용자의 데이터를 조회
B2B SaaS tenant_id, organization_id 테넌트 경계가 강하고 격리 요구가 큼
IoT / 로그 device_id 기기 단위 시계열 접근이 지배적
게임 world_id, shard_id(게임 내 샤드) 월드가 이미 분리되어 있음

피해야 할 샤딩 키

  • 소수의 값에 트래픽이 쏠리는 키 — 예: admin_id, system_id
  • 시간값 단독 — 최근 샤드에만 쓰기가 몰려 핫스팟이 생깁니다
  • 값이 바뀔 수 있는 속성email, nickname처럼 언제든 변경 가능한 값은 샤딩 키로 부적절합니다

Phase 3. 샤드를 고르는 네 가지 전략

1. Hash-based 샤딩

shard_id = hash(key) mod N 으로 샤드를 고릅니다.

hash(user_id=1001) mod 4 = 2  → shard2
hash(user_id=7788) mod 4 = 0  → shard0
  • 장점: 분포가 균등해서 핫스팟이 적습니다
  • 단점: 범위 쿼리가 모든 샤드로 fan-out 됩니다. WHERE user_id BETWEEN 1000 AND 2000은 단일 샤드로 좁혀지지 않습니다
  • 리샤딩 비용: N이 4에서 8로 바뀌면 대부분의 행이 다른 샤드로 이동합니다

2. Range-based 샤딩

키 값의 구간으로 샤드를 나눕니다.

user_id     1 ~ 1,000,000       → shard0
user_id 1,000,001 ~ 2,000,000   → shard1
user_id 2,000,001 ~             → shard2
  • 장점: 범위 쿼리가 자연스럽고, 한 샤드씩 아카이빙할 수도 있습니다
  • 단점: AUTO_INCREMENT 기반이면 최신 샤드에만 쓰기가 몰립니다. 사용자 번호가 뒤쪽에 몰리는 서비스에 특히 취약합니다

3. Directory-based (Lookup) 샤딩

별도의 라우팅 테이블이 "어떤 키가 어느 샤드에 있는지"를 관리합니다.

shard_lookup
  tenant_id | shard_id
  ----------|----------
  101       | shard0
  102       | shard2
  103       | shard0
  ...
  • 장점: 매우 유연합니다. 뜨거운 테넌트만 별도 샤드로 옮기거나, 새 샤드에 원하는 키만 배치하는 리샤딩이 상대적으로 쉽습니다
  • 단점: 라우팅 테이블 자체가 단일 장애점(SPOF) 이자 조회 병목이 될 수 있습니다. 그래서 라우팅 결과를 캐시(로컬 캐시 + 분산 캐시 조합)로 공격적으로 감추는 설계가 필요합니다

4. Geo-based 샤딩

사용자/테넌트의 지역을 샤딩 키로 씁니다.

region=KR → shard-kr
region=JP → shard-jp
region=US → shard-us
  • 장점: 지역 DB가 사용자에 가까워 지연이 작아집니다. 컴플라이언스(데이터 체류 규제)에도 유리합니다
  • 단점: 사용자가 지역을 이동하면 행을 다른 샤드로 옮기는 운영이 필요합니다

보조 기법: Consistent Hashing과 가상 노드

단순 hash mod NN을 바꿀 때 거의 모든 데이터가 이동합니다. Consistent Hashing은 해시 공간을 원형으로 두고, 노드를 링 위의 지점으로 배치해 N이 바뀌어도 이동량을 해당 범위로 제한합니다. 노드 수가 적을 때 분포가 고르지 않은 문제를 줄이려고 가상 노드(virtual node) 를 함께 씁니다. Redis Cluster, Dynamo 계열, 그리고 일부 커스텀 샤딩 구현이 이 기법을 택합니다.

Phase 4. 샤딩이 데려오는 새 문제들

성능 이득은 명확하지만, 샤딩을 적용하는 순간 이전에는 없던 문제들이 생깁니다. 단일 DB의 공짜 기능이 공짜가 아니게 되는 지점이 많습니다.

1. 크로스 샤드 JOIN은 단일 SQL로 풀리지 않습니다

같은 샤드 안에서는 기존 JOIN이 그대로 동작하지만, 서로 다른 샤드 간 JOIN은 DB가 대신 해 주지 않습니다. 애플리케이션이 각 샤드에 쿼리를 날리고(fan-out), 결과를 수신한 뒤 메모리에서 합쳐야(merge) 합니다.

실무적인 해결책은 두 방향입니다.

  • co-location — 같이 읽는 데이터를 같은 샤딩 키로 묶어 하나의 샤드에 모읍니다. user_id로 샤딩했다면 그 유저의 주문, 결제, 장바구니가 전부 같은 샤드에 있어야 합니다
  • 반정규화 — 꼭 필요한 필드만 각 샤드에 복제합니다. 반정규화 글에서 다룬 선택적 복제 전략이 그대로 적용됩니다

2. 글로벌 UNIQUEAUTO_INCREMENT가 깨집니다

MySQL의 AUTO_INCREMENT인스턴스별로 독립이므로, 샤드별로 같은 숫자가 발급될 수 있습니다. 전역 유일한 ID가 필요하면 별도 전략이 필요합니다.

  • UUIDUUIDv4는 랜덤이라 인덱스 지역성이 나빠 InnoDB PRIMARY KEY로는 권장되지 않습니다. RFC 9562UUIDv7 은 앞부분에 유닉스 밀리초 타임스탬프를 두어 시간 순 정렬을 유지하기 때문에, 인덱스 친화적이면서도 전역 유일성을 확보할 수 있습니다
  • Snowflake ID — 64비트를 timestamp + machine_id + sequence로 쪼개 쓰는 설계. 정렬 가능하고 생성 성능도 높지만 machine_id 충돌 관리가 필요합니다
  • 티켓 서버 — 전역 ID 발급 전용 서버 또는 전용 테이블. 단순하지만 발급 서버가 장애점이 됩니다

전역 UNIQUE 제약은 더 까다롭습니다. 이메일/닉네임처럼 전역 유일해야 하는 값은, 별도의 전역 유일성 테이블을 만들거나(샤딩되지 않는 작은 lookup DB), 등록 시점에 모든 샤드에 분산 쿼리를 걸어 충돌을 검증해야 합니다.

참고: UUIDv4InnoDB PRIMARY KEY에 불리한 이유는, InnoDB의 클러스터드 인덱스가 PRIMARY KEY 순서로 행을 정렬해 저장하기 때문입니다. 완전 무작위 값이 들어오면 새 행이 거의 매번 기존 페이지 중간에 끼어들어 페이지 분할인접 행 캐시 효율 저하가 반복됩니다. UUIDv7이 앞자리에 밀리초 타임스탬프를 두는 것은 정확히 이 문제를 피하기 위해서입니다.

3. 분산 트랜잭션이 문제가 됩니다

두 샤드에 걸친 원자적 연산은 단일 트랜잭션으로 불가능합니다. 선택지는 두 가지입니다.

  • 2단계 커밋(2PC) — 이론적으로는 가능하지만, 참여 노드가 늘수록 가용성과 지연이 급격히 악화돼 실무에서 기본 전략으로 택하는 경우는 드뭅니다
  • 보상 트랜잭션(Saga) — 각 샤드에서는 개별 트랜잭션만 쓰고, 실패 시 앞서 커밋된 작업을 역순 보상으로 되돌립니다. 이 접근은 멱등성 글idempotency key와 짝을 이룹니다

참고: 2PC가 기본 전략으로 잘 쓰이지 않는 이유는 단순히 느리기 때문만이 아닙니다. 코디네이터가 PREPARE 이후 장애를 겪으면 참여 노드들이 in-doubt 상태로 락을 계속 쥐고 대기하게 됩니다. 이때 모든 참여자의 가용성이 코디네이터 복구 속도에 묶이므로, 장애 격리 관점에서 Saga 쪽을 먼저 검토하는 경우가 많습니다.

4. 리샤딩(rebalancing)은 무중단이 어렵습니다

샤드 수를 바꾸면 데이터의 상당 부분이 다른 샤드로 이사해야 합니다. 무중단 리샤딩의 대표 패턴은 이렇습니다.

  1. 새 샤드 구성으로 이중 쓰기(dual-write) 를 시작합니다
  2. 기존 데이터를 백필(backfill)합니다
  3. 양쪽 결과가 일치하는지 검증합니다
  4. 읽기를 새 라우팅으로 전환(cutover)합니다
  5. 기존 경로를 정리합니다

각 단계마다 실패 복구와 일관성 검증이 필요하기 때문에, 리샤딩 가능한 구조를 설계 단계에서 미리 준비해 두는 편이 나중에 긴급 대응보다 훨씬 저렴합니다.

5. 전체 집계는 fan-out이 기본입니다

"전체 사용자 수", "전 기간 매출 합" 같은 쿼리는 모든 샤드에 쿼리를 날리고 결과를 합쳐야 합니다. 이런 유형이 많다면 읽기 복제본을 모아 둔 분석 DB나 별도 웨어하우스(BigQuery, Redshift)로 ETL 하는 방향이 더 자연스럽습니다.

6. 스키마 변경과 운영이 배로 늘어납니다

  • 모든 샤드에 동일 스키마를 적용해야 합니다. 자동화 없이 수작업하면 스키마 드리프트가 거의 필연적으로 생깁니다
  • 모니터링 지표는 샤드별로 수집해야 합니다. top shard 하나만 봐선 전체를 파악할 수 없습니다
  • 온콜 플레이북도 "어느 샤드인지"를 먼저 식별하는 단계부터 시작합니다

Phase 5. 성급한 샤딩을 피하는 순서

샤딩은 마지막 수단에 가깝습니다. 그 전에 거쳐야 할 단계가 많고, 대부분의 서비스는 이 단계에서 이미 충분한 여유를 얻습니다.

권장 순서는 대체로 이렇습니다.

  1. 쿼리와 인덱스 튜닝EXPLAIN으로 병목을 먼저 좁힙니다 (실행 계획 글 참고)
  2. 수직 스케일 — CPU/메모리/IOPS를 먼저 키웁니다. 운영 복잡도가 가장 낮은 선택지입니다
  3. 읽기 복제본 — 읽기 트래픽을 replica로 오프로드합니다. 쓰기 한계는 해결하지 못한다는 점을 유념합니다
  4. 캐싱 계층 — 반복 조회를 캐시로 흡수합니다 (캐시 전략 글 참고)
  5. 파티셔닝 — 단일 노드 안에서 저장/삭제 단위를 정돈합니다 (파티셔닝 글)
  6. 수직 샤딩(서비스별 DB 분리) — 도메인 경계로 DB를 쪼갭니다. 교차 쿼리가 줄어드는 구조가 됩니다
  7. 수평 샤딩 — 여기서부터가 이 글의 본론입니다

6단계에서 멈출 수 있으면 최선입니다. 7단계까지 간다는 것은 이미 수직 스케일과 복제/캐시/파티셔닝 조합으로도 감당 안 되는 규모라는 뜻입니다.

참고: 샤딩이 꼭 필요하다면 자체 구현보다 검증된 솔루션을 먼저 검토할 가치가 큽니다. MySQL 진영에서는 Vitess(YouTube 기원, MySQL 프로토콜 호환 샤딩 레이어), ProxySQL 기반 설계, ShardingSphere 등이 자주 거론됩니다. 아예 분산 SQL을 전제로 설계된 TiDB, CockroachDB 같은 선택지도 있습니다. 자체 구현은 단순해 보여도 라우팅/리샤딩/관찰 가능성/스키마 일관성 같은 영역에서 깊은 투자가 필요합니다.

Phase 6. 언제 샤딩해야 하고, 언제 피해야 하나요?

써볼 만한 신호

  • 수직 스케일과 복제로도 쓰기 QPS 한계에 닿았습니다
  • 단일 DB 크기가 운영 가능한 범위를 넘어갑니다(예: 수 TB, 백업/복구 윈도우가 위험 수준)
  • 테넌트/지역 격리가 아키텍처 요구사항입니다
  • 대부분의 쿼리가 한 샤딩 키로 자연스럽게 정리됩니다
  • 팀이 분산 시스템 운영에 충분한 경험이 있거나, 외부 솔루션을 도입할 준비가 되어 있습니다

피해야 할 신호

  • 쿼리의 상당수가 샤딩 키 없이 실행됩니다 → fan-out이 일상화됩니다
  • 전역 집계나 전역 UNIQUE 제약이 서비스의 핵심 기능입니다
  • 읽기 replica, 캐시, 파티셔닝 같은 선행 단계가 적용되지 않았습니다
  • 팀이 분산 운영(라우팅, 리샤딩, 관찰 가능성) 경험이 없습니다
  • "요즘 커졌으니 샤딩해 보자" 수준으로 구체적인 병목 근거가 없습니다

판단을 표로 줄이면 이렇습니다.

상황 먼저 할 일 샤딩 단계
읽기 부하만 높음 복제본, 캐시 아직 아님
쓰기 QPS가 단일 인스턴스 한계 근처 수직 스케일, 배치 쓰기 수평 샤딩 검토 시작
단일 테이블이 수십 TB, 삭제 위주 파티셔닝 + 아카이빙 파티셔닝 먼저, 그래도 부족하면 샤딩
멀티 테넌트 격리 요구 테넌트별 DB(수직 샤딩) 수직 → 수평 순서로
글로벌 서비스의 지역 지연 지역별 read replica Geo-sharding 검토

정리

샤딩은 "테이블을 더 잘게 나누는 일"이 아니라 여러 노드를 하나의 논리 DB처럼 조립하는 일입니다. 쓰기 처리량과 저장 용량을 수평 확장하는 대신, 단일 DB가 공짜로 주던 JOIN, 전역 유일성, 트랜잭션 경계 같은 기능들을 애플리케이션이 다시 조립해야 합니다.

  1. 파티셔닝과 샤딩은 해결하는 문제가 다릅니다 — 한 노드를 정돈하는 설계와 여러 노드를 조립하는 설계는 범위가 완전히 다릅니다.
  2. 샤딩 키가 전략 전체를 결정합니다 — 주요 쿼리의 WHERE에 포함되고, 분포가 균등하며, 불변이고, 관련 데이터가 co-location 되는 키를 골라야 합니다.
  3. 샤딩은 마지막 수단입니다 — 쿼리 튜닝, 수직 스케일, 복제, 캐시, 파티셔닝, 수직 샤딩을 먼저 거친 뒤에 수평 샤딩을 검토합니다.
  4. 샤딩은 반드시 새 문제를 데려옵니다 — 크로스 샤드 조회, 글로벌 ID, 분산 트랜잭션, 리샤딩, fan-out 집계, 스키마 일관성이 대표적입니다.
  5. 자체 구현보다 솔루션 검토가 먼저입니다 — Vitess, ShardingSphere, TiDB, CockroachDB 같은 선택지와 비교해 "왜 자체 구현이어야 하는가"를 먼저 답해 보는 편이 안전합니다.

관련 글