본문으로 건너뛰기
clickhouse검색
핵심 구조

MergeTree 가족 (Replacing, Summing, Aggregating, Collapsing, VersionedCollapsing)

머지 시점에 중복 제거, 합산, 집계 상태 병합, 상쇄를 수행하는 MergeTree 변형 엔진의 동작과 선택 기준, FINAL의 비용과 대안, 각 엔진에서 흔히 생기는 오해를 정리합니다.

ReplacingMergeTreeSummingMergeTreeAggregatingMergeTreeCollapsingMergeTreeFINAL2026-10 기준, 약 11분

공통 원리: 머지 때 일어난다

MergeTree 변형 엔진은 모두 같은 정렬 키를 가진 행들을 머지 시점에 처리합니다. INSERT 직후에는 중복이 그대로 있고, 머지가 언제 어떤 파트를 합칠지는 백그라운드가 정하므로, 쿼리 결과가 "결국에는" 정리되지만 "지금 당장" 정리되어 있다는 보장은 없습니다. 이 한 문장을 이해하면 가족 엔진의 오해 대부분이 풀립니다.

엔진같은 키의 행을대표 용도
ReplacingMergeTree([ver[, is_deleted]])하나만 남김 (버전이 큰 것)변경 사항 CDC, 최신 상태 테이블
SummingMergeTree([columns])숫자 열을 합산카운터, 단순 롤업
AggregatingMergeTree집계 상태(AggregateFunction)를 병합uniq, quantile 등 복합 집계 롤업
CollapsingMergeTree(sign)sign이 +1과 -1인 쌍을 상쇄변경 이력 누적을 상태로 접기
VersionedCollapsingMergeTree(sign, ver)순서 무관하게 쌍을 상쇄비순서 적재되는 상태 변화
GraphiteMergeTreeGraphite 롤업 규칙 적용Graphite 저장소

여기서 "같은 키"는 정렬 키(ORDER BY) 전체를 뜻합니다. 기본 키가 아닙니다.

ReplacingMergeTree

가장 많이 쓰는 변형입니다. 정렬 키가 같은 행 가운데 ver 열이 가장 큰 행(없으면 마지막에 들어온 행)만 남깁니다.

CREATE TABLE users
(
    user_id    UInt64,
    email      String,
    plan       LowCardinality(String),
    updated_at DateTime64(3),
    is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at, is_deleted)
ORDER BY user_id;

-- 최신 상태 읽기
SELECT * FROM users FINAL WHERE plan = 'pro';
  • is_deleted 열을 두 번째 인수로 주면 값이 1인 최신 행은 머지 때 함께 사라집니다(clean_deleted_rows 설정 또는 FINAL 조회 시 제거). CDC로 DELETE 이벤트를 받을 때 쓰는 패턴입니다.
  • ver가 같으면 나중에 들어온 행이 남습니다. 동시에 들어오는 갱신이 있으면 ver에 밀리초나 LSN처럼 단조 증가 값을 씁니다.
  • 중복 제거는 같은 파티션 안에서만 일어납니다. 갱신 행이 다른 파티션(다른 월)에 들어가면 영원히 둘 다 남습니다. 상태 테이블은 보통 파티션을 두지 않거나 키 해시로 둡니다.

FINAL의 비용과 대안

FINAL은 읽기 시점에 모든 파트를 병합 정렬하며 중복을 걷어 냅니다. 최근 버전은 FINAL이 병렬로 처리되고 파티션이 분리된 범위를 건너뛰어 예전보다 훨씬 빠르지만, 여전히 일반 조회보다 느리고 메모리를 더 씁니다.

방법설명적합
SELECT ... FINAL정확. do_not_merge_across_partitions_select_final = 1로 파티션 간 병합 생략상태 테이블 조회 전반
argMax(col, ver) GROUP BY key집계로 최신 행 뽑기. FINAL보다 빠른 경우 많음특정 열 몇 개만 필요할 때
LIMIT 1 BY key + ORDER BY ver DESC키별 최신 한 행상위 N 패턴
그냥 조회중복 허용대략의 집계, 중복이 결과에 거의 영향 없을 때
-- FINAL 없이 최신 상태
SELECT user_id, argMax(plan, updated_at) AS plan, argMax(email, updated_at) AS email
FROM users
GROUP BY user_id;

팁 테이블 설정 apply_final_by_default 또는 세션 설정 final = 1을 켜면 그 테이블을 읽는 모든 쿼리에 FINAL이 자동으로 붙습니다. BI 도구가 FINAL을 못 붙일 때 유용합니다.

SummingMergeTree

정렬 키가 같은 행의 숫자 열을 더해 한 행으로 만듭니다. 더할 열을 지정하지 않으면 정렬 키가 아닌 모든 숫자 열이 대상입니다. 숫자가 아닌 열은 임의의 한 행 값이 남습니다.

CREATE TABLE daily_counts
(
    day      Date,
    event    LowCardinality(String),
    country  LowCardinality(String),
    cnt      UInt64,
    bytes    UInt64
)
ENGINE = SummingMergeTree((cnt, bytes))
ORDER BY (day, event, country);

-- 읽을 때는 항상 다시 합산
SELECT day, event, sum(cnt) FROM daily_counts GROUP BY day, event;
  • 읽을 때 반드시 sum()으로 다시 집계합니다. 머지가 끝나지 않은 행이 여럿일 수 있습니다.
  • 모든 집계 열이 0이 되는 행은 머지 때 삭제됩니다.
  • 평균이나 고유 수는 못 합니다. sum과 count를 따로 저장해 나누거나, AggregatingMergeTree로 갑니다.
  • Map 타입 열은 키별로 합산됩니다. 라벨별 카운터에 편리합니다.

AggregatingMergeTree

AggregateFunction(f, T) 타입 열에 집계의 중간 상태를 저장하고, 머지 때 상태를 병합합니다. uniq, quantile, avg, argMax처럼 단순 합으로 안 되는 집계를 롤업할 때 씁니다. 보통 머티리얼라이즈드 뷰의 대상 테이블로 쓰입니다.

CREATE TABLE hourly_stats
(
    hour    DateTime,
    event   LowCardinality(String),
    users   AggregateFunction(uniq, UInt64),
    p95     AggregateFunction(quantile(0.95), UInt32),
    cnt     SimpleAggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (hour, event);

CREATE MATERIALIZED VIEW hourly_stats_mv TO hourly_stats AS
SELECT toStartOfHour(ts) AS hour, event,
       uniqState(user_id) AS users,
       quantileState(0.95)(duration) AS p95,
       count() AS cnt
FROM events
GROUP BY hour, event;

-- 읽기: -Merge 접미사
SELECT hour, event, uniqMerge(users) AS users, quantileMerge(0.95)(p95) AS p95, sum(cnt)
FROM hourly_stats
GROUP BY hour, event
ORDER BY hour;
  • 쓸 때 -State, 읽을 때 -Merge. 쌍이 맞지 않으면 오류나 엉뚱한 값이 나옵니다.
  • SimpleAggregateFunction은 sum, max, min, any, anyLast, groupBitOr처럼 중간 상태가 값 자체인 함수에 쓰며, 상태 직렬화가 없어 가볍습니다.
  • 상태 열은 압축이 잘 안 되고(특히 uniq 상태) 크기가 큽니다. 롤업 단위(시간, 일)를 너무 잘게 잡지 마세요. uniqCombined나 uniqHLL12 상태는 uniq보다 작고 정확도 손실이 작습니다.

컴비네이터와 상태 함수는 집계 함수와 컴비네이터에서 자세히 다룹니다.

CollapsingMergeTree와 VersionedCollapsingMergeTree

행에 sign 열(+1 또는 -1)을 두고, 같은 키의 +1과 -1 쌍을 머지 때 상쇄합니다. "이전 상태를 -1로 한 번 더 넣고 새 상태를 +1로 넣는" 방식으로 상태 변경을 누적하면, 머지 뒤에는 최신 상태만 남습니다.

CREATE TABLE sessions
(
    session_id UInt64,
    duration   UInt32,
    pages      UInt16,
    sign       Int8
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY session_id;

-- 상태 갱신: 이전 행을 -1 로 취소하고 새 행을 +1 로
INSERT INTO sessions VALUES (1, 120, 3, -1), (1, 180, 5, 1);

-- 읽기: sign 을 곱해 집계
SELECT session_id, sum(duration * sign) AS duration, sum(pages * sign) AS pages
FROM sessions
GROUP BY session_id
HAVING sum(sign) > 0;
  • CollapsingMergeTree는 -1이 +1보다 먼저 들어왔다고 가정합니다. 순서가 뒤바뀌어 들어오는 분산 적재에서는 VersionedCollapsingMergeTree(sign, version)을 써서 버전으로 쌍을 맞춥니다.
  • 취소 행을 넣으려면 이전 값을 전부 알아야 합니다. 상태의 전체 값을 늘 가지고 있는 소스(애플리케이션 DB의 변경 이벤트)에만 현실적입니다. 그렇지 않으면 ReplacingMergeTree가 훨씬 단순합니다.

어떤 걸 고를까

요구선택
키별 최신 상태, 소스가 전체 행을 보내 줌ReplacingMergeTree(ver, is_deleted)
중복 적재 방지 (같은 행이 두 번 올 수 있음)ReplacingMergeTree, 또는 INSERT 블록 중복 제거 (INSERT 모범 사례)
카운터, 합계 롤업SummingMergeTree
uniq, 분위수, argMax 롤업AggregatingMergeTree + MV
증감이 있는 상태 누적, 취소 행을 만들 수 있음CollapsingMergeTree 또는 Versioned
삭제, 갱신이 가끔일반 MergeTree + 경량 삭제, 경량 갱신

흔한 오해

  1. "ReplacingMergeTree인데 중복이 보여요." 정상입니다. 머지 전이거나 다른 파티션에 있습니다. FINAL이나 argMax로 읽으세요.
  2. "OPTIMIZE FINAL을 매 5분 돌려요." 큰 테이블에서는 디스크를 계속 다시 쓰는 셈입니다. 읽기 쪽에서 해결하세요.
  3. "SummingMergeTree인데 합이 틀려요." 읽을 때 sum() 없이 그대로 보면 머지 안 된 행이 여러 개입니다.
  4. "AggregatingMergeTree 테이블을 그냥 SELECT 했더니 바이너리가 나와요." 상태 열은 -Merge로 읽어야 합니다.
  5. "복제본마다 결과가 달라요." 머지 진행 상태가 복제본마다 다를 수 있습니다. FINAL을 쓰면 같아집니다.

공식 문서

이 문서에서