DB Study

10장. 피해야 할 패턴들 — 안티패턴 모음

MySQL·PostgreSQL에서 하던 습관을 ClickHouse에 그대로 가져오는 순간 성능은 무너집니다. 공식 best-practices가 명시적으로 경고하는 7가지 안티패턴을 "왜 위험한지"부터 대안까지 정리합니다.

중급 ⏱ 약 22분 🎬 애니메이션 2개 선수 지식: 5~9장
🧭
이 장에서 배우는 것

① 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)입니다. 그래서 단 한 행을 고치려 해도 그 행이 든 파트 전체를 재작성해야 합니다.

테이블 = 파트 3개 (각 1,000만 행) part_1 (10M rows) part_2 (10M rows) user_id=123 ← 이 한 행만! part_3 (10M rows) ALTER TABLE t UPDATE status='inactive' WHERE user_id=123 ← 비동기 큐로 part_1 전체(1,000만 행) 재작성 🔥 압축 해제 → 수정 → 재압축 → 재기록 행 1개 ÷ 1,000만 행 = 엄청난 write amplification ✅ 대안: ReplacingMergeTree에 새 행 INSERT — 가볍고, 머지가 나중에 정리해 준다
-- 피하라: 행 몇 개 때문에 파트 전체 재작성
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")로 경고하는 대표 안티패턴입니다.

평상시: 백그라운드 머지는 안전 제한을 지킨다 part (120GB) ✓ 머지 대상 제외 part (150GB) ✓ 상한 도달 작은 파트들 max_bytes_to_merge_ at_max_space ≈ 150GB OPTIMIZE TABLE mytable FINAL; ← 150GB 제한 무시! 모든 파트 압축 해제 → 병합 → 재압축 → 재기록 CPU·디스크 I/O 폭증 🔥 일시적으로 디스크 2배 필요 · 장시간 점유 · OOM 위험 거대 단일 파트 (300GB+) — 이후 머지에도 짐이 된다 ✅ 결론: 파트 관리는 백그라운드 머지에 맡겨라. 수동 개입은 거의 항상 유해하다
⚠️
OPTIMIZE FINAL ≠ SELECT ... FINAL

혼동 주의! 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 INSERTInitiator 임시 저장 → 장애 시 유실로컬 테이블 직접 INSERT
부적절한 타입불필요한 스토리지·메모리 소비LowCardinality, 적정 크기 타입, Date
💡
관통하는 원칙 하나

모든 안티패턴의 뿌리는 같습니다 — "파트는 불변이고, 무거운 처리는 백그라운드 머지의 몫"이라는 설계를 거스르는 것. 제자리 수정(mutation), 수동 머지(OPTIMIZE FINAL), 파트 남발(소량 INSERT·과도한 파티셔닝)은 전부 이 원칙과의 충돌입니다.

✍️ 이해도 체크

ALTER TABLE mytable UPDATE status='x' WHERE user_id=123으로 단 한 행을 수정하면 내부에서 일어나는 일은?
✅ 파트는 불변이므로 mutation은 영향받는 파트 전체를 재작성하는 비동기 백그라운드 프로세스입니다. 행 1개를 위해 수천만 행을 다시 쓸 수 있고, 롤백도 불가능합니다(KILL MUTATION으로 취소만 가능). UPDATE가 필요하면 ReplacingMergeTree + 새 행 INSERT 패턴을 쓰세요.
OPTIMIZE TABLE FINAL이 일반 백그라운드 머지보다 더 위험한 이유는?
✅ 일반 머지는 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 동작 방식 등 상세 내용이 담겨 있습니다.