본문으로 건너뛰기
clickhouse검색
수집과 통합

INSERT 모범 사례 (배치, 비동기 INSERT, 멱등성, Too many parts 피하기)

INSERT 한 번이 파트 하나가 되는 ClickHouse 특성에 맞춰 배치 크기, 비동기 INSERT, 블록 중복 제거, 재시도 전략을 어떻게 잡아야 운영 중 Too many parts 를 만나지 않는지 정리합니다.

INSERTasync insert배치멱등성Too many parts2026-10 기준, 약 14분

INSERT 한 번은 파트 하나

ClickHouse 의 MergeTree 는 INSERT 가 들어올 때마다 디스크에 새 파트(part) 를 만듭니다. 파티션이 여러 개에 걸친 INSERT 라면 파티션마다 파트가 하나씩 생깁니다. 그 뒤 백그라운드 머지가 작은 파트를 큰 파트로 합치는데, 머지 속도보다 파트가 생기는 속도가 빠르면 활성 파트 수가 쌓이고 결국 Too many parts 오류(코드 252)로 INSERT 가 거부됩니다.

그래서 ClickHouse 의 INSERT 원칙은 다른 OLTP 데이터베이스와 정반대입니다.

다른 DB 의 습관ClickHouse 에서 생기는 일
행 하나씩 INSERT행 하나짜리 파트가 초당 수백 개, 머지가 못 따라감
트랜잭션으로 묶어 자주 커밋커밋마다 파트. 트랜잭션 자체가 큰 의미 없음
테이블 여러 개에 조금씩테이블마다 파트 수 제한을 따로 받음

권장치는 명확합니다. 한 번에 수만 행 이상, 테이블 하나에 초당 한 번 안팎입니다. 공식 문서는 1만 행 이상(가능하면 10만 행)을 한 배치로, INSERT 는 초당 1회 정도로 보내라고 안내합니다. 이 두 가지만 지켜도 운영 중 만나는 수집 장애의 대부분이 사라집니다.

클라이언트 INSERT 마다 새 파트 백그라운드 머지 배치 INSERT수만 행, 초당 1회 안팎 작은 INSERT 여러 개async_insert 로 서버가 모음 재시도같은 블록은 해시로 중복 제거 part 1 part 2 part 3 버퍼 flush 1회 = 파트 1개10 MiB 또는 200 ms 활성 파트 수가 한도를 넘으면Too many parts (252) 작은 파트를 큰 파트로정렬 유지, 중복 행은 엔진 규칙대로 system.merges진행 중 머지 system.parts (active)파트 수가 줄면 정상
INSERT 가 파트가 되고 머지로 합쳐지는 흐름

배치를 못 만드는 쪽에서는 비동기 INSERT

애플리케이션 서버 수십 대가 각자 이벤트를 조금씩 보내는 구조처럼 클라이언트 쪽에서 배치를 만들기 어려울 때는 서버가 모아 주는 비동기 INSERT(async insert) 를 씁니다. 클라이언트는 작은 INSERT 를 그대로 보내고, 서버가 메모리 버퍼에 모았다가 조건이 되면 한 파트로 씁니다.

참고 26.3 부터는 async_insert 가 기본으로 켜져 있습니다. 그 전 버전을 쓰거나 설정으로 꺼 둔 환경에서는 아래처럼 직접 켭니다. 어느 쪽이든 wait_for_async_insert = 1 인지 확인하세요.

-- 쿼리 단위로 켜기
INSERT INTO events SETTINGS async_insert = 1, wait_for_async_insert = 1
VALUES (now(), 'click', 'user-1');

-- 사용자나 프로필에 걸어 두면 애플리케이션 코드를 안 바꿔도 됨
ALTER USER app_writer SETTINGS async_insert = 1, wait_for_async_insert = 1;

버퍼가 디스크로 내려가는(flush) 조건은 셋 중 먼저 오는 것입니다.

설정기본값뜻
async_insert_max_data_size10 MiB버퍼 크기가 이만큼 되면 flush
async_insert_busy_timeout_ms200 ms첫 INSERT 뒤 이 시간이 지나면 flush. 적응형(async_insert_use_adaptive_busy_timeout) 이 기본이라 부하에 따라 늘었다 줄었다 함
async_insert_max_query_number450모인 INSERT 쿼리 수

wait_for_async_insert 는 꼭 1 로 두세요. 1 이면 서버가 실제로 디스크에 쓴 뒤 응답하므로 응답을 받은 데이터는 안전합니다. 0 으로 두면 버퍼에 들어간 순간 응답이 와서 지연은 짧아지지만, 그 사이 서버가 죽으면 데이터가 사라지고 flush 중 오류(예: 타입 불일치)도 클라이언트가 알 수 없습니다.

팁 비동기 INSERT 는 "작은 INSERT 를 서버가 모아 주는 것" 이지 "빠른 INSERT" 가 아닙니다. 클라이언트에서 이미 수만 행씩 배치를 만들 수 있다면 그냥 동기 INSERT 가 더 단순하고 빠릅니다. 둘을 섞어 쓸 수도 있습니다. 배치 파이프라인은 동기, 애플리케이션 직접 쓰기는 비동기처럼요.

주의 비동기 INSERT 와 머티리얼라이즈드 뷰를 같이 쓰면 flush 시점에 뷰가 실행됩니다. 뷰 쿼리가 실패하면 그 flush 전체가 실패하고 wait_for_async_insert = 1 인 클라이언트들이 함께 오류를 받습니다. 뷰 쪽 타입 변환은 방어적으로 쓰세요. 머티리얼라이즈드 뷰 참고.

멱등성: 같은 블록은 두 번 들어가지 않는다

네트워크 오류로 INSERT 응답을 못 받으면 클라이언트는 재시도합니다. 이때 데이터가 두 번 들어가면 집계가 틀어집니다. ClickHouse 는 블록 단위 중복 제거 로 이 문제를 풉니다.

  • ReplicatedMergeTree (그리고 ClickHouse Cloud 의 SharedMergeTree) 는 INSERT 블록마다 체크섬 해시를 Keeper 에 남기고, 최근 replicated_deduplication_window 개(기본 1000) 안에 같은 해시가 있으면 그 블록을 조용히 버립니다. 응답은 성공으로 옵니다.
  • 복제하지 않는 MergeTree 는 non_replicated_deduplication_window 가 기본 0 이라 꺼져 있습니다. 필요하면 테이블 설정으로 켭니다.
  • 중복 판단 기준은 "같은 데이터, 같은 순서, 같은 블록 경계" 입니다. 재시도 때 행 순서가 바뀌거나 배치가 다르게 잘리면 다른 블록으로 봅니다.

블록 내용 대신 토큰 으로 판단하게 할 수도 있습니다. 소스 쪽 오프셋이나 파일 이름처럼 자연스러운 키가 있을 때 좋습니다.

INSERT INTO events SETTINGS insert_deduplication_token = 'kafka-topic-a-p3-offset-120000'
FORMAT JSONEachRow
{"ts":"2026-10-01 00:00:00","event":"click","user":"u1"}

비동기 INSERT 는 기본으로 중복 제거를 하지 않습니다(async_insert_deduplicate = 0). 켜면 같은 내용의 작은 INSERT 가 반복될 때 걸러 주지만, 서로 다른 INSERT 가 우연히 같은 내용일 때도 걸러진다는 뜻이니 이벤트에 고유 ID 나 타임스탬프가 있는지 확인하고 쓰세요.

참고 블록 중복 제거는 "재시도 안전" 을 위한 장치입니다. 업스트림이 같은 비즈니스 이벤트를 다른 모양으로 두 번 보내는 경우는 걸러 주지 않습니다. 그런 중복은 ReplacingMergeTree 와 FINAL, 또는 쿼리 시점 GROUP BY 로 풉니다. MergeTree 가족 과 삭제와 갱신 에 정리했습니다.

큰 INSERT 는 블록 단위로 쪼개진다

INSERT 하나가 반드시 파트 하나는 아닙니다. 서버는 들어오는 데이터를 max_insert_block_size(기본 1,048,449 행) 단위 블록으로 끊어 처리하고, 블록마다 파트를 만듭니다. 1억 행을 한 번에 넣으면 파트가 100개쯤 생기는데, 이건 정상이고 머지가 금방 합칩니다.

반대로 너무 작은 블록은 min_insert_block_size_rows (기본 1,048,449) 와 min_insert_block_size_bytes (기본 256 MiB) 로 합쳐 줍니다. INSERT ... SELECT 는 max_insert_threads 만큼 병렬로 쓰기 때문에 스레드 수만큼 파트가 동시에 생깁니다. 대용량 재적재(backfill) 를 할 때는 파티션 하나씩, 머지 상황을 보며 진행하는 것이 안전합니다.

-- 한 달치 재적재. 파티션 단위로 끊어서
INSERT INTO events_v2
SELECT * FROM events WHERE toYYYYMM(ts) = 202609
SETTINGS max_insert_threads = 8, max_insert_block_size = 4000000;

포맷과 압축

같은 데이터라도 어떤 포맷으로 보내느냐에 따라 서버 CPU 가 크게 다릅니다.

포맷특징쓰는 곳
NativeClickHouse 내부 열 형식 그대로. 파싱 비용이 거의 없음공식 클라이언트(clickhouse-go, clickhouse-connect 의 일부 경로), 서버 간 이동
RowBinary, RowBinaryWithNamesAndTypes행 단위 바이너리. 파싱이 가볍고 라이브러리에서 만들기 쉬움커스텀 클라이언트
JSONEachRow한 줄에 JSON 객체 하나. 사람이 읽기 쉽고 스키마 변화에 유연애플리케이션 로그, 이벤트
Parquet열 지향 파일. 대량 적재와 레이크 교환에 적합배치 파일 적재
CSV, TSV어디서나 만들 수 있지만 타입 정보가 없어 파싱 비용이 큼일회성 이동

HTTP 로 보낼 때는 요청 본문을 압축하면(Content-Encoding: zstd 또는 gzip, lz4) 네트워크 비용이 크게 줄고, 서버는 enable_http_compression 과 상관없이 압축된 입력을 받습니다. 자세한 포맷 설정은 입출력 포맷 에 있습니다.

재시도와 오류 처리 전략

  1. 타임아웃을 넉넉히 둡니다. 큰 배치는 디스크 쓰기와 머티리얼라이즈드 뷰 실행까지 끝나야 응답이 옵니다. 클라이언트 타임아웃이 짧아 끊기면 재시도만 늘어나고, 서버는 그 사이 이미 썼을 수 있습니다(중복 제거가 받아 주긴 합니다).
  2. 같은 배치를 그대로 재시도 합니다. 행을 다시 섞거나 배치를 다시 자르면 블록 해시가 달라져 중복 제거가 안 됩니다. 재시도는 지수 백오프로, 횟수는 제한하세요.
  3. Too many parts(252) 는 재시도로 안 풀립니다. 머지가 따라올 시간을 주거나 INSERT 빈도를 줄여야 합니다. 원인 분석은 Too many parts 와 머지 지연 에 있습니다.
  4. 입력 오류는 설정으로 허용 범위를 정합니다. input_format_allow_errors_num, input_format_allow_errors_ratio 로 깨진 행 몇 개는 건너뛸 수 있습니다. 다만 조용히 버려지는 행이 생기니 수집 로그와 함께 쓰세요.
  5. 스키마 변경은 input_format_skip_unknown_fields(기본 1) 와 input_format_defaults_for_omitted_fields 로 흡수합니다. 모르는 필드는 버리고 빠진 필드는 DEFAULT 로 채우니, 소스에 컬럼이 추가돼도 수집이 멈추지 않습니다.

머티리얼라이즈드 뷰가 걸려 있을 때

머티리얼라이즈드 뷰 는 INSERT 블록이 들어오는 순간 그 블록을 대상으로 실행되는 트리거입니다. 뷰가 다섯 개 걸린 테이블에 INSERT 하면 파트가 최소 여섯 개(원본 하나, 대상 테이블 다섯) 생깁니다. 작은 INSERT 를 자주 보내는 테이블에 뷰를 많이 걸수록 파트 폭증이 빠릅니다. parallel_view_processing = 1 로 뷰들을 병렬 실행할 수 있지만 메모리도 그만큼 더 씁니다.

Buffer 엔진은 이제 쓰지 않는다

예전에는 Buffer 테이블 엔진으로 작은 INSERT 를 메모리에 모았습니다. 지금은 비동기 INSERT 가 그 역할을 대신하며, Buffer 는 서버가 비정상 종료되면 버퍼 내용이 사라지고, 모니터링과 설정이 테이블마다 따로 들어가는 단점이 있어 새 설계에서는 권하지 않습니다.

확인하는 방법

-- 테이블별 활성 파트 수와 가장 작은 파트들
SELECT table, count() AS parts, sum(rows) AS rows, formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts WHERE active GROUP BY table ORDER BY parts DESC LIMIT 10;

-- 최근 1시간 INSERT 가 만든 파트 수와 평균 행 수 (평균이 수천 행이면 배치가 너무 작음)
SELECT table, count() AS new_parts, round(avg(rows)) AS avg_rows
FROM system.part_log
WHERE event_type = 'NewPart' AND event_time > now() - INTERVAL 1 HOUR
GROUP BY table ORDER BY new_parts DESC;

-- 비동기 INSERT 가 어떻게 모였는지
SELECT table, count() AS flushes, sum(rows) AS rows, round(avg(rows)) AS avg_rows_per_flush
FROM system.asynchronous_insert_log
WHERE event_time > now() - INTERVAL 1 HOUR AND status = 'Ok'
GROUP BY table;

system.part_log 는 서버 설정에서 켜야 기록됩니다(ClickHouse Cloud 는 기본으로 켜져 있습니다). 모니터링 에 대시보드용 쿼리를 더 모아 두었습니다.

ClickHouse Cloud 에서는

  • 저장 엔진이 SharedMergeTree 라 파트가 오브젝트 스토리지에 쓰이지만, 파트 수 제한과 머지 원리는 같습니다. 작은 INSERT 는 오브젝트 스토리지 요청 수까지 늘려 비용에도 영향을 줍니다.
  • ClickPipes 로 Kafka, S3 에서 수집하면 배치와 재시도를 ClickPipes 가 처리합니다. Kafka 와 스트리밍 수집 참고.
  • 비동기 INSERT 설정을 역할(role) 에 걸어 두고 애플리케이션 사용자를 그 역할에 넣는 방식이 깔끔합니다.

공식 문서

이 문서에서