---
title: "대용량 DELETE 전략: chunk delete, sleep, archive, partition drop"
description: "MySQL 대용량 DELETE의 undo·purge·복제 부담을 설명하고, 청크 삭제와 휴지 시간, 아카이브 검증, 파티션 삭제의 선택 기준을 정리한다."
tags: [ MySQL, InnoDB, 성능최적화, 운영 ]
image: "mysql-report-bg.png"
published: "2026-10-21"
updated: "2026-10-21"
author: "MySQL 기술 노트"
source_url: ""
---

오래된 데이터를 지우는 작업은 단순한 저장 공간 정리가 아니다. 수천만 행을 한 트랜잭션으로 삭제하면 업무 쿼리와 잠금을 경쟁하고, undo와 redo를 생성하며, 커밋 이후에도 purge와 복제 적용에 부담을 남긴다. DELETE가 끝났다는 사실과 시스템이 삭제 전 부하 수준으로 돌아왔다는 사실은 서로 다르다.

이 장에서는 **행 단위 삭제는 작은 커밋으로 제어하고, 보관이 필요하면 검증된 아카이브를 먼저 만들며, 보존 경계가 파티션과 일치하면 파티션 단위 제거를 검토한다**는 원칙을 다룬다. 기본 대상은 MySQL 8.0 이상 InnoDB다. 아래 SQL은 폐기용 MySQL 8.0에서 실행한 작은 재현 예제이며, 운영 처리량·복제 지연·Aurora 저장 공간 감소를 측정한 결과는 아니다.

## 1. DELETE 이후에도 작업이 남는 이유

InnoDB는 DELETE가 실행되는 순간 모든 관련 공간을 운영체제로 반환하지 않는다. 레코드에 삭제 표시를 하고 롤백과 이전 버전 읽기를 위한 정보를 유지한다. 다른 트랜잭션의 Read View가 그 이전 버전을 필요로 할 수 있으므로, 커밋된 변경이라도 즉시 물리적으로 정리할 수 없는 경우가 있다. 이후 purge가 더 이상 필요하지 않은 이전 버전과 삭제 표시된 레코드를 정리한다.

이 과정에서 구분해야 할 부담은 다음과 같다.

- **전경 작업:** 검색할 인덱스 범위, 레코드·범위 잠금, undo 기록, redo 생성, 보조 인덱스 변경, 외래 키 검사와 연쇄 작업.
- **커밋 경계:** 로그 내구성 처리와 binlog 기록. ROW 형식에서는 삭제 대상 행의 row event가 발생하므로 전송·적용량도 고려해야 한다.
- **커밋 이후:** purge, dirty page flush, replica 적용, 버퍼 풀에서 밀려난 업무 데이터의 재읽기.
- **공간 회수:** 테이블 내부의 재사용 가능 공간과 파일·클러스터 볼륨의 실제 축소는 별개의 단계다.

청크를 작게 나누면 한 번에 보유하는 잠금과 미커밋 변경량을 제한할 수 있다. 그러나 같은 행을 삭제하는 전체 논리 작업량이 사라지는 것은 아니다. 반대로 너무 작은 청크는 커밋과 왕복 호출을 과도하게 늘린다. 운영 목표는 최대 DELETE 처리량이 아니라 **업무 지연과 복제·purge 부담을 허용 범위에 유지하는 지속 가능한 처리량**이다.

```mermaid
flowchart TD
    A["보존 기준 시각과 대상 범위 고정"] --> B{"전체 파티션을 제거해도 되는가"}
    B -->|예| C["보관 검증과 MDL 사전 점검"]
    C --> D["파티션 단위 제거"]
    B -->|아니오| E["인덱스로 작은 대상 집합 선택"]
    E --> F{"아카이브가 필요한가"}
    F -->|예| G["복사 내용 검증 후 같은 대상 삭제"]
    F -->|아니오| H["청크 DELETE"]
    G --> I["작은 트랜잭션 커밋"]
    H --> I
    I --> J["업무 지연·잠금·복제·purge 관측"]
    J --> K{"부하 예산 이내인가"}
    K -->|예| L["커밋 밖에서 휴지 후 반복"]
    L --> E
    K -->|아니오| M["청크 축소 또는 일시 중지"]
```

## 2. 청크 삭제는 LIMIT보다 접근 경로가 먼저다

### 고정된 기준 시각과 안정적인 순서

작업 시작 시 보존 기준 시각을 한 번 결정하고 끝까지 유지한다. 매 청크마다 현재 시각에서 보존 기간을 빼면 작업하는 동안 대상 집합이 계속 넓어질 수 있다. 시각은 UTC 등 하나의 시간 체계로 정하고, 애플리케이션이 저장한 값의 의미와 맞춰야 한다. 특히 DATETIME은 자체적으로 시간대를 보존하지 않는다.

단일 테이블 DELETE는 `ORDER BY`와 `LIMIT`을 지원한다. 같은 시각의 행이 많을 때는 기본 키를 두 번째 정렬 키로 넣어 대상 순서를 결정한다. `(created_at, id)` 인덱스는 보존 조건과 이 순서에 맞는 접근 경로를 제공한다. InnoDB의 보조 인덱스에는 기본 키가 포함되지만, 여기서는 설계 의도를 명시하기 위해 두 열을 함께 적는다.

LIMIT은 직접 삭제하는 행 수의 상한이지, 읽는 행 수·잠금 수·실행 시간의 상한은 아니다. 조건에 맞는 인덱스가 없거나 잔여 필터가 많으면 적은 행을 삭제하기 위해 많은 행을 조사할 수 있다. DELETE 트리거나 외래 키의 `ON DELETE CASCADE`가 있으면 부수 작업량도 커진다. 다중 테이블 DELETE에는 같은 방식으로 ORDER BY/LIMIT을 붙일 수 없다는 점도 구별해야 한다.

### 작은 데이터로 커밋 경계를 확인한다

아래 예제는 오래된 다섯 행을 두 행씩 나누어 삭제한다. 각 DELETE 직후 `ROW_COUNT()`를 읽고 커밋한다. 마지막 확인은 고정된 기준보다 오래된 행이 실제로 사라졌는지 검증한다. 여러 SQL 블록을 실행할 경우에도 이 예제 자체는 생성부터 정리까지 완결된다.

```sql
CREATE TABLE delete_chunk_demo (
  id BIGINT NOT NULL PRIMARY KEY,
  created_at DATETIME NOT NULL,
  payload VARCHAR(40) NOT NULL,
  KEY ix_created_id (created_at, id)
) ENGINE=InnoDB;
INSERT INTO delete_chunk_demo VALUES
  (1,'2026-08-01','a'), (2,'2026-08-01','b'),
  (3,'2026-08-02','c'), (4,'2026-08-03','d'),
  (5,'2026-08-04','e'), (6,'2026-09-01','keep');
SET @cutoff = '2026-09-01 00:00:00';
START TRANSACTION;
DELETE FROM delete_chunk_demo
WHERE created_at < @cutoff ORDER BY created_at, id LIMIT 2;
SELECT ROW_COUNT() AS deleted_rows;
COMMIT;
SELECT SLEEP(0.05) AS sleep_interrupted;
START TRANSACTION;
DELETE FROM delete_chunk_demo
WHERE created_at < @cutoff ORDER BY created_at, id LIMIT 2;
SELECT ROW_COUNT() AS deleted_rows;
COMMIT;
START TRANSACTION;
DELETE FROM delete_chunk_demo
WHERE created_at < @cutoff ORDER BY created_at, id LIMIT 2;
SELECT ROW_COUNT() AS deleted_rows;
COMMIT;
SELECT COUNT(*) AS expired_remaining FROM delete_chunk_demo
WHERE created_at < @cutoff;
SELECT id, payload FROM delete_chunk_demo ORDER BY id;
DROP TABLE delete_chunk_demo;
```

실행 결과(MySQL 8.0.x):

준비 문장과 반복되는 트랜잭션 제어 출력은 줄이고 핵심 결과를 발췌했다. 실제 검증 버전은 8.0.46이다.

```text
mysql> CREATE TABLE delete_chunk_demo (...);
Query OK, 0 rows affected
mysql> INSERT INTO delete_chunk_demo VALUES (...);
Query OK, 6 rows affected
Records: 6  Duplicates: 0  Warnings: 0

mysql> DELETE FROM delete_chunk_demo ... LIMIT 2;
Query OK, 2 rows affected
mysql> COMMIT;
Query OK, 0 rows affected
mysql> SELECT SLEEP(0.05) AS sleep_interrupted;
+-------------------+
| sleep_interrupted |
+-------------------+
|                 0 |
+-------------------+
1 row in set

mysql> DELETE FROM delete_chunk_demo ... LIMIT 2;
Query OK, 2 rows affected
mysql> COMMIT;
Query OK, 0 rows affected
mysql> DELETE FROM delete_chunk_demo ... LIMIT 2;
Query OK, 1 row affected
mysql> COMMIT;
Query OK, 0 rows affected

mysql> SELECT COUNT(*) AS expired_remaining FROM delete_chunk_demo WHERE created_at < @cutoff;
+-------------------+
| expired_remaining |
+-------------------+
|                 0 |
+-------------------+
1 row in set
mysql> SELECT id, payload FROM delete_chunk_demo ORDER BY id;
+----+---------+
| id | payload |
+----+---------+
|  6 | keep    |
+----+---------+
1 row in set
mysql> DROP TABLE delete_chunk_demo;
Query OK, 0 rows affected
```

실행 결과의 핵심은 삭제 행 수가 청크마다 달라져도 커밋 경계는 유지된다는 점이다. 실제 반복기는 영향을 받은 행이 0이 될 때까지 실행하거나, 청크 상한보다 적게 삭제했을 때 일단 종료한 뒤 최종 재검사를 할 수 있다. 둘 다 **새로 도착한 과거 시각 데이터까지 영원히 차단한다는 의미는 아니다.** 늦게 유입되는 데이터가 있으면 다음 실행이나 별도 후속 스캔으로 처리해야 한다.

복합 커서 `(created_at, id)`를 저장하고 그 이후부터 읽는 방식은 재시작 지점을 명확히 할 수 있다. 다만 커서보다 앞쪽으로 들어온 지연 입력이나 정렬 키가 바뀐 행은 누락될 수 있다. 단순 반복 LIMIT 방식과 커서 방식 중 무엇을 선택하든 대상의 변경 가능성과 재검사 정책을 먼저 정의한다. OFFSET을 늘려가며 삭제하면 앞부분의 행이 사라질 때 위치가 당겨지므로 건너뛰기가 발생할 수 있다.

## 3. sleep은 반드시 커밋 이후에 둔다

한 청크의 트랜잭션을 열어 놓고 sleep을 호출하면 잠금과 undo 수명이 늘어난다. 휴지 시간은 커밋이 완료된 뒤 둔다. 위 SQL의 `SLEEP(0.05)`는 순서를 보여 주기 위한 짧은 실험값일 뿐 권장 운영값이 아니다. 실제 작업기는 DB 연결을 붙잡고 SLEEP을 실행하기보다 애플리케이션 측 대기로 구현하는 편이 일반적이다.

부하 제어는 청크 크기 하나로 끝나지 않는다. 같은 행 수라도 큰 BLOB, 많은 보조 인덱스, 연쇄 삭제, 캐시 적중률 차이 때문에 시간이 크게 달라진다. 다음과 같은 피드백 정책을 둔다.

1. 작은 청크로 시작하고, 청크 실행 시간과 실제 삭제 행 수를 기록한다.
2. 업무 쿼리의 상위 지연, 잠금 대기, redo 생성률, replica 적용 지연, purge 추세를 같은 시간축에서 본다.
3. 중단 기준을 넘으면 새 청크 시작을 멈춘다. 관측값을 얻지 못한 상태를 정상으로 간주하지 않는다.
4. 여유가 충분한 구간에서만 청크 크기를 완만하게 늘리거나 휴지 시간을 줄인다.
5. lock wait timeout이나 deadlock이 발생하면 해당 청크를 명시적으로 롤백하고 제한된 횟수로 재시도한다.

lock wait timeout은 설정에 따라 실패한 문장만 롤백할 수 있으므로, 작업기는 실패 시 트랜잭션 전체를 정리하는 규약을 가져야 한다. 커밋 응답이 끊겼을 때는 성공 여부가 불명확할 수 있다. 순수한 보존 조건 DELETE는 재실행 시 남아 있는 대상만 지우지만, 외부 아카이브·과금·작업 이력 같은 부수 효과는 별도의 중복 방지 설계가 필요하다.

## 4. archive는 복사 완료가 아니라 내용 검증이 기준이다

보관이 필요한 행은 삭제 전에 목적지에서 읽을 수 있어야 한다. 같은 서버의 별도 InnoDB 테이블이라면 한 트랜잭션에서 대상 집합을 잠그고, 복사하고, 동일한 집합을 삭제하는 축소 패턴을 만들 수 있다. 아래 예제는 작은 고정 ID 집합을 사용한다. 운영 작업기에서는 첫 잠금 조회가 반환한 ID를 보관하고 이후 문장에 **그 정확한 ID 집합**을 사용해야 한다.

```sql
CREATE TABLE archive_source_demo (
  id BIGINT NOT NULL PRIMARY KEY,
  created_at DATETIME NOT NULL,
  payload VARCHAR(40) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE archive_target_demo LIKE archive_source_demo;
INSERT INTO archive_source_demo VALUES
  (11,'2026-08-01','alpha'), (12,'2026-08-02','beta'),
  (13,'2026-09-01','keep');
START TRANSACTION;
SELECT id FROM archive_source_demo
WHERE id IN (11,12) AND created_at < '2026-09-01'
ORDER BY id FOR UPDATE;
INSERT INTO archive_target_demo (id, created_at, payload)
SELECT id, created_at, payload FROM archive_source_demo
WHERE id IN (11,12) AND created_at < '2026-09-01';
DELETE s FROM archive_source_demo s
JOIN archive_target_demo a ON a.id = s.id
WHERE s.id IN (11,12) AND s.created_at < '2026-09-01'
  AND a.created_at = s.created_at
  AND CAST(a.payload AS BINARY) = CAST(s.payload AS BINARY);
SELECT ROW_COUNT() AS archived_and_deleted;
COMMIT;
SELECT id, payload FROM archive_source_demo ORDER BY id;
SELECT id, payload FROM archive_target_demo ORDER BY id;
DROP TABLE archive_target_demo, archive_source_demo;
```

실행 결과(MySQL 8.0.x):

준비 DDL·초기 INSERT·트랜잭션 시작 출력은 생략하고 복사와 삭제, 최종 데이터 확인을 발췌했다. 긴 SQL 표기는 축약했다.

```text
mysql> SELECT id FROM archive_source_demo ... FOR UPDATE;
+----+
| id |
+----+
| 11 |
| 12 |
+----+
2 rows in set
mysql> INSERT INTO archive_target_demo ... SELECT ...;
Query OK, 2 rows affected
Records: 2  Duplicates: 0  Warnings: 0
mysql> DELETE s FROM archive_source_demo s JOIN archive_target_demo a ...;
Query OK, 2 rows affected
mysql> SELECT ROW_COUNT() AS archived_and_deleted;
+----------------------+
| archived_and_deleted |
+----------------------+
|                    2 |
+----------------------+
1 row in set
mysql> COMMIT;
Query OK, 0 rows affected
mysql> SELECT id, payload FROM archive_source_demo ORDER BY id;
+----+---------+
| id | payload |
+----+---------+
| 13 | keep    |
+----+---------+
1 row in set
mysql> SELECT id, payload FROM archive_target_demo ORDER BY id;
+----+---------+
| id | payload |
+----+---------+
| 11 | alpha   |
| 12 | beta    |
+----+---------+
2 rows in set
mysql> DROP TABLE archive_target_demo, archive_source_demo;
Query OK, 0 rows affected
```

이 실험은 같은 서버 안에서 두 행을 복사한 후 원본에서 제거하고, 보존 대상 한 행이 남는 것을 확인한다. 동시 갱신 경합이나 장애 주입 시험은 아니다. 기본 collation의 대소문자 무시 비교로 내용 차이를 놓치지 않도록 payload에는 바이트 비교를 사용했다. 실제 스키마는 모든 업무 열을 검증해야 하며, NULL 허용 열에는 NULL-safe 비교 등 별도의 처리가 필요하다.

아카이브 목적지에 동일한 ID가 있으면 INSERT가 실패하도록 두었다. `INSERT IGNORE`로 충돌을 숨긴 뒤 원본을 지우면, 오래된 아카이브 값만 남기고 최신 원본을 잃을 수 있다. 재시작 작업은 이미 복사된 행의 존재뿐 아니라 내용과 버전을 확인해야 한다. 위 SQL에는 다중 테이블 DELETE가 있으므로 LIMIT을 직접 붙이지 않고, 앞에서 정한 대상 집합으로 범위를 제한한다.

목적지가 다른 서버나 오브젝트 스토리지라면 단일 로컬 트랜잭션으로 복사와 삭제를 묶을 수 없다. 이 경우 `대상 확정 → 복사 → 목적지 내구성·전체 내용 확인 → 삭제 승인 → 원본 삭제`의 상태를 지속 저장한다. 검증에는 행 수뿐 아니라 기본 키 집합, 열 값 또는 정규화된 전체 내용 해시, 읽기·복원 가능 여부가 포함되어야 한다. 원본이 변경 가능하면 복사 시 버전과 삭제 시 버전을 비교하고 불일치는 보류한다. 같은 서버의 archive 테이블로 옮기는 것만으로는 그 서버의 총 데이터 용량이 줄지 않는다.

## 5. partition drop은 행 삭제를 대체하지만 의미가 다르다

월별 보존 정책이 RANGE 파티션 경계와 일치하고, 파티션의 모든 행을 제거해도 된다면 행 단위 DELETE보다 파티션 제거가 유리할 수 있다. 각 행의 DELETE를 반복하는 대신 파티션 단위 구조를 제거하기 때문이다. 다만 DDL이므로 메타데이터 잠금(MDL) 획득을 기다릴 수 있고, 작업 시간과 저장소 반환 시점이 언제나 일정한 것은 아니다.

아래 예제는 `ALGORITHM=INPLACE`를 명시한다. 공식 문서에 따르면 DROP PARTITION의 COPY 경로는 테이블을 재구축하면서 일부 행을 호환되는 다른 파티션으로 옮길 수 있어 INPLACE와 의미가 다를 수 있다. 알고리즘을 생략한 채 모든 구현 경로를 같은 동작이라고 설명해서는 안 된다.

```sql
CREATE TABLE delete_partition_demo (
  id BIGINT NOT NULL,
  created_on DATE NOT NULL,
  PRIMARY KEY (id, created_on)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS(created_on) (
  PARTITION p_before_sep VALUES LESS THAN ('2026-09-01'),
  PARTITION p_sep VALUES LESS THAN ('2026-10-01'),
  PARTITION p_future VALUES LESS THAN (MAXVALUE)
);
INSERT INTO delete_partition_demo VALUES
  (1,'2026-08-10'), (2,'2026-08-20'), (3,'2026-09-10');
ALTER TABLE delete_partition_demo
  ALGORITHM=INPLACE, DROP PARTITION p_before_sep;
SELECT id, created_on FROM delete_partition_demo ORDER BY id;
INSERT INTO delete_partition_demo VALUES (4,'2026-08-25');
SELECT id, created_on FROM delete_partition_demo PARTITION (p_sep)
ORDER BY id;
DROP TABLE delete_partition_demo;
```

실행 결과(MySQL 8.0.x):

```text
mysql> CREATE TABLE delete_partition_demo (
    ->   id BIGINT NOT NULL,
    ->   created_on DATE NOT NULL,
    ->   PRIMARY KEY (id, created_on)
    -> ) ENGINE=InnoDB
    -> PARTITION BY RANGE COLUMNS(created_on) (
    ->   PARTITION p_before_sep VALUES LESS THAN ('2026-09-01'),
    ->   PARTITION p_sep VALUES LESS THAN ('2026-10-01'),
    ->   PARTITION p_future VALUES LESS THAN (MAXVALUE)
    -> );

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO delete_partition_demo VALUES
    ->   (1,'2026-08-10'), (2,'2026-08-20'), (3,'2026-09-10');

Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE delete_partition_demo
    ->   ALGORITHM=INPLACE, DROP PARTITION p_before_sep;

Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> SELECT id, created_on FROM delete_partition_demo ORDER BY id;

+----+------------+
| id | created_on |
+----+------------+
|  3 | 2026-09-10 |
+----+------------+
1 row in set (0.00 sec)

mysql> INSERT INTO delete_partition_demo VALUES (4,'2026-08-25');

Query OK, 1 row affected (0.00 sec)

mysql> SELECT id, created_on FROM delete_partition_demo PARTITION (p_sep)
    -> ORDER BY id;

+----+------------+
| id | created_on |
+----+------------+
|  3 | 2026-09-10 |
|  4 | 2026-08-25 |
+----+------------+
2 rows in set (0.00 sec)

mysql> DROP TABLE delete_partition_demo;

Query OK, 0 rows affected (0.00 sec)
```

최초 두 행은 제거되지만, 그 후 입력한 과거 날짜의 행은 남아 있는 첫 RANGE 파티션으로 들어간다. RANGE의 첫 파티션은 명시적 하한을 갖는 독립된 월 구간이 아니기 때문이다. **오래된 파티션을 삭제하는 것은 과거 데이터의 입력을 금지하는 정책이 아니다.** 지연 입력을 거부하려면 애플리케이션·적재 단계에서 별도로 통제해야 한다.

파티션 제거에는 다음과 같은 의미 차이도 있다.

- DELETE 트리거를 행별로 실행하는 작업이 아니다. 행별 감사·보관 로직에 의존하면 같은 업무 결과를 얻지 못한다.
- DDL의 암묵적 커밋 경계가 적용되므로 일반적인 사용자 ROLLBACK으로 삭제 데이터를 되살릴 수 없다.
- 복구가 필요하면 사전 archive, 백업, PITR 등 검증된 복구 경로를 사용한다.
- 파티션을 쓰기 위해서는 고유 키와 파티션 키의 제약, InnoDB 사용자 파티셔닝의 외래 키 제한 등을 먼저 수용해야 한다. 삭제를 빠르게 하겠다는 이유만으로 기존 테이블을 즉석에서 파티션화하지 않는다.

## 6. purge·잠금·복제를 함께 관측한다

DELETE 청크가 짧아도 오래된 일관 읽기 트랜잭션이 이전 버전을 계속 요구하면 purge가 뒤처질 수 있다. 이때 삭제 작업을 더 빨리 돌리는 것은 backlog를 키우는 방향으로 작용할 수 있다. History list length는 삭제한 행 수나 미회수 바이트 수와 같지 않으므로 단일 수치를 공간량으로 환산해서는 안 된다.

다음 조회는 로컬 MySQL에서 실행 가능한 관측점이다. 실제 운영에서는 대상 스키마와 권한 범위를 조정하고, 일정 간격으로 수집해 변화율을 계산한다.

```sql
SELECT NAME, COUNT, STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
SELECT trx_state, trx_rows_modified, trx_rows_locked,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_seconds
FROM information_schema.INNODB_TRX
ORDER BY trx_started LIMIT 5;
SELECT COUNT(*) AS lock_wait_edges
FROM performance_schema.data_lock_waits;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME IN ('Innodb_os_log_written', 'Innodb_buffer_pool_pages_dirty')
ORDER BY VARIABLE_NAME;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT NAME, COUNT, STATUS
    -> FROM information_schema.INNODB_METRICS
    -> WHERE NAME = 'trx_rseg_history_len';

+----------------------+-------+---------+
| NAME                 | COUNT | STATUS  |
+----------------------+-------+---------+
| trx_rseg_history_len |    41 | enabled |
+----------------------+-------+---------+
1 row in set (0.00 sec)

mysql> SELECT trx_state, trx_rows_modified, trx_rows_locked,
    ->        TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_seconds
    -> FROM information_schema.INNODB_TRX
    -> ORDER BY trx_started LIMIT 5;

Empty set (0.00 sec)

mysql> SELECT COUNT(*) AS lock_wait_edges
    -> FROM performance_schema.data_lock_waits;

+-----------------+
| lock_wait_edges |
+-----------------+
|               0 |
+-----------------+
1 row in set (0.01 sec)

mysql> SELECT VARIABLE_NAME, VARIABLE_VALUE
    -> FROM performance_schema.global_status
    -> WHERE VARIABLE_NAME IN ('Innodb_os_log_written', 'Innodb_buffer_pool_pages_dirty')
    -> ORDER BY VARIABLE_NAME;

+--------------------------------+----------------+
| VARIABLE_NAME                  | VARIABLE_VALUE |
+--------------------------------+----------------+
| Innodb_buffer_pool_pages_dirty | 171            |
| Innodb_os_log_written          | 265728         |
+--------------------------------+----------------+
2 rows in set (0.00 sec)
```

여기에 표시되는 값은 비어 있는 시험 인스턴스의 순간값이다. `INNODB_TRX`가 Empty set이거나 잠금 대기가 0인 것은 진단 쿼리가 실패했다는 뜻이 아니다. 반대로 한 번의 0만으로 운영 환경에 장기 트랜잭션이나 경합이 없다고 판정할 수도 없다. 트랜잭션 시작 시각은 유용한 단서이지만 Read View 생성 시각과 항상 같지는 않다.

`Innodb_os_log_written`은 누적량이므로 두 관측 사이의 차이를 경과 시간으로 나눠 추세를 본다. 서버 재시작·카운터 초기화를 감지해야 하며, dirty page 수 역시 순간 상태다. purge를 개선하려면 우선 오래된 트랜잭션의 필요성을 조사하고 보존 정책 작업의 속도를 조정한다. purge thread 수를 무조건 늘려 해결하려 해서는 안 된다.

외부 binlog replica가 있으면 receiver의 연결 정상 여부와 applier의 따라잡기 상태를 분리해 본다. 작은 트랜잭션은 적용·재시도의 단위를 줄이지만, 병렬 적용이 항상 선형으로 빨라지는 것은 아니다. 청크 간 충돌, 키 분포, 로그 전송량, 다른 업무 트랜잭션과의 경합에 따라 달라진다. 이 글의 단일 서버 시험에는 복제 채널이 없으므로 복제 지연 개선을 실측했다고 해석하지 않는다.

## 7. 공간 회수와 Aurora MySQL의 차이

일반 InnoDB에서 행을 삭제한 후 비게 된 페이지는 이후 데이터에 재사용될 수 있다. 그러나 `.ibd` 파일이 삭제 행 수에 비례해 즉시 줄어드는 것은 아니다. 파일 축소를 위한 테이블 재구축은 추가 디스크 여유, I/O, 실행 시간과 잠금 위험을 동반하는 별도 변경 작업이다. DELETE가 끝났다는 이유만으로 OPTIMIZE TABLE을 자동 실행하는 정책은 피한다.

Aurora MySQL에서도 undo·MVCC·잠금과 작은 트랜잭션의 운영 의미는 중요하지만, 클러스터 볼륨은 로컬 `.ibd` 파일과 동일한 용량 관리 모델로 취급할 수 없다. AWS 문서는 동적 축소 대상으로 DROP TABLE, TRUNCATE TABLE, `ALTER TABLE ... DROP PARTITION`처럼 tablespace를 제거하는 작업을 설명하고, 일반 DELETE는 그 자체로 같은 축소 대상이 아니라고 구분한다. 실제 지원 버전·리전 조건과 공간 회수 지연은 사용 중인 엔진의 공식 문서를 확인한다.

Aurora reader의 지연과 외부 binlog replica의 지연도 같은 복제 경로가 아니다. writer의 청크 시간·업무 지연과 함께 AuroraReplicaLag, 클러스터 저장 사용량, 적용 가능한 I/O 지표를 관측하되, 한 지표만으로 전체 삭제 비용을 단정하지 않는다. 이 장에서는 Aurora 실환경에서 DROP PARTITION이나 저장 비용 감소를 시험하지 않았다.

## 8. 실행 전후 체크리스트

- [ ] 보존 기준 시각, 시간대, 예외 보관·법적 보류 대상을 확정했다.
- [ ] 실제 삭제 조건과 정렬에 맞는 인덱스 접근 경로를 확인했다.
- [ ] 트리거, 외래 키, 연쇄 삭제 등 LIMIT 밖의 부수 작업을 확인했다.
- [ ] 청크별 커밋을 사용하고 휴지 시간은 커밋 밖에 배치했다.
- [ ] 청크 시간·삭제 행 수·업무 지연·잠금·복제·purge를 기록한다.
- [ ] 임계치 초과와 관측 불가 시 중단 정책을 정했다.
- [ ] 실패한 청크의 명시적 롤백, 재시도 횟수, 불명확한 커밋의 확인 방법이 있다.
- [ ] archive는 행 수뿐 아니라 대상 키와 전체 내용·복구 가능성을 검증했다.
- [ ] DROP PARTITION은 전체 대상의 제거 승인, MDL 위험, 알고리즘, 복구 경로를 확인했다.
- [ ] 지연 입력과 변경 가능한 정렬 키에 대한 후속 스캔 정책이 있다.
- [ ] 논리 삭제 완료, purge 안정화, replica 따라잡기, 물리 공간 반환을 별도 완료 조건으로 기록한다.

## 결론

대용량 삭제의 핵심은 한 번에 얼마나 많은 행을 지우느냐가 아니라, 데이터 보존 규칙과 시스템의 처리 여유에 맞춰 제거 단위를 선택하는 데 있다. chunk delete는 트랜잭션의 부담을 제한하고, sleep은 그 사이에 업무 부하가 처리될 여유를 준다. archive는 삭제 전 데이터 보존을 증명하며, partition drop은 행 단위 처리와 다른 DDL 경계를 사용한다.

삭제 완료 보고는 영향 행 수에서 끝나지 않아야 한다. purge와 복제가 안정화되었는지, 지연 입력이 남지 않았는지, 공간 회수가 실제로 필요한지까지 확인해야 한다. 이 기준은 이후 데이터 수명주기 관리와 대규모 정리 작업의 운영 자동화를 설계할 때도 그대로 적용된다.

## 참고 문서

- [MySQL 8.4: DELETE 문법](https://dev.mysql.com/doc/refman/8.4/en/delete.html)
- [MySQL 8.4: InnoDB purge 설정과 동작](https://dev.mysql.com/doc/refman/8.4/en/innodb-purge-configuration.html)
- [MySQL 8.4: RANGE·LIST 파티션 관리](https://dev.mysql.com/doc/refman/8.4/en/partitioning-management-range-list.html)
- [Amazon Aurora: 성능과 저장 공간 확장·축소](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Managing.Performance.html)
