- Published on
ClickHouse lazy materialization — LIMIT 10짜리 트릭이 FINAL과 JOIN까지 자란 과정
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — "같은 SQL이 1,576배 빨라졌다"를 읽는 법
- lazy materialization이 정확히 무엇인가
- 벤더 벤치마크의 조건 — 3분의 정체는 디스크였다
- 왜 처음엔 LIMIT 10까지만이었나 — 게이트가 10 → 100 → 10,000이 된 역사
- 2026년: 같은 아이디어가 새 쿼리 모양으로
- 어디서 안 통하고, 무엇을 조심해야 하나
- 마치며
- 참고 자료
들어가며 — "같은 SQL이 1,576배 빨라졌다"를 읽는 법
2025년 4월, ClickHouse 공식 블로그에 이런 문장이 실렸습니다. 쿼리 한 줄 바꾸지 않았는데 219초가 0.139초가 됐다 — 1,576배. 이런 숫자를 보면 두 가지 반응이 가능합니다. "와"라고 하고 넘어가거나, 어떤 조건에서 나온 숫자인지 뜯어보거나. 이 글은 후자입니다.
숫자의 주인공은 lazy materialization(지연 물질화)입니다. 25.4에서 도입될 당시에는 LIMIT 10 이하의 쿼리에만 적용되는, 게이트가 아주 좁은 최적화였습니다. 그런데 2025년 말부터 2026년 상반기에 걸쳐 이 게이트가 눈에 띄게 넓어졌습니다 — 25.12에서 실행 모델이 갈아엎어지며 LIMIT 10,000까지, 26.2에서 UNION ALL 전 브랜치로, 26.4에서 ReplacingMergeTree의 FINAL로, 그리고 26.6(2026년 6월 25일 릴리스)에서는 JOIN 뒤의 selective LIMIT까지. 하나의 트릭이 쿼리 플래너 전반의 원칙으로 자라는 과정이고, 그 과정에는 리버트된 PR과 버그 픽스의 흔적도 그대로 남아 있습니다.
ClickHouse의 MergeTree 구조나 벡터화 실행이 처음이라면 ClickHouse 내부 구조 딥다이브와 실시간 OLAP 최적화 편을 먼저 보고 오셔도 좋습니다. 이 글은 그 위에서 2026년 릴리스들의 변화만 다룹니다.
lazy materialization이 정확히 무엇인가
공식 문서는 이 기능을 ClickHouse I/O 최적화 스택의 마지막 층으로 설명합니다. 스택을 아래에서부터 쌓으면 이렇습니다.
- 컬럼 지향 저장. 쿼리에 필요 없는 컬럼은 아예 읽지 않는다.
- 스파스 기본 키 인덱스, 스키핑 인덱스, 프로젝션. 인덱스된 컬럼의 필터로 그래뉼(기본 8,192행 블록)을 통째로 걸러낸다.
- PREWHERE. 인덱스에 없는 컬럼의 필터도 싼 컬럼부터 순차적으로 평가해서, 뒤 컬럼은 살아남은 행 것만 읽는다.
- 쿼리 조건 캐시. 반복 쿼리에서 지난번에 매치 안 된 그래뉼을 기억해 건너뛴다.
여기까지의 공통 가정이 하나 있습니다 — 필터를 통과한 행이라면, SELECT에 있는 컬럼은 정렬이든 집계든 하기 전에 일단 다 읽어야 한다는 것. lazy materialization은 이 가정을 깹니다. 정렬에 필요한 컬럼만 먼저 읽고, 정렬과 LIMIT이 끝나 최종 행이 확정된 다음에야 나머지 SELECT 컬럼을 그 행들 것만 가져옵니다. ORDER BY 작은컬럼 LIMIT 3에 50GiB짜리 텍스트 컬럼이 SELECT로 딸려 오는 Top-N 쿼리라면, 그 텍스트 컬럼은 딱 3행어치만 읽으면 됩니다.
적용 여부는 실행 계획에서 바로 확인할 수 있습니다.
EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Lazily read columns: review_headline, review_body, product_title
Limit
Sorting
ReadFromMergeTree
Lazily read columns 줄이 보이면 적용된 것입니다. 이런 행 단위의 지연 읽기가 가능한 것 자체가 컬럼 지향 저장 덕이라는 점도 문서가 명시합니다 — 행 지향 DB에서는 어차피 행 전체가 같이 읽히니 성립하지 않는 이야기입니다.
벤더 벤치마크의 조건 — 3분의 정체는 디스크였다
이제 그 1,576배가 나온 조건을 봅시다. 아래 숫자는 전부 ClickHouse가 자사 블로그에 공개한 벤더 자체 측정이고, 측정 환경도 블로그에 명시돼 있습니다: AWS m6i.8xlarge(32 vCPU, 128GiB RAM), 1TiB gp3 SSD를 기본 설정 그대로 — 3,000 IOPS, 최대 처리량 125MiB/s — 붙였고, 매 실행 전에 OS 페이지 캐시를 비웠습니다(cold cache). 데이터는 Amazon 리뷰 150.96M 행, 비압축 70.47GiB, ZSTD(1) 압축 후 30.05GiB입니다.
필터 있는 쿼리(날짜·카테고리·구매인증·별점 필터 + ORDER BY helpful_votes DESC LIMIT 3)에 최적화를 한 층씩 쌓은 사다리가 이렇습니다.
| 단계 | 실행 시간 | 처리 데이터 | 피크 메모리 |
|---|---|---|---|
| 풀스캔(인덱스·PREWHERE·lazy 전부 끔) | 219.508초 | 72.13GB | 953.25MiB |
| + 기본 키 인덱스 | 95.865초 | 27.67GB | 629.00MiB |
| + PREWHERE | 61.148초 | 16.28GB | 583.30MiB |
| + lazy materialization | 0.181초 | 807.55MB | 3.88MiB |
필터를 다 뗀 순수 Top-N 쿼리에서는 219.071초가 0.139초가 됩니다. 처리 데이터 71.38GB → 1.81GB, 피크 메모리 1.11GiB → 3.80MiB. 이것이 1,576배의 출처입니다.
이 숫자를 해석할 때 필요한 산수가 하나 있습니다. 필터 없는 쿼리가 읽는 네 컬럼의 압축 크기를 블로그에 실린 표에서 합치면 대략 26.8GiB입니다(review_body 21.60GiB + product_title 3.53GiB + review_headline 1.58GiB + helpful_votes 72.11MiB). 이걸 디스크 처리량 상한 125MiB/s로 나누면 약 219초 — 측정치 219.071초와 사실상 같습니다. 즉 베이스라인 3분 39초는 ClickHouse가 느린 시간이 아니라, 캡 걸린 디스크에서 27GiB를 읽는 물리 시간입니다. 블로그 스스로도 디스크 스펙 옆에 달팽이 이모지를 붙여 뒀습니다. lazy materialization의 이득은 "읽지 않은 바이트 ÷ 디스크 속도"이므로, 이 세팅은 그 이득이 가장 크게 보이는 세팅이기도 합니다.
공개되지 않은 것도 명확히 해 둡니다. hot cache(페이지 캐시에 데이터가 이미 있는 상태)에서의 비교 수치는 이 블로그에 없습니다 — 전 구간 cold cache입니다. 더 빠른 NVMe나 로컬 SSD에서의 배수도 없습니다. 그리고 이 쿼리는 SELECT 대상의 압축 기준 약 99.7%가 지연 대상 컬럼(위 세 텍스트 컬럼)인, 이 최적화에 가장 유리한 모양입니다. 블로그도 이 점은 정직하게 적었습니다 — 데이터셋과 쿼리 모양에 따라서는 인덱스나 PREWHERE 쪽이 더 큰 이득을 낼 수 있다고. 여러분의 쿼리가 좁은 컬럼 몇 개만 SELECT한다면 지연시킬 바이트 자체가 없다는 뜻이고, 배수는 전혀 다르게 나옵니다.
왜 처음엔 LIMIT 10까지만이었나 — 게이트가 10 → 100 → 10,000이 된 역사
도입 시점(25.4, 2025년 4월 22일)의 게이트는 놀랄 만큼 보수적이었습니다. query_plan_max_limit_for_lazy_materialization 기본값 10 — LIMIT이 10을 넘으면 최적화가 아예 안 걸립니다. 이유는 25.12 릴리스 노트가 사후에 잘 설명해 줍니다. 초기 구현은 Top-N 행이 확정된 뒤 나머지 컬럼을 행 단위로(row by row) 가져왔습니다. LIMIT이 크면 이 lookup이 잘게 흩어진 랜덤 읽기가 되어, 지연 읽기의 이득을 오버헤드가 잡아먹습니다.
벤더 자신의 데모가 이걸 정량화합니다. 웹 분석 데이터셋(hits, 100M 행, 같은 m6i.8xlarge + gp3)에서 SELECT * FROM hits ORDER BY EventTime LIMIT 100000을 게이트를 끈 채(= 0) 돌리면 — 25.11의 행 단위 방식은 non-order 컬럼 104개 × 100,000행, 약 1천만 번의 개별 lookup이 됩니다. 3회 실행에서 34.2초에서 38.7초. 같은 쿼리에서 lazy를 완전히 꺼 버리면(eager로 전부 선행 읽기) 7.0초에서 7.9초입니다. 즉 이 지점에서는 옛 lazy 방식이 eager보다 5배 가까이 느렸습니다 — 대신 처리 데이터는 1.20GB 대 56.83GB, 피크 메모리는 약 1GiB 대 74–78GiB로 압도적으로 적었지만요. 시간만 보면 게이트가 왜 10이었는지가 그대로 보입니다.
25.12(2025년 12월 18일)에서 이 실행 모델이 갈아엎어졌습니다(PR #90309, 릴리스 노트 표현으로는 "join-style execution model"). 행 단위 lookup 대신, Top-N 행의 식별자 집합을 만들어 베이스 테이블에 조인하듯 일괄 조회합니다 — 일반 조인과 같은 벡터화·병렬 실행의 이득을 그대로 받습니다. 같은 데모 쿼리가 0.513–0.524초로 떨어졌고(벤더 표기로는 옛 방식 대비 약 75배, eager 대비 약 14배 — 다만 이 데모는 3회 반복 실행의 최속치 기준이고 캐시 상태는 명시돼 있지 않습니다), 그 결과로 게이트 기본값이 10,000까지 올라갔습니다. 설정 히스토리 파일에는 이 궤적이 그대로 남아 있습니다 — 25.4에서 10으로 도입, 25.11에서 100("More optimal"), 25.12에서 10,000("Increase the limit after performance improvement").
10,000에서 멈춘 이유도 릴리스 노트에 있습니다. 지연 물질화된 컬럼은 결국 Top-N 행 순서에 맞춰 정렬이 필요한데, LIMIT이 아주 커지면 이 비용이 다시 눈에 띄기 시작한다는 것. 게이트는 트레이드오프의 자백입니다 — 이 최적화는 공짜가 아니라, N이 작을 때 이기는 내기입니다.
한 가지 더: 25.8부터 lazy materialization은 새 analyzer가 켜진 경우에만 적용됩니다(#83791). analyzer가 기본값이 된 지 오래지만, 구버전 호환 때문에 enable_analyzer = 0으로 돌리는 환경이라면 이 글의 어떤 것도 적용되지 않습니다.
2026년: 같은 아이디어가 새 쿼리 모양으로
여기까지가 전사(前史)이고, 2026년 릴리스들은 "정렬·LIMIT 뒤로 읽기를 미룬다"는 같은 아이디어를 새로운 쿼리 모양에 이식하는 작업이었습니다. 전부 changelog와 머지된 PR 원문에서 확인한 내용입니다.
26.2 (2026년 2월 26일) — UNION ALL 전 브랜치. 그동안 lazy materialization은 UNION ALL의 첫 브랜치에만 적용됐습니다. #96832부터 모든 브랜치에 적용됩니다 — 서로 다른 MergeTree 테이블에서 정렬·LIMIT된 읽기를 합치는 쿼리라면 브랜치마다 지연 읽기의 I/O 절감을 받습니다. 참고로 이 항목은 공개 changelog에는 실리지 않아서, 26.2에 들어갔다는 사실은 PR에 남은 머지 기록(26.2.1.706) 기준입니다. 기능 추가라기보다 구현의 구멍을 메운 쪽에 가깝지만, 시계열을 테이블 여러 개로 쪼개 두고 UNION ALL로 묶는 흔한 패턴에는 실질적인 차이입니다.
26.4 (2026년 4월 30일) — ReplacingMergeTree의 FINAL. #101647이 FINAL 읽기에 지연 물질화를 이식했습니다. 메커니즘은 설정 설명에 요약돼 있습니다 — selective한 조건일 때 기본 키 집합을 만들어 그것으로 인덱스 분석을 다시 하는 방식. 다만 주의할 점: changelog에는 성능 개선으로 실렸지만, 마스터 브랜치의 Settings.cpp 기준으로 query_plan_optimize_lazy_final은 아직 기본값 false, 즉 옵트인입니다. 게다가 가드레일 설정이 세 개나 같이 들어왔습니다 — PK 집합이 천만 행 또는 256MB를 넘으면 일반 FINAL로 폴백(max_rows_for_lazy_final, max_bytes_for_lazy_final), 인덱스 분석이 마크의 절반 이상을 걸러내지 못해도 폴백(min_filtered_ratio_for_lazy_final, 기본 0.5). 가드레일의 개수는 그 자체로 신뢰 단계의 표시입니다. 실제로 26.5 changelog에는 lazy FINAL 경로의 픽스가 두 건 실려 있습니다 — 파이프라인 확장 중의 로지컬 에러(#103230)와, PREWHERE 조합에서 릴리스 빌드가 segfault로 죽는 서버 abort(#104177). FINAL이 병목인 ReplacingMergeTree 워크로드라면 켜서 A/B해 볼 가치가 있지만, 기본값이 꺼져 있는 데는 이유가 있다고 읽는 게 맞습니다.
26.6 (2026년 6월 25일) — JOIN 뒤의 selective LIMIT. 이번 릴리스의 JOIN 최적화 묶음 중 하나로, JOIN 다음에 selective한 LIMIT·TopN·또 다른 JOIN이 올 때 왼쪽 테이블의 payload 컬럼(조인 키가 아닌 컬럼)을 바로 물질화하지 않고 selector/replication 인덱스로 들고 갑니다(#106566). 행이 줄어든 뒤에야 물질화하니, 조인 결과 대부분이 LIMIT에서 버려지는 쿼리에서 복사 비용이 줄어듭니다. 게이트는 두 개입니다 — 왼쪽 payload 컬럼이 3개 이상(query_plan_min_columns_for_join_lazy_indexing, 0이면 비활성), LIMIT 1,000 이하(query_plan_max_limit_for_join_lazy_indexing, 0이면 무제한). 본체의 10,000보다 훨씬 좁은 게이트로 시작하는 것도, 도입기의 패턴 그대로입니다.
26.6의 이 기능에는 각주가 하나 있습니다. 원래 #98883으로 2026년 5월 26일에 머지됐는데, 사흘 뒤 CI가 Bad cast from type DB::ColumnReplicated to DB::ColumnString 로지컬 에러를 잡아냈고(#106095), 리버트됐다가 정렬 변환 단계에서 컬럼을 물질화하는 수정과 함께 6월 19일에 재도입됐습니다. 전 과정이 26.5와 26.6 릴리스 사이에서 벌어져 깨진 버전이 스테이블에 실린 적은 없지만, "지연된 컬럼 표현이 파이프라인 구석구석에서 진짜 컬럼인 척해야 한다"는 이 최적화 계열의 구조적 난점을 잘 보여 주는 에피소드입니다.
같은 시기에 "lazy"라는 단어 자체가 코드베이스의 다른 곳으로 번지는 것도 흥미롭습니다. 26.3에는 JSON 컬럼의 타입 힌트 변경을 데이터 재작성 없이 메타데이터 연산으로 처리하는 실험 기능이(allow_experimental_json_lazy_type_hints, #97412), 26.6에는 텍스트 인덱스의 포스팅 리스트를 Roaring Bitmap으로 전부 풀지 않고 커서 기반으로 필요한 만큼만 디코드하는 실험 기능이(allow_experimental_text_index_lazy_apply, #100035) 들어왔습니다. 둘 다 아직 experimental이지만, 방향은 같습니다 — 최후의 순간까지 일을 미루면, 그 일의 상당수는 영영 할 필요가 없어진다.
어디서 안 통하고, 무엇을 조심해야 하나
정리하면, 2026년 중반 기준으로 이 최적화가 걸리려면 쿼리가 다음 조건에 있어야 합니다.
- Top-N 모양 —
ORDER BY ... LIMIT N계열이어야 하고, N이 10,000 이하(기본값 기준)여야 합니다. JOIN 변형은 N 1,000 이하 + payload 컬럼 3개 이상. FINAL 변형은 직접 켜야 합니다. - 새 analyzer가 켜져 있어야 합니다(기본값).
arrayJoin이 플랜에 끼면 적용되지 않습니다 — 26.5에서 의도적으로 제외됐습니다(#101644). LIMIT이 지켜지지 않을 수 있는 correctness 문제였고, 성능이 아니라 정확성 때문에 빠진 케이스입니다.
이득이 구조적으로 없는 경우도 그대로 적어 둡니다. SELECT 컬럼이 전부 좁으면(정렬 컬럼과 크기가 비슷하면) 미룰 바이트가 없습니다. 결과를 전부 소비하는 쿼리(집계로 끝나거나 LIMIT이 없는 export성 쿼리)는 애초에 대상이 아닙니다. 그리고 워킹셋이 페이지 캐시에 다 올라가 있는 hot 환경에서의 이득 폭은 벤더가 공개한 적이 없습니다 — cold cache 1,576배를 여러분의 대시보드 지연시간 개선폭으로 번역할 수는 없다는 뜻입니다. 측정은 여러분의 데이터로 해야 합니다. 다행히 A/B는 세션 설정 하나로 됩니다.
-- 끄고 비교
SELECT ... SETTINGS query_plan_optimize_lazy_materialization = false;
-- 게이트 밖 LIMIT에서 강제로 켜 보기 (기본 게이트는 10000)
SELECT ... SETTINGS query_plan_max_limit_for_lazy_materialization = 0;
마지막으로 버그 트레일 이야기를 해야 합니다. 이 기능은 도입 이후 매 릴리스마다 픽스가 이어졌습니다 — Variant 컬럼 읽기(25.8, #84400), 외부 정렬과 겹칠 때의 CORRUPTED_DATA(25.8, #84738), 프로젝션과 겹칠 때의 AMBIGUOUS_COLUMN_NAME(25.6, #80251), ALTER로 추가된 컬럼의 옛 파트 처리(25.12, #91142), 그리고 26.6에도 정렬 컬럼을 잘못 짚어 TYPE_MISMATCH가 나는 케이스의 픽스(#107060)가 실렸습니다. 겁먹으라는 이야기가 아닙니다 — 기본값으로 켜진 최적화의 버그는 여러분이 켠 적 없는 코드 경로의 버그라는 이야기입니다. ClickHouse를 올릴 때 릴리스 노트에서 lazy가 들어간 줄을 읽어야 하는 이유가 여기에 있고, 쿼리가 이상하게 죽거나 느려질 때 query_plan_optimize_lazy_materialization = false가 유효한 1차 격리 수단인 이유이기도 합니다.
마치며
lazy materialization의 지난 1년 남짓 궤적을 한 줄로 줄이면 이렇습니다 — 25.4에서 LIMIT 10짜리 보수적 트릭으로 태어나(PR 자체는 2023년 10월에 열려 1년 반을 묵었습니다), 25.12에서 실행 모델을 조인 스타일로 갈아엎으며 게이트를 1,000배 넓혔고, 2026년 들어 UNION ALL(26.2), FINAL(26.4, 옵트인), JOIN(26.6)으로 적용 범위를 늘리는 중입니다. 쿼리를 한 줄도 바꾸지 않아도 업그레이드만으로 빨라지는 종류의 개선이라는 점에서 실무 가치는 분명합니다.
동시에 이 글에서 확인한 경계도 분명합니다. 헤드라인 배수는 cold cache와 처리량 캡 걸린 디스크, 그리고 지연 대상 컬럼이 바이트의 대부분을 차지하는 쿼리 모양에서 나온 벤더 자체 측정이고, 그 벤더조차 LIMIT이 커지면 옛 방식이 eager보다 5배 느려지는 수치를 함께 공개했습니다. 게이트와 폴백과 옵트인 기본값은 장식이 아니라 이 최적화의 비용 구조에 대한 정직한 문서입니다.
요즘 분석 DB들이 각자 다른 층을 손보고 있는 것도 함께 보면 재미있습니다 — DuckDB는 클라이언트-서버 프로토콜을, Elasticsearch는 벡터 인덱스의 기본값을 갈아 끼우는 동안, ClickHouse는 "읽기를 얼마나 미룰 수 있나"라는 한 가지 질문을 쿼리 모양 하나씩 넓혀 가며 파고 있습니다. 다음 릴리스에서 이 게이트들이 또 어떻게 움직이는지 — SettingsChangesHistory.cpp만 지켜봐도 이 기능의 성숙도를 계속 추적할 수 있습니다.
참고 자료
- ClickHouse gets lazier (and faster): Introducing lazy materialization — 공식 블로그, 2025-04
- Lazy materialization — 공식 문서 가이드
- ClickHouse Release 25.12 — 행 단위에서 조인 스타일로의 재설계와 데모 수치
- ClickHouse CHANGELOG.md — 26.1–26.6 원문
- 2025 Changelog — 25.4 도입 항목(#55518)과 25.x 픽스 이력
- Settings.cpp — lazy 관련 설정의 현재 기본값 / SettingsChangesHistory.cpp — 게이트 10 → 100 → 10,000 이력
- 주요 PR: #55518 도입 · #90309 25.12 리팩토링 · #96832 UNION ALL · #101647 lazy FINAL · #106566 JOIN lazy indexes 재도입
- 릴리스 프레젠테이션 — 25.4 / 25.12 / 26.6