들어가며: '정상'인데 왜 느리지?
Cloudflare는 매일 수백만 건의 ClickHouse 쿼리를 실행합니다. 과금 시스템부터 사기 탐지까지 핵심 파이프라인을 담당하는 이 OLAP 데이터베이스에서, 어느 날 갑자기 일별 집계 작업이 느려지기 시작했습니다. 문제는 모든 지표가 정상이라는 점이었어요. I/O, 메모리, 스캔된 행 수, 읽은 파트 수 — 평소라면 의심할 만한 요소들이 하나도 문제를 드러내지 않았습니다.
이 글은 Cloudflare의 엔지니어링 팀이 ClickHouse 내부 깊숙한 곳에 숨겨진 병목을 발견하고, 세 가지 패치로 해결한 전 과정을 담고 있습니다. 단순한 '쿼리 튜닝 팁'이 아니라, 대규모 시스템에서 예상치 못한 상호작용이 어떻게 성능을 갉아먹는지 보여주는 생생한 사례입니다.
이 글은 Cloudflare 블로그의 ClickHouse 쿼리 플랜 경합 디버깅 스토리를 기반으로 재구성했습니다.

배경: Ready-Analytics 시스템과 파티셔닝의 함정
Cloudflare는 100PB가 넘는 데이터를 수십 개의 ClickHouse 클러스터에 저장합니다. 내부 팀의 온보딩을 간소화하기 위해 2022년 초 'Ready-Analytics'라는 시스템을 구축했는데, 핵심 아이디어는 단일 대형 테이블에 모든 팀이 데이터를 스트리밍하는 것이었습니다.
문제의 시작: '일률적인' 보존 정책
Ready-Analytics 테이블은 day 단위로 파티셔닝되어 있었고, 보존 작업은 31일이 지난 파티션을 그냥 삭제했습니다. 하지만 팀마다 데이터 보존 요구사항이 달랐어요. 어떤 팀은 법적/계약상 수년간 데이터를 보관해야 했고, 다른 팀은 며칠이면 충분했습니다. 결국 이 'one-size-fits-all' 정책 때문에 많은 팀이 시스템을 사용하지 못했습니다.
해결책: 파티셔닝 키 변경
팀은 파티셔닝 키를 (day)에서 (namespace, day)로 변경하기로 결정했습니다. 이렇게 하면 각 네임스페이스별로 파티션을 관리할 수 있고, 기존 보존 시스템도 그대로 활용할 수 있습니다.
-- 변경 전: 일 단위 파티셔닝
PARTITION BY toYYYYMMDD(timestamp)
-- 변경 후: 네임스페이스 + 일 단위 파티셔닝
PARTITION BY (namespace, toYYYYMMDD(timestamp))
핵심 가정: 모든 쿼리는 특정 네임스페이스로 필터링되므로, 단일 쿼리가 읽는 파트 수는 변하지 않을 것이다. 따라서 성능에 영향이 없을 것이다.
이 가정은 틀렸습니다.

본론: 병목 추적의 3막
1막: 'CPU' 플레임 그래프의 함정
2025년 3월, 과금 팀이 일별 집계 작업이 점점 느려진다고 보고했습니다. 팀은 ClickHouse의 trace_log를 이용해 플레임 그래프를 생성했습니다.
-- trace_log를 활용한 플레임 그래프 데이터 추출 (예시)
SELECT
thread_name,
symbol,
count() AS samples
FROM system.trace_log
WHERE
query_id IN (SELECT query_id FROM system.query_log WHERE ...)
AND trace_type = 'CPU'
GROUP BY thread_name, symbol
ORDER BY samples DESC
첫 번째 CPU 기반 플레임 그래프는 filterPartsByPartition 함수에서 45%의 CPU 시간이 소모되고 있음을 보여줬습니다. 팀은 이 함수의 휴리스틱 평가 순서를 변경하는 작은 패치를 적용했지만, 겨우 5% 개선에 그쳤습니다.
2막: 'Real' 플레임 그래프의 반전
팀은 'Real' 트레이스(대기 중인 스레드까지 샘플링)로 전환했습니다. 결과는 충격적이었습니다.
쿼리 시간의 절반 이상이 단일 뮤텍스(MergeTreeData)를 기다리며 소비되고 있었습니다.
쿼리 플래너가 실행되는 과정:
- 이 뮤텍스에 독점 락(Exclusive Lock) 획득
- 테이블의 모든 파트 리스트를 완전히 복사
- 락 해제
- 복사된 리스트에서 관련 파트만 필터링
수만 개의 파트와 수백 개의 동시 쿼리가 있을 때, 모든 스레드가 한 줄로 서서 이 뮤텍스를 기다리고 있었던 겁니다.
3막: 세 가지 패치
패치 1: 공유 락(Shared Lock) 사용
쿼리 플래너는 파트 리스트를 읽기만 합니다. 독점 락이 필요 없었습니다.
// 변경 전: 독점 락
std::lock_guard<std::mutex> lock(mutex);
// 변경 후: 공유 락 (std::shared_lock)
std::shared_lock<std::shared_mutex> lock(mutex);
결과: 락 경합이 즉시 사라지고 쿼리 시간이 대폭 감소했습니다.
패치 2: 벡터 복사 지연
새로운 플레임 그래프는 이제 파트 벡터를 복사하는 시간이 병목임을 보여줬습니다. 수만 개의 요소를 가진 벡터를 초당 수백 번씩 복사하는 건 결코 가벼운 작업이 아닙니다.
// 변경 전: 매번 전체 복사
auto parts = table->getParts(); // 락 획득 후 전체 복사
// 변경 후: 공유 복사본 활용 (Copy-on-Write 패턴)
// 읽기 전용 작업은 캐시된 공유 복사본 참조
// 파트 변경 시에만 캐시 갱신
결과: 추가적인 성능 향상. 이 패치는 ClickHouse 커뮤니티에 PR #85535로 기여되어 25.11 버전부터 포함되었습니다.
패치 3: 이진 탐색(Binary Search) 도입
시간이 지나면서 파트 수가 다시 증가했고, 성능 저하가 재현되었습니다. 하지만 이번에는 filterPartsByPartition 함수에서 선형 스캔(Linear Scan) 이 병목이었습니다.
파트 리스트는 파티셔닝 키(첫 번째 컬럼이 namespace)로 정렬되어 있습니다. 대부분의 쿼리는 특정 네임스페이스로 필터링하므로, 이진 탐색을 활용할 수 있었습니다.
# 개념 예시: 이진 탐색으로 파트 필터링
# 실제 ClickHouse 코드는 C++로 구현됨
def filter_parts_by_namespace(parts, target_namespace):
# 1. 이진 탐색으로 target_namespace 범위 찾기
left = binary_search_left(parts, target_namespace)
right = binary_search_right(parts, target_namespace)
# 2. 해당 범위 내에서만 추가 조건 검사
candidate_parts = parts[left:right]
return [p for p in candidate_parts if meets_other_conditions(p)]
결과: 쿼리 시간이 50% 감소했고, 파트 수와 쿼리 시간의 상관관계가 완전히 사라졌습니다.
주의사항: 이 최적화는
namespace IN (5,10)같은 다중 조건에는 효과가 제한적입니다. 더 일반적인 접근법(쿼리 조건 캐시 확장 등)을 계속 연구 중입니다.

결론: '올바른' 결정도 가정을 의심하라
이번 사례가 주는 교훈은 분명합니다. '당연히 성능에 영향 없을 거야'라는 가정이 가장 위험하다는 것입니다. 파티셔닝 키 변경은 논리적으로 완벽해 보였지만, ClickHouse 내부의 락 메커니즘과 결합되어 예상치 못한 병목을 만들었습니다.
한국 개발 생태계에서의 적용 맥락
국내 SI/금융권 환경에서도 OLAP 데이터베이스(ClickHouse, Druid, Pinot 등) 도입이 늘고 있습니다. 특히 멀티테넌트(Multi-tenant) 아키텍처에서 파티셔닝 전략은 성능에 직결됩니다.
- 테이블 설계 단계에서부터 동시성(Concurrency)과 파트 증가율을 고려해야 합니다.
- '파트 수가 늘어나도 쿼리당 읽는 파트 수는 동일하다' 는 가정은 락 경합과 복사 비용을 간과합니다.
- 성능 모니터링 시 CPU 사용률만 보지 말고, 락 대기 시간(Lock Waits)과 스레드 블로킹 상태도 함께 추적해야 합니다.
이 기술의 한계 및 주의사항
- Cloudflare의 패치는 ClickHouse의 특정 버전과 워크로드에 최적화된 것입니다. 모든 환경에 동일한 효과를 보장하지는 않습니다.
- 파티션 수가 지나치게 많아지면 ZooKeeper 메타데이터에도 부담이 갑니다. (Cloudflare는 이 문제를 '100GB ZooKeeper 클러스터' 이야기로 남겨두었습니다.)
- 네임스페이스 기반 파티셔닝은 필터링 조건이 명확할 때 효과적입니다. 다양한 조건으로 쿼리하는 환경에서는 다른 접근법이 필요할 수 있습니다.
다음 단계 학습 방향
- ClickHouse 공식 문서의 파티셔닝과 파트 관리 부분을 정독해보세요.
- 플레임 그래프 분석 도구 (예:
pyroscope,async-profiler)를 실제 환경에 적용해보는 연습을 해보는 것도 좋습니다. - 대규모 OLAP 시스템의 락 프리(lock-free) 자료구조와 Copy-on-Write 패턴에 대해 공부해보세요.