쿼리 작성 방식을 바꾸는 두 가지 ClickHouse 내부 구조
I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) so I can render the Korean version while preserving the formatting and code blocks?
대부분의 ClickHouse 실수는 SQL 구문에서 비롯되지 않습니다.
잘못된 사고 모델을 사용해서 발생합니다.
ClickHouse는 익숙한 분석용 SQL 데이터베이스처럼 보이지만, 내부적으로는 전통적인 행 기반 시스템과 매우 다르게 동작합니다. PostgreSQL이나 MySQL 감각으로 접근하면 결국 혼란스러운 동작에 마주하게 됩니다: 잘못된 결과, 이상한 중복, 예상치 못한 병합, 혹은 기대한 대로 확장되지 않는 쿼리 등.
특히 두 가지 내부 구조는 제가 테이블을 설계하고 쿼리를 작성하는 방식을 완전히 바꾸어 놓았습니다:
AggregatingMergeTree는 최종 값이 아니라 집계 상태를 저장합니다.argMax는 ClickHouse가 기대하는 방식대로 그룹별 최대값 문제를 해결합니다.
이것들은 트릭이 아니라 엔진이 작동하는 핵심 원리입니다.
1. AggregatingMergeTree는 값이 아니라 상태에 관한 것이다
ClickHouse에서 가장 오해받기 쉬운 엔진 중 하나가 AggregatingMergeTree이다.
언뜻 보면 사전 집계된 결과를 저장하는 방법처럼 보인다. 그래서 다음과 같이 쓰고 싶어질 수 있다:
CREATE TABLE daily_metrics
(
day Date,
avg_delay Float32
)
ENGINE = AggregatingMergeTree()
ORDER BY day;
그런 다음 데이터를 이렇게 삽입한다:
SELECT
toDate(event_time) AS day,
avg(arrival_delay) AS avg_delay
FROM flights
GROUP BY day;
그리고 나중에 다음과 같이 조회한다:
SELECT day, avg_delay FROM daily_metrics;
겉보기엔 합리적으로 보인다. 하지만 개념적으로는 잘못된 것이다.
왜 이렇게 하면 안 되는가
AggregatingMergeTree는 최종 집계값을 저장하지 않는다. 집계 상태—계산의 중간, 병합 가능한 표현—를 저장한다.
ClickHouse는 백그라운드에서 파트를 병합한다. 새로운 데이터가 들어오면:
- 행이 새로운 파트에 추가된다
- 백그라운드 병합이 파트를 비동기적으로 결합한다
- 파트 병합 과정에서 집계 상태가 병합된다
avg()나 sum() 같은 단순 값을 저장하면 ClickHouse가 병합할 것이 없어진다. 엔진이 의도한 동작을 사실상 비활성화한 셈이다.
올바른 사고 모델
값이 아니라 상태를 생각하라.
avg()대신avgState()를 저장한다.sum()대신sumState()를 저장한다.uniq()대신uniqState()를 저장한다.
테이블을 다음과 같이 정의한다:
CREATE TABLE daily_metrics
(
day Date,
avg_delay AggregateFunction(avg, Float32)
)
ENGINE = AggregatingMergeTree()
ORDER BY day;
데이터를 삽입할 때는 이렇게 한다:
SELECT
toDate(event_time) AS day,
avgState(arrival_delay) AS avg_delay
FROM flights
GROUP BY day;
이제 병합 가능한 상태를 저장하고 있다.
조회할 때는 finalize한다:
SELECT
day,
avgMerge(avg_delay) AS avg_delay
FROM daily_metrics
GROUP BY day;
왜 GROUP BY가 여전히 필요한가
또 다른 흔한 혼동은 다음과 같다:
“삽입하기 전에 이미 집계했는데, 조회할 때 왜 아직도
GROUP BY가 필요한가?”
그 이유는 병합이 비동기적으로 이루어지기 때문이다. 같은 day에 대한 상태가 여러 파트에 존재할 수 있다. 최종 집계는 avgMerge() 같은 함수를 사용해 명시적으로 상태를 병합할 때만 일어난다. GROUP BY는 쿼리 시 동일 키에 대한 모든 상태가 올바르게 결합되도록 보장한다.
AggregatingMergeTree가 증분적이고 병합 가능한 집계를 위해 설계되었다는 점을 이해하면 모든 것이 예측 가능해진다:
- 물리화된 뷰가 의미 있게 동작한다
- 백필이 올바르게 수행된다
- 백그라운드 병합이 더 이상 신비롭지 않다
엔진이 고장난 것이 아니다. 사고 모델이 잘못된 것이다.
2. argMax 로 그룹별 최대값을 올바르게 구하기
다른 흔한 분석 요구 사항은 겉보기엔 간단해 보입니다:
“각 그룹마다 최대값을 가진 행을 반환한다.”
예시: “각 항공사마다 도착 지연이 가장 큰 항공편을 반환한다.”
직관적인 접근 방식은 다음과 같이 보일 수 있습니다:
SELECT
airline,
flight_number,
max(arrival_delay)
FROM flights
GROUP BY airline, flight_number;
이 쿼리는 (airline, flight_number) 조합별로 최대 지연 시간을 계산합니다 – 항공사별 최악의 항공편이 아니라요. 또한 항공사당 여러 행이 생성되므로 이후에 추가 필터링이나 서브쿼리가 필요합니다.
ClickHouse‑Native 솔루션
SELECT
airline,
argMax(flight_number, arrival_delay) AS flight_number,
max(arrival_delay) AS max_delay
FROM flights
GROUP BY airline;
argMax(value, weight)는 **“가중치가 최대인 값(value)을 반환한다.”**는 의미입니다.
이 경우:
- 항공사별로 최대
arrival_delay를 추적하고 - 그 최대값에 해당하는
flight_number를 반환하며 - 항공사당 정확히 한 행을 생성합니다
서브쿼리도, 조인도, 행 폭발도 없습니다.
왜 중요한가
argMax는 ClickHouse가 내부적으로 집계를 수행하는 방식과 일치합니다. 값 선택과 집계를 한 번의 패스로 결합합니다. 관계형 재작성(relational rewrite) 관점이 아니라, 의도를 직접 표현하는 집계 함수를 사용하는 것이죠. 이는 더 빠르고 명확합니다.
argMax를 사용하기 시작하면, 많은 복잡한 “그룹당 최적(최악) 항목” 쿼리가 단일 SELECT 로 축소됩니다.
더 큰 패턴
ClickHouse는 단순히 “Postgres보다 빠른” 것이 아닙니다.
다음과 같은 설계 원칙을 가지고 있습니다:
- 불변 파트
- 백그라운드 병합
- 병합 가능한 집계 상태
- 컬럼형 실행
- 전문화된 집계 함수
전통적인 행 기반 데이터베이스처럼 다루면 계속해서 싸우게 될 것입니다. 데이터가 저장되고 병합되는 방식을 생각에 맞추면, 버그와 비효율성의 전체 범주가 사라집니다.
생각
AggregatingMergeTree를 상태 기반 엔진으로 이해하고, 그룹별 로직에 argMax와 같은 함수를 사용하면 ClickHouse에서 데이터를 모델링하는 방식이 바뀝니다.
이것은 예외 상황이 아닙니다. 실제 운영 시스템에서도 나타납니다:
- 사전 집계된 물리화된 뷰
- 스트리밍 인제스트 파이프라인
- 분석 대시보드
- 테넌트별 메트릭
- 최고/최악 보고
ClickHouse는 내부 구조를 이해하는 엔지니어에게 보상을 줍니다.
값을 생각하는 것을 멈추고 상태를 생각하기 시작하면—그리고 엔진이 기반으로 만든 집계 함수를 사용하면—쿼리 설계가 더 간단하고 예측 가능해집니다.
성능도 자연스럽게 따라옵니다.