Ken
ReplacingMergeTree는 Eventual Consistency를 준수하지만, 그 시간을 어느 정도 기간으로 확정할 수 있는지를 확인하기 위해 테스트 하였습니다. 결론부터 말씀드리면, ReplacingMergeTree의 중복 제거 지연 시간은 "측정 불가"입니다. 이론적으로 무한대까지 가능하며, 정확한 결과가 필요하면 반드시 FINAL 키워드 또는 argMax 패턴을 사용해야 합니다.
- 들어가며
- 배경: ReplacingMergeTree의 동작 원리
- 실험 1: FINAL 유무에 따른 결과 차이
- 실험 환경
- 테이블 생성
- 데이터 삽입 (3회 별도 INSERT)
- 결과 비교
- 실험 2: 대량 데이터에서의 정합성 불일치
- 시나리오
- 결과: 행 수 차이
- 결과: 집계 값 차이
- 핵심 질문: Merge는 언제 발생하는가?
- 시간 경과 관찰
- part_log 확인
- 왜 "최대 지연 시간"을 측정할 수 없는가?
- 이유 1: Merge는 확정적(Deterministic)이 아니다
- 이유 2: Merge 우선순위는 파트 크기에 의존
- 이유 3: 시스템 부하가 Merge를 지연
- 이유 4: 연속 삽입 시 악화
- 결론
- 해결 방안
- 방안 1: FINAL 키워드 (가장 안전)
- 방안 2: argMax/argMin 함수
- 방안 3: 주기적 OPTIMIZE FINAL
- 방안 4: min_age_to_force_merge_seconds 설정
- 패턴 선택 가이드
- 모니터링 권장 쿼리
- 결론
- ReplacingMergeTree의 핵심 특성
- 실무 핵심 권장사항
들어가며
ClickHouse의 ReplacingMergeTree는 동일한 정렬 키를 가진 행들 중 최신 버전만 유지하는 테이블 엔진입니다. 하지만 이 "중복 제거"는 즉시 일어나지 않습니다.
이 글에서는 실제 ClickHouse Cloud 환경에서 실험을 통해:
FINAL키워드 사용 유무에 따른 데이터 정합성 차이- 왜 "최대 지연 시간"을 정확히 측정할 수 없는지
- 실무에서의 해결 방안
을 다룹니다.
배경: ReplacingMergeTree의 동작 원리
ReplacingMergeTree는 동일한 ORDER BY 키를 가진 행들 중 가장 최신 버전만 유지합니다. 하지만 중요한 점은:
INSERT → 새 파트 생성 → (언젠가) Background Merge → 중복 제거
중복 제거는 INSERT 시점이 아닌 Merge 시점에 발생합니다.
실험 1: FINAL 유무에 따른 결과 차이
실험 환경
- ClickHouse Cloud (SharedMergeTree)
- 테스트 일시: 2025-12-14
테이블 생성
CREATE TABLE blog_test.user_events_replacing
(
user_id UInt64,
event_type String,
event_value Int64,
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, event_type)
데이터 삽입 (3회 별도 INSERT)
결과 비교
FINAL 없이 조회:
SELECT user_id, event_value, updated_at, _part
FROM blog_test.user_events_replacing ORDER BY user_id, updated_at;
user_id | event_value | updated_at | _part |
1 | 100 | 10:00:00 | all_0_0_1 |
1 | 150 | 11:00:00 | all_1_1_1 |
1 | 999 | 12:00:00 | all_2_2_1 |
2 | 200 | 10:00:00 | all_0_0_1 |
2 | 250 | 11:00:00 | all_1_1_1 |
3 | 300 | 10:00:00 | all_0_0_1 |
→ 6개 행 반환 (중복 포함)
FINAL 사용 조회:
SELECT user_id, event_value, updated_at
FROM blog_test.user_events_replacing FINAL ORDER BY user_id;
user_id | event_value | updated_at |
1 | 999 | 12:00:00 |
2 | 250 | 11:00:00 |
3 | 300 | 10:00:00 |
→ 3개 행 반환 (정확한 최신값)
실험 2: 대량 데이터에서의 정합성 불일치
시나리오
결과: 행 수 차이
쿼리 유형 | 행 수 | 비고 |
WITHOUT FINAL | 160,000 | 중복 포함 |
WITH FINAL | 100,000 | 정확한 결과 |
→ 60% 더 많은 행이 중복으로 존재!
결과: 집계 값 차이
쿼리 유형 | sum(event_value) | avg(event_value) |
WITHOUT FINAL | 224,776,927 | 1,404.86 |
WITH FINAL | 184,866,480 | 1,848.66 |
→ 집계 결과가 약 22% 차이남!
핵심 질문: Merge는 언제 발생하는가?
시간 경과 관찰
데이터 삽입 후 파트 상태를 모니터링했습니다:
SELECT name, rows, modification_time,
dateDiff('second', modification_time, now()) as age_seconds
FROM system.parts
WHERE database = 'blog_test' AND table = 'user_events_large' AND active = 1;
시점 | 파트 수 | 자동 Merge 발생 |
삽입 직후 | 3개 | No |
+2분 | 3개 | No |
+7분 | 3개 | No |
+새 INSERT | 4개 | No |
→ 7분이 지나도 자연 merge가 발생하지 않음!
part_log 확인
SELECT event_type, part_name, rows
FROM system.part_log
WHERE database = 'blog_test' AND event_type = 'MergeParts';
결과: MergeParts 이벤트 없음
왜 "최대 지연 시간"을 측정할 수 없는가?
이유 1: Merge는 확정적(Deterministic)이 아니다
Background merge 스케줄러는 다음 설정에 의해 동작합니다:
설정 | 기본값 | 의미 |
merge_selecting_sleep_ms | 5000ms | Merge 후보 선택 간격 |
max_merge_selecting_sleep_ms | 60000ms | 최대 대기 시간 |
min_age_to_force_merge_seconds | 0 | 강제 merge (비활성화) |
min_age_to_force_merge_seconds = 0은 **"시간 기반 강제 merge가 없다"**는 의미입니다.
이유 2: Merge 우선순위는 파트 크기에 의존
ClickHouse는 write amplification을 최소화하기 위해 작은 파트들을 우선 merge합니다:
max_bytes_to_merge_at_max_space_in_pool = 150GB
max_bytes_to_merge_at_min_space_in_pool = 1MB
결과적으로:
- 대형 파트 + 소형 파트 공존 시 → 소형끼리만 merge
- 대형 파트의 중복은 무기한 방치 가능
이유 3: 시스템 부하가 Merge를 지연
상황 | Merge 지연 가능성 |
CPU 부하 높음 | 높음 |
디스크 I/O 포화 | 매우 높음 |
동시 INSERT 다수 | 높음 |
메모리 압박 | 높음 |
이유 4: 연속 삽입 시 악화
지속적인 INSERT가 발생하면:
- 새 파트가 계속 생성됨
- Merge 스케줄러가 새 파트 처리에 집중
- 기존 중복 파트 merge가 후순위로 밀림
- 정합성 지연이 누적
결론
"최대 지연 시간"은 이론적으로 무한대입니다.
확정적 상한선이 없으며, 외부 요인(부하, 삽입 패턴, 파트 크기)에 전적으로 의존합니다.
해결 방안
방안 1: FINAL 키워드 (가장 안전)
SELECT * FROM user_events FINAL WHERE user_id = 123;
✅ 항상 정확한 결과
⚠️ 쿼리 오버헤드 존재
방안 2: argMax/argMin 함수
SELECT
user_id,
argMax(event_value, updated_at) as latest_value,
max(updated_at) as latest_time
FROM user_events
GROUP BY user_id;
✅ FINAL 없이 정확한 결과
✅ 복잡한 집계 가능
⚠️ GROUP BY 필수
방안 3: 주기적 OPTIMIZE FINAL
-- 스케줄러로 실행
OPTIMIZE TABLE user_events FINAL;
실험에서 OPTIMIZE 실행 후:
상태 | 파트 수 | 행 수 |
실행 전 | 4개 | 170,000 (중복) |
실행 후 | 1개 | 100,000 (정확) |
✅ Background에서 정합성 보장
⚠️ I/O 부하, 주기적 실행 필요
방안 4: min_age_to_force_merge_seconds 설정
CREATE TABLE user_events (...)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id)
SETTINGS min_age_to_force_merge_seconds = 3600;
✅ 자동화된 정합성 보장
⚠️ 설정 시간까지는 여전히 불일치 가능
패턴 선택 가이드
상황 | 권장 패턴 |
단순 조회, 정확성 최우선 | FINAL |
복잡한 집계 쿼리 | argMax/argMin |
배치 처리 후 정합성 필요 | OPTIMIZE FINAL |
대시보드 (약간의 지연 허용) | 일반 쿼리 + 주기적 OPTIMIZE |
실시간 정확성 필수 | FINAL 또는 argMax |
모니터링 권장 쿼리
-- 파트 상태 모니터링
SELECT
table,
count() as part_count,
sum(rows) as total_rows,
min(modification_time) as oldest_part,
dateDiff('hour', min(modification_time), now()) as oldest_age_hours
FROM system.parts
WHERE database = 'your_db' AND active = 1
GROUP BY table
HAVING part_count > 10
ORDER BY oldest_age_hours DESC;
파트가 많거나 오래된 파트가 있으면 OPTIMIZE FINAL을 검토하세요.
결론
ReplacingMergeTree의 핵심 특성
- 중복 제거는 Merge 시점에 발생 → 즉시성 보장 없음
- FINAL 없이 조회하면 중복 노출 가능
- 자연 Merge 타이밍은 예측 불가
- 최대 지연 시간은 이론적으로 무한대
실무 핵심 권장사항
- 정확성이 필요하면 반드시 FINAL 또는 argMax 사용
- "어느 정도 지연되어도 괜찮은지" 먼저 판단
- 주기적 OPTIMIZE FINAL 스케줄링 검토
- system.parts로 파트 상태 모니터링
ReplacingMergeTree는 "eventually consistent" 엔진입니다.정확한 결과가 필요하면, 직접 FINAL을 명시하세요.
활용 코드
clickhouse-hols/workload/replacingmergetree at main · litkhai/clickhouse-hols
ClickHouse Hands-on Labs . Contribute to litkhai/clickhouse-hols development by creating an account on GitHub.
github.com