본문으로 건너뛰기
clickhouse검색
SQL과 쿼리

쿼리 최적화 (EXPLAIN, PREWHERE, 설정, 쿼리 캐시, 병렬 복제본)

느린 쿼리를 받았을 때 보는 순서를 정리합니다. query_log로 읽은 양을 재고, EXPLAIN으로 파트와 마크가 얼마나 걸러졌는지 확인하고, PREWHERE와 설정, 쿼리 캐시, 병렬 복제본으로 손보는 실전 절차입니다.

EXPLAINPREWHEREquery_log쿼리 캐시병렬 복제본성능2026-10 기준, 약 12분상태 표시 설명

최적화의 순서

ClickHouse에서 쿼리가 느린 이유는 거의 읽는 양입니다. 먼저 얼마나 읽었는지 재고, 그다음 왜 그만큼 읽었는지를 플랜에서 확인하고, 마지막에 설정을 만집니다. 순서를 거꾸로 하면(설정부터) 시간을 낭비합니다.

  1. system.query_log에서 read_rows, read_bytes, memory_usage, query_duration_ms를 봅니다.
  2. EXPLAIN indexes = 1로 파티션, 기본 키, 스킵 인덱스가 파트와 그래뉼을 얼마나 걸렀는지 봅니다.
  3. WHERE가 정렬 키를 쓰는지, 필요한 컬럼만 읽는지, JOIN의 오른쪽이 작은지 확인합니다.
  4. PREWHERE, 집계 함수 선택, 설정(스레드, 메모리, 순서 읽기)으로 다듬습니다.
  5. 같은 쿼리가 반복되면 쿼리 캐시나 머티리얼라이즈드 뷰, 프로젝션으로 미리 계산합니다.
clickhouse-server (쿼리 한 개, 코어 수만큼 병렬 스트림) 저장 (테이블 하나 = 파트 여러 개, 파트 = 열 파일 + 희소 인덱스) 클라이언트TCP 9000, HTTP 8123 파서, 분석기AST, 이름과 타입 해석 플래너, 최적화인덱스 범위, PREWHERE 파이프라인 실행벡터화, 열 블록 단위 결과 직렬화JSONEachRow, Native, Parquet primary.idx8,192행마다 마크 하나 읽을 그래뉼 범위 결정파트, 파티션, 스킵 인덱스 열 파일만 읽기ts.bin, country.bin (압축 블록) 백그라운드 머지작은 파트를 큰 파트로
ClickHouse가 쿼리 하나를 처리하는 길. 플래너 단계에서 파티션, 기본 키, 스킵 인덱스로 읽을 범위를 정하고 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_by0 (안 함)GROUP BY를 디스크로 흘리는 기준
max_bytes_before_external_sort0ORDER BY를 디스크로
optimize_read_in_order1ORDER BY가 정렬 키와 같으면 정렬 없이 순서대로 읽고 LIMIT에서 일찍 멈춤
optimize_aggregation_in_order0GROUP BY 키가 정렬 키 접두어면 순서 집계로 메모리 절약
max_execution_time0초 단위 시간 제한
max_rows_to_read, max_bytes_to_read0읽기 상한. 넘으면 예외 (BI 보호용)
use_uncompressed_cache0자주 읽는 작은 테이블의 압축 해제 캐시
enable_parallel_replicas0여러 복제본이 한 쿼리를 나눠 읽기
use_query_cache0쿼리 결과 캐시
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%'처럼 인덱스 컬럼에 함수를 씌워 비교하기.

공식 문서

이 문서에서