ClickHouse 입수와 데이터 저장 (Ingestion & Merge)

ClickHouse 입수와 데이터 저장 (Ingestion & Merge)

ClickHouse 분류
Core Architecture
Type
Research
작성자

Ken

1. ClickHouse의 데이터 입수(Ingestion) 과정과 파티션/데이터 블록 구조

ClickHouse에서 데이터가 INSERT될 때, MergeTree 계열 엔진(예: MergeTree, ReplacingMergeTree 등)은 새로운 데이터 파트(part)를 생성합니다. INSERT 시 입력된 행들은 파티션 키(partition key) 값에 따라 우선적으로 분할되며, 각 파티션별로 별도의 파트가 생성됩니다. 예를 들어 월 단위 파티셔닝(PARTITION BY toYYYYMM)일 경우, 한 번의 INSERT에 여러 월의 데이터가 섞여 있으면 월별로 데이터를 분리해 각각의 파트로 저장합니다.

각 파트 내부 데이터는 기본 키(Primary Key, 정렬 키) 순서로 미리 정렬되어 컬럼 단위 파일들(예: column1.bin, column2.bin 등)로 분할·압축되어 디스크에 기록됩니다. 각 컬럼 파일은 일정한 그레뉼(granule, 기본 8192행) 단위로 구성되고, 그 경계마다 마크(mark) 파일에 오프셋이 기록됩니다. 파티션 키에 사용된 컬럼들은 해당 파트 내 최소/최대값 인덱스 파일이 자동 생성되어 파티션 프루닝(partition pruning)에 활용됩니다.

코드 레벨에서는 StorageMergeTree::write() 함수가 호출되어 새로운 파트를 임시 디렉토리에 생성한 뒤, IMergedBlockOutputStream을 통해 입력 블록을 정렬 키 순으로 정렬, 컬럼별로 직렬화 및 압축하여 파일에 기록합니다. 모든 파일과 인덱스, 체크섬 등이 완성되면 임시 파트를 최종 위치로 이동시키고 메타데이터를 갱신합니다. 이 과정에서 생성된 각 파트는 **불변(immutable)**이며, 기존 파트를 직접 수정하지 않고 항상 새로운 파트가 추가되는 방식으로 동작합니다.

2. MergeTree 엔진의 Merge(병합) 로직과 컴팩션(Compaction)

출처: Alibaba Cloud
출처: Alibaba Cloud

MergeTree 계열 엔진은 **백그라운드에서 주기적으로 파트 병합(merge)**을 수행합니다. 작은 파트가 많이 쌓이면 조회 시 I/O 비용이 증가하므로, ClickHouse는 동일 파티션 내의 여러 파트를 모아 더 큰 파트로 합치는 컴팩션을 지속적으로 진행합니다. 병합은 항상 파티션 경계 내에서만 이루어지며, 서로 다른 파티션의 데이터는 함께 병합되지 않습니다.

병합 작업의 주요 단계는 다음과 같습니다.

  1. 병합할 파트 선정: 내부 히유리스틱과 MergeSelector 정책에 따라, 같은 파티션 내의 작은 파트 여러 개를 선택합니다. 너무 큰 파트는 제외하여 디스크 증폭을 방지합니다. 병합 후보 선정은 MergeTreeDataMergerMutator::selectPartsToMerge 함수에서 이루어집니다.
  2. 메모리상 병합: 선택된 파트들의 데이터를 메모리에 디컴프레스 및 적재한 뒤, 정렬 키 순으로 병합합니다. 엔진 종류에 따라 특수 로직이 적용될 수 있습니다. 예를 들어, ReplacingMergeTree는 동일 키의 중복 행 중 최신 행만 남기고 나머지는 버립니다. 메모리 부족 시에는 수직 병합(vertical merge) 최적화가 적용되어 일부 컬럼만 부분적으로 병합하기도 합니다.
  3. 병합 결과 파트 생성: 병합된 데이터를 새로운 파트로 디스크에 저장합니다. 파트 이름에는 병합된 원래 파트들의 범위와 병합 레벨(level)이 반영됩니다.
  4. 메타데이터 갱신 및 파트 정리: 새 파트를 테이블에 등록하고, 기존 파트들은 비활성화 처리합니다. 일정 시간이 지나면 백그라운드 클린업 스레드가 비활성 파트를 삭제하여 디스크 공간을 회수합니다. 병합 도중 장애가 발생하면 미완성 파트는 detached/ 디렉토리에 보관되어 수동 복구·삭제가 가능합니다.

병합 빈도와 리소스 사용량은 여러 설정값(예: max_bytes_to_merge_at_max_space_in_pool, background_pool_size, max_background_merges 등)으로 제어할 수 있습니다. 예를 들어 background_pool_size를 늘리면 병합 스레드 수를 증가시켜 대규모 병합 작업을 병렬로 처리할 수 있습니다[1][2].

3. FINAL 키워드 미사용 시 데이터 정합성(consistency) 문제와 대응

3.1. 문제의 본질

ReplacingMergeTree, CollapsingMergeTree 등은 중복 제거 및 삭제 처리를 즉시 보장하지 않고 "eventual consistency" 모델을 따릅니다. 즉, INSERT 시 중복 데이터가 허용되고, 백그라운드 병합이 완료되기 전까지는 동일 키의 여러 버전의 행(혹은 삭제된 행)이 공존할 수 있습니다. SELECT 시 FINAL 키워드를 생략하면, 이러한 중복 또는 이전 버전의 데이터가 그대로 노출됩니다.

예를 들어, ReplacingMergeTree에서 동일 기본키의 여러 버전이 존재할 때, 백그라운드 병합이 완료되지 않았다면 SELECT 결과에 중복 행이 나타나거나, 오래된 값이 반환될 수 있습니다. 공식 예시에서는 10,000개 행 중 5,000개를 업데이트(중복 삽입)하고 1,000개를 삭제한 직후 COUNT를 하면, 병합이 되지 않은 상태에서는 16,000행(중복 포함)이 조회되고, FINAL을 붙이면 9,000행(최신 상태)만 반환됩니다.

CollapsingMergeTree의 경우도, 삭제를 의미하는 Sign=-1 레코드가 병합되지 않았다면 원본 Sign=1 레코드와 함께 공존하여 SELECT 결과에 삭제되지 않은 것처럼 보일 수 있습니다. 이러한 엔진들은 즉각적인 중복 제거/삭제 처리를 보장하지 않으므로, 사용자가 FINAL을 명시하거나 병합이 완료되기를 기다려야만 논리적으로 일관된 결과를 얻을 수 있습니다[3][4].

3.2. 대응 방안 및 대체 전략

(1) SELECT 시 FINAL 사용

가장 확실한 방법은 SELECT 쿼리에서 FINAL 키워드를 사용하는 것입니다. FINAL은 쿼리 실행 시 해당 파티션의 모든 파트를 논리적으로 병합하여 중복 및 삭제 대상 행을 제거합니다. 단, FINAL은 상당한 추가 비용(특히 WHERE 절에 정렬 키가 없을 때 전체 파트 스캔 및 deduplication 필요)으로 인해 쿼리 성능이 저하될 수 있습니다. 최신 ClickHouse에서는 FINAL 사용 시 PREWHERE 최적화가 미흡하다는 이슈가 있었으나, 관련 개선이 진행 중입니다[5].

(2) 주기적인 OPTIMIZE … FINAL 실행

OPTIMIZE TABLE mytable FINAL 명령을 주기적으로 수동 실행하여, 병합이 되지 않은 파트들도 강제로 한 개로 합쳐 즉시 중복을 제거할 수 있습니다. 그러나 대용량 테이블에 대해 OPTIMIZE FINAL은 매우 무거운 작업이 될 수 있으므로, 운영 환경에서는 스케줄링 및 리소스 모니터링이 필요합니다.

(3) 쿼리 설계 및 엔진 선택

  • CollapsingMergeTree에서는 집계 쿼리에서 Sign 값을 활용(SUM(amount * Sign) 등)하여 중복 없이 최종 결과를 얻을 수 있습니다.
  • SummingMergeTree, AggregatingMergeTree 등에서도 anyLast, maxBy 등의 집계함수를 활용하여 최신값만 취합하도록 쿼리를 구성할 수 있습니다.
  • 쿼리가 복잡해지고 제한적인 시나리오에만 적용 가능하므로, 기본적으로는 데이터 모델링 단계에서 중복 유입을 최소화하는 것이 권장됩니다.

(4) 애플리케이션 또는 파이프라인 레벨 중복 억제

  • 데이터 적재 전 애플리케이션 레벨에서 중복을 제거하거나, Kafka 등에서 at-least-once로 중복 유입 시 임시 테이블+머지 뷰 파이프라인을 통해 일정 시간 내부 dedup 후 본 테이블로 삽입하는 아키텍처를 적용할 수 있습니다.
  • ReplicatedMergeTree의 insert_deduplicate 설정을 통해 블록 단위 dedup이 가능하나, 최근 몇 개 삽입만 체크하므로 한계가 있습니다[3].

4. MergeTree 병합(merge) 성능 최적화 및 최근 동향

  • 병합 스레드(pool) 조정: background_pool_size, max_background_merges 등 설정을 통해 병합 스레드 수와 병렬성을 조정할 수 있습니다. 대용량 서버에서는 background_pool_size 값을 높여 병합 처리량을 극대화할 수 있습니다.
  • 파트 수 제어: parts_to_throw_insert, parts_to_delay_insert 등 설정을 통해 파트 수가 일정 임계치를 넘으면 병합을 더 적극적으로 수행하거나 INSERT를 지연시킬 수 있습니다[1][2].
  • 병합 크기 조정: max_bytes_to_merge_at_max_space_in_pool 등의 설정으로 병합 시 생성되는 파트의 최대 크기를 제어할 수 있습니다.
  • PREWHERE 최적화: FINAL 쿼리에서 PREWHERE 절의 최적화가 부족하다는 이슈가 있었으며, 2024~2025년에도 관련 개선 PR이 논의되고 있습니다. PREWHERE를 활용한 쿼리 최적화가 병합 성능에 긍정적 영향을 줄 수 있습니다[5].

5. 참고 자료 및 실무 적용 시 고려사항

  • ReplacingMergeTree Explained: The Good, The Bad & The Ugly 등 Altinity의 공식 블로그 및 Knowledge Base는 ReplacingMergeTree의 동작 원리, FINAL의 의미, 병합 정책, 파티셔닝 전략 등 실무에 유용한 정보를 제공합니다[6][3][4].
  • GitHub 이슈 트래커(#31411 등)에서는 FINAL 쿼리의 PREWHERE 최적화, 병합 정책 개선, 대용량 파트 관리 등 최신 이슈와 개선 사항이 활발히 논의되고 있습니다[5].
  • 공식 문서(2025년 8월 현재 일부 링크는 404이나, 기존 문서 기준)에서는 파트 구조, 파티셔닝, 병합 프로세스, 엔진별 특성 등 저장 엔진 아키텍처의 전체적인 맥락을 제공합니다.

결론

ClickHouse의 MergeTree 기반 테이블은 고속 입수와 조회를 위해 파트 기반 저장 및 백그라운드 병합 구조를 사용하며, 이는 대용량 데이터 환경에서 뛰어난 성능을 제공합니다. 그러나 ReplacingMergeTree, CollapsingMergeTree 등 일부 엔진에서는 "eventual consistency" 모델로 인해, FINAL 키워드 없이 조회할 경우 중복, 이전 버전, 미삭제 데이터가 노출될 수 있습니다. 실무에서는 FINAL 쿼리, OPTIMIZE FINAL, 쿼리 설계, 파이프라인 dedup, 병합 설정 최적화 등 다양한 전략을 병행하여 데이터 정합성과 성능을 균형 있게 관리해야 합니다. 최신 ClickHouse에서는 FINAL 쿼리의 PREWHERE 최적화 등 성능 개선이 지속적으로 이루어지고 있으므로, 관련 이슈와 PR을 지속적으로 모니터링하는 것이 중요합니다.