Ken
목적
이 문서는 OOM 및 AsyncInsertFlush 실패 incident를 계기로, 운영팀이 자체적으로 문제를 진단하고 대응할 수 있도록 ClickHouse의 데이터 처리 아키텍처를 깊이 있게 풀어 설명합니다.
이 문서를 읽고 나면 다음과 같은 질문에 답할 수 있게 됩니다.
- INSERT 요청은 ClickHouse 내부에서 정확히 어떤 단계를 거치는가?
async_insert,wait_for_async_insert옵션은 각 단계에서 무엇을 제어하는가?- Background Merge는 언제 어떻게 발동되며, 왜 메모리를 많이 쓰는가?
- ReplacingMergeTree의 중복 제거는 정확히 언제 일어나는가?
- 문제가 발생했을 때 어떤 system 테이블을 어떤 순서로 봐야 하는가?
1. ClickHouse 처리 파이프라인 — 전체 그림
1.1 한 장으로 보는 데이터 흐름
이 그림에서 가장 중요한 인사이트 두 가지를 먼저 짚고 가겠습니다.
첫째, INSERT 처리와 Background Merge는 완전히 비동기입니다. INSERT가 성공해도 그 데이터가 즉시 최종 형태로 정리된다는 보장은 없습니다. Merge가 따로 돌아갑니다.
둘째, Async Insert를 사용할 때 데이터의 안정성은 wait_for_async_insert 한 줄에 달려 있습니다. 0이면 클라이언트는 메모리 버퍼에 들어간 시점에 ack를 받고, 1이면 디스크 part로 flush된 시점에 ack를 받습니다. 이 차이가 이번 incident의 본질입니다.
1.2 단계별 책임 구분
레이어 | 책임 | 실패 시 영향 | 모니터링 위치 |
Client | Batch 구성, 재시도 정책 | Kafka lag 증가 | Connect Worker 로그, MSK 메트릭 |
Ingest | 쿼리 파싱, 권한 체크 | INSERT 거부 | system.query_log (type=ExceptionBeforeStart) |
Async Queue | 메모리 버퍼링, 배치 결합 | 버퍼 손실 (wait=0) | system.asynchronous_inserts, system.asynchronous_insert_log |
Storage | Part 파일 생성, S3 업로드 | INSERT 실패 | system.part_log (event_type=NewPart) |
Background | Part 병합, 중복 제거 | 파트 누적, OOM | system.merges, system.part_log (event_type=MergeParts) |
이 표는 미팅 후에도 운영팀이 어떤 시그널을 어디서 봐야 하는지 결정할 때 참고할 수 있는 일종의 의사결정 지도입니다.
2. INSERT 처리의 상세 단계
2.1 동기 INSERT (async_insert=0)
가장 단순한 경로입니다. Application이나 Kafka Connect가 INSERT를 보내면, ClickHouse는 다음 순서로 처리합니다.
핵심 특성:
- Client는 Part 파일이 Object Storage에 안전히 기록될 때까지 기다립니다.
- 실패하면 Client에게 명확한 에러가 전달됩니다.
- Connect Sink의 DLQ(Dead Letter Queue) 메커니즘이 정상 작동합니다.
단점:
- 작은 INSERT가 많으면 Part 수가 폭발적으로 증가합니다 (Part-per-Insert).
- 1초에 100건 INSERT를 보내면 1초에 100개의 Part가 생깁니다.
2.2 비동기 INSERT (async_insert=1)
이 경로는 작은 INSERT를 메모리에서 모았다가 한 번에 Part로 만드는 최적화입니다. ClickHouse Cloud에서는 기본 권장 방식이지만, 옵션 조합에 따라 동작이 크게 달라집니다.
wait_for_async_insert=0 동작:
- Client는 Queue에 데이터가 들어간 즉시 ACK를 받습니다.
- 이후 Flush가 실패해도 Client는 모릅니다.
- Kafka Connect는 offset commit을 진행합니다.
- Flush 실패 시 데이터 손실이 발생합니다.
wait_for_async_insert=1 동작:
- Client는 Flush가 완료될 때까지 대기합니다.
- Flush 실패 시 명확한 에러를 받습니다.
- Kafka Connect의 DLQ가 정상 작동합니다.
- 단, Flush 주기 때문에 Latency가 증가합니다.
2.3 Async Insert Queue의 내부 동작
Async Insert Queue가 언제 Flush를 트리거하는지 정확히 이해하면 문제시 병목 탐지가 쉬워집니다.
관련 핵심 설정:
설정 | 기본값 | 설명 |
async_insert_busy_timeout_max_ms | 1000ms | Flush까지 최대 대기 시간 |
async_insert_busy_timeout_min_ms | 50ms | Flush까지 최소 대기 시간 |
async_insert_max_data_size | 10MB | 버퍼 크기 임계치 |
async_insert_max_query_number | 450 | 큐에 누적 가능한 쿼리 수 |
async_insert_deduplicate | 0 | 동일 데이터 중복 제거 |
busy_timeout_max_ms를 줄이면 Flush가 더 자주 발생합니다. 이는 Latency를 낮추지만 Part 수를 늘립니다.
3. MergeTree Storage 구조
3.1 Part란 무엇인가
ClickHouse의 모든 데이터는 Part라는 단위로 저장됩니다. 하나의 Part는 다음과 같은 구조입니다.
Part의 핵심 특성:
- Part는 불변(immutable) 입니다. 한 번 만들어진 Part는 수정되지 않습니다.
- UPDATE/DELETE는 새로운 Part를 만들거나 mutation으로 처리됩니다.
- ORDER BY 키 기준으로 정렬되어 저장되며, 컬럼별로 별도 파일에 압축 저장됩니다.
3.2 Part가 만들어지는 시점
가장 중요한 점은 Background Merge도 Part를 만든다는 것입니다. Merge는 작은 Part들을 읽어서 큰 Part 하나를 새로 쓰는 작업입니다. 이 때 메모리를 많이 쓰는 것이 이번 incident의 직접 원인이었습니다.
3.3 Part 수와 시스템 부하의 관계
ClickHouse는 Part 수에 따라 자동으로 INSERT를 조절하는 메커니즘을 가지고 있습니다.
Part 수 (per partition) | 동작 | 의미 |
0~150 | 정상 | 일반 운영 상태 |
150~300 | parts_to_delay_insert 진입 | INSERT 지연 시작 |
300+ | parts_to_throw_insert 진입 | INSERT 에러 (TOO_MANY_PARTS, code 252) |
이번 4월 16일~21일 사이 발생한 7,421건의 TOO_MANY_PARTS 에러는 정확히 이 메커니즘이 발동된 결과입니다.
왜 이 임계치가 존재하는가: Part 수가 너무 많아지면,
- 쿼리 시 모든 Part를 읽어야 하므로 SELECT 성능 저하.
- Background Merge의 부담이 기하급수적으로 증가.
- 메타데이터 관리 오버헤드 증가.
따라서 ClickHouse는 의도적으로 INSERT를 거부해서 시스템이 회복할 시간을 확보합니다.
4. Background Merge의 동작 원리
4.1 왜 Merge가 필요한가
Part 수가 계속 늘어나면 위에서 설명한 임계치에 도달합니다. 따라서 ClickHouse는 백그라운드에서 작은 Part들을 큰 Part로 병합합니다.
Merge의 효과:
- Part 수 감소 → SELECT 성능 향상.
- ORDER BY 키 기준 재정렬 → 압축률 향상.
- ReplacingMergeTree의 경우 이 시점에 중복 제거.
4.2 Merge Scheduler — 무엇을 언제 머지할지 결정
ClickHouse의 Merge Scheduler는 단순히 "오래된 Part부터" 머지하지 않습니다. 다음 알고리즘을 사용합니다.
핵심 파라미터:
파라미터 | 기본값 | 영향 |
background_pool_size | 인스턴스별 (~16) | 동시 실행 가능한 Merge 수 |
max_bytes_to_merge_at_max_space_in_pool | 161 GB | 단일 Merge가 처리할 최대 총 크기 |
merge_max_block_size | 8192 rows | Merge 시 메모리에 한 번에 올리는 행 수 |
merge_max_block_size_bytes | 10 MB | Merge block의 바이트 상한 |
enable_vertical_merge_algorithm | 1 | 컬럼별 분리 머지 활성화 |
ClickHouse Cloud에서는 background_pool_size 같은 일부 파라미터는 인스턴스 스펙에 따라 자동 설정되며, 변경하려면 Support 요청이 필요합니다.
4.3 Horizontal Merge vs Vertical Merge
Merge에는 두 가지 알고리즘이 있고, 메모리 사용량이 크게 다릅니다.
Vertical Merge의 활성화 조건:
enable_vertical_merge_algorithm = 1(기본 활성)vertical_merge_algorithm_min_rows_to_activate이상 (기본 16,777,216 rows)vertical_merge_algorithm_min_columns_to_activate이상 (기본 11개)
ingamelog.game_log처럼 String/Map 컬럼이 많은 테이블은 위 임계치를 낮춰 더 자주 Vertical Merge가 발동되도록 하는 것이 메모리 안정성에 유리합니다.
4.4 Squashing — 메모리 폭발의 진원지
스택 트레이스에서 등장한 Squashing 단계가 무엇인지 정확히 이해할 필요가 있습니다.
Squashing의 의미: 작은 데이터 단위(Chunk)를 모아서 효율적인 크기의 Block으로 만드는 과정입니다. 디스크 I/O 효율을 위한 최적화입니다.
왜 메모리를 많이 쓰는가:
merge_max_block_size(기본 8192 rows) 만큼 모든 컬럼을 메모리에 적재합니다.- String/Map 컬럼은 행마다 크기가 다르므로 PODArray를 동적으로 재할당합니다.
- ingamelog.game_log의
map(평균 80바이트),body(가변),log_reason같은 컬럼은 한 행만으로도 수백 바이트~수 KB. - 8192 rows × 수 KB = 수십 MB 이상이 단일 Squashing 작업에서 사용됩니다.
이번 incident의 정확한 메커니즘:
- 7개 파티션에서 동시 Merge가 발동.
- 각 Merge가 Squashing 단계에서 수십 MB~수 GB 메모리 사용.
- 누적 메모리 사용량이 인스턴스 메모리(54 GiB)에 근접.
- 추가 9 MiB 할당이 거부되며 OOM 발생.
- 실패한 Merge가 재시도되며 악순환.
완화 방법:
ALTER TABLE ingamelog.game_log MODIFY SETTING
merge_max_block_size = 2048, -- 기본 8192의 1/4
merge_max_block_size_bytes = 52428800; -- 50 MiB 상한 명시이 한 줄 변경으로 Squashing 피크 메모리를 75%까지 줄일 수 있습니다.
5. ReplacingMergeTree의 중복 제거 메커니즘
5.1 언제 중복이 제거되는가
이 부분은 운영 시 가장 자주 혼란을 일으키는 영역입니다.
핵심 사실 3가지:
- INSERT 시점에는 중복 제거가 일어나지 않습니다. 같은 키로 두 번 INSERT하면 두 행이 모두 저장됩니다.
- Background Merge가 발생해야 중복이 제거됩니다. 그것도 같은 Part로 합쳐질 때만.
- 서로 다른 Part에 있는 중복은 제거되지 않습니다. 두 Part가 Merge될 때까지 남습니다.
따라서 정확한 결과를 보장하려면 두 가지 옵션이 있습니다.
옵션 A: FINAL 키워드
SELECT * FROM ingamelog.game_log FINAL WHERE event_date = today();- 쿼리 시 강제로 중복 제거 수행.
- 정확하지만 쿼리 성능 저하 (모든 Part를 ORDER BY 기준으로 머지).
옵션 B: argMax 패턴
SELECT
event_id,
argMax(event_time, version) as event_time,
argMax(user_id, version) as user_id
FROM ingamelog.game_log
WHERE event_date = today()
GROUP BY event_id;- FINAL 없이도 최신 버전만 선택 가능.
- 일부 케이스에서 FINAL보다 빠름.
5.2 ReplacingMergeTree에서 Merge가 무거운 이유
일반 MergeTree와 비교하면 ReplacingMergeTree의 Merge는 추가 작업이 있습니다.
오버헤드의 원천:
- 각 행마다 ORDER BY 키로 동일성 비교.
- 동일 키 그룹에서 version 비교.
- is_deleted 플래그 체크.
이 추가 작업 때문에 ReplacingMergeTree의 Merge는 일반 MergeTree보다 CPU 사용량이 약 10~30% 더 높습니다. 메모리 사용량 자체는 크게 차이나지 않습니다.
5.3 ORDER BY 설계 검토 권장
ReplacingMergeTree의 효과는 ORDER BY 키 설계에 직접 연결됩니다.
시나리오 | ORDER BY | 효과 |
너무 좁음 | (user_id) | 다른 이벤트도 중복으로 잘못 제거 |
너무 넓음 | (event_time, user_id, event_id, request_id, ...) | 중복 제거 거의 안 됨 |
적절 | (event_time, user_id, event_id) | 의도한 중복만 제거 |
ORDER BY와 deduplication key를 함께 검토하면 도움이 될 수 있습니다.
6. 운영 모니터링 — 핵심 쿼리 카탈로그
이 섹션은 운영팀이 즉시 사용할 수 있도록 설계된 SQL 모음입니다. 카테고리별로 정리했습니다.
6.1 Health Check — 1분 점검
서비스 상태를 빠르게 파악할 때 사용합니다.
6.2 Part 분포 분석
특정 테이블의 Part 누적 상태를 파악합니다.
판단 기준:
active_parts > 200→ 주의 단계, 원인 조사 필요.active_parts > 300→ 위험 단계, TOO_MANY_PARTS 임박.max_part_size > 50 GB→ 큰 Part 머지 부담 검토.
6.3 Merge 실패 추적
Background Merge가 어떻게 실패하고 있는지 분석합니다.
해석 가이드:
merge_failures시간당 10건 이상 → 비정상.unique_failed_parts가 높으면 → 광범위한 문제.- 같은 Part가 반복 등장 → 특정 Part가 Merge 불가 상태.
6.4 AsyncInsertFlush 모니터링
이번 incident에서 가장 중요했던 쿼리입니다.
알람 임계치 권장:
failure_count > 50/hour→ 운영팀 알람.failure_count > 200/hour→ 긴급 대응.- 특히
exception_code = 252 (TOO_MANY_PARTS)가 등장하면 즉시 조치.
6.5 진행 중인 Merge 실시간 확인
지금 이 순간 어떤 Merge가 어떻게 돌고 있는지 봅니다.
SELECT
database,
table,
elapsed,
round(progress * 100, 1) AS progress_pct,
num_parts,
formatReadableSize(memory_usage) AS memory_used,
formatReadableSize(bytes_read_uncompressed) AS bytes_read,
merge_algorithm,
merge_type
FROM clusterAllReplicas(default, system.merges)
ORDER BY memory_usage DESC;6.6 컬럼 크기 분석
Squashing 메모리 부담의 원인 컬럼을 식별합니다.
bytes_per_row 가 높은 컬럼이 Merge 시 메모리 부담의 주된 원인입니다.
7. 이슈 발생 시 대응 플레이북
7.1 시나리오별 진단 플로우
7.2 액션 매트릭스
진단 결과별로 즉시 취해야 할 조치입니다.
진단 결과 | 즉시 조치 (운영팀) | Support 요청 (24h 이내) |
Part 수 200~300 (주의) | SELECT optimize_throw_if_noop=0 OPTIMIZE 자제, 자연 머지 대기 | 없음 |
Part 수 300+ (위험) | parts_to_throw_insert 임시 상향, 신규 INSERT 토픽 wait=0으로 fallback | background_pool_size 증가 검토 |
Memory 90%+ | merge_max_block_size 축소 | 인스턴스 메모리 증설 검토 |
MEMORY_LIMIT_EXCEEDED 다수 | 위 + max_bytes_to_merge_at_max_space_in_pool 축소 | 동일 |
AsyncInsertFlush 실패 | 정합성 토픽은 wait=1 확인 | 원인 분석 요청 |
데이터 손실 의심 | Kafka offset 기록 후 분석 시작 | 손실 범위 확정 지원 요청 |
7.3 자체 조치 가능한 SQL 모음
운영팀이 Support 없이 직접 실행할 수 있는 명령들입니다.
주의: (4)번은 일시적 조치이며, 근본 원인 해결 후 원복해야 합니다. 영구 적용 시 더 큰 누적 문제가 발생할 수 있습니다.
7. 결론 및 다음 단계
여기서 다루는 이슈는 다음 세 가지 요인의 합성으로 발생할 수 있습니다.
첫째, 구조적 요인으로 ReplacingMergeTree에 긴 String/Map 컬럼이 다수 포함되어 Merge 시 메모리 부담이 본질적으로 큰 구성이었습니다. 둘째, 설정 요인으로 merge_max_block_size 기본값(8192)이 이 데이터 특성에 비해 컸고, wait_for_async_insert=0이 무차별 적용되어 데이터 손실 위험이 노출되어 있었습니다. 셋째, 트리거 요인으로 4월 들어 신규 파티션이 추가되며 활성 파티션이 7개로 늘어났고, 이것이 동시 Merge 부하의 임계점을 넘어섰습니다.
부록 A. 주요 system 테이블 레퍼런스
테이블 | 용도 | 보존 기간 |
system.query_log | 모든 쿼리(INSERT 포함) 이력 | 30일 |
system.part_log | Part 생성/병합/삭제 이벤트 | 30일 |
system.merges | 현재 진행 중인 Merge | 실시간 |
system.parts | 현재 활성 Part 메타정보 | 실시간 |
system.asynchronous_insert_log | Async Insert 큐 이력 | 30일 |
system.asynchronous_inserts | 현재 큐 상태 | 실시간 |
system.asynchronous_metrics | 시스템 메트릭 (메모리 등) | 실시간 |
system.metric_log | 메트릭 시계열 | 30일 |
system.text_log | ClickHouse 서버 로그 | 30일 |
system.errors | 에러 카운터 (서버 시작 이후) | 인스턴스 lifecycle |
ClickHouse Cloud에서는 clusterAllReplicas(default, system.<table>) 형태로 모든 replica의 정보를 조회할 수 있습니다. 단일 replica만 보면 클러스터 전체 상황을 놓칠 수 있으므로, 운영 쿼리는 가능하면 clusterAllReplicas를 사용하는 것을 권장합니다.
부록 B. 주요 에러 코드 레퍼런스
코드 | 이름 | 의미 | 일반적 원인 |
60 | UNKNOWN_TABLE | 테이블 없음 | 스키마 변경, 권한 |
241 | MEMORY_LIMIT_EXCEEDED | 메모리 한계 초과 | 머지/쿼리 메모리 부담 |
242 | TABLE_IS_READ_ONLY | 읽기 전용 상태 | 복제 이슈 |
252 | TOO_MANY_PARTS | Part 수 초과 | 작은 INSERT 다수, Merge 적체 |
394 | QUERY_WAS_CANCELLED | 쿼리 취소됨 | 타임아웃, 사용자 취소 |
497 | ACCESS_DENIED | 권한 거부 | 사용자 권한 부족 |
부록 C. 참고 문서 링크
- ClickHouse 공식 문서: https://clickhouse.com/docs
- MergeTree Engine: https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree
- ReplacingMergeTree: https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/replacingmergetree
- Async Inserts: https://clickhouse.com/docs/en/cloud/bestpractices/asynchronous-inserts
- ClickPipes: https://clickhouse.com/docs/en/integrations/clickpipes
- Kafka Connect Sink: https://clickhouse.com/docs/en/integrations/kafka/kafka-connect-clickhouse