최적화 접근법 선택하기
최적화 접근법 선택하기
쿼리 로그의 증거, 통제된 비교, 쿼리 계획을 사용해 측정된 병목을 해결하는 최적화 접근법을 평가해요.
출처: 문서
본문
시작하기 전에 (Before you begin)
반복 가능한 기준선과 병목에 대한 가설부터 시작해요. 아직 식별하지 못했다면 느린 쿼리 진단과 쿼리 병목 분리부터 시작해요. 이 가이드의 예제는 nyc_taxi.trips_small_inferred 테이블을 사용해요. 그대로 실행하려면 아직 안 했다면 테이블을 만들고 데이터를 로드해주세요:
예제 데이터셋 설정하기
원본 Parquet 파일은 약 5.8 GB예요. 네트워크와 사용 가능한 리소스에 따라 로드에 몇 분이 걸릴 수 있어요.
CREATE DATABASE IF NOT EXISTS nyc_taxi;
USE nyc_taxi;
CREATE TABLE nyc_taxi.trips_small_inferred
ORDER BY () EMPTY
AS SELECT *
FROM s3(
'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet',
NOSIGN,
Parquet
);
INSERT INTO nyc_taxi.trips_small_inferred
SELECT *
FROM s3(
'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet',
NOSIGN,
Parquet
);
접근법 선택하기
수집한 증거를 사용해 어디서 시작할지 골라요. 병목을 해결하는 가장 덜 특화된 변경을 선호해요:
| 증거 | 시작할 곳 | 기대 효과 |
|---|---|---|
| 쿼리가 넓은 컬럼이나 필요 없는 컬럼을 읽음 | 읽는 데이터 줄이기 | 읽은 바이트, 메모리 사용량, 처리 작업 |
| 선택적인 필터가 여전히 많은 part나 granule을 읽음 | 데이터 레이아웃을 쿼리와 정렬 | 읽은 행과 granule |
| 반복적인 변환이나 집계가 쿼리를 지배함 | 반복 가능한 작업 미리 계산 | 쿼리 시 수행되는 계산 |
증거가 이 범주 중 하나와 맞지 않으면 쿼리를 접근법에 억지로 맞추지 말고 쿼리 계획으로 돌아가요.
읽는 데이터 줄이기
- 언제 사용: 쿼리가 넓은 컬럼이나 필요 없는 컬럼을 읽을 때.
- 변경: 쿼리가 읽는 컬럼의 크기나 개수를 줄여요.
- 검증: 같은 조건에서
read_bytes, 메모리 사용량, 지속 시간을 비교해요.
ClickHouse는 쿼리가 요구하는 컬럼만 읽지만, 선택된 데이터를 여전히 읽고, 압축 해제하고, 처리해야 해요. 선택된 컬럼과 그 타입을 모두 검토해요. 스키마 추론은 실용적인 시작점을 제공하지만, 추론된 타입은 프로덕션 데이터가 요구하는 것보다 더 넓거나 관대할 수 있어요.
컬럼 타입 검토하기
정밀한 타입 선택 워크로드가 요구하는 범위와 정밀도를 보존하면서 필요한 것보다 더 많은 데이터를 저장하지 않는 타입을 선택해요. 그 값들에 범용 String 대신 숫자·날짜 타입을 사용하고, 예상 범위를 안전하게 표현하는 가장 작은 부호 있는·부호 없는 숫자 타입을 선택해요. 시간 컬럼에는 더 넓은 범위나 분수 정밀도가 필요한 Date32나 DateTime64가 아니라면 Date나 DateTime을 사용해요. Nullable 컬럼은 의도적으로 사용 Nullable 컬럼은 값 외에 별도의 null 마스크를 저장하는데 ClickHouse가 이것도 읽고 처리해야 해요. null 값과 타입 기본값의 구분이 의미가 있을 때 사용해요. 컬럼이 항상 값을 가질 것이 보장되면 non-nullable 타입이 그 추가 작업을 피해요. 컬럼을 바꾸기 전에 관찰된 non-null 데이터가 항상 non-null로 유지될 것이라고 가정하지 말고 원본 데이터와 수집 경로를 확인해요. 워크드 최적화 예제는 null 값을 포함하는 컬럼을 식별하고 스키마 변경 효과를 측정하는 방법을 보여줘요. 반복 값에 딕셔너리 인코딩 사용 LowCardinality는 딕셔너리 인코딩을 사용하며, 상태 값, 국가 코드, 또는 행보다 고유 값이 훨씬 적은 다른 차원 같은 문자열 컬럼에 종종 효과적이에요. 약 10,000개의 고유 값이 후보를 식별하는 유용한 시작점이지 고정된 한계는 아니에요. 식별자와 거의 고유한 컬럼은 피하고, 타입 변경 전후의 측정값을 비교해요. 더 자세한 지침은 데이터 타입 선택을 참고하세요.
필요한 컬럼만 읽기
ClickHouse는 데이터를 컬럼별로 저장하므로, 더 적은 컬럼을 선택하면 읽는 데이터가 직접 줄어요. 필요한 컬럼을 나열하고 SELECT *는 피해요. 특히 넓은 테이블이나 각 행의 작은 부분집합만 반환하는 쿼리에서요. read_bytes를 system.query_log에서 사용해 선택한 컬럼을 좁히기 전후의 읽는 데이터 양을 비교해요. read_bytes가 여전히 높으면 실행 계획에서 추가 컬럼을 여전히 요구하는 표현식, 필터, 조인, 중첩 쿼리를 검사해요. 예를 들어 대시보드가 픽업 시간, 지불 유형, 총 금액만 필요하다면 전체 행 대신 그 컬럼들을 선택해요:
SELECT
pickup_datetime,
payment_type,
total_amount
FROM nyc_taxi.trips_small_inferred
WHERE pickup_datetime >= '2009-01-01'
AND pickup_datetime < '2009-04-01'
LIMIT 1000;
이 쿼리를 같은 필터와 limit로 SELECT *와 비교해요. 반환되는 행 수는 같지만 read_bytes는 더 작은 컬럼 집합을 반영해야 해요.
데이터 레이아웃을 쿼리와 정렬하기
- 언제 사용: 선택적인 필터가 여전히 많은 part나 granule을 읽을 때.
- 변경: 물리적 레이아웃을 반복 쿼리가 사용하는 필터와 정렬해요.
- 검증:
EXPLAIN indexes = 1이 선택하는 part와 granule을 비교한 다음read_rows,read_bytes, 지속 시간을 확인해요.
순서 키부터 시작하기
MergeTree 계열 테이블에서 순서 키는 행이 디스크에 어떻게 배열되는지 결정해요. 기본적으로 희소 기본 인덱스를 정의하는 기본 키 역할도 해요. OLTP 데이터베이스의 기본 키와 달리 ClickHouse 기본 키는 유일성을 강제하지 않아요. 그 성능 이점은 ClickHouse가 쿼리의 필터를 충족할 수 없는 granule을 건너뛸 수 있게 해주는 데서 나와요. 선택적인 필터에 자주 나타나는 컬럼을 키의 순서를 포함해 우선시해요. 관련 값을 함께 그룹화하면 압축도 개선돼요. 쿼리의 그룹화나 정렬 순서가 키와 정렬되면 ClickHouse는 GROUP BY나 ORDER BY에 in-order 최적화를 사용할 수 있어요. 다른 순서 키를 테스트하기 전후에 EXPLAIN indexes = 1이 선택하는 part와 granule을 비교해요. 같은 조건에서 read_rows, read_bytes, 지속 시간도 비교해요. 자세한 선택 지침은 기본 키 선택을 참고하세요. 예제 테이블은 ORDER BY ()를 사용하므로, 다음 선택적인 날짜 필터는 granule을 제거할 수 있는 순서 키가 없어요:
EXPLAIN indexes = 1
SELECT
payment_type,
count()
FROM nyc_taxi.trips_small_inferred
WHERE pickup_datetime >= '2009-01-01'
AND pickup_datetime < '2009-04-01'
GROUP BY payment_type
SETTINGS
use_query_condition_cache = 0,
use_skip_indexes_on_data_read = 0;
ClickHouse 25.9 이상에서 이 설정은 EXPLAIN이 사용된 인덱스와 그것들이 제거하는 part·granule을 보고하도록 보장해요.
이 출력을 기준선으로 사용해요. 비교를 완료하려면 워크드 예제의 순서 키 변경 적용을 따라 pickup_datetime을 포함하는 순서 키가 있는 테이블을 만든 다음, 같은 EXPLAIN을 그 테이블에 대해 실행해요. 지속 시간이나 메모리 측정으로 전체 변경을 평가하기 전에 계획의 기본 키 섹션이 더 적은 선택된 granule을 보여야 해요.
PREWHERE는 처리되는 행 수를 바꾸지 않고 읽는 컬럼 값을 줄일 수 있어요. ClickHouse는 optimize_move_to_prewhere가 활성화되어 있을 때(기본값) 자격이 되는 조건을 WHERE에서 PREWHERE로 자동으로 옮겨요. PREWHERE를 수동으로 추가하기 전에 계획을 검사하고, 그 효과를 측정할 때 read_rows뿐만 아니라 read_bytes도 사용해요.
추가 인덱스와 데이터 레이아웃 옵션 평가
순서 키가 중요한 접근 패턴을 효율적으로 지원할 수 없으면, 다음의 더 특화된 옵션을 평가해요. 데이터 관리와 프루닝을 위한 파티셔닝 파티셔닝은 주로 보존, 이동, 삭제 같은 작업을 위한 데이터 관리 메커니즘이에요. 필터가 ClickHouse가 전체 파티션을 제외할 수 있게 하면 쿼리 작업을 줄일 수 있지만, 쿼리를 가속화하기 위한 첫 메커니즘이어서는 안 돼요. 예를 들어 월별 파티션은 보존도 월 단위로 관리할 때 전체 월을 삭제하는 것을 지원할 수 있어요. 파티션 키가 데이터 수명 주기 요구나 잘 이해된 접근 패턴과 정렬될 때만 파티셔닝을 고려해요. 카디널리티를 낮게 유지해요: 카디널리티가 높은 키는 파티션 간에 병합할 수 없는 많은 part를 만들어 성능을 저하시킬 수 있어요. EXPLAIN indexes = 1을 사용해 쿼리가 실제로 파티션을 프루닝하는지 확인해요. 지역화된 필터를 위한 데이터 스킵 인덱스 추가 데이터 스킵 인덱스는 ClickHouse가 필터와 일치할 수 없는 블록 읽기를 피할 수 있게 해주는 메타데이터를 저장해요. 순서 키가 중요한 필터를 지원하지 못하고 일치 값이 블록 내에 충분히 지역화되어 있을 때 가장 유용해요. 예를 들어 블룸 필터 인덱스는 대부분의 블록이 검색 값을 포함하지 않을 때 동등 조회에 도움이 돼요. 데이터 타입과 순서 키를 검토한 후 스킵 인덱스를 사용해요. 블록을 거의 제외하지 않는 인덱스는 작업을 많이 줄이지 않으면서 저장·평가 오버헤드를 추가해요. 대표 데이터로 인덱스 타입과 granularity를 테스트한 다음 EXPLAIN indexes = 1로 선택된 granule을 비교하고 read_rows, read_bytes, 지속 시간을 확인해요. 프로젝션은 선택적으로 사용 프로젝션은 테이블 옆에 대체 데이터 레이아웃을 저장해요. 다른 순서 키나 미리 계산된 결과를 제공할 수 있고, ClickHouse는 쿼리가 직접 참조하지 않아도 적용 가능한 프로젝션을 선택할 수 있어요. 예를 들어 payment_type으로 정렬된 프로젝션은 기본 테이블의 순서가 지원하지 않는 반복 필터를 지원할 수 있어요. 기본 순서가 효율적으로 제공할 수 없는 중요한 접근 패턴에 소수의 프로젝션을 사용해요. 프로젝션은 추가 인덱스·컬럼 데이터를 저장하고 삽입·병합 중 작업을 추가해요; 전체 컬럼 프로젝션은 저장하는 컬럼을 중복해요. 프로젝션을 많이 사용하면 쿼리 시 최적 프로젝션을 고르는 데 필요한 작업도 늘 수 있어요. 많은 별개의 접근 패턴이 있는 대규모 배포에서는 더 적은 프로젝션이나 별도의 목적별 테이블이 운영하기 쉬운 경우가 많아요. 두 메커니즘 사이를 고를 때는 Materialized views 대 projections를 참고하세요. 원본 테이블을 계속 조회하면서 지불 유형과 픽업 시간으로 필터링하는 쿼리를 위한 대체 순서를 추가해요:
ALTER TABLE nyc_taxi.trips_small_inferred
ADD PROJECTION trips_by_payment_type
(
SELECT
payment_type,
pickup_datetime,
trip_distance,
total_amount
ORDER BY (payment_type, pickup_datetime)
);
ALTER TABLE nyc_taxi.trips_small_inferred
MATERIALIZE PROJECTION trips_by_payment_type;
프로젝션을 구체화하면 기존 데이터에 대해 채워지고, 이후 삽입은 자동으로 유지해요. 원본 테이블에 대해 대표 쿼리를 반복하고 EXPLAIN projections = 1을 사용해 ClickHouse가 프로젝션을 선택하고 더 적은 행이나 바이트를 읽는지 확인해요. 패턴을 광범위하게 적용하기 전에 삽입·저장 오버헤드도 측정해요.
반복 가능한 작업 미리 계산하기
- 언제 사용: 같은 변환이나 집계가 반복적으로 쿼리 시간을 지배할 때.
- 변경: 반복 계산을 수집, 예약 갱신, 또는 목적별 데이터 레이아웃으로 옮겨요.
- 검증: 쿼리가 더 작은 결과를 읽고 쿼리 시 더 적은 계산을 수행하는지, 수집·갱신 작업은 여전히 허용 가능한지 확인해요.
결과를 어떻게 유지·접근할지에 따라 선택해요. 이 옵션들은 상호 배타적이지 않아요:
| 필요할 때 | 시작할 곳 |
|---|---|
| 데이터가 도착하면 갱신되는 결과 | Incremental materialized view |
| 허용 가능한 지연이 있는 주기적 재계산 | Refreshable materialized view |
| 독립적인 스키마, 순서 키, 또는 수명 주기 | 목적별 테이블 |
각 섹션에는 기본 구현, 주요 운영상의 트레이드오프, 결과 검증 방법이 포함돼요.
Incremental materialized view
반복 필터·변환·집계가 데이터가 도착할 때 최신 상태로 유지되어야 할 때 incremental materialized view를 사용해요. 새로 삽입된 각 블록을 처리하고 변환된 결과를 대상 테이블에 써요. 트레이드오프는 추가 수집 작업과 명시적인 대상 테이블이에요. 예를 들어 일별 여행 수를 반복적으로 세는 대시보드는 매 요청마다 원본 데이터를 그룹화하는 대신 작은 집계 테이블에서 읽을 수 있어요:
CREATE TABLE nyc_taxi.trips_by_day
(
pickup_date Date,
trip_count UInt64
)
ENGINE = SummingMergeTree
ORDER BY pickup_date;
CREATE MATERIALIZED VIEW nyc_taxi.trips_by_day_mv
TO nyc_taxi.trips_by_day
AS SELECT
toDate(assumeNotNull(pickup_datetime)) AS pickup_date,
count() AS trip_count
FROM nyc_taxi.trips_small_inferred
WHERE pickup_datetime IS NOT NULL
GROUP BY pickup_date;
백그라운드 병합을 기다리는 행이 쿼리 시 결합되도록 pickup_date로 그룹화된 sum(trip_count)으로 대상 테이블을 조회해요. 뷰는 새 삽입만 처리하므로 기존 원본 데이터는 별도로 백필해요. 변경을 원래 집계와 지속 시간·읽은 행을 비교해 검증한 다음, 추가 수집 작업이 허용 가능한지 확인해요.
Refreshable materialized view
약간 오래된 결과가 허용되고 전체 결과를 실용적인 간격으로 재계산할 수 있을 때 refreshable materialized view를 사용해요. 스케줄에 따라 쿼리를 다시 실행해요. 트레이드오프는 결과 신선도와 각 갱신 비용이에요. 예를 들어 보고서는 지불 유형별 여행 합계를 매 시간마다 재구축할 수 있어요:
CREATE TABLE nyc_taxi.trips_by_payment_type
(
payment_type Int64,
trip_count UInt64
)
ENGINE = MergeTree
ORDER BY payment_type;
CREATE MATERIALIZED VIEW nyc_taxi.trips_by_payment_type_mv
REFRESH EVERY 1 HOUR
TO nyc_taxi.trips_by_payment_type
AS SELECT
assumeNotNull(source.payment_type) AS payment_type,
count() AS trip_count
FROM nyc_taxi.trips_small_inferred AS source
WHERE source.payment_type IS NOT NULL
GROUP BY payment_type;
ClickHouse가 스케줄에 따라 전체 결과를 갱신하는 동안 보고서는 미리 계산된 대상을 읽어요. 변경을 원래 집계와 쿼리 지속 시간을 비교해 검증한 다음, system.view_refreshes를 검사해 갱신 지속 시간, 상태, 빈도가 워크로드에 적합한지 확인해요.
목적별 테이블
별도의 워크로드가 실질적으로 다른 스키마, 순서 키, 수명 주기가 필요할 때 목적별 테이블을 사용해요. 물리적 설계에 대한 명시적인 제어를 제공하고 많은 프로젝션을 유지하는 것보다 명확할 수 있어요. 트레이드오프는 추가 저장과 파이프라인 관리예요. 원본 데이터와 신선도 요구가 실용적일 때 반복 조인·변환이 수집 파이프라인으로 옮겨갈 수도 있어요. 자세한 설계 지침은 Materialized views 사용과 데이터 비정규화를 참고하세요. 예를 들어 지불 유형과 픽업 시간으로 여행을 필터링하는 대시보드를 위해 정렬된 더 좁은 테이블을 만들어요:
CREATE TABLE nyc_taxi.trips_for_payment_dashboard
ENGINE = MergeTree
ORDER BY (payment_type, pickup_datetime)
AS SELECT
assumeNotNull(source.payment_type) AS payment_type,
assumeNotNull(source.pickup_datetime) AS pickup_datetime,
trip_distance,
total_amount
FROM nyc_taxi.trips_small_inferred AS source
WHERE source.payment_type IS NOT NULL
AND source.pickup_datetime IS NOT NULL;
이 예제는 null 순서 키 값을 제외하고 그 두 대상 컬럼에서 Nullable을 제거해요. 이 처리가 워크로드의 데이터 요구와 일치하는지 확인해요. 대시보드는 이 테이블을 명시적으로 조회해야 하고, 수집 파이프라인이 이를 최신 상태로 유지해야 해요. 원본 테이블 쿼리와 행·바이트 읽기, 메모리 사용량, 지속 시간을 비교해 변경을 검증해요. 추가 저장과 파이프라인 유지보수를 결정에 포함해요.
다음 단계 (Next steps)
변경을 평가할 때는 비슷한 조건에서 원래 측정값을 반복해요. 변경이 의도한 작업을 줄이면서 병목을 다른 곳으로 옮기지 않는지 확인해요. 워크드 최적화 예제로 계속해 원래 기준선에 대해 측정한 스키마와 순서 키 변경을 확인해보세요.