10장. 피해야 할 패턴들 — 안티패턴 모음
MySQL·PostgreSQL에서 하던 습관을 ClickHouse에 그대로 가져오는 순간 성능은 무너집니다. 공식 best-practices가 명시적으로 경고하는 7가지 안티패턴을 "왜 위험한지"부터 대안까지 정리합니다.
① Mutation(ALTER UPDATE/DELETE)이 파트 전체를 다시 쓰는 이유와 대안 ② OPTIMIZE FINAL이 안전 제한까지 무시하는 위험한 명령인 이유 ③ Nullable·과도한 파티셔닝·소량 INSERT 등 자잘하지만 치명적인 습관들 ④ 안티패턴 → 대안 매핑 총정리
10.1 Mutation 남용 — ALTER TABLE UPDATE/DELETE
ClickHouse에도 ALTER TABLE ... UPDATE / DELETE 구문이 있습니다. SQL 문법은 낯익지만, 내부 동작은 OLTP DB와 근본적으로 다릅니다. 공식 문서의 표현: "행을 제자리에서 수정하는 대신, mutation은 변경의 영향을 받는 데이터 파트 전체를 다시 쓰는 비동기 백그라운드 프로세스다."
4장에서 배운 대로 파트는 불변(immutable)입니다. 그래서 단 한 행을 고치려 해도 그 행이 든 파트 전체를 재작성해야 합니다.
1,000만 행짜리 파트 3개로 이뤄진 테이블. 수정하고 싶은 것은 part_1의 단 한 행(user_id=123)입니다.
ALTER TABLE ... UPDATE를 제출합니다. 이 명령은 즉시 완료되지 않고 비동기 백그라운드 큐에 들어갑니다. 롤백은 불가능하고(KILL MUTATION으로 취소만 가능), 서버를 재시작해도 큐에 남아 계속 실행됩니다.
파트는 불변이므로 part_1의 1,000만 행 전체를 압축 해제 → 수정 → 재압축 → 재기록합니다. 행 1개 때문에 파트 하나를 통째로 다시 쓰는 극단적인 write amplification! 진행 중에는 SELECT가 mutated + unmutated 파트의 혼합을 읽을 수도 있습니다.
대안: ReplacingMergeTree에 새 행을 INSERT하면 끝. 쓰기는 가볍고, 중복 정리는 백그라운드 머지가 처리합니다(6장). 대량 삭제라면 파티션 DROP, 가벼운 삭제라면 Lightweight Delete를 쓰세요.
-- 피하라: 행 몇 개 때문에 파트 전체 재작성
ALTER TABLE mytable UPDATE status = 'inactive' WHERE user_id = 123;
ALTER TABLE mytable DELETE WHERE date < '2023-01-01';
-- 대안 1: ReplacingMergeTree에 새 행 INSERT (UPDATE 대체)
INSERT INTO mytable VALUES (123, 'inactive', now());
-- 대안 2: Lightweight Delete — 파트를 다시 쓰지 않고 마스크 파일로 처리
DELETE FROM mytable WHERE user_id = 123;
-- 대안 3: 대량 삭제는 파티션 단위로 (초 단위 완료)
ALTER TABLE mytable DROP PARTITION '2022-01-01';
10.2 OPTIMIZE TABLE FINAL 남용
OPTIMIZE TABLE ... FINAL은 모든 활성 파트를 하나의 파트로 강제 머지하는 명령입니다. "머지를 빨리 끝내면 좋은 것 아닌가?"라고 생각하기 쉽지만, 공식 문서가 별도 문서("Avoid OPTIMIZE FINAL")로 경고하는 대표 안티패턴입니다.
평상시 백그라운드 머지는 max_bytes_to_merge_at_max_space_in_pool(약 150GB) 제한을 지킵니다. 상한에 도달한 파트는 더 이상 머지 대상이 되지 않아 시스템이 안정적으로 유지됩니다.
OPTIMIZE TABLE ... FINAL이 실행되면 이 안전 제한을 무시하고 모든 활성 파트를 하나로 합치려 시도합니다 — 이미 150GB인 파트들까지도.
전체 테이블 데이터를 압축 해제 → 병합 → 재압축 → 재기록하므로 CPU와 I/O가 폭증하고, 원본+결과가 공존해 디스크가 일시적으로 2배 필요하며, 장시간 머지·OOM으로 이어질 수 있습니다.
남는 것은 거대 단일 파트 — 이후 머지에도 부담이고, ReplacingMergeTree에서는 오히려 중복이 누적되는 상황도 생깁니다. 백그라운드 머지에 맡기는 것이 거의 항상 정답입니다.
혼동 주의! OPTIMIZE TABLE ... FINAL은 물리적으로 파트를 병합하는 명령(피해야 함)이고, SELECT ... FINAL은 쿼리 시점에 머지 로직을 적용하는 키워드입니다. 후자는 프라이머리 키 필터가 있으면 성능이 괜찮을 수 있어, 필요하면 사용할 수 있습니다(6장 참조).
10.3 나머지 안티패턴 — 습관이 부르는 성능 저하
🗂 과도한 파티셔닝
파티션 간에는 머지가 일어나지 않으므로 파티션이 많을수록 파트 수가 폭증합니다. 파티션 수는 1,000~10,000 이하로 유지하고, 파티션 키가 프라이머리 키에 이미 있다면 중복 효과일 뿐입니다. 쿼리 최적화 목적이라면 대부분 파티셔닝하지 않는 것이 낫습니다(5장).
❓ Nullable 컬럼 남용
Nullable(T)는 내부적으로 별도의 UInt8 컬럼(NULL 마스크)을 추가로 만듭니다. 스토리지가 늘고 모든 연산에 추가 컬럼 처리가 붙어 거의 항상 성능이 저하됩니다. 비즈니스상 NULL이 꼭 필요하지 않다면 DEFAULT 0 같은 기본값으로 회피하세요.
💧 소량 빈번 INSERT
INSERT마다 파트가 생겨 초당 수백 회면 파트 폭증 → parts_to_throw_insert(기본 3000) 초과 시 INSERT 거부. 10,000~100,000행 배치 또는 async insert로 해결합니다(9장).
📮 Distributed 테이블에 직접 INSERT
Initiator에 임시 저장 후 비동기 전달되므로 장애 시 유실 위험 + 추가 네트워크 홉. 로컬 테이블(ReplicatedMergeTree)에 직접 INSERT하고 라우팅은 클라이언트/로드밸런서에 맡기세요(8장).
🔤 LowCardinality 미사용
고유값이 수천 이하인 String 컬럼은 LowCardinality(String)으로! 딕셔너리 인코딩이 적용되어 스토리지와 쿼리 성능이 모두 크게 향상됩니다.
📏 과도하게 넓은 타입
UInt16이면 충분한데 습관적으로 Int64를 쓰지 마세요. 컬럼 지향 스토리지에서 타입 크기는 디스크·메모리 소비에 직결됩니다. 일 단위면 Date(2B), 꼭 필요할 때만 DateTime(4B)/DateTime64(8B).
-- 피하라: Nullable + 넓은 타입 + 일반 String
CREATE TABLE t_bad (
x Int64,
y Nullable(Int8),
town String,
ts DateTime64
) ENGINE = MergeTree ORDER BY x;
-- 권장: 적정 타입 + DEFAULT + LowCardinality + Date
CREATE TABLE t_good (
x UInt16,
y Int8 DEFAULT 0,
town LowCardinality(String),
d Date
) ENGINE = MergeTree ORDER BY x;
10.4 총정리 — 안티패턴 → 대안 매핑
| 안티패턴 | 문제 | 대안 |
|---|---|---|
| Mutation (ALTER UPDATE/DELETE) | 파트 전체 재작성 → I/O 폭증 | ReplacingMergeTree, Lightweight Delete, 파티션 DROP |
| OPTIMIZE FINAL | 모든 파트 강제 병합, 150GB 안전 제한 무시 | 백그라운드 머지에 위임 |
| 과도한 파티셔닝 | 파티션 간 머지 불가 → Too many parts | 저카디널리티 파티션 키, 1,000개 이하 |
| Nullable 컬럼 | 추가 UInt8 마스크 컬럼 → 스토리지·성능 저하 | DEFAULT 값 사용 |
| 소량 빈번 INSERT | 파트 폭증 → 머지 지연 → INSERT 거부 | 배칭(1만~10만 행), async insert |
| Distributed INSERT | Initiator 임시 저장 → 장애 시 유실 | 로컬 테이블 직접 INSERT |
| 부적절한 타입 | 불필요한 스토리지·메모리 소비 | LowCardinality, 적정 크기 타입, Date |
모든 안티패턴의 뿌리는 같습니다 — "파트는 불변이고, 무거운 처리는 백그라운드 머지의 몫"이라는 설계를 거스르는 것. 제자리 수정(mutation), 수동 머지(OPTIMIZE FINAL), 파트 남발(소량 INSERT·과도한 파티셔닝)은 전부 이 원칙과의 충돌입니다.
✍️ 이해도 체크
ALTER TABLE mytable UPDATE status='x' WHERE user_id=123으로 단 한 행을 수정하면 내부에서 일어나는 일은?max_bytes_to_merge_at_max_space_in_pool(~150GB) 제한을 준수하지만 OPTIMIZE FINAL은 이를 무시합니다. 전체 데이터를 다시 쓰면서 CPU·I/O 폭증, 일시적 2배 디스크, OOM 위험을 만들고, 결과로 남은 거대 파트는 이후 머지에도 부담이 됩니다.이 장의 원문 문서: chapters/ch10_anti_patterns.md — mutation의 totally-ordered 특성, Lightweight Delete 동작 방식 등 상세 내용이 담겨 있습니다.