홈으로 돌아가기

ClickHouse의 SummingMergeTree와 AggregatingMergeTree

이 문서는 증분 집계를 위한 두 가지 ClickHouse 엔진을 설명합니다: SummingMergeTree는 병합 중 숫자 열을 자동으로 합산하고, AggregatingMergeTree는 복잡한 메트릭(고유값, 평균, 최대값)을 위한 집계 함수 상태를 저장합니다. 구체화된 뷰, GROUP BY를 사용한 안전한 읽기 패턴, 정렬 키 설계의 일반적인 함정에 대해 논의합니다.

SummingMergeTree와 AggregatingMergeTree: 증분 집계
Advertisement 728x90

SummingMergeTree와 AggregatingMergeTree: 고통 없는 증분 집계

1. 집계 엔진이 필요한 이유 — 대규모 데이터 계산 문제

온라인 카지노로 돌아가 봅시다. 매일 플레이어들은 수백만 건의 베팅을 합니다. 대시보드 소유자는 각 사용자가 하루에 몇 번의 베팅을 했고 총 얼마를 베팅했는지 확인해야 합니다.

일반 데이터베이스(PostgreSQL, MySQL)에서는 다음과 같이 작성합니다:

SELECT user_id, date, COUNT(*), SUM(amount)
FROM bets
GROUP BY user_id, date

1억 개의 행이 있는 테이블에서 이러한 쿼리는... 음, 아시겠죠 — 오래 걸립니다. 매우 오래 걸립니다. 데이터베이스가 모든 행을 읽고, 정렬 또는 해싱한 다음 집계를 계산해야 하기 때문입니다.

Google AdInline article slot

ClickHouse가 더 빠르긴 하지만, 마법은 아닙니다. 데이터가 많을수록 GROUP BY는 더 오래 걸립니다. 그리고 보고서가 많고 "지금 당장" 필요하다면 성능이 문제가 됩니다.

아이디어: 집계를 미리 계산해서 저장하면 어떨까요? 그러면 "user_id=123이 어제 몇 번 베팅했는지" 같은 쿼리는 전체 집계가 아닌 단순히 한 행을 SELECT하는 것일 뿐입니다.

이를 위해 ClickHouse는 두 가지 특수 테이블 엔진인 SummingMergeTreeAggregatingMergeTree를 제공합니다. 이 엔진들은 백그라운드에서 데이터 파트 병합 중에 무거운 작업을 대신 수행합니다.

Google AdInline article slot

실생활 비유: 가게에서 판매를 추적한다고 상상해 보세요. 각 판매는 영수증입니다. 주인이 "오늘 얼마나 팔았나요?"라는 보고서를 요청하면 매번 모든 영수증을 뒤질 수 있습니다. 또는 하루가 끝날 때 "오늘 150건 판매, 5000루블"이라고 총계를 적는 노트를 유지할 수도 있습니다. SummingMergeTree는 이 노트를 자동으로 유지하는 것과 같습니다.

2. SummingMergeTree — 자동 합산기

작동 방식

SummingMergeTree는 데이터 파트 병합(백그라운드) 중에 동일한 정렬 키(ORDER BY)를 가진 행들의 숫자 값을 합산하는 엔진입니다.

합산 규칙:

Google AdInline article slot
  • 모든 숫자 열(UInt*, Int*, Float*, Decimal* 타입)이 자동으로 합산됩니다.
  • 다른 열(문자열, 날짜, 배열)은 처음 발견된 행에서 가져옵니다 — 이 점을 기억하는 것이 중요하며, 예상과 다를 수 있습니다.
  • 열이 숫자가 아니지만 어떻게든 집계하려면 SummingMergeTree는 적합하지 않으며 AggregatingMergeTree가 필요합니다.

"Summing"인 이유: 동일한 키를 가진 여러 행이 하나로 축소되고, 그 안의 숫자는 원래 행들의 숫자 합계입니다.

CREATE TABLE — 자세히 살펴보기

-- 사용자별 일일 통계 테이블 생성
CREATE TABLE daily_stats
(
    date         Date,                -- 통계 일자
    user_id      UInt64,              -- 플레이어 ID
    bets_count   UInt64,              -- 일일 베팅 수 (합산됨)
    total_amount Decimal(18,2)       -- 총 베팅 금액 (합산됨)
)
ENGINE = SummingMergeTree()           -- 자동 합산 엔진
ORDER BY (date, user_id)              -- 그룹화 키: 날짜와 사용자별

여기서 중요한 점:

  • ENGINE = SummingMergeTree() — 괄호 안에 합산할 열을 지정할 수 있습니다: SummingMergeTree(bets_count, total_amount). 지정하지 않으면 모든 숫자 열이 합산됩니다(ORDER BY의 열은 제외, 해당 값은 고유성을 결정합니다).

  • ORDER BY (date, user_id) — 이 열들은 어떤 행이 하나로 병합될지 결정합니다. 즉, 동일한 날짜와 동일한 user_id를 가진 모든 행은 병합 중에 하나의 행으로 축소되며, 여기서 bets_counttotal_amount는 합계입니다.

ORDER BY가 너무 넓으면 어떻게 되나요? 예를 들어 bets_count를 포함하면 각 고유한 베팅(다른 카운트를 가진)은 별도의 행으로 남습니다. 키가 다르기 때문에 합산이 발생하지 않습니다. 함정 #1 (나중에 다시 다룹니다).

데이터 삽입 방법

원시 이벤트 삽입 (각 행은 하나의 베팅):

-- 2025-06-01에 사용자 123의 세 번 베팅 삽입
INSERT INTO daily_stats VALUES 
    ('2025-06-01', 123, 1, 100.00),   -- 100루블 베팅 1회
    ('2025-06-01', 123, 1, 250.00),   -- 250루블 베팅 2회
    ('2025-06-01', 456, 1, 50.00);    -- 다른 사용자

-- 집계되지 않은 데이터를 삽입할 수 있습니다 — 엔진이 처리합니다

백그라운드 병합 후(수초에서 수시간 소요 가능), date='2025-06-01'이고 user_id=123인 행들은 하나로 병합됩니다: ('2025-06-01', 123, 2, 350.00).

GROUP BY 없이 읽기 — 마법과 한계

아이디어는 병합이 발생한 후 집계 없이 데이터를 읽을 수 있다는 것입니다:

-- 병합이 이미 발생했다면, 이 쿼리는 사용자별 하루에 한 행을 반환합니다
SELECT date, user_id, bets_count, total_amount
FROM daily_stats
WHERE date = '2025-06-01';

하지만 문제가 있습니다. 병합 사이에 데이터는 중복 키를 가진 다른 파트에 있을 수 있습니다. 따라서 실제로는 여전히 SUM과 함께 작성합니다:

SELECT date, user_id, SUM(bets_count), SUM(total_amount)
FROM daily_stats
WHERE date = '2025-06-01'
GROUP BY date, user_id;

왜 작동하나요? 데이터가 병합되지 않았더라도 SUM이 모든 것을 올바르게 더하기 때문입니다. 병합되었다면 그룹당 하나의 행이 있고 SUM은 단순히 그 값을 반환합니다. 쿼리는 여전히 데이터를 읽지만, 이제는 더 적은 양(원시 행 대신 집계된 행)을 읽습니다.

비유: SummingMergeTree는 동일한 영수증을 미리 붙여주는 조수와 같습니다. 하지만 여전히 "각 날짜의 총계를 보여줘"라고 요청합니다. 영수증이 이미 붙어 있으면 총계는 한 행의 숫자와 일치합니다. 그렇지 않아도 올바른 총계를 얻습니다. 중요한 것은 수백만 개의 영수증이 아니라 수천 개의 요약을 읽는다는 점입니다.

3. 문제: 병합 사이에 SELECT에서 SUM이 필요함

이것은 종종 오해되는 핵심 포인트입니다.

순진한 접근 방식 (잘못됨):

-- 병합 후 데이터가 이미 집계되었다고 생각하여 초보자가 작성:
SELECT * FROM daily_stats WHERE date = '2025-06-01';
-- 그리고 한 사용자에 대해 여러 행을 얻음 (병합되지 않은 경우)

올바른 접근 방식 (안전):

SELECT date, user_id, SUM(bets_count), SUM(total_amount)
FROM daily_stats
GROUP BY date, user_id;

왜?

  1. 병합 전후 모두 항상 올바른 결과를 얻습니다.
  2. 데이터 크기는 여전히 원시 베팅 테이블보다 작습니다.
  3. ClickHouse는 이러한 쿼리를 잘 최적화합니다.

SUM을 생략할 수 있는 경우는? 필요한 데이터가 이미 병합되었음을 절대적으로 확신하는 경우에만 가능합니다. 예를 들어, 강제 OPTIMIZE TABLE daily_stats 후 (하지만 이는 비용이 많이 드는 작업이므로 매번 하지 마십시오).

4. AggregatingMergeTree — 단순 합산으로 충분하지 않을 때

SummingMergeTree는 숫자만 더할 수 있습니다. 하지만 다음이 필요하다면?

  • 고유 사용자 수 (합계가 아님)?
  • 최대 또는 최소 찾기?
  • 평균 계산?
  • 고유 값 계산을 위한 uniq와 같은 근사 알고리즘 사용?

이를 위해 AggregatingMergeTree가 있습니다. 이 엔진은 값 자체가 아니라 집계 함수의 상태 — 나중에 최종 결과를 얻을 수 있는 특수 중간 데이터를 저장합니다.

비유: SummingMergeTree는 최종 합계만 저장합니다. 하지만 AggregatingMergeTree는 합계뿐만 아니라 카운터(나중에 평균을 계산하기 위해) 또는 고유 값의 해시 테이블(나중에 몇 개인지 알려주기 위해)도 저장합니다. 이는 "총계가 있습니다"와 "모든 데이터가 압축된 형태로 기록된 노트가 있습니다"의 차이와 같습니다.

AggregateFunction을 사용한 CREATE TABLE

-- 집계된 대시보드 통계 테이블 생성
CREATE TABLE dashboard_hourly
(
    event_hour     DateTime,                               -- 이벤트 시간
    sport_type     String,                                 -- 스포츠 종목 (축구, 농구...)
    total_bets     AggregateFunction(sum, UInt64),         -- 베팅 수 합계
    total_amount   AggregateFunction(sum, Decimal(18,2)), -- 금액 합계
    unique_users   AggregateFunction(uniq, UInt64),        -- 고유 플레이어 (근사)
    avg_bet_amount AggregateFunction(avg, Decimal(18,2)), -- 평균 베팅 크기
    max_bet        AggregateFunction(max, Decimal(18,2))   -- 최대 베팅
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_hour, sport_type);

낯선 부분 설명:

  • AggregateFunction(sum, UInt64)UInt64 타입 데이터에 대한 집계 함수 sum상태를 저장하는 열 타입입니다. 숫자가 아니라 ClickHouse 내부 구조입니다.
  • 왜 단순히 UInt64가 아닌가? 일부 함수(uniq, avg)의 경우 최종 결과보다 더 많은 데이터를 저장해야 하기 때문입니다. avg는 합계와 개수를 모두 저장합니다. uniq는 해시 테이블을 저장합니다.
  • 동일한 ORDER BY(동일한 시간과 스포츠 종목)를 가진 두 행이 병합될 때 — 집계 함수의 상태가 결합됩니다. sum의 경우 중간 합계를 더하는 것입니다. uniq의 경우 두 고유 값 해시 테이블을 병합하는 것입니다.

State 함수를 사용한 INSERT SELECT로 데이터 삽입

AggregateFunction 열에는 일반 값을 삽입할 수 없습니다. 원시 값에서 상태를 생성하는 특수 *State 함수를 사용해야 합니다.

-- 원시 베팅 테이블에서 집계된 데이터 삽입
INSERT INTO dashboard_hourly
SELECT
    toStartOfHour(event_time) AS event_hour,               -- 시간을 시간 단위로 반올림
    sport_type,
    sumState(bets_count) AS total_bets,                    -- sum의 상태
    sumState(amount) AS total_amount,                      -- 금액 합계의 상태
    uniqState(user_id) AS unique_users,                    -- 고유를 위한 상태
    avgState(amount) AS avg_bet_amount,                    -- 평균을 위한 상태
    maxState(amount) AS max_bet                            -- 최대를 위한 상태
FROM raw_bets
WHERE event_time >= '2025-06-01 00:00:00'
GROUP BY event_hour, sport_type;

여기서 일어나는 일:

  • toStartOfHour(event_time) — 시간을 시간 단위로 자르는 ClickHouse 함수: 2025-06-01 12:34:562025-06-01 12:00:00.
  • sumState(amount)SUM(amount) 대신 sumState(amount)를 작성합니다. 결과는 집계 함수의 상태이며, 타입은 AggregateFunction(sum, ...)입니다.
  • 삽입 쿼리에서 GROUP BY는 필수입니다! 원시 테이블의 데이터를 그룹(시간+스포츠)으로 집계한 다음 각 그룹을 dashboard_hourly에 하나의 행으로 삽입하기 때문입니다.

Merge 함수를 통한 읽기

데이터를 읽으려면 *Merge 함수를 사용합니다:

SELECT
    event_hour,
    sport_type,
    sumMerge(total_bets) AS total_bets,                    -- 상태 → 숫자
    sumMerge(total_amount) AS total_amount,
    uniqMerge(unique_users) AS unique_users,               -- 고유 사용자
    avgMerge(avg_bet_amount) AS avg_bet_amount,
    maxMerge(max_bet) AS max_bet
FROM dashboard_hourly
WHERE event_hour >= '2025-06-01 00:00:00'
GROUP BY event_hour, sport_type;   -- 여전히 그룹화 필요 (데이터가 병합되지 않은 경우)

왜 다시 GROUP BY인가? SummingMergeTree와 같은 이유입니다: 병합 사이에 동일한 ORDER BY를 가진 여러 행이 있을 수 있습니다. *Merge와 함께 GROUP BY를 사용하면 어떤 상태에서든 올바른 결과를 얻을 수 있습니다.

5. 구체화된 뷰(Materialized View)와의 사용 패턴

AggregatingMergeTree를 가장 강력하게 사용하는 방법은 구체화된 뷰와 결합하는 것입니다. 원시 데이터를 일반 테이블에 삽입하면 뷰가 자동으로 집계하여 집계 테이블에 저장합니다.

비유: 컨베이어 벨트를 설정하는 것과 같습니다: 원시 영수증은 한 상자에 들어가고, 자동 분류기가 매분 일일 총계를 수집하여 다른 상자에 넣습니다. 분석가는 두 번째 상자만 봅니다 — 빠르고 즉석에서 GROUP BY할 필요 없습니다.

전체 예제: 운영자 대시보드를 위한 시간별 통계

1단계: 원시 테이블 — 이벤트(베팅)가 여기에 삽입됩니다

CREATE TABLE raw_bets
(
    event_time DateTime,
    sport_type String,
    user_id UInt64,
    amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY event_time;

2단계: 집계 테이블 — 준비된 통계가 여기에 저장됩니다

CREATE TABLE bets_hourly_agg
(
    hour DateTime,
    sport_type String,
    total_bets AggregateFunction(sum, UInt64),
    total_amount AggregateFunction(sum, Decimal(18,2)),
    unique_users AggregateFunction(uniq, UInt64),
    avg_bet AggregateFunction(avg, Decimal(18,2))
)
ENGINE = AggregatingMergeTree()
ORDER BY (hour, sport_type);

3단계: 구체화된 뷰 — 둘 사이의 다리

CREATE MATERIALIZED VIEW bets_mv TO bets_hourly_agg AS
SELECT
    toStartOfHour(event_time) AS hour,
    sport_type,
    sumState(1) AS total_bets,                    -- 각 행은 하나의 베팅
    sumState(amount) AS total_amount,
    uniqState(user_id) AS unique_users,
    avgState(amount) AS avg_bet
FROM raw_bets
GROUP BY hour, sport_type;

이제 어떻게 되나요?

  1. 일반 INSERTraw_bets에 행을 삽입합니다.
  2. ClickHouse가 자동으로 (거의 즉시) 구체화된 뷰를 통해 실행합니다.
  3. 뷰는 삽입된 배치에서만 데이터를 집계하고 결과를 bets_hourly_agg에 삽입합니다.
  4. bets_hourly_agg에서는 동일한 (hour, sport_type)을 가진 여러 행이 일시적으로 누적될 수 있지만, 백그라운드 병합 중에 병합됩니다.

대시보드 읽기:

SELECT
    hour,
    sport_type,
    sumMerge(total_bets) AS total_bets,
    sumMerge(total_amount) AS total_amount,
    uniqMerge(unique_users) AS unique_users,
    avgMerge(avg_bet) AS avg_bet
FROM bets_hourly_agg
WHERE hour >= today() - 7
GROUP BY hour, sport_type;

이 쿼리는 집계된 데이터만 읽으며, 원시 베팅보다 수천 배 적은 공간을 차지합니다.

6. 실제 예제: 스포츠 이벤트 대시보드

당신이 북메이커 운영자라고 상상해 보세요. 대시보드에 다음을 표시해야 합니다:

  • 각 경기(축구, 챔피언스리그, "레알" 대 "바이에른")에 대해
  • 지난 5분 동안 얼마나 많은 베팅이 이루어졌는지
  • 모든 베팅의 총 금액
  • 고유 플레이어 수
  • 평균 베팅

원시 데이터: 초당 5000건의 베팅. 모든 것을 저장하고 매번 처음부터 집계하는 것은 미친 짓입니다.

해결책:

-- 5분 간격으로 경기별 집계 테이블
CREATE TABLE match_stats_5min
(
    match_id String,
    interval_5min DateTime,
    total_bets AggregateFunction(sum, UInt64),
    total_amount AggregateFunction(sum, Decimal(18,2)),
    unique_users AggregateFunction(uniq, UInt64),
    max_bet AggregateFunction(max, Decimal(18,2))
)
ENGINE = AggregatingMergeTree()
ORDER BY (match_id, interval_5min);

-- 구체화된 뷰
CREATE MATERIALIZED VIEW match_stats_mv TO match_stats_5min AS
SELECT
    match_id,
    toStartOfFiveMinute(event_time) AS interval_5min,
    sumState(1) AS total_bets,
    sumState(amount) AS total_amount,
    uniqState(user_id) AS unique_users,
    maxState(amount) AS max_bet
FROM raw_bets
GROUP BY match_id, interval_5min;

이제 대시보드는 match_stats_5min을 쿼리하여 초 대신 밀리초 단위로 응답을 받습니다.

7. SummingMergeTree와 AggregatingMergeTree를 사용하지 말아야 할 때

SummingMergeTree가 적합한 경우:

  • 숫자 값만 합산하면 됩니다.
  • 병합 사이에 SUM과 함께 GROUP BY를 사용해도 괜찮습니다.
  • 그룹화 키의 카디널리티가 너무 높지 않습니다(예: 수십억 고유 사용자는 괜찮지만 데이터가 더 많아집니다).

SummingMergeTree가 적합하지 않은 경우:

  • 고유 사용자 수(uniq, count(DISTINCT))를 계산해야 합니다 — AggregatingMergeTree만 작동합니다.
  • 다른 집계(avg, min, max)가 필요합니다 — 역시 AggregatingMergeTree만 가능합니다.
  • 데이터가 항상 이미 집계된 상태일 것으로 예상합니다 — 그렇게 작동하지 않습니다.
  • 데이터가 업데이트됩니다(삽입만이 아님) — 이 엔진들은 업데이트 의미론에 적합하지 않습니다.

AggregatingMergeTree가 적합한 경우:

  • 다양한 유형의 집계(합계, 고유, 평균, 최대)가 필요합니다.
  • *StateINSERT하고 *MergeSELECT하는 것을 기꺼이 할 의향이 있습니다.
  • 자동 집계를 위해 구체화된 뷰를 사용합니다.
  • 원시 데이터의 양이 방대하고 집계가 훨씬 작습니다.

AggregatingMergeTree가 적합하지 않은 경우:

  • 팀에 AggregateFunction이 무엇이고 어떻게 작업하는지 설명할 준비가 되지 않았습니다. 학습 곡선이 더 높습니다.
  • 정확한 고유성이 필요하고 근사가 허용되지 않습니다(uniq는 확률적 구조, 오류 ~2%). 정확한 경우 groupBitmap을 사용하거나 다른 시스템에서 계산하십시오.
  • 데이터 양이 적습니다(수백만 행) — 일반 GROUP BY가 더 간단합니다.
  • 집계 스키마를 자주 변경합니다(새 메트릭 추가) — 구체화된 뷰를 다시 만드는 것이 번거롭습니다.

8. 일반 MergeTree + GROUP BY와 비교

특성 MergeTree + GROUP BY SummingMergeTree AggregatingMergeTree
삽입 속도 최대 높음 중간 (상태로 인해)
읽기 속도 (넓은 범위) 낮음 (모든 것 읽음) 높음 (집계 읽음) 높음
읽기 속도 (포인트) 중간 높음 높음
저장 공간 최대 최소 (집계) 약간 더 많음 (상태)
코드 복잡성 낮음 낮음 (테이블만) 높음 (*State, *Merge)
집계 유연성 모든 것 합계만 모든 것 (AggregateFunction 통해)

9. 일반적인 함정

함정 #1: ORDER BY에 충분한 필드가 포함되지 않음

-- 나쁨: user_id 없이 date만
CREATE TABLE bad_agg ENGINE = SummingMergeTree ORDER BY date;

-- 병합 중에 동일한 날짜의 모든 행이 하나로 축소됨
-- 사용자 수준 세부 정보를 잃음

올바름: 집계하려는 모든 필드를 ORDER BY에 포함하십시오.

함정 #2: SELECT에서 GROUP BY를 잊음

-- 나쁨: 데이터가 병합되지 않았더라도 GROUP BY 없음
SELECT date, SUM(bets_count) FROM daily_stats WHERE date = '2025-06-01';

-- 동일한 날짜지만 다른 user_id를 가진 두 행이 있으면 오류 발생
-- ClickHouse가 어떤 user_id를 표시할지 모름

올바름: 항상 ORDER BY와 동일한 필드로 그룹화하십시오.

함정 #3: 비확률적 uniq

ClickHouse의 uniq는 확률적 함수입니다. 오류 ~2-3%. 정확한 고유성이 필요하면 uniqExact 또는 groupBitmap을 사용하십시오.

함정 #4: 오래된 데이터 업데이트

SummingMergeTreeAggregatingMergeTree는 업데이트를 좋아하지 않습니다. 어제의 베팅을 수정해야 한다면 반대 부호로 새 행을 삽입하는 것이 더 쉽습니다(CollapsingMergeTree 통해).

10. 다음 단계

이제 집계 엔진을 마스터했으므로 다음 주제를 살펴보십시오:

  • 작업에 적합한 엔진을 선택하는 방법 — 모든 *MergeTree 엔진 비교.
  • 구체화된 뷰 자세히 알아보기 — 디버깅 방법, 스키마 업데이트 방법.
  • 백그라운드 병합 튜닝 — 집계가 더 빠르게 축소되도록.

결론: SummingMergeTreeAggregatingMergeTree는 테라바이트 단위의 데이터에서 대시보드가 지연되는 것을 원하지 않는 사람들을 위한 도구입니다. 초기에 약간 더 많은 이해가 필요하지만 실제 워크로드에서 여러 배로 보상받습니다. 주요 규칙: 읽을 때 항상 GROUP BY와 집계 함수를 사용하십시오 — 그러면 병합 전후로 안전합니다.


이전 글:
다음 글: CollapsingMergeTree: ClickHouse에서 UPDATE 없이 집계를 업데이트하는 방법

— Editorial Team

Advertisement 728x90

다음 읽기