쿼리 최적화 (EXPLAIN, PREWHERE, 설정, 쿼리 캐시, 병렬 복제본)
느린 쿼리를 받았을 때 보는 순서를 정리합니다. query_log로 읽은 양을 재고, EXPLAIN으로 파트와 마크가 얼마나 걸러졌는지 확인하고, PREWHERE와 설정, 쿼리 캐시, 병렬 복제본으로 손보는 실전 절차입니다.
최적화의 순서
ClickHouse에서 쿼리가 느린 이유는 거의 읽는 양입니다. 먼저 얼마나 읽었는지 재고, 그다음 왜 그만큼 읽었는지를 플랜에서 확인하고, 마지막에 설정을 만집니다. 순서를 거꾸로 하면(설정부터) 시간을 낭비합니다.
system.query_log에서read_rows,read_bytes,memory_usage,query_duration_ms를 봅니다.EXPLAIN indexes = 1로 파티션, 기본 키, 스킵 인덱스가 파트와 그래뉼을 얼마나 걸렀는지 봅니다.- WHERE가 정렬 키를 쓰는지, 필요한 컬럼만 읽는지, JOIN의 오른쪽이 작은지 확인합니다.
- PREWHERE, 집계 함수 선택, 설정(스레드, 메모리, 순서 읽기)으로 다듬습니다.
- 같은 쿼리가 반복되면 쿼리 캐시나 머티리얼라이즈드 뷰, 프로젝션으로 미리 계산합니다.
1단계: 얼마나 읽었나
SELECT
event_time, query_duration_ms, read_rows,
formatReadableSize(read_bytes) AS read,
formatReadableSize(memory_usage) AS mem,
result_rows,
ProfileEvents['SelectedParts'] AS parts,
ProfileEvents['SelectedMarks'] AS marks,
normalizeQuery(query) AS q
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 1 HOUR
AND query_kind = 'Select'
ORDER BY query_duration_ms DESC
LIMIT 10;read_rows가 테이블 전체 행 수에 가깝다면 인덱스를 전혀 못 쓰는 쿼리입니다.read_bytes가 크고read_rows는 작다면 넓은 컬럼(String, JSON)을 많이 읽는 쿼리입니다. 컬럼을 줄이거나 PREWHERE를 봅니다.memory_usage가 크면 GROUP BY 키 카디널리티, JOIN 오른쪽 크기, 정렬을 의심합니다.ProfileEvents에는SelectedMarks,SelectedRanges,OSReadBytes,QueryCacheHits같은 세부 수치가 Map으로 들어 있습니다.
더 깊은 진단 절차는 느린 쿼리 진단에 있습니다.
2단계: EXPLAIN 읽기
| 구문 | 보여 주는 것 |
|---|---|
EXPLAIN SYNTAX | 재작성된 쿼리 (별칭 풀림, 최적화 적용) |
EXPLAIN PLAN (기본) | 논리 플랜 단계 |
EXPLAIN indexes = 1 | 파티션 키, 기본 키, 스킵 인덱스가 파트와 그래뉼을 얼마나 걸렀는지 |
EXPLAIN PIPELINE | 실행 파이프라인, 스레드 수, JOIN 알고리즘 |
EXPLAIN ESTIMATE | 읽을 파트, 행, 마크 수 추정 |
EXPLAIN AST | 파싱 트리 |
EXPLAIN indexes = 1
SELECT count()
FROM web_logs
WHERE event_date = '2026-10-21' AND user_id = 12345;결과에서 볼 곳은 PrimaryKey 아래 Parts: 3/120, Granules: 8/50000 같은 비율입니다. 분모 대비 분자가 작으면 인덱스가 잘 듭니다. 거의 같다면 WHERE 조건이 정렬 키 접두어가 아니라는 뜻입니다. 설계 요령은 정렬 키, 기본 키, 파티션에 있습니다.
SET send_logs_level = 'trace'를 켜고 쿼리를 실행하면 "Selected 3/120 parts by partition key, 2 parts by primary key, 8/50000 marks by primary key" 같은 로그가 클라이언트에 바로 보입니다. EXPLAIN보다 실제 실행 기준이라 믿을 만합니다.
3단계: 읽는 양 줄이기
WHERE가 정렬 키를 타는가
정렬 키가 (tenant_id, event_time)인데 WHERE event_time > ...만 걸면 기본 키 인덱스를 거의 못 씁니다(앞 컬럼이 빠짐). 조건에 tenant_id를 추가하거나, 그 접근 패턴이 흔하면 프로젝션이나 별도 정렬의 MV를 둡니다. 스킵 인덱스와 프로젝션
파티션 가지치기
파티션 키(toYYYYMM(event_date))에 쓰인 컬럼으로 조건을 걸면 파트 자체를 건너뜁니다. WHERE toYYYYMM(event_date) = 202610처럼 함수까지 같게 써도 되고, 단조 함수는 event_date BETWEEN ...만으로도 가지치기가 됩니다.
컬럼 줄이기
SELECT *는 모든 컬럼 파일을 읽습니다. 넓은 테이블에서 count()나 몇 컬럼만 필요하면 그 컬럼만 적습니다. 컬럼 하나가 전체 읽기량의 절반을 차지하는 일이 흔합니다(JSON, 원문 String).
PREWHERE
PREWHERE는 조건에 쓰인 컬럼을 먼저 읽어 행을 거르고, 남은 행에 대해서만 다른 컬럼을 읽습니다. optimize_move_to_prewhere = 1(기본)이면 ClickHouse가 WHERE 조건 중 가벼운 컬럼을 알아서 PREWHERE로 옮깁니다. 직접 쓸 때는 선택도가 높고 컬럼이 작은 조건을 PREWHERE에 둡니다.
SELECT url, duration_ms, raw_payload
FROM web_logs
PREWHERE status = 500 -- UInt16, 거의 다 걸러냄
WHERE event_date = today() AND raw_payload LIKE '%timeout%';EXPLAIN SYNTAX로 자동 이동 결과를 볼 수 있습니다. 보통은 자동에 맡기고, 큰 String 컬럼에 LIKE를 거는 쿼리처럼 자동 판단이 아쉬울 때만 수동으로 적습니다.
FINAL과 중복 제거 비용
ReplacingMergeTree에 FINAL을 붙이면 조회 때 머지를 흉내 내느라 비용이 큽니다. do_not_merge_across_partitions_select_final = 1로 파티션 간 비교를 줄이거나, 조회 쿼리에서 argMax로 최신 행을 고르거나, 데이터 모델을 바꿉니다. MergeTree 가족, 삭제와 갱신
4단계: 설정으로 다듬기
| 설정 | 기본 | 뜻 |
|---|---|---|
max_threads | 코어 수 | 쿼리 스레드. 작은 쿼리 여러 개가 겹치면 줄이는 것이 전체에 유리 |
max_memory_usage | 서버 설정 따라 | 쿼리 메모리 상한 |
max_bytes_before_external_group_by | 0 (안 함) | GROUP BY를 디스크로 흘리는 기준 |
max_bytes_before_external_sort | 0 | ORDER BY를 디스크로 |
optimize_read_in_order | 1 | ORDER BY가 정렬 키와 같으면 정렬 없이 순서대로 읽고 LIMIT에서 일찍 멈춤 |
optimize_aggregation_in_order | 0 | GROUP BY 키가 정렬 키 접두어면 순서 집계로 메모리 절약 |
max_execution_time | 0 | 초 단위 시간 제한 |
max_rows_to_read, max_bytes_to_read | 0 | 읽기 상한. 넘으면 예외 (BI 보호용) |
use_uncompressed_cache | 0 | 자주 읽는 작은 테이블의 압축 해제 캐시 |
enable_parallel_replicas | 0 | 여러 복제본이 한 쿼리를 나눠 읽기 |
use_query_cache | 0 | 쿼리 결과 캐시 |
SELECT user_id, max(event_time)
FROM web_logs
WHERE event_date >= today() - 30
GROUP BY user_id
ORDER BY user_id
LIMIT 100
SETTINGS optimize_aggregation_in_order = 1, max_threads = 8;팁 대시보드 사용자에게는 설정 프로필로
max_execution_time,max_rows_to_read,max_memory_usage를 걸어 두세요. 한 사람의 실수 쿼리가 서버를 흔드는 것을 막습니다. 서버 설정
쿼리 캐시
같은 쿼리가 짧은 간격으로 반복되면(대시보드 새로 고침) 결과를 캐시합니다. 서버 전체 캐시가 아니라 쿼리마다 켜는 방식입니다.
SELECT country, count() FROM web_logs
WHERE event_date = today()
GROUP BY country
SETTINGS use_query_cache = 1, query_cache_ttl = 60;query_cache_min_query_duration,query_cache_min_query_runs로 느리고 자주 오는 쿼리만 캐시합니다.now()처럼 비결정적 함수가 있으면 기본적으로 캐시하지 않습니다(query_cache_nondeterministic_function_handling).- 캐시는 사용자별로 분리되어 권한 우회가 안 됩니다. 공유하려면
query_cache_share_between_users. - 상태는
system.query_cache, 히트는ProfileEvents['QueryCacheHits']. - 데이터가 바뀐 뒤에도 TTL 동안 옛 결과가 보일 수 있습니다. 실시간성이 중요한 화면에는 TTL을 짧게.
병렬 복제본
복제본이 여럿인데 한 쿼리는 한 복제본만 쓰는 것이 기본입니다. enable_parallel_replicas = 1과 max_parallel_replicas = N을 켜면 여러 복제본이 같은 테이블의 다른 범위를 나눠 읽어 한 쿼리를 함께 처리합니다. ClickHouse Cloud에서는 이미 GA로 쓰이고, 셀프 호스팅 환경에서는 클러스터 설정(cluster_for_parallel_replicas)이 필요합니다.
- 큰 테이블 전체를 훑는 분석 쿼리에서 효과가 크고, 작은 포인트 조회에는 네트워크 비용만 더합니다.
- 조인 쿼리에서는 오른쪽 테이블 처리 방식(
parallel_replicas_prefer_local_join등)에 따라 결과 속도가 달라지므로 EXPLAIN으로 확인합니다. - 구조는 복제와 샤딩, Cloud의 컴퓨트 구성은 컴퓨트와 스케일링에서 다룹니다.
5단계: 미리 계산하기
반복되는 무거운 쿼리는 실행할 때 빠르게 하는 것보다 미리 계산해 두는 쪽이 결국 싸습니다.
| 방법 | 맞는 상황 | 문서 |
|---|---|---|
| 머티리얼라이즈드 뷰 (증분) | INSERT 때 집계를 쌓아 두면 되는 대시보드 | 머티리얼라이즈드 뷰 |
| Refreshable MV | 조인, 전체 재계산이 필요한 보고서를 주기적으로 | 같은 문서 |
| 프로젝션 | 같은 테이블을 다른 정렬 순서나 집계로 읽어야 할 때 (쿼리 자동 선택) | 스킵 인덱스와 프로젝션 |
| 딕셔너리 | 차원 테이블 조인 | 딕셔너리 |
| 쿼리 캐시 | 같은 쿼리 반복, 약간의 지연 허용 | 위 절 |
자주 보는 안티 패턴
SELECT *로 넓은 테이블을 읽고 애플리케이션에서 거르기.- 정렬 키에 없는 컬럼으로만 WHERE를 걸고 "인덱스가 안 먹는다"고 하기. ClickHouse 기본 키는 B-tree가 아니라 희소 인덱스이고 접두어만 씁니다.
count(DISTINCT x)를 대시보드에서 매번 돌리기.uniq로 바꾸거나 상태를 저장합니다. 집계 함수와 컴비네이터- 큰 테이블을 JOIN의 오른쪽에 두기. JOIN 전략
FINAL을 모든 조회에 붙이기.- 파티션을 날짜 단위로 너무 잘게 만들어 파트 수가 폭발하기. Too many parts
toString(event_time) LIKE '2026-10%'처럼 인덱스 컬럼에 함수를 씌워 비교하기.