LabHub
배우기 러닝패스 코스

ClickHouse — 열 지향 분석 DB 를 속까지 · 구체화 뷰는 INSERT 트리거 · 이론

증분 구체화 뷰 — 저장된 쿼리가 아니라 INSERT 트리거

LabHub 에서 이어서 보기

한 줄 요약

ClickHouse 의 (증분) 구체화 뷰는 결과를 저장해 둔 스냅숏이 아니라 원천 표에 INSERT 가 들어올 때마다, 들어온 블록에만 SELECT 를 돌려 결과를 대상 표에 추가하는 트리거다. 뷰가 생기기 전의 행, 오른쪽 조인 표의 변화, 원천의 UPDATE·DELETE 는 뷰가 보지 못한다.

개념 지도: 조회할 때에서 넣을 때로 · 자기가 본 블록만 안다. · 삽입된 데이터의 일부 · 삽입된 블록 하나 안에서만

왜 이게 필요했나

앞 모듈에서 SummingMergeTree·AggregatingMergeTree 가 병합 때 행을 합친다는 것을 봤다. 그런데 날마다 쌓이는 원천 로그에 대해 "상점별 일 매출" 을 대시보드가 초마다 묻는다면, 매번 원천 수억 행을 다시 집계하는 것은 낭비다. 계산을 조회할 때에서 넣을 때로 옮기고 싶다 — 공식 문서가 구체화 뷰의 동기로 드는 문장이 바로 이것이다.

PostgreSQL 의 구체화 뷰는 REFRESH MATERIALIZED VIEW 를 칠 때 쿼리 전체를 다시 돌리는 스냅숏이다. ClickHouse 는 다른 길을 골랐다. 원천이 수십억 행이어도 비용이 "새로 들어온 블록 크기" 에만 비례하도록, 뷰를 INSERT 경로에 걸린 트리거로 만들었다. 대가는 분명하다. 뷰는 자기가 본 블록만 안다. 그 한계를 모르고 쓰면 숫자가 조용히 틀린다.

어떻게 동작하나

INSERT 블록 하나가 원천 표 mv.orders 의 새 파트가 되는 동시에, 구체화 뷰가 그 블록만을 입력으로 SELECT 를 돌려 결과를 TO 로 지정한 대상 표 mv.daily_sales 의 새 파트로 쓴다. 뷰를 만들기 전에 이미 있던 파트는 화살표가 없어 대상 표에 반영되지 않는다. JOIN 이 있는 뷰에서는 맨 왼쪽 표의 INSERT 만 방아쇠이고 오른쪽 표는 읽기만 하므로, 오른쪽 표에 나중에 들어온 행은 이미 쓰인 결과를 고치지 않는다

CREATE MATERIALIZED VIEW mv.daily_sales_mv TO mv.daily_sales ASSELECT toDate(ts) AS day, shop, count() AS orders, sum(qty * price) AS revenueFROM mv.orders GROUP BY day, shop;

TO 는 결과를 보낼 표다. CREATE VIEW 문서는 동작을 한 문장으로 적는다 — 원천 표에 INSERT 할 때 삽입된 데이터의 일부가 이 SELECT 로 변환되어 뷰에 들어간다. 그리고 GROUP BY 가 있어도 삽입된 블록 하나 안에서만 집계되고 더 합쳐지지 않는다. 이 실습 파드에서 20만 행을 INSERT 한 번으로 넣었더니 대상 표에는 (상점 8 × 날짜 15) = 120행이 생겼다. 여러 번 넣으면 같은 (shop, day) 가 여러 줄이 된다 — 실측에서 키 240개에 행이 720줄이었다. 그래서 대상 표는 같은 키를 병합 때 합쳐 주는 엔진(SummingMergeTree·AggregatingMergeTree)으로 두고, 정렬 키를 뷰의 GROUP BY 와 맞추며, 조회에서도 반드시 sum() 으로 다시 더한다. 병합이 언제 일어날지는 정해져 있지 않기 때문이다.

뷰의 생애에서 놓치기 쉬운 세 가지:

| 상황 | 뷰가 하는 일 |
| --- | --- |
| 뷰를 만들기 전에 원천에 있던 행 | 아무것도 안 한다 — 대상 표는 0행에서 시작한다 |
| 원천의 ALTER UPDATE·DELETE, DROP PARTITION | 대상 표를 고치지 않는다 |
| JOIN 의 오른쪽 표에 행이 추가됨 | 뷰가 돌지 않는다 — 방아쇠는 맨 왼쪽 표뿐 |

첫째 칸을 메우는 일을 되채우기(backfill) 라 한다. 방법은 둘이다. POPULATE 를 붙여 만들거나, 뷰를 만든 뒤 같은 SELECT 로 INSERT INTO 대상 SELECT ... FROM 원천 WHERE (뷰가 못 본 구간) 을 직접 친다. 26.8 문서에 따르면 평범한 CREATE 의 POPULATE 는 이제 원천에 짧은 배타 잠금을 걸어 동시 INSERT 를 한 번씩만 넘긴다(설정 materialized_views_populate_atomically 기본 1 — 파드에서 확인). 그래도 함정은 남는다 — TO 표에 이미 행이 있으면 되채운 행이 덧붙고, 실패한 CREATE 를 다시 치면 이미 들어간 행이 또 들어가며, CREATE OR REPLACE 와 Replicated 데이터베이스에서는 옛 비원자 방식이거나 아예 막힌다. 그래서 현장은 대개 구간을 정해 직접 INSERT 한다. 핵심은 "뷰가 이미 본 구간을 또 넣지 않는 것" 이다.

더할 수 없는 값은 결과 대신 중간 상태를 저장한다. 날마다 센 고유 사용자 수를 더하면 여러 날 온 사람이 겹쳐 세어진다. uniqExactState(user_id) 로 상태를 AggregateFunction(uniqExact, UInt32) 열에 담고, 조회 때 uniqExactMerge 로 합치면 원천을 다시 센 것과 같아진다.

연쇄도 된다. 대상 표 역시 표이므로 거기에 INSERT 가 들어가면 그 표를 원천으로 삼은 뷰가 또 돈다. 원시 자료를 저장하지 않으려면 원천을 ENGINE = Null 로 두는데, Null 표는 아무것도 저장하지 않지만 뷰는 깨운다 — 입구로만 쓰는 표다.

JOIN 은 공식 문서가 따로 경고한다. 맨 왼쪽 표가 삽입된 블록으로 바뀌고 오른쪽 표는 통째로 읽힌다. 그래서 주문이 상품보다 먼저 들어오면 INNER JOIN 은 그 주문을 버리고, 상품이 나중에 들어와도 되살리지 않는다. 한 원천에 뷰가 여럿이면 기본(parallel_view_processing = 0)으로 뷰의 uuid 순서대로 하나씩 돈다.

주기적으로 쿼리 전체를 다시 돌리는 새로 고침 뷰(REFRESH EVERY 1 HOUR)도 있다. INSERT 트리거가 없고 JOIN·UNION 에 제약이 없는 대신, 결과가 마지막 새로 고침 시점만큼 늦다. 시각에 따라 결과가 달라지므로 이 실습에서는 다루지 않는다.

현장에서 만나는 모습

가장 흔한 사고는 "뷰를 배포했는데 대시보드의 지난달 숫자가 비었다" 이다. 뷰는 배포 순간부터의 INSERT 만 보므로 되채우기가 빠진 것이다. 급하게 되채우다 배포 이후 구간까지 넣어 최근 며칠이 두 배가 되는 것이 두 번째 사고다. 되채울 때는 경계 시각을 정하고 그 앞만 넣는다.

세 번째는 차원 표 조인이다. 주문에 상품 범주를 붙이는 뷰를 만들었는데 신상품 주문이 범주 집계에서 사라진다. 상품 마스터가 주문보다 늦게 동기화되는 날마다 조용히 빠진다. 문서가 권하는 길은 조인을 조회 시점으로 미루거나, 딕셔너리나 새로 고침 뷰를 쓰는 것이다.

네 번째는 대상 표를 SELECT orders FROM daily_sales WHERE ... 처럼 더하지 않고 읽는 코드다. 병합이 끝난 날에는 맞고, 막 INSERT 가 들어온 날에는 두 줄이 나와 틀린다 — 재현이 안 되는 버그가 된다.

다음 실습에서 할 것

mv.orders 에 1차분 20만 건을 넣고 SummingMergeTree 대상과 뷰를 만든다. 2차분을 넣어 원천과 대상의 차이를 기록하고, 1차분만 되채워 두 표를 맞춘다. 대상 표를 sum 으로 읽는 쿼리를 쓰고, AggregatingMergeTree 에 고유 구매자 상태를 담는다. Null 표에서 paid 주문만 흘려 연쇄를 확인하고, 마지막으로 상품 표가 늦게 채워질 때 JOIN 뷰가 잃는 주문 수를 센다.