홈으로 돌아가기

ClickHouse의 CollapsingMergeTree: UPDATE 없이 업데이트하기

이 문서는 CollapsingMergeTree가 UPDATE를 지원하지 않는 ClickHouse에서 집계 데이터(예: 플레이어 잔액)를 업데이트하는 방법을 설명합니다. sign 열 원리(+1/-1), 병합 중 축소 쌍, SUM(amount*sign)을 통한 올바른 읽기를 다룹니다. 분산 시스템에서 행 순서 문제와 VersionedCollapsingMergeTree를 통한 해결(버전 열 사용)에 대해 논의합니다. 성능을 PostgreSQL과 비교합니다.

CollapsingMergeTree: UPDATE 없이 플레이어 잔액 업데이트
Advertisement 728x90

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

1. CollapsingMergeTree가 필요한 이유 — 집계 업데이트의 문제

온라인 카지노로 돌아가 보겠습니다. 각 플레이어는 잔액을 가지고 있습니다. 플레이어가 베팅을 하면 잔액이 감소하고, 승리하면 증가합니다. 관리자가 의심스러운 거래를 취소하면 잔액이 다시 변경됩니다.

일반 데이터베이스(PostgreSQL)에서는 간단히 UPDATE players SET balance = balance - 100 WHERE user_id = 123을 수행하면 됩니다. 간단하고 명확합니다.

하지만 ClickHouse는 데이터를 업데이트할 수 없습니다. 전혀요. 이유가 무엇일까요? ClickHouse는 분석용으로 설계되었으며, 데이터는 추가 전용입니다. 업데이트는 압축된 블록에 데이터가 저장되는 컬럼 기반 스토리지에 치명적입니다. 단일 셀을 변경하려면 전체 블록을 다시 작성해야 합니다.

Google AdInline article slot

그렇다면 잔액을 어떻게 변경할까요? 이전 레코드를 수정하지 않습니다. "이전 변경을 취소"하고 "새로운 변경을 추가"하는 새로운 레코드를 추가합니다. 이를 취소를 통한 변경 구체화라고 합니다.

실생활 비유: 모든 항목이 잉크로 기록된 회계 장부를 상상해 보세요. 지우고 수정할 수 없습니다. 대신 하단에 "#45행 — 오류, 취소됨. 새 #46행 — 올바른 금액"이라고 씁니다. 그런 다음 총계를 계산할 때 취소를 고려하여 모든 행을 읽습니다.

CollapsingMergeTree는 백그라운드 병합 중에 자동으로 "+1"과 "-1" 쌍을 축소하는 ClickHouse 엔진입니다. 취소 쌍을 보고 두 행을 모두 폐기하는 회계사처럼 작동합니다.

Google AdInline article slot

2. sign 열의 원리 — 취소의 수학

CollapsingMergeTree의 주요 아이디어는 두 가지 가능한 값을 가진 특별한 sign 열입니다:

  • +1 — "추가" (현재 버전)
  • -1 — "취소" (구 버전)

데이터 파트 병합 중에 ClickHouse는 동일한 정렬 키(ORDER BY)를 가진 행 중 하나는 sign = +1이고 다른 하나는 sign = -1인 쌍을 찾습니다. 이러한 쌍이 발견되면 두 행이 모두 삭제됩니다. 쌍이 없는 행, 즉 취소되지 않은 행만 남습니다.

이것이 작동하는 이유: 모든 데이터 변경은 이전 버전을 취소하고 새 버전을 추가하는 것으로 표현됩니다. (+1, -1) 쌍은 합이 0이 됩니다. SUM(amount * sign)을 수행하면 이전 버전이 상쇄되고 새 버전만 남습니다.

Google AdInline article slot

복식 부기와의 유사점: 회계에서 각 거래는 차변과 대변으로 두 번 기록됩니다. CollapsingMergeTree도 동일하게 수행합니다. 모든 변경에는 반대가 있습니다. 합산할 때 서로 상쇄됩니다.

3. CREATE TABLE — 자세히 살펴보기

-- 변경 이력이 있는 플레이어 잔액 테이블 생성
CREATE TABLE player_balance
(
    user_id      UInt64,              -- 플레이어 ID
    date         Date,                -- 잔액 변경 날짜
    amount       Int64,               -- 잔액 변경 (+100, -50 등)
    balance_after Int64,              -- 작업 후 잔액 (선택 사항)
    sign         Int8,                -- +1 — 추가, -1 — 취소
    updated_at   DateTime DEFAULT now()  -- 작업 타임스탬프
)
ENGINE = CollapsingMergeTree(sign)    -- 축소 엔진, sign 열 지정
ORDER BY (user_id, date)              -- 그룹화 및 축소를 위한 키

여기서 중요한 점:

  • ENGINE = CollapsingMergeTree(sign) — 유일한 필수 매개변수는 sign 열의 이름입니다(보통 sign 또는 is_active). 이 열은 Int8 유형(-128에서 127까지의 정수)이어야 하지만 실제로는 +1과 -1만 사용됩니다.

  • ORDER BY (user_id, date) — 이 키의 열은 어떤 행이 "쌍"으로 간주되는지 결정합니다. 모든 ORDER BY 열에서 동일한 값을 가지며 sign이 반대(+1과 -1)인 두 행은 축소됩니다.

ORDER BY에 필요한 모든 필드가 포함되지 않으면 어떻게 될까요? 예를 들어 user_id를 포함하지 않으면 다른 사용자의 행이 축소될 수 있습니다. 이는 재앙입니다. 작업을 구별해야 하는 모든 필드는 ORDER BY에 있어야 합니다.

PRIMARY KEY는 왜 안 되나요? 다른 MergeTree 엔진과 동일한 이유로 ORDER BY는 물리적 순서와 병합을 제어하고, PRIMARY KEY(지정된 경우)는 인덱스만 제어합니다.

4. 삽입 — 데이터를 올바르게 업데이트하는 방법

플레이어의 잔액이 1000루블이라고 가정합니다. 100루블을 베팅합니다. 단일 행에서 잔액을 변경하는 대신 두 개의 삽입을 수행합니다:

-- 1단계: 이전 버전의 잔액 취소 (1000이었으나 이제 900이어야 함)
-- 이전 버전: user_id=123, date='2025-06-01', amount = 1000 (작업 전 잔액)
-- 취소하려면 sign = -1인 행 삽입
INSERT INTO player_balance VALUES 
    (123, '2025-06-01', 1000, 1000, -1, now());   -- 이전 잔액 취소

-- 2단계: 새 버전의 잔액 추가 (베팅 후 900)
INSERT INTO player_balance VALUES 
    (123, '2025-06-01', -100, 900, +1, now());   -- 새 버전: 변경 -100, 결과 900

하지만 이것은 불편합니다. 실제로는 "전체 잔액"보다는 "변경" 측면에서 생각하는 것이 더 쉽습니다. 다음은 더 일반적인 패턴입니다:

-- 플레이어가 100루블 베팅 (잔액 감소)
-- amount가 잔액 변경(-100)인 sign = +1 행만 삽입
INSERT INTO player_balance VALUES 
    (123, '2025-06-01', -100, 900, +1, now());

-- 이 베팅을 취소해야 하는 경우 (예: 기술적 오류로 인해)
-- 취소 쌍 삽입
INSERT INTO player_balance VALUES 
    (123, '2025-06-01', -100, 900, -1, now()),   -- 베팅 취소
    (123, '2025-06-01', +100, 1000, +1, now());   -- 잔액 복원

이것이 작동하는 이유: 병합 시간이 되면 ClickHouse는 동일한 user_id, date, amount(amount가 ORDER BY에 있는 경우)와 다른 sign을 가진 행 쌍을 찾아 삭제합니다. 현재 잔액만 남습니다.

중요: 쌍의 정확성은 직접 보장해야 합니다. ClickHouse는 변경 합계가 균형을 이루는지 확인하지 않습니다. 단순히 반대 sign과 동일한 ORDER BY 키를 가진 행을 축소합니다.

5. sign의 SUM을 사용한 SELECT — 올바르게 읽는 방법

데이터를 읽을 때는 sign을 고려하여 집계해야 합니다. 주요 패턴:

-- 각 플레이어의 현재 잔액 가져오기
SELECT 
    user_id,
    SUM(amount * sign) AS current_balance
FROM player_balance
WHERE sign != 0   -- 우발적인 0 필터링 (존재해서는 안 됨)
GROUP BY user_id;

로직 분석:

  • amount * sign — sign = +1이면 항은 amount이고, sign = -1이면 항은 -amount입니다(이전 항목 취소)
  • SUM(...) — 모든 취소 쌍은 합계에서 서로 상쇄됩니다.
  • GROUP BY user_id — 사용자별 집계

FINAL을 사용하지 않는 이유는? ReplacingMergeTree와 달리 CollapsingMergeTree의 경우 항상 SUM(amount * sign)을 사용한 집계를 사용합니다. 이는 sign 수학이 행이 물리적으로 축소되었는지 여부에 의존하지 않기 때문에 병합 전후 모두 올바르게 작동합니다.

실생활 예:

-- 초기 데이터 (병합 전):
-- (123, -100, +1)   — 100루블 베팅
-- (123, -100, -1)   — 베팅 취소
-- (123, +100, +1)   — 잔액 복원

-- 쿼리: SUM(amount * sign) = (-100*1) + (-100*-1) + (100*1) = -100 + 100 + 100 = 100
-- 올바름: 잔액이 100 증가 (베팅 취소로 돈 반환)

집계 없이 이력을 보려면(예: 모든 작업을 시간순으로) 단순히 SELECT *를 수행하면 취소된 행을 포함한 모든 행이 표시됩니다. 이는 정상이며 의도된 것입니다.

6. 함정 — 행 순서 문제

가장 큰 함정: CollapsingMergeTree는 동일한 키를 가진 행이 올바른 순서로 도착해야 합니다. 먼저 +1, 그 다음 -1(또는 그 반대? 알아봅시다).

ClickHouse는 타임스탬프를 확인하지 않습니다. 병합 중에 각 파트 내의 행 순서를 확인합니다. 파트에 쌍(+1, -1)이 포함되어 있으면 축소됩니다. 그러나 +1이 한 파트에 있고 -1이 다른 파트에 있으면 해당 파트가 하나로 병합될 때까지 축소되지 않습니다(시간이 걸릴 수 있음).

분산 시스템에서 이것이 왜 문제인가요?

데이터가 세 대의 다른 서버에서 Kafka(메시지 큐)를 통해 들어온다고 상상해 보세요. 서버 #1이 "100루블 베팅"(+1)을 보냈습니다. 서버 #2가 "베팅 취소"(-1)를 보냈습니다. 서버 #3이 "잔액 복원"(+1)을 보냈습니다. 이들은 다른 ClickHouse 파트에 다른 순서로 도착할 수 있습니다.

한 파트에 +1만 포함되고 다른 파트에 -1이 포함되면 잔액이 일시적으로 잘못될 수 있습니다(합계에 추가 금액이 표시됨). 파트가 병합되면 데이터가 수정되지만 한 시간이 걸릴 수 있습니다.

비유: 두 개의 다른 폴더에 편지를 넣는 것과 같습니다. 한 폴더에는 "100루블 부채"(+1)가 있고, 다른 폴더에는 "부채 탕감"(-1)이 있습니다. 폴더를 하나로 병합할 때까지 회계 시스템은 100루블을 빚진 것으로 생각합니다.

7. VersionedCollapsingMergeTree — 순서 문제 해결

순서 문제를 극복하기 위해 ClickHouse 개발자는 VersionedCollapsingMergeTree를 추가했습니다. 세 번째 열인 버전(보통 version 또는 timestamp)을 추가합니다.

CREATE TABLE player_balance_versioned
(
    user_id  UInt64,
    date     Date,
    amount   Int64,
    version  UInt64,          -- 단조 증가하는 버전 번호
    sign     Int8
)
ENGINE = VersionedCollapsingMergeTree(sign, version)   -- 두 개의 매개변수!
ORDER BY (user_id, date);

작동 방식:

  • 병합 중에 ClickHouse는 동일한 ORDER BY및 동일한 버전을 가진 쌍(+1, -1)을 찾습니다.
  • 행이 동일한 키를 가지지만 다른 버전을 가지면 축소되지 않습니다. 대신 가장 높은 버전(최신 상태)의 행이 남습니다.
  • 버전을 사용하면 행이 순서 없이 도착하더라도 취소가 원래 작업과 동일한 버전을 가지는 한 올바르게 처리할 수 있습니다.

이것이 순서 문제를 해결하는 이유: +1이 -1보다 늦게 도착하더라도 ClickHouse는 다른 버전(또는 동일한 버전이면 축소)을 가지고 있음을 확인합니다. 버전이 동일하면 파트 내 물리적 순서에 관계없이 쌍이 축소됩니다. 버전이 다르면 최신 버전이 남습니다.

버전 사용 예:

-- 작업 1: 100루블 베팅 (버전 1001)
INSERT INTO player_balance_versioned VALUES (123, '2025-06-01', -100, 1001, +1);

-- 동일한 베팅 취소 (동일한 버전 1001, sign = -1)
INSERT INTO player_balance_versioned VALUES (123, '2025-06-01', -100, 1001, -1);

-- 새 올바른 베팅 50루블 (버전 1002)
INSERT INTO player_balance_versioned VALUES (123, '2025-06-01', -50, 1002, +1);

이제 세 행이 모두 다른 순서와 다른 파트에 도착하더라도 동일한 버전으로 병합하는 동안 쌍이 축소됩니다. 버전 1002의 50루블 베팅만 남습니다.

8. 실제 사용 사례: 카지노의 실시간 플레이어 잔액

최대 5초 지연으로 플레이어의 현재 잔액을 정확하게 표시해야 하는 "잔액" 마이크로서비스가 있다고 상상해 보세요.

워크플로:

-- 모든 잔액 작업 테이블
CREATE TABLE balance_operations
(
    user_id      UInt64,
    operation_id String,          -- 고유 작업 ID (베팅, 지급, 취소)
    amount       Int64,           -- 변경 (+1000 승리, -500 베팅)
    version      UInt64,          -- 단조 버전 번호
    sign         Int8,            -- +1 = 새 작업, -1 = 취소
    created_at   DateTime DEFAULT now()
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
ORDER BY (user_id, operation_id, version);

시나리오 1: 플레이어가 100루블 베팅

-- 한 행 삽입 (sign = +1)
INSERT INTO balance_operations VALUES (123, 'bet_001', -100, 1001, +1, now());

시나리오 2: 플레이어가 500루블 승리 (지급)

INSERT INTO balance_operations VALUES (123, 'win_001', +500, 1002, +1, now());

시나리오 3: 관리자가 bet_001 베팅 취소 (플레이어가 속임수?)

-- 이전 베팅 취소 (동일한 operation_id, sign = -1, 동일한 버전 1001)
INSERT INTO balance_operations VALUES (123, 'bet_001', -100, 1001, -1, now());
-- 수정 작업 추가 (100루블 반환)
INSERT INTO balance_operations VALUES (123, 'admin_correction_bet_001', +100, 1003, +1, now());

실시간으로 잔액을 읽는 방법:

-- 대시보드 쿼리 (3초마다 실행)
SELECT 
    user_id,
    SUM(amount * sign) AS current_balance
FROM balance_operations
WHERE user_id = 123 AND created_at > now() - interval 1 day  -- 시간 제한
GROUP BY user_id;

버전을 적절히 사용하면 이 쿼리는 혼란스러운 삽입 순서에도 올바른 잔액을 반환합니다.

9. 접근 방식 비교: 잔액을 위한 CollapsingMergeTree vs ReplacingMergeTree

많은 초보자가 묻습니다: "왜 ReplacingMergeTree를 사용하고 잔액을 행 버전으로 업데이트하지 않습니까?"

비교해 보겠습니다.

특성 CollapsingMergeTree ReplacingMergeTree
변경 표현 방법 두 행: 취소(-1)와 새(+1) 더 높은 버전의 새 행 하나
전체 이력 저장 필요 예, 축소될 때까지 예, 병합될 때까지
읽기 접근 방식 SUM(amount * sign) argMax(amount, version) 또는 FINAL
삽입 복잡성 높음 (쌍 고려 필요) 낮음 (새 버전만)
읽기 복잡성 낮음 (간단한 집계) 높음 (FINAL은 느리거나 argMax)
오류 위험 쌍 불일치 (로직 오류) 버전 비단조 (클라이언트 오류)

CollapsingMergeTree를 선택해야 하는 경우:

  • 동일한 키를 자주 변경해야 하는 경우 (예: 플레이어 잔액이 시간당 100번 변경).
  • 간단한 집계 SUM(amount * sign)을 사용하고 FINAL에 의존하고 싶지 않은 경우.
  • 삽입 순서를 제어하거나 VersionedCollapsingMergeTree를 사용하는 경우.
  • 작업을 롤백해야 하는 경우 (베팅 취소) — ReplacingMergeTree에서는 증가된 버전으로 새 행을 삽입해야 하며, 이는 "취소"를 명시적으로 반영하지 않습니다.

ReplacingMergeTree가 더 나은 경우:

  • 드물게 업데이트하는 경우 (예: 주문 상태: 생성됨 → 결제됨 → 배송됨).
  • 숫자 집계가 아닌 비숫자 변경 가능 속성을 저장하는 경우.
  • 각 레코드의 버전 이력을 확인해야 하는 경우.

잔액 예 — 어떤 것이 더 나은가요? 높은 부하 계정(초당 수천 건의 베팅)의 경우 VersionedCollapsingMergeTree가 더 좋습니다. 예측 가능한 성능과 혼란스러운 순서에서 올바른 동작을 제공합니다.

10. 성능 및 PostgreSQL의 UPDATE보다 나은 경우

CollapsingMergeTree의 성능

  • 삽입: 매우 빠름 (일반 INSERT, 잠금 없음). 업데이트 대신 두 행을 저장하는 비용을 지불하지만 컬럼 기반 데이터베이스에서는 그렇게 나쁘지 않습니다.
  • 집계 읽기: ClickHouse는 amountsign 열만 읽고(컬럼 기반 스토리지!) 빠른 벡터화 계산을 수행합니다. 수십억 행의 경우 1초 미만입니다.
  • 병합: 백그라운드 작업. 삽입에 영향을 주지 않습니다.

PostgreSQL과 비교

PostgreSQL에서 잔액 업데이트:

-- 행 잠금이 있는 원자적 업데이트
UPDATE players SET balance = balance - 100 WHERE user_id = 123;

장점: 간단함, ACID 보장(원자성, 일관성, 격리성, 지속성), 즉시 일관성.

단점: 초당 10,000 업데이트에서 잠금(행 잠금), WAL(미리 쓰기 로그), 진공. IO 한계에 도달합니다.

ClickHouse와 CollapsingMergeTree:

장점: 단일 서버에서 초당 100,000개 이상의 삽입, 데이터 압축(10:1), 잠금 없음, 선형 확장.

단점: 즉시 일관성 없음(병합까지 집계 필요), 더 복잡한 로직(sign, 버전), 최종 일관성 — 시스템이 올바른 상태에 도달하지만 즉시는 아닙니다.

CollapsingMergeTree가 PostgreSQL보다 나은 경우:

  • 매우 많은 업데이트가 필요한 경우 (초당 수천에서 수만).
  • 몇 초의 지연(축소를 위한)이 허용되는 경우.
  • 이미 ClickHouse를 분석에 사용하고 있는 경우.

PostgreSQL이 여전히 더 나은 경우:

  • 엄격한 즉시 일관성이 필요한 경우 (계좌 간 은행 송금).
  • 업데이트가 적은 경우 (<1000/초).
  • 아키텍처를 복잡하게 만들고 싶지 않은 경우.

다음 단계

이제 CollapsingMergeTree와 그 큰 형제인 VersionedCollapsingMergeTree에 대해 알게 되었습니다. 다음 탐구 주제:

  • CollapsingMergeTree와 ReplacingMergeTree 중 선택하는 방법 — 각 작업에 대한 체크리스트.
  • 병합 최적화merge_with_ttl_timeout과 같은 설정으로 쌍이 더 빨리 축소되도록.
  • 패턴: 구체화된 뷰 + CollapsingMergeTree — 다중 레벨 집계용.

요약: CollapsingMergeTree는 강력하지만 규율이 필요한 도구입니다. 삽입 순서나 쌍 정확성의 실수를 용서하지 않습니다. 그러나 올바르게 설정하면(특히 버전 사용) 전통적인 데이터베이스로는 달성할 수 없는 성능을 제공합니다. 황금률을 기억하세요: 항상 SUM(amount * sign)으로 쿼리를 확인하고 분산 시스템에서는 VersionedCollapsingMergeTree를 사용하세요.


이전 글:
다음 글: ClickHouse 파티셔닝: 폴더 수준에서 데이터 관리하기

— Editorial Team

Advertisement 728x90

다음 읽기