ClickHouse — 열 지향 분석 DB 를 속까지 · SummingMergeTree 와 AggregatingMergeTree · 讲解
SummingMergeTree 와 AggregatingMergeTree — 병합이 집계를 이어 간다
한 줄 요약
두 엔진은 정렬 키가 같은 행을 병합 때 하나로 접으면서 값을 합친다. 합으로 줄일 수 있는 값(건수·합계)은 SummingMergeTree 가 그냥 더하고, 합으로 줄일 수 없는 값(서로 다른 사용자 수·평균)은 AggregatingMergeTree 가 숫자 대신 집계의 중간 상태를 들고 다닌다. 병합은 언제 끝날지 모르므로 조회는 늘 GROUP BY 와 sum()·-Merge 로 마무리한다.
왜 이게 필요했나
대시보드가 "사이트별 일일 방문 수" 를 보여 준다고 하자. 원본 이벤트가 하루 수억 행이면 화면을 열 때마다 원본을 다 훑는 것은 낭비다. 답은 하루 몇백 행이면 되는데 말이다. 그래서 미리 요약해 둔다. 문제는 자료가 계속 들어온다는 것이다. 오늘 오전 요약을 넣어 두었는데 오후 자료가 오면, 요약 행을 찾아 고쳐야 한다 — 그런데 앞 모듈에서 봤듯이 MergeTree 의 파트는 고치지 않는다.
ReplacingMergeTree 가 "새 버전을 덧붙이고 병합 때 옛 것을 버린다" 였다면, 이 두 엔진은 "새 조각을 덧붙이고 병합 때 더한다" 다. 오후 요약을 그냥 한 줄 더 넣으면, 같은 키의 행들이 병합에서 하나로 합쳐진다. 쓰기는 여전히 덧붙이기뿐이다.
어떻게 동작하나
SummingMergeTree — 레퍼런스 문서의 규칙은 짧다. 정렬 키가 같은 행들을, 숫자 타입 열의 값을 더한 한 행으로 바꾼다. 더할 열을 엔진 인자로 적지 않으면 정렬 키에 없는 모든 숫자 열을 더한다. 더할 열이 모두 0 이 된 행은 지운다. 더하지 않는 열은 있는 값 중 아무거나 남는다.
이 "모든 숫자 열" 이 함정이다. 실습 서버에서 (k, a Int32, mx UInt32) 표에 (1, 5, 10)과 (1, −5, 20)을 따로 넣고 합쳤더니 (1, 0, 30)이 되었다. 최댓값이라고 넣은 mx 가 더해져 30이 되었고, a 가 0 이 되었어도 mx 가 0 이 아니어서 행은 남았다. 최댓값이 필요하면 AggregatingMergeTree 의 SimpleAggregateFunction(max, UInt32) 열을 쓴다(같은 두 행이 20 으로 합쳐졌다).
실습 자료(100만 행, 사이트 5개 × 30일 = 150개 키)를 시간대별로 세 번 넣고 병합을 멈추면 요약 표는 450행이다. 문서가 "합산이 완전하지 않을 수 있으니 조회에 sum() 과 GROUP BY 를 쓰라" 고 하는 이유가 이 상태다. SELECT * 는 키마다 세 행을 돌려주고, GROUP BY site, day 로 다시 더하면 병합 여부와 상관없이 원본과 똑같다.
AggregatingMergeTree — 서로 다른 사용자 수는 더할 수 없다. 오전에 온 1,772명과 오후에 온 1,771명 사이에 같은 사람이 있기 때문이다. 그래서 숫자 대신 상태를 저장한다. 조합자 문서의 표현으로 -State 는 결과값이 아니라 "집계의 중간 상태(uniq 라면 해시 테이블)" 를 돌려주고, -Merge 는 상태들을 합쳐 결과값을 낸다.
users AggregateFunction(uniqExact, UInt64) -- 열 타입: 어떤 함수의 상태인가INSERT ... SELECT uniqExactState(user_id) ... -- 넣을 때 -StateSELECT uniqExactMerge(users) ... GROUP BY ... -- 읽을 때 -Merge-If 는 조건을 붙인다(countIfState(is_bot = 0) 의 타입은 AggregateFunction(countIf, UInt8) 였다). 평균도 상태로 둔다 — avgState 는 합과 개수를 들고 있어, 합친 뒤 나누면 원본 평균과 정확히 같다. 상태는 사람이 읽는 값이 아니어서 JSON 으로 뽑으면 이진 바이트가 찍힌다.
가장 큰 장점은 상태를 다시 합칠 수 있다는 것이다. -MergeState 는 상태들을 합쳐 결과값이 아니라 다시 상태를 돌려준다. 사이트별 일일 상태를 날짜별 상태로 접을 수 있고, 숫자만 남겼다면 불가능한 "사이트를 합친 일일 방문자" 를 원본 없이 구한다.
현장에서 만나는 모습
가장 흔한 버그는 행마다 끝낸 숫자를 더하는 것이다. 실습에서 병합 전 450개 상태 행을 finalizeAggregation 으로 각각 끝내 더하면 807,524, 키마다 합친 뒤 끝내 더하면 552,634 였다. 대시보드에 "일일 사용자 합" 이 이상하게 크다면 이 실수를 의심한다. 평균도 마찬가지다 — 평균의 평균은 평균이 아니다.
두 번째는 SummingMergeTree 에 요약하면 안 되는 숫자 열(최댓값, 식별자, 비율)이 섞여 들어가는 것이다. 문서는 원본 전체는 MergeTree 에 두고 SummingMergeTree 는 요약용으로만 쓰라고 권한다. 정렬 키를 잘못 골라도 원본에서 다시 만들 수 있게 하려는 것이다.
세 번째는 이 표를 누가 채우느냐다. 실습에서는 INSERT ... SELECT 를 손으로 세 번 돌리지만, 현장에서는 원본에 INSERT 가 들어올 때마다 자동으로 요약을 넣는 구체화 뷰와 짝을 짓는다. 그것이 다음 모듈이다. 한 가지 더 — 한 INSERT 안에 같은 키가 여러 번 있으면 넣는 순간 합쳐진다(optimize_on_insert). 100만 행을 hits = 1 로 한 번에 넣었더니 곧바로 150행이 되었다.
다음 실습에서 할 것
원본 agg.hits 에 100만 행을 넣고, (site, day) 요약을 SummingMergeTree 에 시간대별로 세 번 나눠 넣어 병합 전 행 수를 확인한다. GROUP BY + sum() 쿼리가 병합 전에도 원본과 같은지 대조한 뒤 직접 합쳐 본다. 이어 서로 다른 사용자 수·평균·사람만 센 조회수를 -State 로 AggregatingMergeTree 에 담고 -Merge 로 끝내며, 행마다 끝내 더한 값이 얼마나 틀리는지 기록한다. 마지막으로 일별 상태를 -MergeState 로 날짜 단위로 다시 합친다.