DB Study

8장. Shards & Replicas — 분산 아키텍처

한 대로 감당이 안 되면 나누고(Shard), 장애가 두려우면 복사한다(Replica). 그리고 그 전체를 하나의 테이블처럼 보이게 하는 Distributed 테이블 — ClickHouse 클러스터의 구조와 솔직한 한계까지 살펴봅니다.

고급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 4장 Parts, 6장 Merges
🧭
이 장에서 배우는 것

① Shard와 Distributed 테이블 — 데이터 분산과 쿼리 라우팅 ② 분산 SELECT/INSERT의 실행 흐름과 "global query plan이 없다"는 제약 ③ ReplicatedMergeTree의 비동기 멀티마스터 복제와 Keeper의 역할 ④ 클러스터의 구조적 한계(not elastic)와 운영 주의점

8.1 Shard — 데이터를 나누는 단위

샤딩이 필요한 경우는 두 가지입니다: 데이터가 단일 서버의 스토리지를 초과하거나, 단일 서버의 처리 성능이 부족할 때. 샤딩은 테이블의 데이터를 여러 서버로 수평 분할합니다. 각 shard는 데이터의 부분 집합을 담은 평범한 ClickHouse 테이블이며, 독립적으로 쿼리할 수도 있습니다.

[전체 데이터]
   ├── Shard 1 (Server A): 행 1 ~ 1000만
   ├── Shard 2 (Server B): 행 1000만+1 ~ 2000만
   └── Shard 3 (Server C): 행 2000만+1 ~ 3000만

그런데 개별 shard에 직접 쿼리하면 부분 데이터만 보입니다. 전체를 하나의 테이블처럼 보여주는 것이 Distributed 테이블 — 데이터를 전혀 저장하지 않는 라우팅 레이어입니다.

CREATE TABLE uk.uk_price_paid_simple_dist ON CLUSTER test_cluster
(
    date   Date,
    town   LowCardinality(String),
    street LowCardinality(String),
    price  UInt32
)
ENGINE = Distributed('test_cluster', 'uk', 'uk_price_paid_simple', rand())
--                     클러스터         DB      로컬 테이블            sharding key

8.2 분산 SELECT — 쿼리는 어떻게 흩어지고 다시 모이는가

🧑‍💻 클라이언트 SELECT count() ... Initiator 노드 Distributed: events_dist Shard 1 Replica A ✓ / Replica B Shard 2 Replica A / Replica B ✓ Shard 3 Replica A ✓ / Replica B shard마다 레플리카 1개 선택 count = 150 count = 200 count = 180 ⚡ 각 shard가 로컬에서 독립·병렬 실행 (인덱스, granule skip 모두 로컬에서) 중간 결과 수집 Initiator가 병합: 150 + 200 + 180 = 530 → 클라이언트에 반환 "가능한 많은 작업을 원격에서, 네트워크 전송은 최소로"

"Global Query Plan이 없다" — 가장 중요한 구조적 제약

공식 architecture 문서는 이렇게 솔직하게 말합니다: "분산 쿼리 실행을 위한 global query plan은 없다. 각 노드는 자기 몫에 대한 로컬 플랜만 가진다. 우리는 단순한 1-pass 분산 실행만 지원한다 — 원격 노드에 쿼리를 보내고 결과를 머지할 뿐." 노드 간 데이터 재분배(reshuffle)가 필요한 실행은 지원하지 않습니다.

이 제약이 문제 되는 쿼리 유형:

😵 고카디널리티 GROUP BY

GROUP BY user_id처럼 유니크 키가 수백만이면, 각 shard의 방대한 중간 결과가 전부 Initiator로 몰려 네트워크·메모리가 폭증합니다.

😵 대용량 분산 JOIN

Distributed 테이블끼리 JOIN하면 한쪽 데이터를 모든 shard에 브로드캐스트해야 할 수 있습니다. 큰 테이블이면 OOM 위험.

🛠 우회 전략

같은 sharding key로 co-locate, 작은 테이블은 Dictionary화, GLOBAL JOIN 명시, MV로 pre-aggregation.

8.3 분산 INSERT — sharding key가 행의 목적지를 정한다

INSERT INTO events_dist VALUES (user_id=1001, ...), (user_id=1002, ...) Initiator: 행마다 sharding key 평가 cityHash64(1001) % 3 = 1 → Shard 2 · cityHash64(1002) % 3 = 0 → Shard 1 Shard 1 (로컬 테이블) Shard 2 (로컬 테이블) Shard 3 + user_1002 (새 파트) + user_1001 (새 파트) (해당 없음) ⚠️ Distributed INSERT는 Initiator에 임시 저장 후 비동기 전달 — Initiator 장애 시 유실 위험 → 로컬 테이블 직접 INSERT 권장
⚠️
Sharding key 선택과 데이터 쏠림(skew)

rand()는 균등 분산을 보장하지만 같은 엔티티가 여러 shard에 흩어져 JOIN이 비효율적입니다. 결정적 키는 JOIN·GROUP BY에 유리하지만 특정 shard에 데이터가 쏠릴 수 있습니다. shard 간 크기 차이를 주기적으로 모니터링하세요.

-- shard별 데이터 분포 확인 (쏠림 감지)
SELECT
    hostName() AS host,
    count() AS rows,
    formatReadableSize(sum(bytes_on_disk)) AS size
FROM clusterAllReplicas('test_cluster', system.parts)
WHERE database = 'uk' AND `table` = 'uk_price_paid_simple' AND active
GROUP BY host;

8.4 Replica — 비동기 멀티마스터 복제

복제의 목적은 세 가지입니다: 데이터 무결성(하드웨어 장애 시 손실 방지), 페일오버(장애 시 다른 레플리카로 서비스 지속), 쿼리 처리량(여러 레플리카에 읽기 분산). 각 shard 안의 모든 레플리카는 동일한 데이터를 보유하며, 복제는 ReplicatedMergeTree 엔진으로 구현됩니다.

CREATE TABLE uk.uk_price_paid_simple ON CLUSTER test_cluster
(
    date   Date,
    town   LowCardinality(String),
    street LowCardinality(String),
    price  UInt32
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/uk_price_paid', '{replica}')
ORDER BY (town, street);
-- 같은 Keeper 경로를 가진 테이블들이 서로의 레플리카가 된다

ClickHouse 복제의 세 가지 핵심 특성:

🗂 ClickHouse Keeper 복제 로그 · 리더 선출 · 체크섬 Replica A [파트1] [파트2] Replica B [파트1] [파트2] Replica C [파트1] [파트2] + 파트3 (INSERT) "파트3 생성됨" 로그 기록 + 파트3 (다운로드) + 파트3 (다운로드) 쿼리가 아니라 압축된 파트 자체가 전송된다 = 물리적 복제 📦 👑 리더: "파트1+2 머지" 로그 기록 세 레플리카 모두 로그를 읽고 같은 머지를 각자 독립 실행 → 파트12 생성 결과는 byte-identical → 머지 결과를 네트워크로 보낼 필요 없음 ✨ 네트워크 증폭 회피 — 복제 지연이 심할 때만 머지된 파트를 전송
💡
일관성 복구도 Keeper 기준

각 레플리카는 로컬 파일시스템 상태를 Keeper의 참조 상태와 비교합니다. 누락·손상 파트는 다른 레플리카에서 다운로드해 복구하고, 예상치 못한 데이터는 삭제하지 않고 별도 디렉터리로 옮깁니다. 데이터 유실이 허용되지 않는 환경이라면 SET insert_quorum = 2;로 N개 레플리카 확인 후 INSERT를 완료하도록 할 수 있습니다(레이턴시 증가 트레이드오프).

8.5 클러스터의 구조적 한계 — "Not Elastic"

공식 문서가 스스로 인정하는 한계를 아는 것이 중요합니다.

🚨
새 shard를 추가해도 데이터는 저절로 재분배되지 않는다

"The cluster is not elastic, so after adding a new shard, data is not rebalanced between shards automatically." — 리밸런싱은 수동 작업입니다. 또한 노드 간 데이터 reshuffle을 지원하지 않아 복잡한 분산 JOIN·고카디널리티 GROUP BY에 한계가 있으며, 공식 문서는 "수십 노드까지는 괜찮지만 수백 노드에서는 심각한 단점"이라고 명시합니다.

ClickHouse Cloud는 이 한계를 다른 아키텍처로 우회합니다: 오브젝트 스토리지 기반 SharedMergeTree가 레플리카를 대체해 고가용성을 확보하고, Parallel Replicas가 전통적 shard처럼 동작합니다.

ClickHouse Keeper 운영 요점

-- 복제 지연(replication lag) 모니터링
SELECT
    database, `table`, replica_name, is_leader,
    absolute_delay,      -- 최신 레플리카 대비 지연(초)
    queue_size,
    inserts_in_queue,    -- 높으면: 네트워크/파트 다운로드 병목
    merges_in_queue      -- 높으면: 머지 스레드 부족
FROM system.replicas
WHERE absolute_delay > 0
ORDER BY absolute_delay DESC;

✍️ 이해도 체크

Distributed 테이블에 대한 설명으로 옳은 것은?
✅ Distributed 테이블은 로컬 테이블들에 대한 "view"일 뿐 데이터를 자체 저장하지 않습니다. SELECT는 모든 shard(각각 레플리카 1개 선택)에 전달 후 결과를 병합하고, INSERT는 행마다 sharding key를 평가해 라우팅합니다. 레플리카 동기화는 ReplicatedMergeTree + Keeper의 몫입니다.
레플리카 간 머지가 네트워크 전송 없이 동기화될 수 있는 이유는?
✅ 리더가 Keeper의 복제 로그에 "merge parts" 액션을 기록하면 각 레플리카가 동일한 머지를 독립적으로 수행합니다. 결과 파트가 byte-identical하므로 전송이 불필요해 네트워크 증폭을 회피합니다. 대용량 머지 파트는 복제 지연이 심각할 때만 예외적으로 전송됩니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch08_shards_replicas.md — INSERT quorum, 분산 안티패턴별 해결책, Keeper 모니터링 상세가 담겨 있습니다.