삭제와 갱신 (뮤테이션, 경량 삭제, 경량 갱신, 중복 제거)
ClickHouse 에서 행을 지우고 고치는 네 가지 길(파티션 드롭, 뮤테이션, 경량 삭제와 갱신, ReplacingMergeTree)을 비용과 쓸 자리로 비교하고, INSERT 중복 제거와 FINAL 의 동작을 정리합니다.
먼저 알아야 할 것: 파트는 불변입니다
MergeTree 의 파트는 한 번 쓰이면 바뀌지 않습니다. 그래서 "행 하나를 고친다" 는 OLTP 식 UPDATE 가 없고, 대신 다음 네 가지 방법이 있습니다. 어떤 것을 고를지는 "얼마나 자주, 얼마나 많이, 얼마나 빨리 반영되어야 하나" 로 정해집니다.
| 방법 | 비용 | 반영 시점 | 어울리는 자리 |
|---|---|---|---|
파티션 드롭 ALTER TABLE ... DROP PARTITION | 거의 0 | 즉시 | 기간 단위 삭제, 재적재 |
뮤테이션 ALTER TABLE ... UPDATE/DELETE | 파트 전체 다시 쓰기 | 비동기, 수 분에서 수 시간 | 가끔 하는 대량 정정 |
경량 삭제 DELETE FROM | 삭제 마스크 쓰기 | 즉시 보임, 공간은 머지 때 | 드문 행 단위 삭제 (개인정보 삭제) |
경량 갱신 UPDATE ... SET | 패치 파트 쓰기 | 즉시 보임 | 드문 행 단위 수정 |
| ReplacingMergeTree + 새 행 INSERT | INSERT 비용만 | 머지 뒤 또는 FINAL 로 즉시 | 자주 바뀌는 상태 (CDC, 최신 값) |
파티션 드롭: 가장 싼 삭제
날짜나 배치 단위로 데이터를 지우거나 다시 넣을 일이 많으면 그 단위로 파티션을 잡습니다. 파티션 드롭은 디렉터리를 지우는 것이라 수 TB 라도 즉시 끝납니다.
ALTER TABLE logs.events DROP PARTITION '202609';
-- 재적재: 임시 테이블에 넣고 파티션 교체
ALTER TABLE logs.events REPLACE PARTITION '202609' FROM logs.events_staging;REPLACE PARTITION 은 두 테이블의 구조와 파티션 키가 같아야 합니다. 보존 기간이 정해진 데이터는 TTL로 자동화합니다.
뮤테이션: 무겁고 비동기입니다
ALTER TABLE logs.events DELETE WHERE user_id = 42;
ALTER TABLE logs.events UPDATE country = 'KR' WHERE country = 'KOR';뮤테이션은 조건에 맞는 행이 한 줄이라도 있는 파트를 전부 다시 씁니다(UPDATE 는 바뀐 열만, DELETE 는 모든 열). 10 TB 테이블에서 조건이 넓으면 10 TB 를 다시 쓰는 작업이고, 복제 테이블이면 모든 복제본이 각자 다시 씁니다. 비동기로 돌아가서 명령은 바로 돌아오고, 결과는 system.mutations 에서 봅니다.
SELECT mutation_id, command, create_time, parts_to_do, is_done,
latest_failed_part, latest_fail_reason
FROM system.mutations
WHERE table = 'events' AND NOT is_done;
KILL MUTATION WHERE table = 'events' AND mutation_id = '0000000012';- 기다리려면
SET mutations_sync = 1(이 복제본) 또는2(모든 복제본)로 두고 실행합니다. - 정렬 키와 기본 키 열은 UPDATE 할 수 없습니다. 바꾸려면 새 테이블로 복사합니다.
- 뮤테이션은 생성 순서대로 적용되고, 하나가 실패하면(
latest_fail_reason) 뒤의 것도 멈춥니다. 머지도 막힐 수 있습니다 (Too many parts, 복제 지연). - 뮤테이션이 돌 때 쿼리에 바로 반영해 보이게 하는
apply_mutations_on_fly설정이 있습니다. Cloud 에서는 기본으로 쓰이고, 셀프 호스팅에서는 버전과 단계를 공식 문서에서 확인하세요.
주의 뮤테이션을 하루에 수백 번 돌리는 것은 설계 신호입니다. "행마다 상태가 바뀌는 데이터" 는 아래 ReplacingMergeTree 로 풉니다.
경량 삭제: DELETE FROM
DELETE FROM logs.events WHERE user_id = 42;경량 삭제는 파트를 다시 쓰지 않고 _row_exists 마스크 열만 씁니다. 명령이 끝나면 바로 쿼리에서 사라지고, 실제 공간은 다음 머지에서 돌아옵니다. 23.3 부터 GA 입니다. 대상이 수십 행에서 수백만 행 정도이고 가끔 일어나는 삭제(탈퇴 사용자 데이터 삭제 같은 것)에 맞습니다.
- 뮤테이션의 일종이라
system.mutations에UPDATE _row_exists = 0 WHERE ...로 보입니다. 다만 마스크만 쓰므로 훨씬 가볍습니다. lightweight_deletes_sync(기본 2, 모든 복제본에서 끝나길 기다림)로 동기 여부를 정합니다.- 삭제된 행은 머지 전까지 디스크에 남아 있습니다. 규정상 "물리 삭제" 가 필요하면
OPTIMIZE TABLE ... FINAL로 머지를 강제하거나 삭제가 끝났는지system.parts의has_lightweight_delete를 봅니다. - 프로젝션이 있는 테이블에서는
lightweight_mutation_projection_mode설정으로 프로젝션을 어떻게 할지(지우기, 다시 만들기) 정합니다.
경량 갱신: UPDATE ... SET
UPDATE logs.events SET country = 'KR' WHERE user_id = 42;경량 갱신은 바뀐 값만 담은 작은 "패치 파트" 를 써서 원본 파트를 건드리지 않습니다. 읽을 때 패치를 겹쳐 보여 주고, 머지 때 합쳐집니다. 25.7 에서 Beta 로 들어왔고, 지금 단계와 제한 사항(어떤 열을 고칠 수 있는지, 머지와의 상호작용)은 공식 문서를 확인하세요. 쓸 자리는 경량 삭제와 같습니다. 드물게, 적은 행을, 바로 반영해야 할 때입니다.
ReplacingMergeTree: 상태가 자주 바뀌는 데이터
주문 상태, 사용자 프로필처럼 같은 키의 최신 값만 필요한 데이터는 고치지 않고 새 행을 넣습니다. 머지가 같은 정렬 키의 행 중 버전이 높은 것만 남깁니다.
CREATE TABLE orders
(
order_id UInt64,
status LowCardinality(String),
amount Decimal(18, 2),
updated_at DateTime64(3),
is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at, is_deleted)
ORDER BY order_id;ver(여기선updated_at)가 없으면 마지막에 들어온 행이 남습니다. 있으면 가장 큰 값이 남습니다. CDC 처럼 순서가 섞여 올 수 있으면 반드시 버전 열을 둡니다.is_deleted열(23.2 부터)에 1 을 넣은 행을 INSERT 하면 머지 때 그 키가 사라집니다. 삭제도 INSERT 로 처리하는 셈입니다.clean_deleted_rows또는FINAL에서 걸러지는 조건은 문서를 확인합니다.- 머지가 언제 일어날지는 보장이 없습니다. 지금 당장 중복 없는 결과가 필요하면 쿼리에
FINAL을 붙입니다.
SELECT order_id, status, amount FROM orders FINAL WHERE order_id IN (1, 2, 3);FINAL 은 읽으면서 중복을 제거하므로 비용이 있습니다. 비용을 줄이는 설정과 패턴입니다.
| 방법 | 설명 |
|---|---|
do_not_merge_across_partitions_select_final = 1 | 파티션이 다르면 중복이 없다는 전제로 파티션 안에서만 처리. 파티션 키가 정렬 키의 일부일 때 |
WHERE 로 범위를 좁힌 뒤 FINAL | 기본 키 조건이 먼저 적용되어 읽는 양이 줄어듦 |
argMax 패턴 | SELECT order_id, argMax(status, updated_at) FROM orders GROUP BY order_id. FINAL 없이 집계로 최신 값 |
LIMIT 1 BY order_id + ORDER BY updated_at DESC | 최신 한 행 뽑기 |
| 머티리얼라이즈드 뷰로 최신 상태 테이블 유지 | 읽기가 아주 많으면 미리 정리 (머티리얼라이즈드 뷰) |
같은 가족의 다른 엔진(Collapsing, VersionedCollapsing, Aggregating)이 어울리는 경우는 MergeTree 가족에 있습니다.
INSERT 중복 제거: 같은 블록을 두 번 넣으면
복제 테이블은 INSERT 블록의 해시를 Keeper 에 기억해 두고, 같은 블록이 다시 들어오면 조용히 버립니다. 네트워크 오류로 클라이언트가 재시도했을 때 중복이 생기지 않는 이유입니다.
- 기억하는 블록 수는
replicated_deduplication_window(기본 1000, 버전에 따라 다름)와replicated_deduplication_window_seconds로 정합니다. - 비복제 MergeTree 는 기본으로 하지 않습니다.
non_replicated_deduplication_window를 0 보다 크게 두면 똑같이 됩니다. - 블록 해시가 아니라 내가 정한 키로 중복을 막으려면 INSERT 에
SETTINGS insert_deduplication_token = 'batch-2026-10-21-001'을 붙입니다. 같은 토큰의 INSERT 는 데이터가 달라도 두 번째가 버려집니다. 재처리 파이프라인에서 멱등성을 만드는 가장 쉬운 길입니다. - 머티리얼라이즈드 뷰 대상 테이블로는 기본으로 중복 제거가 이어지지 않습니다.
deduplicate_blocks_in_dependent_materialized_views = 1로 켭니다. - 이 중복 제거는 "같은 INSERT 가 두 번" 을 막는 것이고, 소스 데이터 자체의 중복(같은 이벤트를 두 번 보냄)은 ReplacingMergeTree 나
OPTIMIZE ... DEDUPLICATE영역입니다.
-- 이미 들어간 중복을 정리 (지정한 열 기준, 비쌈)
OPTIMIZE TABLE events FINAL DEDUPLICATE BY event_id, ts;INSERT 쪽 설계는 INSERT 모범 사례에 이어집니다.
상황별 고르기
| 상황 | 고르는 것 |
|---|---|
| 지난달 데이터 전체 재적재 | 파티션 REPLACE PARTITION |
| 탈퇴 사용자 데이터 삭제 요청 (하루 수십 건) | 경량 삭제 DELETE FROM, 규정상 물리 삭제가 필요하면 머지 확인 |
| 잘못 들어간 열 값 전체 정정 (1 회) | 뮤테이션 ALTER TABLE ... UPDATE, 업무 시간 밖 |
| 주문 상태처럼 계속 바뀌는 값 | ReplacingMergeTree + 버전 열, 읽기는 FINAL 또는 argMax |
| CDC 로 PostgreSQL 변경을 받음 | ReplacingMergeTree(ver, is_deleted). PostgreSQL, MySQL 연동과 CDC |
| 재시도로 생기는 중복 | 복제 중복 제거 + insert_deduplication_token |
| 소스가 보낸 중복 이벤트 | 집계 쿼리에서 uniq(event_id), 또는 ReplacingMergeTree |