Cloudflare는 Ready-Analytics의 파티셔닝 방식을 바꾼 뒤 ClickHouse 내부의 숨겨진 쿼리 계획 병목을 발견했고, 이를 해결하기 위해 일련의 최적화 패치를 작성했습니다.
2026-05-14
9분 읽기

Cloudflare에서는 오픈소스 분석형 데이터베이스 관리 시스템인 ClickHouse를 대규모로 사용하고 있습니다. 우리는 가장 큰 ClickHouse 테이블 중 하나를 재설계해 파티셔닝 키에 열 하나를 추가했습니다. 이 변경으로 수백 개의 내부 팀이 사용하는 테이블에서 테넌트별 보존 정책을 적용할 수 있게 되었습니다. 최종 접근 방식을 정하기 전까지 여러 팀의 엔지니어들과 함께 여러 차례 수정과 검토를 거쳤습니다. 하지만 배포 후 몇 주가 지나자 Cloudflare 청구서의 대부분을 생성하는 작업들이 일일 고정 마감 시간에 점점 가까워지기 시작했습니다.
일반적으로 의심하는 지표들은 모두 깨끗해 보였습니다. I/O, 메모리, 스캔한 행 수, 읽은 파트 수 모두 문제가 없었습니다. ClickHouse 쿼리가 느릴 때 평소 확인하는 모든 항목이 정상이었습니다. 문제의 원인은 이전까지 확인할 이유가 없었던, 쿼리 계획 단계의 락 경합이었습니다.
이 글은 이 마이그레이션이 ClickHouse 내부의 숨겨진 병목을 어떻게 드러냈는지, 그리고 이를 해결하기 위해 우리가 어떤 패치를 작성했는지에 대한 이야기입니다.
우리는 수십 개의 클러스터에 걸쳐 100페타바이트가 넘는 데이터를 ClickHouse에 저장하고 있습니다. 많은 내부 팀의 온보딩을 단순화하기 위해, 2022년 초에 "Ready-Analytics"라는 시스템을 구축했습니다.
개념은 단순합니다. 팀들이 새 테이블을 설계하는 대신, 하나의 거대한 테이블로 데이터를 스트리밍할 수 있게 하는 것입니다. 데이터셋은 namespace로 구분되며, 각 레코드는 표준 스키마를 사용합니다. 예를 들면 20개의 float 필드, 20개의 string 필드, 타임스탬프, 그리고 indexID가 있습니다.
ClickHouse에서는 데이터가 정렬되는 방식이 쿼리 성능에 매우 중요합니다. 여기서 indexID가 역할을 합니다. 이것은 문자열 필드이며 기본 키의 일부를 이루기 때문에, 각 namespace는 그 소유자가 실행할 것으로 예상하는 쿼리에 최적인 방식으로 데이터를 정렬할 수 있습니다. 전체적으로 기본 키는 다음과 같은 형태가 됩니다: (namespace, indexID, timestamp).
이 시스템은 수백 개의 애플리케이션이 사용할 정도로 인기가 높습니다. 2024년 12월에는 이미 2PiB가 넘는 데이터로 성장했고, 초당 수백만 행을 수집하고 있었습니다. 하지만 치명적인 결함이 하나 있었습니다. 바로 보존 정책이었습니다.
Cloudflare는 ClickHouse에 기본 제공되는 Time-to-Live(TTL) 기능이 생기기 전부터 여러 해 동안 ClickHouse를 사용해 왔습니다. 그 결과 우리는 파티셔닝을 기반으로 한 자체 보존 시스템을 구축했습니다. Ready-Analytics 테이블은 day 기준으로 파티셔닝되어 있었고, 보존 작업은 단순히 31일보다 오래된 파티션을 삭제했습니다.
이 "모두에게 같은" 31일 보존 정책은 큰 제약이었습니다. 어떤 팀은 법적 또는 계약상 의무 때문에 데이터를 수년간 저장해야 했고, 다른 팀은 며칠만 필요했습니다. 이런 제한 때문에 해당 사용 사례들은 Ready-Analytics를 사용할 수 없었고, 훨씬 더 복잡한 온보딩 과정을 가진 일반적인 구성 방식을 선택해야 했습니다.
우리는 namespace별 보존 정책을 허용하는 새로운 시스템이 필요했습니다.
우리는 두 가지 주요 접근 방식을 검토했습니다.
namespace별 테이블: 이는 보존 문제를 자연스럽게 해결하지만, 수천 개의 테이블을 필요할 때마다 관리하기 위한 상당한 자동화가 새로 필요합니다.
새로운 파티셔닝 키: 파티셔닝 키를 단순한 (day)에서 (namespace, day)로 바꿀 수 있습니다.
우리는 두 번째 옵션을 선택했습니다. 이렇게 하면 기존 보존 시스템이 계속 파티션을 관리할 수 있으면서도, 이제는 namespace 단위의 세밀한 제어가 가능해집니다.
이렇게 하면 테이블의 전체 데이터 파트 수가 증가한다는 점은 알고 있었습니다. 하지만 우리는 중요한 가정을 했습니다. 모든 쿼리는 특정 namespace로 필터링되므로,개별 쿼리 하나가 읽는 파트 수는 바뀌지 않을 것이다. 따라서 성능에는 영향이 없을 것이라고 믿었습니다.
이 그림은 파티셔닝을 어떻게 변경했는지 보여주며, 이를 통해 단일 namespace의 데이터를 저렴하게 삭제할 수 있게 되었습니다
이 새로운 시스템 덕분에 정교한 스토리지 관리 계층도 구축할 수 있었습니다. max-min fairness algorithm을 사용해 목표 디스크 사용률(예: 90%)을 설정하고, 사용 가능한 공간을 자동으로 "공유"할 수 있었습니다. 공정한 몫보다 적게 사용하는 namespace는 사용하지 않는 용량을 더 많이 필요한 쪽에 넘겨줍니다. 이를 통해 클러스터를 90% 사용률로도 자신 있게 운영할 수 있었습니다.
우리는 2025년 1월에 마이그레이션을 시작했습니다. ClickHouse의 Merge 테이블 기능을 사용해 기존 테이블과 새 테이블을 결합하고, 모든 신규 데이터를 새 파티셔닝 테이블에 기록하면서 오래된 데이터는 자연스럽게 만료되도록 했습니다.
두 달 뒤인 2025년 3월 말, 청구 팀이 일일 집계 작업이 느려지고 있다고 보고했습니다. 이 작업들은 시간에 매우 민감합니다. 끝나지 않으면 청구서가 발송되지 않습니다. 작업은 점점 더 느려지고 있었고, 우리는 마감에 가까워지고 있었습니다.
우리는 조사했지만, 일반적으로 의심하는 원인들은 모두 아니었습니다. I/O는 괜찮았습니다. 메모리도 괜찮았습니다. 개별 쿼리의 메트릭을 보면 이전보다 _더 많은 데이터나 더 많은 파트_를 읽고 있지도 않았습니다. 초기 가정은 맞는 것처럼 보였지만, 시스템은 실제로는 거의 멈춰가고 있었습니다.
이론 하나를 세우는 데만 며칠이 걸렸습니다. 마침내 우리는 쿼리 지속 시간과 클러스터의 _전체 파트 수_를 비교한 그래프를 그렸습니다. 상관관계는 부정할 수 없었습니다.
Ready Analytics ClickHouse 클러스터의 평균 SELECT 쿼리 지속 시간으로, 점진적인 성능 저하를 보여줍니다.
새로운 (namespace, day) 파티셔닝 방식 도입 이후 테이블 복제본당 전체 데이터 파트 수의 선형 증가.
그런데 왜 그랬을까요? 추가 파트를 읽고 있지 않다면, 단지 그것들이 존재한다는 사실만으로 왜 느려졌을까요?
우리는 플레임 그래프를 만들기 위해 ClickHouse 내장 trace_log를 사용했습니다. 이것은 실행 중인 ClickHouse 서버의 트레이스를 기록하는 내장 테이블입니다. 어떤 코드가 실행되고 있는지의 트레이스뿐 아니라 특정 사용자, 쿼리 ID, 기타 메타데이터와 연결해서 기록하므로, 필요하면 상당히 정밀한 이벤트 집합으로 필터링할 수 있습니다. 우리의 경우에는 특히 _leaf SELECT 쿼리_만 보고 싶었습니다. 이 테이블에 있는 메타데이터 덕분에 이는 쉽게 가능했습니다.
첫 번째 CPU 기반 플레임 그래프는 우리의 의심을 빠르게 확인해 주었습니다. 엄청난 시간이 쿼리 계획(query planning) 에 쓰이고 있었습니다. 이것은 ClickHouse가 어떤 파트를 읽을지 결정하는 실행 이전 단계입니다.
샘플링된 leaf 쿼리 CPU 시간의 45%가 partition ID를 기준으로 파트 벡터를 필터링하는 데 쓰이고 있음을 보여주는 플레임 그래프
플레임 그래프는 분명했습니다. 샘플링된 CPU 시간의 45%가 filterPartsByPartition이라는 단일 함수에서 소비되고 있었습니다.
우리가 처음 시도한 수정은 바로 이 코드 경로에 대한 작은 패치였습니다. 플래너는 파트를 가지치기하기 위한 휴리스틱을 평가하는데, 우리 테이블에서는 그것들이 최적의 순서로 평가되지 않는다고 판단했습니다. 패치를 통해 그 순서를 바꿨고, 5% 정도의 작은 개선을 얻었습니다. 방향은 맞았지만, 진짜 문제는 놓치고 있었습니다.
우리는 그동안 활성 스레드만 샘플링하는 "CPU" 트레이스를 만들고 있었습니다. 그래서 비활성 또는 대기 중인 스레드까지 포함해 모든 스레드를 샘플링하는 "Real" 트레이스로 바꿨습니다. 새 플레임 그래프는 충격적이었습니다.
leaf 쿼리 지속 시간의 절반 이상이 활성 파트 목록을 보호하는 mutex를 기다리는 데 쓰이고 있음을 보여주는 플레임 그래프
문제는 CPU 바운드 작업이 아니었습니다. 엄청난 락 경합이었습니다. 쿼리 지속 시간의 절반 이상이 테이블의 파트 목록을 보호하는 단일 mutex(MergeTreeData)를 획득하기 위해 기다리는 데 쓰이고 있었습니다. 쿼리를 계획하려면 모든 스레드가 다음을 수행해야 했습니다.
이 mutex에 대해 배타 락을 획득한다.
테이블의 모든 파트 목록 전체를 완전히 복사한다.
락을 해제한다.
그 목록을 관련 있는 파트만 남도록 필터링한다.
수만 개의 파트와 수백 개의 동시 쿼리가 있는 상황에서, 이들은 모두 한 줄로 서서 기다리고 있었던 셈입니다.
이 통찰 덕분에 우리는 이 병목 지점을 완화하기 위한 일련의 최적화를 계획할 수 있었습니다. 우리가 ClickHouse에 적용하는 모든 패치와 마찬가지로, 가능한 한 범용적으로 만들고 궁극적으로는 업스트림 코드베이스에 기여하려고 합니다. 그래야 자체 포크를 유지보수하기 쉬워지고, 우리가 만든 변경의 혜택을 커뮤니티도 함께 누릴 수 있기 때문입니다.
쿼리 플래너는 파트 목록을 _수정_하지 않고 그저 읽기만 합니다. 배타 락을 사용할 이유가 없었습니다.
해결 방법: 코드를 수정해 대신 공유 락(std::shared_lock)을 획득하도록 했습니다. 그러자 모든 쿼리 플래너가 동시에 크리티컬 섹션에 들어갈 수 있게 되었습니다.
결과: 쿼리 지속 시간이 즉각적이고 대폭 감소했습니다. 락 경합이 사라졌습니다.
평균 SELECT 쿼리 지속 시간에 대한 공유 락 최적화(최적화 1)의 즉각적인 영향으로, 락 경합이 해소되었음을 보여줍니다.
성능은 크게 좋아졌지만, 여전히 기준선으로 돌아오지는 못했습니다. 우리는 다시 trace log로 돌아가 또 하나의 ‘Real’ 플레임 그래프를 만들었습니다.
leaf 쿼리 지속 시간의 4분의 1이 모든 파트 벡터를 복사하는 데, 또 다른 4분의 1이 이를 필터링하며 다시 복사하는 데 쓰이고 있음을 보여주는 플레임 그래프.
새 플레임 그래프는 병목이 단지 다른 곳으로 이동했음을 보여주었습니다. 이제는 공유 락을 사용하더라도 거대한 파트 벡터를 복사하는 데 시간이 쓰이고 있었습니다. 직관적으로는 벡터 복사가 저렴해 보일 수 있지만, 여기에 수만 개의 원소가 있고 이를 초당 수백 번 수행하면 비용이 커집니다.
해결 방법: 복사를 아예 뒤로 미뤘습니다. 파트 목록의 "공유 복사본"을 만들었습니다. 쿼리 계획 같은 읽기 전용 작업은 이 복사본에서 바로 읽습니다. 새 insert처럼 파트 집합을 수정하는 작업이 일어나면 캐시를 다시 생성합니다. 이제 플래너는 실제로 필요한 필터링된 파트 목록만 복사합니다.
결과: 또 한 번 의미 있는 성능 향상이 있었습니다.
벡터 복사 최적화(최적화 2) 배포 후의 추가 성능 향상.
내부적으로 이런 큰 절감 효과를 확인한 후, 우리는 이 변경을 커뮤니티에도 제공하기로 했습니다. ClickHouse Inc.의 유지관리자들과 몇 차례 작은 설계 반복을 거친 끝에, 해당 변경은 PR #85535. 로 병합되었습니다. 이 변경은 ClickHouse version 25.11부터 사용할 수 있습니다.
아직 끝나지 않았습니다. 파트 수가 늘어날수록 성능은 여전히 저하되며, 다만 훨씬 더 느리게 진행될 뿐입니다. 파트 수와의 상관관계는 여전히 존재했습니다. 몇 달 후 다시 이 문제를 들여다보니, 새로운 플레임 그래프는 시간 대부분이 필터링 코드 경로(처음에 고치려 했던 바로 그 부분)에서 쓰이고 있음을 보여주었습니다. 이 코드는 모든 파트에 대해 선형 스캔을 수행하며, 각 파트에 조건을 적용합니다. 몇 달이 지나자 우리는 최적화 이전과 비슷한 SELECT 지속 시간으로 되돌아와 있었습니다.
하지만 우리는 이 파트 목록이 파티셔닝 키 기준으로 정렬되어 있다는 사실을 알고 있습니다. 기억하시겠지만, 파티션 키의 첫 번째 열은 namespace이며, 대부분의 쿼리는 이것으로 필터링합니다. 왜냐하면 이것이 “테넌트”를 식별하기 때문입니다. 그렇다면 이것을 어떻게 활용할 수 있을까요?
해결 방법: partition ID의 namespace 부분을 기준으로 한 이진 탐색을 구현했습니다. 벡터가 정렬되어 있기 때문에, 실제로 항목을 하나하나 보지 않고도 많은 엔트리를 걸러낼 수 있습니다. 특히 namespace가 그 정렬 키의 첫 번째 요소이기 때문에 더욱 효과적입니다. 이 첫 번째 이진 탐색 단계를 거치면, 우리가 검사해야 할 파트 범위는 훨씬 작아집니다. 그 후 남은 범위에 대해서는 여전히 각 파트를 순회하면서, 다른 조건에 따라 제외하기 위해 이전과 같은 로직을 적용합니다.
결과: 2026년 3월에 이 패치를 배포한 뒤 쿼리 지속 시간이 50% 감소했습니다(그림 8 참조). 더 중요한 점은, 이것이 마침내 쿼리 지속 시간과 파트 수 사이의 상관관계를 끊었다는 것입니다. 안타깝게도 이 해결책은 임의의 쿼리 조건(예: namespace in (5,10) 같은 조건)으로는 그다지 잘 일반화되지 않습니다. 우리는 query condition cache를 확장해 파트 필터링까지 다루는 방식 같은 더 범용적인 접근을 검토하고 있습니다.
파트 가지치기를 위한 이진 탐색 구현(최적화 3) 이후 지속적으로 유지된 지연 시간 감소.
이러한 최적화는 청구 시스템의 즉각적인 위기를 해결했습니다. 하지만 이번 여정은 우리의 파티셔닝 선택이 가진 깊고 비직관적인 비용을 드러냈습니다.
다른 문제들도 여전히 남아 있습니다. 이 글에서는 파트 수 증가가 SELECT 지속 시간에 미친 문제만 설명했지만, ClickHouse의 모든 파트 메타데이터를 추적하는 ZooKeeper에도 문제가 생겼습니다. 언젠가 100기가바이트짜리 ZooKeeper 클러스터 이야기도 하게 될지 모릅니다.
우리는 상당한 여유를 확보했지만, 근본적인 질문은 남아 있습니다. 이 파티셔닝 방식이 장기적으로 정말 올바른 선택이었을까요? 아니면 결국 결단을 내리고 다른 아키텍처로 옮겨야 할까요? 지금은 우리의 패치가 버티고 있지만, 이번 경험은 아무리 잘 계획된 변경이라도 잘못된 가정 때문에 실패할 수 있음을 보여주는 분명한 사례였습니다.
청구 팀이 처음 이 문제를 보고했을 때 복제본당 파트 수는 30,000개였습니다. 파트 증가율은 한 번도 멈추지 않았고, 1년 뒤에는 복제본당 160k 파트에 도달했습니다. 하지만 여기서 만든 최적화 덕분에 쿼리 지속 시간은 안정적으로 유지되고 있습니다.
Cloudflare에서는 대규모 환경에서 복잡한 엔지니어링 문제를 해결합니다. 여기서 설명한 디버깅과 최적화가 여러분이 찾는 도전처럼 들린다면, 현재 채용 중인 open roles도 확인해 보세요.