DB Study

9장. INSERT 전략 선택

"한 번에 얼마나, 얼마나 자주 넣을 것인가" — 이 단순한 질문의 답이 "Too many parts" 장애와 안정적인 수집 파이프라인을 가릅니다. 배칭, async insert, 포맷·압축까지 INSERT의 모든 선택지를 정리합니다.

중급 ⏱ 약 25분 🎬 애니메이션 3개 선수 지식: 4장 Parts, 6장 Merges
🧭
이 장에서 배우는 것

① 소량 잦은 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개씩 · 초당 500회 😎 10,000행 배치 · 초당 1회 INSERT×500/s ↓↓↓ 파트 수천 개 (전부 level 0) 머지가 도저히 못 따라감 ⚙️🔥 DB::Exception: Too many parts (3000) parts_to_throw_insert 초과 → INSERT 거부! INSERT 10,000행 × 1회/초 초당 파트 1개씩만 생성 머지가 여유롭게 수렴 ✅ 쿼리도 빠르고 INSERT도 안정적 같은 5,000행/초 — 파트 500개/초 vs 1개/초. 배칭이 전부다 ⚡
💡
사전 정렬은 "선택적" 최적화

클라이언트에서 프라이머리 키 순서로 정렬해 보내면 서버가 정렬 단계를 건너뛰어 CPU가 절약됩니다. 하지만 ClickHouse의 서버 측 병렬 정렬이 매우 효율적이므로, 데이터가 이미 거의 정렬돼 있거나 클라이언트 자원이 남을 때만 유의미합니다. observability 같은 고속 스트리밍에서는 보통 생략합니다.

9.3 재시도해도 안전하다 — INSERT 멱등성

네트워크는 언제든 끊길 수 있습니다. "INSERT가 성공했는지 모르겠는데 다시 보내면 중복되지 않을까?" — MergeTree는 이 문제를 INSERT 중복 제거(deduplication)로 해결합니다.

🧑‍💻 클라이언트 배치 #42 (10,000행) ClickHouse 서버 배치 해시 기록 유지 ① INSERT 배치 #42 파트 생성 ✓ ② 응답(ACK)이 네트워크 중단으로 유실 ⚡✂️ "실패했나...?" 😨 ③ 동일한 배치 #42 재전송 (내용·순서 동일) 해시 비교 → "이미 받은 배치!" ④ 중복 파트를 만들지 않고 성공 응답 → 재시도가 안전(멱등) ✅ 조건: 재시도 시 배치의 내용과 순서가 완전히 동일해야 한다

9.4 배칭이 불가능하다면 — Async Insert

수백~수천 개의 에이전트가 실시간으로 소량 데이터를 계속 쏘는 observability 환경에서는 클라이언트 측 배칭이 어렵습니다. 이럴 때 배칭을 서버에게 맡기는 것이 async_insert입니다.

에이전트 A 에이전트 B 에이전트 C … ×1000 소량 행 실시간 전송 🧠 서버 메모리 버퍼 행들이 계속 누적... (flush 전엔 쿼리 불가!) Flush 조건 (하나라도 충족) ① 버퍼 크기 초과 (max_data_size) ② 타임아웃 (busy_timeout_ms) ③ 누적 쿼리 수 (max_query_number) 💾 하나의 파트로 flush 수천 개의 소량 INSERT → 파트 1개 wait_for_async_insert = 1 (기본): flush 완료 후 응답 → 에러가 클라이언트에 전달됨 ✅ 프로덕션 권장 = 0 (fire-and-forget): 버퍼링 즉시 응답 — 에러 미인지 + 과부하 위험. 유실 허용 시에만
-- 사용자 레벨로 활성화
ALTER USER default SETTINGS async_insert = 1;

-- 또는 쿼리 레벨
INSERT INTO mytable SETTINGS async_insert = 1 VALUES (...);
⚠️
async insert의 중복 제거는 기본 꺼짐

동기 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")
권장최대 처리량이 필요할 때대부분의 프로덕션 환경
💡
분산 환경에서 INSERT 대상 선택

8장에서 배운 대로, Distributed 테이블 INSERT는 Initiator를 거치는 오버헤드와 장애 시 임시 데이터 유실 위험이 있습니다. 가능하면 로컬 테이블(ReplicatedMergeTree)에 직접 INSERT하고, 클라이언트나 로드밸런서가 shard별 라우팅을 담당하게 하세요.

✍️ 이해도 체크

ClickHouse 공식 문서가 권장하는 INSERT 배치 크기와 빈도는?
✅ INSERT마다 고정 오버헤드를 가진 파트가 하나씩 생기므로, 배치를 키우고 빈도를 초당 약 1회로 유지해야 백그라운드 머지가 따라잡을 수 있습니다. 소량 잦은 INSERT는 파트 폭발 → "Too many parts"(기본 3000 초과 시 INSERT 거부)로 이어집니다.
wait_for_async_insert = 0이 위험한 이유는?
✅ =0은 fire-and-forget 모드입니다. 서버가 버퍼에 넣자마자 성공을 반환하므로, 실제 파트 기록에서 에러가 나도 클라이언트는 모른 채 데이터가 유실됩니다. 공식 문서는 "very risky"라고 표현하며, 프로덕션에서는 기본값인 wait_for_async_insert=1을 유지하라고 권고합니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch09_insert_strategy.md — 서버 측 처리 13단계, 사전 정렬 트레이드오프, 비동기 INSERT 중복 제거 상세가 담겨 있습니다.