4. Trade-offs and How to Read the Benchmarks

쓰기를 막지 않으려고 정확성을 나중으로 미룬 대가

  • Haram Lee
  • 2026-10-17
  • studies / Topics / ClickHouse

2장과 3장에서는 ClickHouse가 빠른 이유를 봤다. 이번 장에서는 그 속도를 얻으려고 내준 것들을 본다. 원인은 모두 같다. ClickHouse는 append-only 쓰기와 비동기 background 작업으로 INSERT를 빠르게 유지하고, 그 대신 데이터를 정리하는 일을 나중으로 미룬다.

4.1 Updates and Deletes

MergeTree는 append-only 워크로드를 전제로 설계됐다. 2장에서 봤듯 tombstone도 없다. 그래도 규제에 대응하느라 기존 데이터를 고쳐야 할 때가 있어서, ClickHouse는 두 가지 방법을 제공한다. 둘 다 동시에 들어오는 INSERT를 막지는 않는다.

mutation은 table의 모든 part를 다시 쓴다. 다시 쓰는 동안 table이나 컬럼의 크기가 일시적으로 두 배가 되는 것을 막으려고, mutation은 part를 하나씩 바꾼다. 그래서 원자적이지 않다. mutation이 진행되는 동안 실행된 SELECT는 이미 고친 part와 아직 안 고친 part를 섞어 읽을 수 있다. 보장하는 것은 mutation이 끝나면 데이터가 물리적으로 바뀌어 있다는 것뿐이다. 삭제 mutation은 모든 part의 모든 컬럼을 다시 쓰므로 비싸다.

lightweight delete는 행이 삭제됐는지를 담은 내부 bitmap 컬럼만 고친다. 이후 SELECT에는 이 bitmap으로 거르는 조건이 자동으로 붙는다. 삭제 자체는 mutation보다 훨씬 빠르지만 그만큼 SELECT가 느려지고, 행이 실제로 사라지는 시점은 언제 돌지 모르는 merge에 달려 있다.

같은 table을 수정하고 삭제하는 일은 드물다고 가정한다. 그래서 논리적 충돌을 피하려고 순서대로 하나씩 처리한다.

4.2 Idempotent Inserts

클라이언트가 데이터를 보낸 뒤 연결이 끊기면, 데이터가 서버에 들어갔는지 알 수 없다. 흔한 해법은 다시 보내고 unique 제약으로 중복을 걸러내는 것이다. 하지만 unique 제약을 검사하려면 모든 행을 색인해야 하고, 데이터가 크고 쓰기가 많을수록 그 비용이 감당하기 어려워진다.

ClickHouse는 INSERT 하나가 결국 part 하나가 된다는 점을 이용한다. 서버는 최근에 들어온 part N개의 hash를 기억하고, 이미 아는 hash의 part가 다시 들어오면 무시한다. 클라이언트는 timeout이 나면 같은 묶음을 그대로 다시 보내면 된다. hash 대신 클라이언트가 insert token을 직접 지정할 수도 있다.

이 방식에는 전제가 있다. 다시 보낸 묶음이 처음과 바이트 단위로 같아야 한다. 묶음 경계가 달라지면 다른 part로 보여 걸러지지 않는다. 또 버전에 따라서는 이 기능이 기본으로 꺼져 있기도 하다. 5장에서 이 전제가 실제로 어떤 문제를 일으키는지 본다.

4.3 Replication Is Eventually Consistent

복제는 table 상태라는 개념을 바탕으로 동작한다. table 상태란 part의 집합과, 컬럼 이름이나 타입 같은 메타데이터를 말한다. 노드는 세 가지 연산으로 상태를 바꾼다. INSERT는 part를 더하고, merge는 part를 더하면서 원본 part를 지우고, mutation과 DDL은 part를 더하거나 지우거나 메타데이터를 바꾼다.

각 노드는 이 연산을 자기 노드에서 실행하고, 그 기록을 전역 복제 log에 남긴다. 복제 log는 보통 세 개의 ClickHouse Keeper 프로세스가 Raft 합의로 관리한다. Keeper는 ZooKeeper를 대신하도록 C++로 다시 만든 조정 서비스다. 다른 노드들은 이 log를 비동기로 따라가며 같은 연산을 재현한다. 새 part는 쓴 노드에서 내려받고, merge는 직접 다시 하거나 결과 part를 내려받는다. 데이터센터를 넘는 복제라면 네트워크 비용을 줄이려고 직접 merge하는 쪽을 고른다.

그래서 복제된 table은 eventually consistent다. 노드에 따라 잠깐 동안 예전 상태가 읽힐 수 있다. 필요하면 과반이나 전체 노드가 새 상태를 받을 때까지 기다리게 설정할 수도 있다.

4.4 Snapshot Isolation, Not ACID

쿼리는 시작 시점에 관련 table의 모든 part로 snapshot을 잡고 그 위에서 실행한다. 실행 도중 INSERT나 merge로 생긴 새 part는 이 쿼리에서 보이지 않는다. 읽는 동안 part가 지워지지 않도록 reference count를 올려 둔다. 형식적으로는 버전이 붙은 part로 구현한 MVCC 변형이고, snapshot isolation에 해당한다.

그래서 SQL 문은 일반적으로 ACID를 만족하지 않는다. snapshot을 잡는 순간에 동시에 일어난 쓰기가 각각 part 하나씩만 건드리는 드문 경우만 예외다.

또 하나, 기본 설정으로는 새 part를 쓸 때 fsync를 강제하지 않는다. 쓰기가 많은 분석 워크로드라면 정전 때 최근 데이터를 조금 잃는 위험은 대체로 감수한다고 판단한 것이다. 커널이 쓰기를 묶어 처리하게 두는 대신 원자성을 포기한다.

설계얻은 것내준 것
비동기 mergeINSERT가 빠르다merge 전에는 중복이나 덜 합친 행이 보인다. 확실한 결과를 보려면 FINAL 비용을 치러야 한다
mutation물리적으로 확실히 고친다모든 part를 다시 쓰고, 도중의 SELECT는 고친 part와 안 고친 part를 섞어 읽는다
lightweight delete삭제가 빠르다SELECT가 느려지고, 실제 삭제는 언제 돌지 모르는 merge에 맡긴다
복제어느 노드에서나 쓸 수 있다노드마다 잠깐 옛 상태를 읽는다
트랜잭션잠금 없이 snapshot으로 읽는다일반적으로 ACID가 아니고, 기본값으로 fsync도 하지 않는다

4.5 Reading the Benchmarks

논문의 6장은 성능 평가다. 그래프만 보면 ClickHouse가 거의 모든 경우에 빠르다는 인상을 받기 쉽다. 그래서 숫자 세 개를 각각의 워크로드와 함께 읽어야 한다.

ClickBench, 43개 쿼리. 1억 행짜리 익명 웹 로그 table 하나를 거르고 집계하는 벤치마크다. ClickHouse가 처음부터 잘하려고 만든 바로 그 일이다. 비교 대상 중 연구용 엔진인 Umbra가 hot 실행에서 가장 빨랐고, ClickHouse는 실서비스용 데이터베이스 중에서는 hot과 cold 모두 가장 빨랐다. 논문도 이 벤치마크가 비정규화 table 워크로드를 대표한다고 밝힌다.

VersionsBench, 1.72배. 매달 새 버전에서 같은 쿼리 묶음을 돌려 성능 변화를 추적하는 내부 벤치마크다. 2018년 3월부터 2024년 3월까지 77개 버전에서 1.72배 빨라졌다. 중간에 느려진 버전이 있었다는 사실도 논문에 나와 있다.

TPC-H, 5 대 6. 스키마가 정규화돼 있고 join이 많은 벤치마크다. 22개 쿼리 중 11개는 비교에서 뺐다. 7개는 당시 ClickHouse가 상관 subquery를 지원하지 않았고, 4개는 아직 구현하지 않은 계획 수준 최적화가 필요했다. 남은 11개에서 ClickHouse가 5개, 같은 규모의 Snowflake가 6개를 더 빨리 끝냈다. 논문 스스로도 정규화 table을 ClickHouse의 “새로 떠오르는 쓰임"이라고 부른다.

정리하면 ClickHouse의 강점은 넓은 table 하나를 거르고 묶는 일이다. 내 웹 성능 이벤트 table이 정확히 그런 모양이라서 이 결과를 내 경우에도 적용할 수 있다. 반면 join이 많은 분석이라면 이야기가 달라진다.

Discussion