9장. INSERT 전략 선택
"한 번에 얼마나, 얼마나 자주 넣을 것인가" — 이 단순한 질문의 답이 "Too many parts" 장애와 안정적인 수집 파이프라인을 가릅니다. 배칭, async insert, 포맷·압축까지 INSERT의 모든 선택지를 정리합니다.
① 소량 잦은 INSERT가 파트 폭발을 일으키는 이유와 배칭의 힘 ② 배칭이 불가능할 때의 대안 — async_insert의 서버 측 버퍼링 ③ 재시도해도 안전한 이유 — INSERT 멱등성(dedup) ④ 포맷(Native/RowBinary/JSON)·압축(LZ4/ZSTD)·인터페이스(Native/HTTP) 선택 기준
9.1 INSERT 한 번에 일어나는 일 — 고정 오버헤드의 존재
ClickHouse의 INSERT는 기본적으로 동기(synchronous)입니다. 쿼리 하나가 곧바로 디스크에 파트 하나를 만듭니다. 그런데 파트 하나를 만드는 데는 행이 10개든 10만 개든 거의 일정한 고정 비용이 듭니다.
-- 서버 측 INSERT 처리 파이프라인
클라이언트: ① 배칭 → ② 사전 정렬(선택) → ③ 포맷 → ④ 압축(선택) → ⑤ 전송
서버: ⑥ 수신 → ⑦ 압축 해제 → ⑧ 파싱 → ⑨ in-memory Block 구성
→ ⑩ 프라이머리 키 정렬 → ⑪ sparse index 생성
→ ⑫ 컬럼별 압축 → ⑬ 디스크에 새 파트 기록
⑩~⑬은 행 수와 무관하게 항상 수행됩니다. 그래서 배치가 클수록 행당 비용이 급감합니다. 공식 권장 수치는 명확합니다.
"최소 1,000행, 이상적으로는 10,000~100,000행 단위로 INSERT하라." 그리고 빈도는 "초당 약 1회의 INSERT 쿼리를 유지하라." INSERT마다 파트가 생기고, 백그라운드 머지가 그것을 따라잡아야 하기 때문입니다.
9.2 파트 폭발 — 안티패턴과 배칭의 극적인 차이
초당 수백 번의 소량 INSERT가 왜 장애로 이어지는지, 그리고 같은 데이터를 배치로 넣으면 어떻게 달라지는지 비교해 봅시다.
행 10개짜리 INSERT가 초당 500번 쏟아집니다. INSERT 1회 = 파트 1개이므로 초당 파트 500개가 생성됩니다.
level 0 파트가 수천 개로 불어나고, 백그라운드 머지는 파트 생성 속도를 도저히 따라잡지 못합니다. 파트마다 고정 오버헤드(인덱스·메타데이터·디렉터리)도 반복 지불됩니다.
활성 파트가 parts_to_throw_insert(기본 3000)를 넘는 순간 — "Too many parts" 에러와 함께 INSERT가 거부됩니다. 가장 흔한 ClickHouse 운영 사고입니다.
같은 데이터를 10,000행 배치로 초당 1회 넣으면? 파트가 초당 1개만 생기고, 고정 오버헤드도 1/500로 줄어듭니다.
머지는 여유롭게 계층을 수렴시키고, SELECT는 적은 파트만 읽어 빠릅니다. 배칭은 ClickHouse 수집 최적화의 알파이자 오메가입니다.
클라이언트에서 프라이머리 키 순서로 정렬해 보내면 서버가 정렬 단계를 건너뛰어 CPU가 절약됩니다. 하지만 ClickHouse의 서버 측 병렬 정렬이 매우 효율적이므로, 데이터가 이미 거의 정렬돼 있거나 클라이언트 자원이 남을 때만 유의미합니다. observability 같은 고속 스트리밍에서는 보통 생략합니다.
9.3 재시도해도 안전하다 — INSERT 멱등성
네트워크는 언제든 끊길 수 있습니다. "INSERT가 성공했는지 모르겠는데 다시 보내면 중복되지 않을까?" — MergeTree는 이 문제를 INSERT 중복 제거(deduplication)로 해결합니다.
클라이언트가 배치 #42를 INSERT하고, 서버는 파트를 성공적으로 생성합니다.
그런데 성공 응답이 클라이언트에 도달하기 전에 네트워크가 끊깁니다. 클라이언트 입장에서는 성공인지 실패인지 알 수 없습니다. (서버 측 실패 후 타임아웃도 같은 상황)
클라이언트가 동일한 배치를 재전송합니다. 서버는 배치의 해시를 기존 기록과 비교해 "이미 처리한 INSERT"임을 감지합니다.
중복 파트는 만들어지지 않고 성공으로 처리됩니다. 단, 배치 내용과 순서가 완전히 동일할 때만 동작하므로, 재시도 로직에서 배치를 재구성하지 않도록 주의하세요.
9.4 배칭이 불가능하다면 — Async Insert
수백~수천 개의 에이전트가 실시간으로 소량 데이터를 계속 쏘는 observability 환경에서는 클라이언트 측 배칭이 어렵습니다. 이럴 때 배칭을 서버에게 맡기는 것이 async_insert입니다.
수백~수천 개의 에이전트가 각자 소량의 행을 실시간으로 보냅니다. 클라이언트 측 배칭이 불가능한 전형적 상황입니다.
서버가 INSERT를 즉시 파트로 만드는 대신 메모리 버퍼에 누적합니다. 클라이언트에게는 투명하게 동작하지만, flush 전까지 그 데이터는 쿼리할 수 없습니다.
세 가지 flush 조건 중 하나가 충족되면: ① 버퍼 크기(async_insert_max_data_size) ② 타임아웃(async_insert_busy_timeout_ms) ③ 누적 쿼리 수(async_insert_max_query_number).
버퍼 전체가 하나의 파트로 기록됩니다. 수천 개의 소량 INSERT가 파트 1개로 — 파트 폭발이 원천 차단됩니다.
응답 모드가 중요합니다. wait_for_async_insert=1(기본)은 flush 완료 후 응답해 에러를 알 수 있습니다. =0은 매우 위험 — 공식 문서도 "클라이언트가 에러를 알 수 없고 과부하를 유발할 수 있다"고 강하게 경고합니다.
-- 사용자 레벨로 활성화
ALTER USER default SETTINGS async_insert = 1;
-- 또는 쿼리 레벨
INSERT INTO mytable SETTINGS async_insert = 1 VALUES (...);
동기 INSERT와 달리 비동기 INSERT에서는 dedup이 기본 비활성화입니다. 활성화할 수는 있지만, 의존하는 Materialized View가 있으면 문제가 생길 수 있으니 주의하세요.
9.5 포맷·압축·인터페이스 — 전송 경로의 선택
| 포맷 | 특징 | 권장 용도 |
|---|---|---|
Native | 가장 효율적. 컬럼 지향이라 서버 측 파싱 최소. Go/Python 클라이언트 기본값 | 고처리량 수집 |
RowBinary | 효율적인 행 기반 포맷. Java 클라이언트 기본값 | 클라이언트에서 컬럼 변환이 어려울 때 |
JSONEachRow | 사용은 편리하나 파싱 비용 높음 | 저볼륨, 빠른 통합 |
압축은 LZ4(기본)가 대부분 최적입니다 — 빠르고 가벼우며 50% 이상 크기를 줄입니다. 리전 간 전송이나 클라우드 egress 비용처럼 네트워크 비용이 높을 때만 ZSTD(높은 압축률, CPU 부하 증가)를 고려하세요. Native 포맷 + LZ4 조합이 최고 수집 성능을 냅니다.
| Native 인터페이스 | HTTP 인터페이스 | |
|---|---|---|
| 포맷 | 항상 Native (최효율) | 70+ 포맷 지원 |
| 압축 | 블록 단위 LZ4/ZSTD | 전체 페이로드 단위 |
| 로드밸런싱 | 어려움 | 용이 |
| 성능 | 약간 더 빠름 | 약간 느림 (차이는 "small") |
| 권장 | 최대 처리량이 필요할 때 | 대부분의 프로덕션 환경 |
8장에서 배운 대로, Distributed 테이블 INSERT는 Initiator를 거치는 오버헤드와 장애 시 임시 데이터 유실 위험이 있습니다. 가능하면 로컬 테이블(ReplicatedMergeTree)에 직접 INSERT하고, 클라이언트나 로드밸런서가 shard별 라우팅을 담당하게 하세요.
✍️ 이해도 체크
wait_for_async_insert = 0이 위험한 이유는?wait_for_async_insert=1을 유지하라고 권고합니다.이 장의 원문 문서: chapters/ch09_insert_strategy.md — 서버 측 처리 13단계, 사전 정렬 트레이드오프, 비동기 INSERT 중복 제거 상세가 담겨 있습니다.