ClickHouse Korea Collateral
ClickHouse Korea Collateral
/ClickHouse Docs
ClickHouse Docs
/
ClickHouse Cloud 데이터 처리 아키텍처

ClickHouse Cloud 데이터 처리 아키텍처

ClickHouse 분류
ClickHouse Cloud
Type
Research
작성자

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 수가 너무 많아지면,

  1. 쿼리 시 모든 Part를 읽어야 하므로 SELECT 성능 저하.
  2. Background Merge의 부담이 기하급수적으로 증가.
  3. 메타데이터 관리 오버헤드 증가.

따라서 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의 정확한 메커니즘:

  1. 7개 파티션에서 동시 Merge가 발동.
  2. 각 Merge가 Squashing 단계에서 수십 MB~수 GB 메모리 사용.
  3. 누적 메모리 사용량이 인스턴스 메모리(54 GiB)에 근접.
  4. 추가 9 MiB 할당이 거부되며 OOM 발생.
  5. 실패한 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가지:

  1. INSERT 시점에는 중복 제거가 일어나지 않습니다. 같은 키로 두 번 INSERT하면 두 행이 모두 저장됩니다.
  2. Background Merge가 발생해야 중복이 제거됩니다. 그것도 같은 Part로 합쳐질 때만.
  3. 서로 다른 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
SELECT 'parts_total' AS metric,
       toString(sum(parts)) AS value
FROM (SELECT count() AS parts FROM clusterAllReplicas(default, system.parts)
      WHERE active GROUP BY database, table)
UNION ALL
SELECT 'memory_resident_gib',
       toString(round(value / 1024 / 1024 / 1024, 1))
FROM clusterAllReplicas(default, system.asynchronous_metrics)
WHERE metric = 'MemoryResident'
UNION ALL
SELECT 'merges_running',
       toString(count())
FROM clusterAllReplicas(default, system.merges)
UNION ALL
SELECT 'async_failures_1h',
       toString(count())
FROM clusterAllReplicas(default, system.query_log)
WHERE event_time > now() - INTERVAL 1 HOUR
  AND query_kind = 'AsyncInsertFlush'
  AND type IN ('ExceptionBeforeStart', 'ExceptionWhileProcessing');
SELECT
    database,
    table,
    partition,
    count() AS active_parts,
    formatReadableSize(sum(bytes_on_disk)) AS total_size,
    formatReadableSize(avg(bytes_on_disk)) AS avg_part_size,
    formatReadableSize(max(bytes_on_disk)) AS max_part_size,
    sum(rows) AS total_rows
FROM clusterAllReplicas(default, system.parts)
WHERE active
  AND database = 'ingamelog'
GROUP BY database, table, partition
ORDER BY active_parts DESC
LIMIT 20;
SELECT
    toStartOfHour(event_time) AS hour,
    database,
    table,
    count() AS merge_failures,
    countDistinct(part_name) AS unique_failed_parts,
    formatReadableSize(avg(peak_memory_usage)) AS avg_peak_mem,
    formatReadableSize(max(peak_memory_usage)) AS max_peak_mem,
    any(exception) AS sample_exception
FROM clusterAllReplicas(default, system.part_log)
WHERE event_date >= today() - 1
  AND event_type = 'MergeParts'
  AND error != 0
GROUP BY hour, database, table
ORDER BY hour DESC, merge_failures DESC;
SELECT
    toStartOfHour(event_time) AS hour,
    exception_code,
    errorCodeToName(exception_code) AS error_name,
    count() AS failure_count,
    countDistinct(query_id) AS unique_queries,
    any(query_id) AS sample_query_id
FROM clusterAllReplicas(default, system.query_log)
WHERE event_date >= today() - 1
  AND query_kind = 'AsyncInsertFlush'
  AND type IN ('ExceptionBeforeStart', 'ExceptionWhileProcessing')
GROUP BY hour, exception_code
ORDER BY hour DESC, failure_count DESC;
SELECT
    database,
    table,
    name AS column_name,
    type,
    formatReadableSize(data_compressed_bytes) AS compressed,
    formatReadableSize(data_uncompressed_bytes) AS uncompressed,
    round(data_uncompressed_bytes / nullIf(rows, 0), 2) AS bytes_per_row
FROM clusterAllReplicas(default, system.columns) AS c
JOIN (
    SELECT database, table, sum(rows) AS rows
    FROM clusterAllReplicas(default, system.parts)
    WHERE active AND database = 'ingamelog'
    GROUP BY database, table
) AS t USING (database, table)
WHERE c.database = 'ingamelog'
  AND (c.type LIKE '%String%' OR c.type LIKE '%Map%')
ORDER BY data_uncompressed_bytes DESC
LIMIT 30;
-- (1) 특정 테이블의 머지 메모리 부담 완화
ALTER TABLE ingamelog.game_log MODIFY SETTING
    merge_max_block_size = 2048,
    merge_max_block_size_bytes = 52428800;

-- (2) 큰 Part 머지 차단으로 OOM 위험 감소
ALTER TABLE ingamelog.game_log MODIFY SETTING
    max_bytes_to_merge_at_max_space_in_pool = 10737418240;  -- 10 GiB

-- (3) Vertical Merge 활성화 임계치 낮춤
ALTER TABLE ingamelog.game_log MODIFY SETTING
    vertical_merge_algorithm_min_rows_to_activate = 131072,
    vertical_merge_algorithm_min_columns_to_activate = 2;

-- (4) Part 임계치 임시 상향 (긴급 시에만)
ALTER TABLE ingamelog.game_log MODIFY SETTING
    parts_to_throw_insert = 5000,
    parts_to_delay_insert = 2500;