8장. Shards & Replicas — 분산 아키텍처
한 대로 감당이 안 되면 나누고(Shard), 장애가 두려우면 복사한다(Replica). 그리고 그 전체를 하나의 테이블처럼 보이게 하는 Distributed 테이블 — ClickHouse 클러스터의 구조와 솔직한 한계까지 살펴봅니다.
① 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
- SELECT: 모든 shard에 쿼리를 전달하고, 결과를 수집·병합합니다.
- INSERT: 각 행마다 sharding key를 평가해 적절한 shard로 라우팅합니다.
8.2 분산 SELECT — 쿼리는 어떻게 흩어지고 다시 모이는가
클라이언트가 Distributed 테이블에 SELECT를 보냅니다. 이 쿼리를 받은 서버가 Initiator가 됩니다.
Distributed 테이블이 모든 shard에 쿼리를 전달합니다. 각 shard에서는 레플리카 중 하나만 선택됩니다.
각 서버가 로컬에서 독립적으로, 병렬로 쿼리를 실행합니다. 7장의 granule skip 같은 최적화는 전부 로컬에서 일어납니다.
각 shard의 중간 결과(로컬 count)가 Initiator로 수집됩니다. 중간 결과만 전송하므로 네트워크 부담이 최소화됩니다.
Initiator가 결과를 병합해 최종 글로벌 결과(530)를 만들고 클라이언트에 반환합니다. 이것이 ClickHouse의 1-pass 분산 실행의 전부입니다.
"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가 행의 목적지를 정한다
클라이언트가 Distributed 테이블에 여러 행을 INSERT합니다.
Initiator가 각 행에 대해 sharding key를 평가합니다: 키 값 % shard 수 = 대상 서버. rand()는 균등 분산, cityHash64(user_id) 같은 결정적 키는 같은 사용자를 같은 shard에 모아줍니다.
각 행이 계산된 대상 shard의 로컬 테이블로 전달되어 새 파트로 저장됩니다.
주의: 이 전달은 Initiator에 임시 저장 후 비동기로 일어납니다. Initiator 장애 시 임시 데이터가 유실될 수 있으므로, 프로덕션에서는 클라이언트가 sharding key를 직접 계산해 로컬 테이블에 직접 INSERT하는 것이 가장 안전합니다.
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 복제의 세 가지 핵심 특성:
- 비동기(Asynchronous): 어느 레플리카에 INSERT되면 다른 레플리카로 비동기 복제됩니다. INSERT가 모든 레플리카의 확인을 기다리지 않습니다.
- 멀티마스터(Multi-master): Keeper 세션이 있는 어떤 레플리카에든 INSERT 가능. 단일 마스터 병목이 없습니다.
- Conflict-free: ClickHouse는 제자리 UPDATE가 없으므로(INSERT only + 머지 기반 변환) 복제 충돌 자체가 발생하지 않습니다.
시점 T0: 세 레플리카가 동일한 파트 목록 [파트1, 파트2]를 갖고 있고, Keeper가 상태(파트 목록 + 체크섬)를 관리합니다.
Replica B에 INSERT가 들어와 파트3이 생깁니다. B는 Keeper의 복제 로그에 "파트3 생성됨"(get part) 액션을 기록합니다. 멀티마스터라 어느 레플리카든 INSERT를 받을 수 있습니다.
A와 C가 로그를 감지하고 자신의 큐에 복사한 뒤, B로부터 압축된 파트3 자체를 다운로드합니다. 쿼리 재실행이 아닌 물리적 복제입니다.
머지도 조율됩니다: 리더 하나가 "merge parts" 액션을 로그에 기록하면, 모든 레플리카가 같은 머지를 각자 독립 실행합니다.
결과 파트는 모든 레플리카에서 byte-identical — 그래서 머지 결과를 네트워크로 전송할 필요가 없습니다(네트워크 증폭 회피). 복제 지연이 심각할 때만 예외적으로 머지된 파트를 전송합니다.
각 레플리카는 로컬 파일시스템 상태를 Keeper의 참조 상태와 비교합니다. 누락·손상 파트는 다른 레플리카에서 다운로드해 복구하고, 예상치 못한 데이터는 삭제하지 않고 별도 디렉터리로 옮깁니다. 데이터 유실이 허용되지 않는 환경이라면 SET insert_quorum = 2;로 N개 레플리카 확인 후 INSERT를 완료하도록 할 수 있습니다(레이턴시 증가 트레이드오프).
8.5 클러스터의 구조적 한계 — "Not Elastic"
공식 문서가 스스로 인정하는 한계를 아는 것이 중요합니다.
"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 운영 요점
- Keeper는 ZooKeeper의 ClickHouse 네이티브 대체품으로,
ReplicatedMergeTree·ON CLUSTERDDL·분산INSERT ... SELECT에 필수입니다. - 과반수 합의를 위해 홀수 노드(3, 5, 7)로 구성하세요.
- Keeper 장애 시 INSERT와 머지는 중단되지만, SELECT는 정상 동작합니다 (읽기는 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;
✍️ 이해도 체크
이 장의 원문 문서: chapters/ch08_shards_replicas.md — INSERT quorum, 분산 안티패턴별 해결책, Keeper 모니터링 상세가 담겨 있습니다.