INSERT 모범 사례 (배치, 비동기 INSERT, 멱등성, Too many parts 피하기)
INSERT 한 번이 파트 하나가 되는 ClickHouse 특성에 맞춰 배치 크기, 비동기 INSERT, 블록 중복 제거, 재시도 전략을 어떻게 잡아야 운영 중 Too many parts 를 만나지 않는지 정리합니다.
INSERT 한 번은 파트 하나
ClickHouse 의 MergeTree 는 INSERT 가 들어올 때마다 디스크에 새 파트(part) 를 만듭니다. 파티션이 여러 개에 걸친 INSERT 라면 파티션마다 파트가 하나씩 생깁니다. 그 뒤 백그라운드 머지가 작은 파트를 큰 파트로 합치는데, 머지 속도보다 파트가 생기는 속도가 빠르면 활성 파트 수가 쌓이고 결국 Too many parts 오류(코드 252)로 INSERT 가 거부됩니다.
그래서 ClickHouse 의 INSERT 원칙은 다른 OLTP 데이터베이스와 정반대입니다.
| 다른 DB 의 습관 | ClickHouse 에서 생기는 일 |
|---|---|
| 행 하나씩 INSERT | 행 하나짜리 파트가 초당 수백 개, 머지가 못 따라감 |
| 트랜잭션으로 묶어 자주 커밋 | 커밋마다 파트. 트랜잭션 자체가 큰 의미 없음 |
| 테이블 여러 개에 조금씩 | 테이블마다 파트 수 제한을 따로 받음 |
권장치는 명확합니다. 한 번에 수만 행 이상, 테이블 하나에 초당 한 번 안팎입니다. 공식 문서는 1만 행 이상(가능하면 10만 행)을 한 배치로, INSERT 는 초당 1회 정도로 보내라고 안내합니다. 이 두 가지만 지켜도 운영 중 만나는 수집 장애의 대부분이 사라집니다.
배치를 못 만드는 쪽에서는 비동기 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_size | 10 MiB | 버퍼 크기가 이만큼 되면 flush |
async_insert_busy_timeout_ms | 200 ms | 첫 INSERT 뒤 이 시간이 지나면 flush. 적응형(async_insert_use_adaptive_busy_timeout) 이 기본이라 부하에 따라 늘었다 줄었다 함 |
async_insert_max_query_number | 450 | 모인 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 가 크게 다릅니다.
| 포맷 | 특징 | 쓰는 곳 |
|---|---|---|
Native | ClickHouse 내부 열 형식 그대로. 파싱 비용이 거의 없음 | 공식 클라이언트(clickhouse-go, clickhouse-connect 의 일부 경로), 서버 간 이동 |
RowBinary, RowBinaryWithNamesAndTypes | 행 단위 바이너리. 파싱이 가볍고 라이브러리에서 만들기 쉬움 | 커스텀 클라이언트 |
JSONEachRow | 한 줄에 JSON 객체 하나. 사람이 읽기 쉽고 스키마 변화에 유연 | 애플리케이션 로그, 이벤트 |
Parquet | 열 지향 파일. 대량 적재와 레이크 교환에 적합 | 배치 파일 적재 |
CSV, TSV | 어디서나 만들 수 있지만 타입 정보가 없어 파싱 비용이 큼 | 일회성 이동 |
HTTP 로 보낼 때는 요청 본문을 압축하면(Content-Encoding: zstd 또는 gzip, lz4) 네트워크 비용이 크게 줄고, 서버는 enable_http_compression 과 상관없이 압축된 입력을 받습니다. 자세한 포맷 설정은 입출력 포맷 에 있습니다.
재시도와 오류 처리 전략
- 타임아웃을 넉넉히 둡니다. 큰 배치는 디스크 쓰기와 머티리얼라이즈드 뷰 실행까지 끝나야 응답이 옵니다. 클라이언트 타임아웃이 짧아 끊기면 재시도만 늘어나고, 서버는 그 사이 이미 썼을 수 있습니다(중복 제거가 받아 주긴 합니다).
- 같은 배치를 그대로 재시도 합니다. 행을 다시 섞거나 배치를 다시 자르면 블록 해시가 달라져 중복 제거가 안 됩니다. 재시도는 지수 백오프로, 횟수는 제한하세요.
Too many parts(252) 는 재시도로 안 풀립니다. 머지가 따라올 시간을 주거나 INSERT 빈도를 줄여야 합니다. 원인 분석은 Too many parts 와 머지 지연 에 있습니다.- 입력 오류는 설정으로 허용 범위를 정합니다.
input_format_allow_errors_num,input_format_allow_errors_ratio로 깨진 행 몇 개는 건너뛸 수 있습니다. 다만 조용히 버려지는 행이 생기니 수집 로그와 함께 쓰세요. - 스키마 변경은
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) 에 걸어 두고 애플리케이션 사용자를 그 역할에 넣는 방식이 깔끔합니다.