배경
SELECT 쿼리를 더 빠르게 실행할 수 있습니다.
Postgres와 같은 트랜잭션 데이터베이스와 달리 ClickHouse materialized view는 테이블에 데이터 블록이 삽입될 때 해당 블록에 대해 쿼리를 실행하는 트리거일 뿐입니다. 이 쿼리의 결과는 두 번째 “대상” 테이블에 삽입됩니다. 이후 더 많은 행이 삽입되면 결과가 다시 대상 테이블로 전달되고, 그곳에서 중간 결과가 갱신되고 머지됩니다. 이렇게 머지된 결과는 원본 데이터 전체에 대해 쿼리를 실행한 결과와 같습니다.
Materialized Views의 주된 목적은 대상 테이블에 삽입되는 결과가 행에 대한 집계, 필터링 또는 변환 결과를 나타내도록 하는 데 있습니다. 이러한 결과는 대개 원본 데이터보다 더 작은 형태로 표현됩니다(집계의 경우 부분 스케치). 또한 대상 테이블에서 결과를 읽는 쿼리도 단순해지므로, 동일한 계산을 원본 데이터에 직접 수행할 때보다 쿼리 시간이 더 빨라집니다. 즉 계산 비용(따라서 쿼리 지연 시간)을 쿼리 시점에서 삽입 시점으로 옮길 수 있습니다.
ClickHouse의 materialized view는 기반 테이블로 데이터가 유입되면 실시간으로 갱신되며, 지속적으로 업데이트되는 인덱스와 비슷하게 동작합니다. 이는 Materialized Views가 일반적으로 갱신이 필요한 정적인 쿼리 스냅샷인 다른 데이터베이스와는 대조적입니다(ClickHouse의 갱신 가능 구체화 뷰와 유사합니다).
예시
toStartOfDay 함수 덕분에 ClickHouse에서 비교적 간단하게 작성할 수 있는 쿼리입니다:
votes에 삽입된 데이터에 대해 위의 SELECT를 수행하고, 그 결과를 up_down_votes_per_day로 전송합니다.
TO 절은 결과가 전송될 대상, 즉 up_down_votes_per_day를 지정하는 핵심 부분입니다.
앞서 수행한 삽입으로 Votes 테이블을 다시 채울 수 있습니다.
up_down_votes_per_day의 크기를 확인할 수 있습니다. 하루에 1개의 행이 있어야 합니다:
votes의 2억 3,800만 개에서 5000개로 효과적으로 줄였습니다. 그러나 여기서 핵심은 votes 테이블에 새로운 투표가 삽입되면 해당 날짜의 새 값이 up_down_votes_per_day로 전달되고, 그곳에서 백그라운드에서 비동기적으로 자동 머지되어 날짜별로 행이 1개만 유지된다는 점입니다. 따라서 up_down_votes_per_day는 항상 작고 최신 상태로 유지됩니다.
행 머지는 비동기적으로 수행되므로 사용자가 쿼리할 때는 하루에 투표가 2개 이상 있을 수 있습니다. 아직 머지되지 않은 행이 쿼리 시점에 머지되도록 하려면 두 가지 방법이 있습니다:
- 테이블 이름에
FINAL수정자를 사용합니다. 위의 count 쿼리에서 이 방법을 사용했습니다. - 최종 테이블에서 사용한 정렬 키, 즉
CreationDate를 기준으로 집계하고 메트릭을 합산합니다. 일반적으로 이 방법이 더 효율적이고 유연합니다(테이블을 다른 용도로도 사용할 수 있음). 반면 앞의 방법은 일부 쿼리에서 더 단순할 수 있습니다. 아래에서는 두 방법을 모두 보여줍니다:
좀 더 복잡한 예시
Score의 99.9백분위수와 CommentCount의 평균을 계산할 수 있습니다. 이를 계산하는 쿼리는 다음과 같을 수 있습니다:
posts 테이블에 삽입될 때 위 쿼리를 실행하는 materialized view를 생성할 수 있습니다.
예시를 위해, 그리고 S3에서 posts 데이터를 로드하지 않기 위해 posts와 동일한 스키마를 가진 복제 테이블 posts_null을 생성하겠습니다. 하지만 이 테이블은 데이터를 전혀 저장하지 않으며, 행이 삽입될 때 materialized view에서만 사용됩니다. 데이터가 저장되지 않도록 하려면 Null 테이블 엔진 유형을 사용할 수 있습니다.
/dev/null과 비슷하다고 생각하면 됩니다. materialized view는 posts_null 테이블이 삽입 시점에 행을 받으면 요약 통계를 계산해 저장합니다. 즉, 이것은 단지 트리거 역할만 합니다. 하지만 원시 데이터는 저장되지 않습니다. 이 경우에는 원본 posts도 여전히 저장하는 것이 좋겠지만, 이 접근 방식은 원시 데이터의 저장 오버헤드를 피하면서 집계를 계산하는 데 사용할 수 있습니다.
따라서 materialized view는 다음과 같습니다:
State를 붙인 점에 유의하십시오. 이렇게 하면 최종 결과 대신 함수의 집계 상태가 반환됩니다. 여기에는 이 부분 상태를 다른 상태와 머지할 수 있도록 하는 추가 정보가 포함됩니다. 예를 들어 평균의 경우 해당 컬럼의 개수와 합계가 포함됩니다.
부분 집계 상태는 올바른 결과를 계산하는 데 필요합니다. 예를 들어 평균을 계산할 때 하위 범위들의 평균을 단순히 다시 평균내면 잘못된 결과가 나옵니다.이제 이러한 부분 집계 상태를 저장하는 이 뷰
post_stats_per_day의 대상 테이블을 생성합니다:
SummingMergeTree는 개수를 저장하는 데 충분했지만, 다른 함수에는 더 고급 엔진 유형인 AggregatingMergeTree가 필요합니다.
ClickHouse가 집계 상태(aggregate states)를 저장한다는 점을 인식하도록, Score_quantiles와 AvgCommentCount를 AggregateFunction 유형으로 정의하고, 부분 상태의 함수 원본과 원본 컬럼의 유형을 지정합니다. SummingMergeTree와 마찬가지로 동일한 ORDER BY 키 값을 가진 행은 머지됩니다(위 예시에서는 Day).
materialized view를 통해 post_stats_per_day를 채우려면, posts의 모든 행을 posts_null에 삽입하면 됩니다:
프로덕션 환경에서는 일반적으로 materialized view를최종 쿼리에서는 함수에posts테이블에 연결합니다. 여기서는 null 테이블을 보여주기 위해posts_null을 사용했습니다.
Merge 접미사를 사용해야 합니다(컬럼이 부분 집계 상태를 저장하기 때문입니다):
FINAL 대신 GROUP BY를 사용합니다.
기타 활용 사례
필터링 및 변환
posts_null 테이블로 먼저 삽입한 다음, posts 테이블에 삽입하기 전에 SELECT 쿼리로 행을 필터링할 수 있습니다. 예를 들어 posts 테이블의 Tags 컬럼을 변환하려는 경우를 가정해 보겠습니다. 이 컬럼에는 파이프(|)로 구분된 태그 이름 목록이 들어 있습니다. 이를 배열로 변환하면 개별 태그 값을 기준으로 더 쉽게 집계할 수 있습니다.
이 변환은이 변환을 위한 materialized view는 아래와 같습니다.INSERT INTO SELECT를 실행할 때 수행할 수도 있습니다. materialized view를 사용하면 이 로직을 ClickHouse DDL로 캡슐화하고INSERT는 단순하게 유지하면서, 새로 들어오는 모든 행에 변환을 적용할 수 있습니다.
룩업 테이블
comments 테이블을 살펴보겠습니다:
PostId로 필터링하는 쿼리에 맞게 테이블(table)을 최적화합니다.
특정 UserId를 기준으로 필터링하고 해당 사용자의 평균 Score를 계산하려는 경우를 가정해 보겠습니다:
UserId에 대한 순서 지정 키 값 PostId를 조회할 수 있습니다. 그런 다음 이 값을 사용해 효율적으로 조회할 수 있습니다.
이 예시에서 materialized view는 매우 단순하게 구성할 수 있으며, 삽입 시 comments에서 PostId와 UserId만 선택합니다. 이렇게 생성된 결과는 다시 UserId를 기준으로 정렬된 comments_posts_users 테이블로 전송됩니다. 아래에서는 Comments 테이블의 null 버전을 생성한 뒤, 이를 사용해 뷰와 comments_posts_users 테이블을 채웁니다:
체이닝 / 캐스케이딩 materialized views
materialized view와 JOIN
갱신 가능 구체화 뷰다음 내용은 증분형 materialized view에만 적용됩니다. 갱신 가능 구체화 뷰는 대상 데이터셋 전체에 대해 주기적으로 쿼리를 실행하며, JOIN을 완전히 지원합니다. 결과 최신성이 다소 낮아져도 괜찮다면 복잡한 JOIN에는 이 방식을 사용하는 것을 고려하십시오.
JOIN 작업을 완전히 지원하지만, 한 가지 중요한 제약이 있습니다: materialized view는 원본 테이블(쿼리에서 가장 왼쪽에 있는 테이블)에 대한 삽입에서만 트리거됩니다. JOIN의 오른쪽 테이블은 데이터가 변경되더라도 업데이트를 트리거하지 않습니다. 이 동작은 데이터가 삽입 시점에 집계되거나 변환되는 증분형 materialized view를 만들 때 특히 중요합니다.
증분형 materialized view가 JOIN을 사용해 정의되면, SELECT 쿼리에서 가장 왼쪽에 있는 테이블이 원본 테이블 역할을 합니다. 이 테이블에 새 행이 삽입되면 ClickHouse는 새로 삽입된 행에 대해서만 materialized view 쿼리를 실행합니다. 이 실행 중 JOIN의 오른쪽 테이블은 전체를 읽지만, 해당 테이블의 변경만으로는 뷰가 트리거되지 않습니다.
이 동작으로 인해 Materialized View의 JOIN은 정적인 차원 데이터에 대한 스냅샷 조인과 비슷합니다.
이 방식은 참조 테이블이나 차원 테이블로 데이터를 보강할 때 효과적입니다. 하지만 오른쪽 테이블(예: 사용자 메타데이터)이 업데이트되더라도 materialized view는 소급해 갱신되지 않습니다. 업데이트된 데이터를 보려면 원본 테이블에 새 데이터가 삽입되어야 합니다.
예시
users 테이블의 사용자 표시 이름을 포함해 사용자별 일일 배지 수를 계산하기 위해 materialized view를 사용하겠습니다.
다시 말씀드리면, 테이블 스키마는 다음과 같습니다:
users 테이블에 이미 데이터가 채워져 있다고 가정하겠습니다:
그룹화 및 정렬 정합성materialized view의
GROUP BY 절에는 SummingMergeTree 대상 테이블의 ORDER BY와 일치하도록 DisplayName, UserId, Day가 포함되어야 합니다. 이렇게 해야 행이 올바르게 집계되고 머지됩니다. 이들 중 하나라도 빠지면 잘못된 결과가 발생하거나 머지가 비효율적으로 수행될 수 있습니다.badges를 채우면 뷰가 트리거되어 daily_badges_by_user 테이블이 채워집니다.
materialized view에서 JOIN 사용 모범 사례
-
가장 왼쪽 테이블을 트리거로 사용하십시오. materialized view는
SELECT문의 왼쪽에 있는 테이블만 트리거합니다. 오른쪽 테이블의 변경은 업데이트를 트리거하지 않습니다. - 조인할 데이터를 미리 삽입하십시오. 원본 테이블에 행을 삽입하기 전에 조인 대상 테이블에 데이터가 존재하는지 확인하십시오. JOIN은 삽입 시점에 평가되므로, 데이터가 없으면 일치하지 않는 행이나 NULL이 발생합니다.
- JOIN으로 가져오는 컬럼을 제한하십시오. 메모리 사용량을 최소화하고 삽입 시점의 지연 시간을 줄이려면 조인된 테이블에서 필요한 컬럼만 선택하십시오(아래 참조).
- 삽입 시점 성능을 평가하십시오. JOIN은 삽입 비용을 증가시키며, 특히 오른쪽 테이블이 큰 경우 그 영향이 더 큽니다. 실제 운영 환경을 대표하는 데이터로 삽입 속도를 벤치마크하십시오.
- 단순 lookup에는 Dictionaries를 우선 사용하십시오. 비용이 큰 JOIN 연산을 피하려면 키-값 lookup(예: 사용자 ID에서 이름으로)에 Dictionaries를 사용하십시오.
-
머지 효율을 위해
GROUP BY와ORDER BY를 일치시키십시오.SummingMergeTree또는AggregatingMergeTree를 사용할 때는 효율적인 행 머지가 가능하도록 대상 테이블의GROUP BY가ORDER BY절과 일치해야 합니다. - 명시적인 컬럼 alias를 사용하십시오. 테이블 간에 같은 컬럼 이름이 겹치는 경우 alias를 사용해 모호성을 방지하고 대상 테이블에서 올바른 결과를 보장하십시오.
- 삽입량과 빈도를 고려하십시오. JOIN은 중간 수준의 삽입 workload에서는 잘 작동합니다. 높은 처리량의 수집이 필요한 경우에는 스테이징 테이블, 사전 조인, 또는 Dictionaries 및 갱신 가능 구체화 뷰와 같은 다른 접근 방식을 고려하십시오.
필터와 조인에서 원본 테이블 사용
예시 시나리오
설명
t0를 참조하는 방식에 약간 차이가 있는 두 개의 Materialized View mvw1과 mvw2가 있습니다.
mvw1에서는 테이블 t0를 JOIN 오른쪽의 (SELECT * FROM t0) 서브쿼리 안에서 직접 참조합니다. 데이터가 t0에 삽입되면 materialized view의 쿼리는 t0가 삽입된 데이터 블록으로 대체된 상태로 실행됩니다. 즉, JOIN 연산은 테이블 전체가 아니라 새로 삽입된 행에 대해서만 수행됩니다.
두 번째 경우처럼 vt0를 조인하면, 뷰는 t0의 모든 데이터를 읽습니다. 따라서 JOIN 연산은 새로 삽입된 블록뿐 아니라 t0의 모든 행을 기준으로 수행됩니다.
핵심적인 차이는 ClickHouse가 materialized view의 쿼리에서 원본 테이블을 처리하는 방식에 있습니다. materialized view가 삽입에 의해 트리거되면 원본 테이블(이 경우 t0)은 삽입된 데이터 블록으로 대체됩니다. 이 동작은 쿼리를 최적화하는 데 활용할 수 있지만, 예상하지 못한 결과를 방지하려면 주의해서 검토해야 합니다.
사용 사례와 주의사항
IN (SELECT id FROM t0) 서브쿼리로 만든 집합에는 새로 삽입된 행만 있으므로, 이를 기준으로 t1을 필터링하는 데 도움이 될 수 있습니다.
Stack Overflow 예시
users 테이블의 표시 이름도 포함하는 앞서 살펴본 materialized view 예시를 살펴보겠습니다.
badges 테이블의 삽입 지연 시간에 큰 영향을 주었습니다. 예:
users 테이블에 필터를 추가하겠습니다:
2936484에 대해 users 테이블에서 1개의 행만 조회됩니다. 이 조회 역시 테이블 정렬 키인 Id를 통해 최적화됩니다.
materialized view와 union
UNION ALL 쿼리는 여러 원본 테이블의 데이터를 단일 결과 집합으로 결합할 때 일반적으로 사용됩니다.
UNION ALL은 증분형 materialized view에서 직접 지원되지 않지만, 각 SELECT 분기별로 별도의 materialized view를 생성하고 그 결과를 공유 대상 테이블에 기록하면 동일한 효과를 얻을 수 있습니다.
이 예시에서는 Stack Overflow 데이터셋을 사용합니다. 아래의 badges 및 comments 테이블은 사용자가 획득한 배지와 게시물에 작성한 댓글을 나타냅니다:
INSERT INTO 명령으로 데이터를 삽입할 수 있습니다:
badges 또는 comments에 새 행이 삽입될 때마다 이 테이블이 업데이트되도록 하려면, 가장 단순한 방법으로는 앞서의 union 쿼리를 사용해 materialized view를 생성하려고 할 수 있습니다:
comments 테이블에 삽입이 일어날 때만 실행됩니다. 예시:
badges 테이블에 삽입해도 뷰가 트리거되지 않으므로 user_activity는 업데이트를 받지 못합니다:
comments 테이블에 삽입하면 다음과 같습니다:
badges 테이블에 삽입된 내용은 user_activity 테이블에도 반영됩니다:
병렬 처리와 순차 처리
parallel_view_processing에 따라 달라집니다.
기본적으로 이 설정값은 0(false)이며, 이 경우 Materialized View는 uuid 순서에 따라 순차적으로 실행됩니다.
예를 들어, 다음과 같은 source 테이블과 3개의 Materialized View가 있고, 각각이 행을 target 테이블로 전송한다고 가정해 보겠습니다:
target 테이블에 행을 삽입하기 전에 각각 1초씩 대기한다는 점에 유의하십시오.
source 테이블에 행 하나를 삽입하는 데는 약 3초가 걸리며, 각 뷰는 순차적으로 실행됩니다:
SELECT로 확인할 수 있습니다:
uuid와 동일합니다:
parallel_view_processing=1이 활성화된 상태에서 행을 삽입하면 어떻게 되는지 살펴보겠습니다. 이 설정이 활성화되면 뷰가 병렬로 실행되므로, 행이 대상 테이블에 도착하는 순서는 보장되지 않습니다:
병렬 처리를 사용해야 하는 경우
parallel_view_processing=1을 활성화하면 삽입 처리량을 크게 높일 수 있습니다. 특히 하나의 테이블에 여러 materialized view가 연결된 경우 효과가 큽니다. 하지만 다음과 같은 트레이드오프를 이해하는 것이 중요합니다.
- 삽입 부하 증가: 모든 구체화된 뷰가 동시에 실행되므로 CPU 및 메모리 사용량이 증가합니다. 각 뷰가 무거운 계산이나 조인을 수행하면 시스템에 과부하가 걸릴 수 있습니다.
- 엄격한 실행 순서 필요: 드문 워크플로에서는 뷰 실행 순서가 중요할 수 있습니다(예: 연쇄된 종속성). 이런 경우 병렬 실행으로 인해 상태 불일치나 경쟁 상태(race condition)가 발생할 수 있습니다. 이를 피하도록 설계할 수는 있지만, 이러한 구성은 취약하며 향후 버전에서 깨질 수 있습니다.
과거 기본값과 안정성순차 실행은 오류 처리의 복잡성 때문에 오랫동안 기본값이었습니다. 과거에는 하나의 materialized view에서 실패가 발생하면 다른 뷰가 실행되지 못할 수 있었습니다. 최신 버전에서는 block별로 실패를 격리하도록 개선되었지만, 순차 실행은 여전히 실패 동작을 더 명확하게 파악할 수 있게 해줍니다.
parallel_view_processing=1을 활성화하십시오.
- 서로 독립적인 여러 구체화된 뷰가 있는 경우
- 삽입 성능을 최대화하려는 경우
- 시스템이 동시 뷰 실행을 감당할 수 있는지 파악하고 있는 경우
- 구체화된 뷰가 서로 종속되어 있는 경우
- 예측 가능하고 순서가 보장된 실행이 필요한 경우
- 삽입 동작을 디버깅하거나 감사할 때 결정론적으로 재현되기를 원하는 경우
materialized view와 공통 테이블 표현식(CTE)
**공통 테이블 표현식(CTE)는 구체화되지 않습니다ClickHouse는 CTE를 구체화하지 않습니다. 대신 CTE 정의를 쿼리에 직접 대입하므로, 동일한 표현식이 여러 번 평가될 수 있습니다(CTE를 두 번 이상 사용하는 경우).
- CTE가 원본 테이블(즉, materialized view가 attached 상태인 테이블)이 아닌 다른 테이블을 참조하고
JOIN또는IN절에서 사용되면, 트리거가 아니라 서브쿼리 또는 조인처럼 동작합니다. - materialized view는 여전히 주 원본 테이블에 대한 삽입에서만 트리거되지만, CTE는 삽입이 일어날 때마다 다시 실행되므로, 특히 참조하는 테이블이 큰 경우 불필요한 오버헤드가 발생할 수 있습니다.