본문으로 건너뛰기
clickhouse검색
운영과 트러블슈팅

느린 쿼리 진단 (system.query_log, ProfileEvents, 트레이스, EXPLAIN)

느린 쿼리를 찾는 query_log 쿼리, 읽은 행과 결과 행의 비율로 인덱스 활용을 판단하는 법, EXPLAIN 과 trace 로그, 샘플링 프로파일러로 병목을 찾는 순서를 정리합니다.

query_logEXPLAINProfileEvents프로파일러인덱스2026-10 기준, 약 12분

진단 순서

느린 쿼리는 세 질문으로 좁혀 갑니다.

  1. 얼마나 읽었나: read_rows 가 result_rows 보다 몇 배나 큰가. 수백 배 이상이면 인덱스를 못 타고 있습니다.
  2. 어디서 시간을 썼나: 읽기(디스크, 네트워크), 계산(CPU), 대기(머지, 락, 다른 쿼리) 중 무엇인가.
  3. 재현되나: 두 번째 실행도 느린가(캐시 영향), 다른 시간대에도 느린가(동시성 영향).

이 세 가지는 전부 system.query_log 와 EXPLAIN 으로 답할 수 있습니다. 쿼리 자체를 어떻게 고치는지는 쿼리 최적화에 있고, 이 문서는 "무엇이 문제인지 찾는" 쪽입니다.

system.query_log 로 찾기

query_log 에는 쿼리마다 시작(QueryStart)과 끝(QueryFinish 또는 예외) 행이 들어갑니다. 끝 행만 보면 됩니다.

SELECT event_time, query_duration_ms AS ms, read_rows, result_rows,
       round(read_rows / greatest(result_rows, 1)) AS read_per_result,
       formatReadableSize(read_bytes) AS read, formatReadableSize(memory_usage) AS mem,
       user, substring(query, 1, 100) AS q
FROM system.query_log
WHERE type = 'QueryFinish' AND event_date = today()
  AND query_kind = 'Select'
ORDER BY query_duration_ms DESC
LIMIT 20;

같은 모양의 쿼리를 묶어 보면 "가끔 느린 쿼리" 와 "항상 느린 쿼리" 가 갈립니다.

SELECT normalized_query_hash AS h, count() AS n,
       quantile(0.5)(query_duration_ms) AS p50, quantile(0.95)(query_duration_ms) AS p95,
       max(query_duration_ms) AS max_ms,
       sum(read_rows) AS rows, any(substring(query, 1, 100)) AS sample
FROM system.query_log
WHERE type = 'QueryFinish' AND event_date >= today() - 7 AND query_kind = 'Select'
GROUP BY h
HAVING n > 10
ORDER BY p95 DESC
LIMIT 20;

애플리케이션에서 쿼리에 SETTINGS log_comment = 'dashboard:sales' 를 붙여 두면 log_comment 열로 화면별, 서비스별로 묶을 수 있습니다. 어떤 쿼리가 어느 화면에서 왔는지 찾는 시간이 사라집니다.

팁 query_log 는 기본으로 켜져 있지만 TTL 이 없어 무한히 자랍니다. config.d 에 <query_log><ttl>event_date + INTERVAL 30 DAY</ttl></query_log> 처럼 보존 기간을 줍니다. 모니터링에서 system 테이블 관리를 다룹니다.

read_rows 와 result_rows 비율 읽기

비율뜻할 일
1~10인덱스를 잘 탐CPU 나 네트워크 쪽을 봄
100~1,000기본 키 일부만 쓰거나 PREWHERE 로 걸러짐WHERE 조건과 정렬 키 순서 비교
수만 이상풀스캔정렬 키, 스킵 인덱스, 프로젝션 검토

읽은 행이 많은데 결과가 적다는 것은 ClickHouse 가 많이 읽고 버렸다는 뜻입니다. 정렬 키 설계는 정렬 키, 기본 키, 파티션, 보조 수단은 스킵 인덱스와 프로젝션에 있습니다.

EXPLAIN 으로 인덱스 사용 확인

EXPLAIN indexes = 1
SELECT count() FROM logs.events
WHERE ts >= '2026-10-01' AND service = 'api';
ReadFromMergeTree (logs.events)
Indexes:
  PrimaryKey
    Keys: ts, service
    Condition: and((ts in [1759276800, +Inf)), (service in ['api', 'api']))
    Parts: 12/40
    Granules: 1830/98211

Parts 와 Granules 가 전체 중 얼마나 골라졌는지가 핵심입니다. 98,211 그래뉼 중 1,830 개만 읽으면 인덱스가 일을 한 것입니다. 조건이 Condition: true 로 나오면 기본 키를 전혀 쓰지 못한 것입니다. 스킵 인덱스가 있으면 Skip 항목에 같은 식으로 나옵니다.

다른 EXPLAIN 종류도 각각 쓸 데가 있습니다.

문법보여 주는 것언제
EXPLAIN (PLAN)논리 실행 계획 단계조인 순서, 집계 위치
EXPLAIN indexes = 1파트와 그래뉼 선택인덱스 확인
EXPLAIN PIPELINE실제 프로세서 파이프라인과 스레드 수병렬도, 병목 단계
EXPLAIN ESTIMATE읽을 파트, 행, 마크 수 추정실행 전에 비용 짐작
EXPLAIN SYNTAX최적화 뒤 다시 쓴 쿼리PREWHERE 로 옮겨졌는지
EXPLAIN QUERY TREE새 분석기의 쿼리 트리별칭, 상수 접힘 확인

서버 로그를 쿼리에 붙여 보기

clickhouse-client 에서 send_logs_level 을 켜면 서버가 쿼리를 처리하면서 남기는 로그를 그대로 받아 봅니다.

SET send_logs_level = 'trace';
SELECT count() FROM logs.events WHERE ts >= today() - 1 AND user_id = 42;
<Debug> logs.events (SelectExecutor): Key condition: (column 0 in [20250, +Inf))
<Debug> logs.events (SelectExecutor): Selected 3/40 parts by partition key,
        3 parts by primary key, 412/9100 marks by primary key, 412 marks to read from 3 ranges
<Trace> logs.events (SelectExecutor): Spreading mark ranges among streams (default reading)
<Debug> executeQuery: Read 3375104 rows, 26.42 MiB in 0.183 sec., 18.4 million rows/sec.

"Selected N/M parts by partition key" 는 파티션 프루닝, "marks by primary key" 는 기본 키 인덱스가 걸러 준 양입니다. 여기서 user_id 조건이 마크 수를 줄이지 못했다면 정렬 키에 없거나 뒤쪽에 있다는 뜻입니다.

ProfileEvents 로 시간 쪼개기

query_log.ProfileEvents 는 쿼리 한 건의 카운터 묶음입니다. 자주 보는 키입니다.

SELECT query_duration_ms,
       ProfileEvents['SelectedParts'] AS parts, ProfileEvents['SelectedMarks'] AS marks,
       ProfileEvents['SelectedRows'] AS sel_rows,
       ProfileEvents['OSCPUVirtualTimeMicroseconds'] / 1000 AS cpu_ms,
       ProfileEvents['DiskReadElapsedMicroseconds'] / 1000 AS disk_ms,
       ProfileEvents['NetworkReceiveElapsedMicroseconds'] / 1000 AS net_ms,
       ProfileEvents['S3ReadMicroseconds'] / 1000 AS s3_ms,
       ProfileEvents['ReadCompressedBytes'] AS compressed_bytes,
       ProfileEvents['QueryCacheHits'] AS cache_hit
FROM system.query_log
WHERE query_id = '...' AND type = 'QueryFinish';
  • cpu_ms 가 query_duration_ms * max_threads 에 가까우면 CPU 가 꽉 찼던 것입니다. 계산을 줄여야 합니다.
  • disk_ms 나 s3_ms 가 전체 시간의 대부분이면 읽기 병목입니다. 열을 줄이거나 압축 코덱을 바꾸거나 캐시를 키웁니다 (압축과 코덱).
  • net_ms 가 크면 분산 쿼리에서 다른 노드를 기다린 것입니다.
  • 둘 다 작은데 전체 시간이 길면 대기입니다. 동시 쿼리, 머지, 락을 봅니다.

ClickHouse Cloud 처럼 데이터가 오브젝트 스토리지에 있으면 첫 실행은 S3 에서 읽고 두 번째는 로컬 파일시스템 캐시에서 읽습니다. ProfileEvents['CachedReadBufferReadFromCacheBytes'] 와 ...FromSourceBytes 의 비율로 캐시 적중을 봅니다. 콜드 캐시 상태의 느림은 데이터를 자주 읽는 노드로 모으거나 캐시 크기를 키우는 문제입니다 (컴퓨트와 스케일링).

샘플링 프로파일러로 함수 수준까지

CPU 병목이면 어느 함수가 시간을 먹는지 샘플링 프로파일러로 봅니다. 쿼리에 설정을 붙이면 system.trace_log 에 스택이 쌓입니다.

SET query_profiler_cpu_time_period_ns = 10000000,   -- 10ms 마다 샘플
    query_profiler_real_time_period_ns = 10000000;
SELECT ... ;  -- 느린 쿼리

SELECT count() AS samples,
       arrayStringConcat(
           arrayReverse(arrayMap(x -> demangle(addressToSymbol(x)), trace)), ';') AS stack
FROM system.trace_log
WHERE query_id = '...' AND trace_type = 'CPU'
GROUP BY trace
ORDER BY samples DESC
LIMIT 10;

addressToSymbol, demangle 을 쓰려면 allow_introspection_functions = 1 이 필요합니다. 결과를 접힌 스택 형식으로 뽑아 flamegraph 도구에 넣으면 그림으로 보입니다. 보통 여기서 GROUP BY 해시 테이블 리사이즈, 문자열 함수, JSON 파싱 같은 것이 위에 올라옵니다.

자주 나오는 원인 체크리스트

증상원인고치는 방향
읽은 행이 결과의 수천 배WHERE 열이 정렬 키에 없음, 키 열에 함수를 씌움정렬 키 조정, 함수 제거, 스킵 인덱스
첫 실행만 느림콜드 캐시 (마크, 비압축, 파일시스템 캐시)캐시 크기, 쿼리 캐시, 예열
피크 시간에만 느림동시 쿼리, 머지와 CPU 경쟁동시성 제한, 복제본 추가, 워크로드 분리
FINAL 붙은 쿼리읽을 때 중복 제거ReplacingMergeTree 설계 재검토 (삭제와 갱신)
조인이 느림큰 오른쪽 테이블, 알고리즘JOIN 전략
파트가 수천 개INSERT 패턴Too many parts
SELECT *넓은 열 전부 읽음필요한 열만
분산 쿼리에서 한 샤드만 느림데이터 쏠림, 느린 디스크샤딩 키, 하드웨어

재발 방지

  1. 대시보드와 API 쿼리에 log_comment 를 붙여 출처를 남깁니다.
  2. 주간 리포트로 p95 상위 20 개 쿼리 모양을 뽑아 리뷰합니다.
  3. 새 쿼리는 배포 전에 EXPLAIN indexes = 1 결과를 PR 에 붙입니다. 그래뉼 선택 비율이 10% 를 넘으면 설계를 다시 봅니다.
  4. 느린 쿼리 로그 자체를 경보로 걸려면 query_duration_ms > 30000 인 QueryFinish 행 수를 셉니다.

공식 문서

이 문서에서