앞선 포스팅에서 ALGORITHM=INSTANT를 활용한 Native Online DDL과 gh-ost를 통한 무중단 스키마 변경 전략을 살펴보았습니다. 하지만 "0.1초 만에 끝나는 INSTANT 컬럼 추가니까 낮에 바로 실행해도 되겠지?", 혹은 "gh-ost로 데이터 복사를 다 끝내고 마지막 Cut-over(테이블 교체)만 남았으니 안전하겠지?"라고 방심하는 순간, 예상치 못한 대형 장애가 터지곤 합니다.

 

모니터링 화면에 갑자기 Waiting for table metadata lock 상태의 세션이 수백 개씩 쌓이더니, 불과 몇 초 만에 애플리케이션의 DB 커넥션 풀(HikariCP 등)이 고갈되어 서비스 전체가 응답 불능(Hang)에 빠지는 현상입니다.

 

이번 글에서는 무중단 스키마 변경의 가장 마지막이자 치명적인 함정인 메타데이터 락(Metadata Lock, 이하 MDL)의 내부 동작 아키텍처를 파헤쳐 보고, 작업 전/중/후에 이를 사전에 감지하고 방어하는 실무 모니터링 쿼리 및 핵심 파라미터 설정 가이드를 정리해 보겠습니다.

 


1. Metadata Lock(MDL)의 내부 작동 원리와 도미노 장애 시나리오

InnoDB 스토리지 엔진이 레코드 단위의 락(Record Lock, Gap Lock)을 관리한다면, Metadata Lock(MDL)은 그보다 상위 계층인 MySQL Server 엔진(SQL Layer)에서 테이블의 구조(스키마 정의)를 보호하기 위해 관리하는 테이블 레벨의 락입니다.

트랜잭션이 특정 테이블을 조회하고 있는 도중에 다른 세션에서 그 테이블의 컬럼을 지우거나 이름을 바꿔버리면 데이터 정합성이 깨지기 때문에, MySQL은 모든 쿼리에 암묵적으로 MDL을 부여합니다.

① MDL 잠금 모드와 호환성 매트릭스

잠금 종류 획득하는 작업 (SQL) Shared Read / Write와 호환 여부 Exclusive와 호환 여부
MDL_SHARED_READ (SRO) 단순 SELECT (잠금 없는 일반 조회) O (동시 실행 가능) X (대기 발생)
MDL_SHARED_WRITE (SW) INSERT, UPDATE, DELETE, SELECT ... FOR UPDATE O (동시 실행 가능) X (대기 발생)
MDL_EXCLUSIVE (X) ALTER TABLE (시작/종료 시점), DROP TABLE, RENAME TABLE X (모든 쿼리와 충돌) X (대기 발생)
  • 핵심 규칙 1: MDL은 단일 쿼리가 끝날 때 해제되는 것이 아니라, 트랜잭션이 완전히 종료(COMMIT 또는 ROLLBACK)될 때까지 유지됩니다.
  • 핵심 규칙 2 (장애의 근본 원인): MDL 대기 큐(Wait Queue)는 단순한 선입선출(FIFO)이 아니라 잠금 요청의 우선순위(Priority)를 따릅니다. MDL_EXCLUSIVE는 MDL_SHARED_READ보다 우선순위가 높습니다.

② 공포의 도미노 현상 (Convoy Effect) 발생 과정

ALGORITHM=INSTANT로 컬럼을 하나 추가하는 데 왜 서버가 죽을까요? 시간순으로 재구성해 보겠습니다.

[정상 상태] ──> [Session A: Long Query/Tx 시작] ──> [Session B: ALTER/RENAME 실행] ──> [Session C, D, E...: 일반 SELECT 유입]
                 (MDL_SHARED_READ 점유 중)           (MDL_EXCLUSIVE 요청 -> 대기!)       (Session B에 막혀 도미노 대기 -> 장애!)
  1. 빌런 트랜잭션 등장 (Session A): 배치 서버나 어드민 페이지에서 orders 테이블을 조회하는 무거운 슬로우 쿼리가 실행되었거나, 애플리케이션 코드에서 @Transactional을 열어두고 외부 API를 호출하느라 커밋을 안 하고 버티고 있습니다. 이 세션은 orders 테이블에 대해 MDL_SHARED_READ를 쥐고 놓지 않는 상태입니다.
  2. 스키마 변경 시도 (Session B): DBA가 ALTER TABLE orders ADD COLUMN ... ALGORITHM=INSTANT를 실행(또는 gh-ost가 마지막 RENAME Cut-over를 시도)합니다. 이 작업을 완료하려면 단 1밀리초라도 MDL_EXCLUSIVE를 획득해야 합니다. 하지만 Session A가 Shared Lock을 쥐고 있으므로 Session B는 큐에서 대기(Waiting for table metadata lock)합니다.
  3. [14:00:06 ~] 일반 트래픽 전면 차단 (Session C, D, E...): 일반 유저들이 주문 내역을 보기 위해 0.001초짜리 가벼운 SELECT * FROM orders WHERE id = 100을 날립니다. 평소 같으면 바로 통과했겠지만, 앞에서 우선순위가 높은 Session B(MDL_EXCLUSIVE)가 대기 중이기 때문에, 뒤따라오는 모든 단순 SELECT 쿼리들까지 Session B 뒤로 줄을 서며 전부 블로킹됩니다.
  4. 서비스 장애: 초당 1,000개씩 들어오던 조회 쿼리가 단 5초 만에 5,000개의 대기 스레드를 만들고, DB의 max_connections가 꽉 차며 서비스가 완전히 다운됩니다.

 

2. 왜 gh-ost의 Cut-over는 일반 DDL보다 안전할까? (내부 알고리즘)

pt-osc나 일반 DDL은 마지막에 RENAME TABLE을 던져놓고 블로킹되면 사람이 수동으로 죽이기 전까지 마냥 기다리지만, gh-ost는 독자적인 Atomic Two-Step Cut-over 알고리즘을 통해 MDL 대기로 인한 장애를 방어하도록 설계되어 있습니다.

  1. Session 1 (가짜 파수꾼): gh-ost가 먼저 원본 테이블에 LOCK TABLES orders WRITE를 겁니다. 이때 만약 롱 트랜잭션이 있다면 이 요청은 대기하지만, 뒤이어 들어오는 일반 SELECT를 막기 시작합니다.
  2. Session 2 (교체 집행자): gh-ost의 다른 커넥션이 들어와 RENAME TABLE orders TO _orders_del, _orders_gho TO orders를 실행합니다. Session 1이 WRITE 락을 쥐고(또는 대기하고) 있으므로 Session 2도 대기 큐에 들어갑니다.
  3. 타임아웃 감지 및 즉각 롤백: gh-ost는 이 과정에 매우 짧은 타임아웃(--cut-over-lock-timeout-seconds, 기본값 3초)을 겁니다. 만약 롱 쿼리 때문에 3초 안에 RENAME이 성공하지 못하면, gh-ost는 즉시 Session 1과 2의 커넥션을 스스로 끊어버립니다.
  4. 자동 재시도: 락 요청이 사라졌으므로 막혀있던 일반 서비스 쿼리들은 즉시 정상 처리되어 장애로 번지지 않습니다. gh-ost는 다시 Binlog를 따라가며 잠시 후 Cut-over를 자동으로 재시도합니다.

주의: gh-ost가 아무리 안전하게 빠져나온다 해도, 재시도하는 3초 동안은 순간적인 쿼리 적체(Spike)가 발생할 수 있습니다. 따라서 어떤 도구를 쓰든 작업 직전 MDL을 유발할 수 있는 롱 트랜잭션을 찾아 제거하는 것이 정석입니다.

 

3. 실무 방어 가이드 1: 필수 안전핀 파라미터 설정 (lock_wait_timeout)

Native Online DDL이나 수동 RENAME, DROP을 실행할 때 도미노 장애를 막는 가장 확실하고 간단한 방법은 세션 레벨에서 lock_wait_timeout을 극단적으로 짧게 설정하는 것입니다.

많은 개발자가 헷갈려하는 두 가지 타임아웃 파라미터의 차이를 명확히 알아야 합니다.

파라미터명 기본값 (Default) 적용 대상 및 역할
innodb_lock_wait_timeout 50초 InnoDB 스토리지 엔진 내부의 Row-level Lock(레코드 락, 갭 락) 대기 시간
lock_wait_timeout 31536000초 (1년) MySQL 서버 레이어의 Metadata Lock(MDL) 대기 시간

보시다시피 lock_wait_timeout의 기본값은 무려 1년입니다. 즉, 설정 없이 ALTER TABLE을 날렸다가 롱 쿼리에 걸리면 1년 동안 취소되지 않고 모든 트래픽을 막아버립니다.

실무 DDL 실행 표준 패턴

운영 DB에서 DDL을 직접 실행할 때는 반드시 아래와 같이 세트로 묶어서 실행하는 습관을 들여야 합니다.

-- 1. 현재 세션의 MDL 대기 타임아웃을 3초로 제한 (3초 내 락 획득 실패 시 즉시 에러 반환 후 포기)
SET SESSION lock_wait_timeout = 3;

-- 2. InnoDB 레코드 락 타임아웃도 함께 축소
SET SESSION innodb_lock_wait_timeout = 3;

-- 3. 안전하게 Online DDL 실행
ALTER TABLE orders 
ADD COLUMN delivery_memo VARCHAR(255) DEFAULT NULL, 
ALGORITHM=INSTANT, LOCK=NONE;

이렇게 하면 3초 안에 MDL을 얻지 못할 경우 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction 에러를 뱉고 DDL만 깔끔하게 실패합니다. 서비스 장애는 전혀 발생하지 않으며, 잠시 후 다시 시도하면 됩니다.

4. 실무 방어 가이드 2: 사전 감지 및 범인 색출 모니터링 쿼리

스키마 변경 작업을 시작하기 직전, 그리고 gh-ost의 Cut-over 플래그를 해제하기 직전에는 반드시 아래 쿼리들을 통해 대상 테이블을 물고 있는 미커밋 트랜잭션이나 롱 쿼리가 없는지 확인해야 합니다.

① [사전 점검] 현재 실행 중인 모든 장기 트랜잭션(Long Transaction) 조회

단순 SHOW PROCESSLIST만으로는 트랜잭션이 열린 채 아무 쿼리도 안 날리고 대기 중인 Sleep 상태의 좀비 트랜잭션을 찾을 수 없습니다. 반드시 information_schema.innodb_trx를 조회해야 합니다.

SELECT 
    trx.trx_id,
    trx.trx_mysql_thread_id AS thread_id,
    trx.trx_state,
    trx.trx_started,
    TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) AS duration_sec,
    p.USER,
    p.HOST,
    p.DB,
    p.COMMAND,
    p.STATE,
    trx.trx_query AS current_query
FROM information_schema.innodb_trx trx
JOIN information_schema.processlist p 
  ON trx.trx_mysql_thread_id = p.ID
WHERE TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) > 5
ORDER BY duration_sec DESC;
  • 해석: COMMAND가 Sleep인데 duration_sec이 수십 초 이상인 세션이 있다면, 애플리케이션에서 트랜잭션을 열고 COMMIT을 안 한 채 외부 통신 등을 하고 있는 상태입니다. 이 세션이 대상 테이블을 한 번이라도 조회했다면 보이지 않는 MDL_SHARED_READ를 쥐고 있는 상태이므로 끝날 때까지 기다리거나 강제 종료(KILL)해야 합니다.

② [장애 대응] MDL 대기를 유발하고 있는 '진짜 범인(Blocker)' 스레드 정확히 찾아내기

만약 이미 Waiting for table metadata lock 현상이 발생했다면, 수백 개의 대기 세션 중 누가 맨 앞에서 락을 쥐고 안 놔주는지 1초 만에 찾아내어 KILL 해야 합니다. (MySQL 8.0 기준 performance_schema.metadata_locks 활용)

참고: MySQL 8.0에서는 기본 활성화되어 있으나, 만약 조회되지 않는다면 performance_schema.setup_instruments에서 wait/lock/metadata/sql/mdl 항목이 ENABLED='YES'인지 확인해야 합니다.

SELECT 
    locked_Scroll.OBJECT_SCHEMA AS db_name,
    locked_Scroll.OBJECT_NAME AS table_name,
    waiting_trx.PROCESSLIST_ID AS waiting_thread_id,
    waiting_trx.PROCESSLIST_INFO AS waiting_query,
    blocking_trx.PROCESSLIST_ID AS blocking_thread_id,
    blocking_trx.PROCESSLIST_COMMAND AS blocker_command,
    blocking_trx.PROCESSLIST_TIME AS blocker_idle_time_sec,
    blocking_trx.PROCESSLIST_INFO AS blocking_current_query,
    CONCAT('KILL ', blocking_trx.PROCESSLIST_ID, ';') AS kill_command
FROM performance_schema.metadata_locks waiting_lock
JOIN performance_schema.metadata_locks locked_Scroll 
  ON waiting_lock.OBJECT_TYPE = locked_Scroll.OBJECT_TYPE
  AND waiting_lock.OBJECT_SCHEMA = locked_Scroll.OBJECT_SCHEMA
  AND waiting_lock.OBJECT_NAME = locked_Scroll.OBJECT_NAME
JOIN performance_schema.threads waiting_trx 
  ON waiting_lock.OWNER_THREAD_ID = waiting_trx.THREAD_ID
JOIN performance_schema.threads blocking_trx 
  ON locked_Scroll.OWNER_THREAD_ID = blocking_trx.THREAD_ID
WHERE waiting_lock.LOCK_STATUS = 'PENDING'
  AND locked_Scroll.LOCK_STATUS = 'GRANTED'
  AND waiting_lock.OWNER_THREAD_ID != locked_Scroll.OWNER_THREAD_ID;

쿼리 결과 활용법:

  1. 위 쿼리를 실행하면 현재 MDL을 기다리는 쿼리(waiting_query, 보통 ALTER나 RENAME)와 이를 가로막고 있는 범인 스레드 ID(blocking_thread_id), 그리고 즉시 실행할 수 있는 kill_command(KILL 12345;)가 출력됩니다.
  2. 긴급 상황에서는 고민할 것 없이 두 가지 중 하나를 즉시 선택해 실행합니다:
    • 선택 A (권장): 대기 중인 DDL 세션(waiting_thread_id)을 KILL하여 스키마 변경을 취소하고 서비스 트래픽부터 즉시 정상화한다.
    • 선택 B: 만약 범인(blocking_thread_id)이 단순 배치 조회나 어드민 슬로우 쿼리임이 확실하다면, 범인 세션을 KILL하여 DDL이 즉시 완료되게 만든다.

5. 마무리: 무중단 스키마 변경 최종 안전 수칙 4계명

  1. INSTANT도 MDL 앞에서는 평등하다: 아무리 0.1초 만에 끝나는 ALGORITHM=INSTANT라도 시작과 끝에는 MDL_EXCLUSIVE가 필요합니다. 방심하지 말고 작업 전 반드시 innodb_trx를 확인하세요.
  2. 타임아웃 없는 수동 DDL은 자살 행위다: 직접 콘솔에서 DDL을 실행할 때는 무조건 SET SESSION lock_wait_timeout = 3;을 먼저 선언하여 도미노 블로킹을 원천 차단하세요.
  3. gh-ost의 Cut-over는 눈으로 보며 수동으로 제어하라: 대용량 테이블 작업 시 --postpone-cut-over-flag-file 옵션을 사용해 복사는 밤새 돌려두고, 마지막 테이블 교체 순간에는 엔지니어가 직접 롱 쿼리가 없는 타이밍을 확인한 뒤 플래그를 풀어 마무리하세요.
  4. 애플리케이션의 트랜잭션 범위를 다이어트하라: 근본적으로 MDL 장애가 잦은 팀은 애플리케이션 코드 내에 @Transactional 범위가 너무 넓거나(외부 API 호출 포함 등), 인덱스 없는 슬로우 쿼리가 방치되어 있는 경우가 많습니다. DB 작업 전후로 슬로우 쿼리 튜닝이 병행되어야 진정한 무중단 아키텍처가 완성됩니다.

앞선 글에서 수억 건의 대용량 테이블 데이터를 안전하게 삭제(Purge)하는 전략과, 그 과정에서 필연적으로 발생하는 테이블스페이스 단편화(DATA_FREE) 문제를 살펴보았습니다. 데이터를 다 지웠더라도 물리적인 디스크 공간을 OS로 반환받거나, 운영 중인 핵심 테이블에 새로운 컬럼 및 인덱스를 추가해야 할 때 DBA와 백엔드 엔지니어는 또 다른 거대한 벽에 부딪힙니다. 바로 '무중단 스키마 변경(Online Schema Change)'입니다.

트래픽이 24시간 쏟아지는 프로덕션 환경에서 수백 GB에 달하는 테이블에 무심코 ALTER TABLE을 실행하는 것은 서비스 전체에 사망 선고를 내리는 것과 같습니다.

이번 글에서는 MySQL이 자체 제공하는 Native Online DDL(INSTANT, INPLACE, COPY)의 내부 아키텍처와 한계를 짚어보고, 이를 극복하기 위해 실무에서 표준으로 자리 잡은 외부 도구인 Percona의 pt-online-schema-change(pt-osc)와 GitHub의 gh-ost의 작동 원리, 장단점, 그리고 상황별 최적의 선택 기준을 깊이 있게 파헤쳐 보겠습니다.

 


1. MySQL Native Online DDL의 진화와 한계

과거 MySQL 5.5 이하 시절에는 ALTER TABLE을 실행하면 무조건 원본 테이블에 잠금을 걸고 임시 테이블로 데이터를 통째로 복사하는 방식만 존재했습니다. 하지만 MySQL 5.6에서 Online DDL(INPLACE)이 도입되고, MySQL 8.0.12에서 INSTANT 알고리즘이 추가(8.0.29에서 한 단계 더 진화)되면서 자체 기능만으로도 상당 부분 무중단 작업이 가능해졌습니다.

작업 방식을 명시할 때는 아래와 같이 ALGORITHM과 LOCK 옵션을 지정하는 것이 안전합니다. (원하는 알고리즘으로 실행 불가능할 경우 즉시 에러를 발생시켜 장애를 막아줍니다.)

ALTER TABLE orders 
ADD COLUMN delivery_memo VARCHAR(255) DEFAULT NULL, 
ALGORITHM=INSTANT, LOCK=NONE;

① 3가지 알고리즘 내부 작동 원리 비교

알고리즘 작동 원리 (Architecture) DML 허용 여부 디스크 여유 공간 필요 대표 지원 작업
INSTANT 실제 데이터 파일(.ibd)은 전혀 건드리지 않고, 데이터 딕셔너리(Data Dictionary)의 메타데이터만 즉시 수정함 O (LOCK=NONE) 불필요 (0초 완료) 컬럼 추가/삭제(8.0.29+), 컬럼 기본값 변경, ENUM/SET 마지막 항목 추가
INPLACE MySQL 서버 레이어의 임시 테이블 생성 없이, **InnoDB 스토리지 엔진 내부에서 직접 .ibd 파일을 재구성(Rebuild)**하거나 인덱스 페이지만 생성함 O (LOCK=NONE 가능) 테이블 크기만큼 필요 (Rebuild 시) 세컨더리 인덱스 추가/삭제, 컬럼 위치 변경, OPTIMIZE TABLE (공간 회수), VARCHAR 길이 확장
COPY 변경된 스키마의 새 임시 테이블을 만든 뒤, 원본 데이터를 한 줄씩 읽어서 INSERT하는 고전적인 방식 X (읽기만 허용, 쓰기 불가) 테이블 크기 + Undo/Redo 공간 필요 컬럼 데이터 타입 변경(INT -> BIGINT), 문자셋(Charset) 변경

② "Online"이라는 이름을 맹신하면 안 되는 4가지 이유 (Native DDL의 한계)

ALGORITHM=INSTANT가 지원되는 작업이라면 고민할 필요 없이 Native DDL을 쓰는 것이 정답입니다. 문제는 인덱스 추가, 컬럼 타입 변경, 디스크 공간 회수(OPTIMIZE) 등 테이블 리빌드(Rebuild)가 수반되는 INPLACE나 COPY 작업을 수행할 때입니다.

1) 시작과 끝의 배타적 메타데이터 락 (Exclusive MDL)

INPLACE 알고리즘이라도 작업을 시작하는 준비 단계(Prepare)와 마지막에 변경을 확정하는 단계(Commit)에는 아주 잠깐 Exclusive Metadata Lock(MDL)을 획득해야 합니다.

  • 만약 해당 테이블에 30초 걸리는 무거운 조회 쿼리나 트랜잭션이 열려 있다면, DDL은 MDL을 얻기 위해 대기합니다.
  • 진짜 재앙은 그다음입니다. Exclusive MDL은 우선순위가 높기 때문에, DDL이 대기하기 시작하는 순간 그 뒤로 들어오는 모든 단순 SELECT, INSERT 쿼리들까지 전부 MDL 대기 큐(Waiting for table metadata lock)에 갇혀버립니다. 순식간에 DB 커넥션 풀이 고갈되며 서비스 전체가 마비됩니다.

2) Online Alter Log 버퍼 초과 에러 (DB_ONLINE_LOG_TOO_BIG)

INPLACE 작업 도중 발생하는 애플리케이션의 DML(INSERT/UPDATE/DELETE)은 메모리/디스크 버퍼인 Row Log에 임시로 기록되었다가, 테이블 리빌드가 끝나는 마지막 순간에 일괄 반영됩니다.

  • 이 버퍼의 최대 크기는 innodb_online_alter_log_max_size(기본값 128MB) 파라미터로 제한됩니다.
  • 수백 GB 테이블을 리빌드하는 데 3시간이 걸리는데, 그사이 쓰기 트래픽이 몰려 변경 로그가 128MB를 초과하면? 3시간 동안 돌던 DDL이 막판에 실패하며 롤백됩니다. (작업 전 해당 파라미터를 1G~4G로 동적 상향 조정해야 합니다.)

3) 스로틀링(Throttling) 불가능과 I/O 폭주

Native DDL은 InnoDB 엔진 내부에서 최고 속도로 디스크를 읽고 씁니다. 서버의 CPU나 디스크 I/O가 100%를 치더라도 작업 속도를 늦추거나 잠시 멈추는(Pause) 제어 기능이 전혀 없습니다.

4) 치명적인 복제 지연 (Replication Lag)

가장 큰 문제입니다. Source(Master) DB에서 3시간 동안 Online DDL이 수행되었다고 해봅시다. MySQL의 복제 구조상 DDL은 Source에서 완전히 끝난 직후에야 Binlog에 기록되어 Replica(Slave)로 전달됩니다.

  • 즉, Source에서는 3시간 동안 온라인으로 잘 처리되었더라도, 그 직후 Replica 서버에서도 똑같이 3시간 동안 해당 DDL이 실행됩니다.
  • Replica에서 DDL이 도는 동안 복제 스레드(SQL Thread)가 블로킹되거나 극심한 부하를 겪으면서 수 시간 단위의 Replication Lag(복제 지연)이 발생하여 읽기 분산(Read Scale-out) 아키텍처가 무너지게 됩니다.

2. 외부 OSC(Online Schema Change) 양대 산맥: pt-osc vs gh-ost

Native DDL의 폭주하는 I/O와 복제 지연 문제를 해결하기 위해 등장한 것이 외부 OSC 도구들입니다. 핵심 아이디어는 동일합니다. "원본 테이블과 똑같은 모양의 유령(Ghost/Shadow) 테이블을 옆에 만들고, 데이터를 아주 작은 청크(Chunk) 단위로 살살 복사하면서, 동시에 실시간으로 발생하는 변경분만 동기화해 준 뒤 마지막에 테이블 이름만 바꿔치기(Swap)하자!"

하지만 그 '실시간 변경분을 어떻게 동기화하느냐'에 따라 트리거(Trigger) 방식의 pt-osc와 Binlog 파싱 방식의 gh-ost로 완전히 갈리게 됩니다.

① Percona pt-online-schema-change (pt-osc): 검증된 전통의 강자

pt-osc는 MySQL의 트리거(Trigger) 기능을 활용합니다.

내부 작동 아키텍처

  1. Shadow 테이블 생성: 원본 테이블(orders)과 동일한 구조의 _orders_new 테이블을 생성하고, 여기에 원하는 ALTER 문을 적용합니다. (빈 테이블이므로 0.1초 만에 끝납니다.)
  2. 3개의 트리거(Trigger) 설치: 원본 orders 테이블에 AFTER INSERT, AFTER UPDATE, AFTER DELETE 트리거를 생성합니다. 이제부터 원본 테이블에 들어오는 모든 DML은 트리거에 의해 _orders_new 테이블에도 REPLACE INTO / DELETE 형태로 똑같이 반영됩니다.
  3. 청크 단위 데이터 복사 (Row Copy): PK 범위를 기준으로 1,000건 등 작은 단위로 끊어서 INSERT IGNORE INTO _orders_new SELECT ... FROM orders LOCK IN SHARE MODE를 수행합니다. 이때 Replica 서버의 지연 상태를 계속 체크하며 속도를 조절합니다.
  4. 테이블 교체 (Atomic Rename): 복사가 100% 완료되면 RENAME TABLE orders TO _orders_old, _orders_new TO orders를 실행하여 원자적으로 테이블을 바꿔치기하고 _orders_old를 삭제합니다.

pt-osc의 치명적인 단점 (왜 트리거가 위험한가?)

  • 트랜잭션 결합과 성능 저하: 트리거는 원본 쿼리와 동일한 트랜잭션 공간에서 실행됩니다. 즉, 사용자가 orders에 INSERT를 한 번 할 때마다 _orders_new에도 쓰기 작업이 동기적으로 일어나므로, 작업 시간 내내 모든 쓰기 쿼리의 응답 속도(Latency)가 거의 2배로 느려지고 락 경합이 심화됩니다.
  • 중단 및 속도 제어의 한계: 만약 작업 도중 DB 부하가 너무 높아져서 pt-osc 프로세스를 강제로 죽이더라도, 원본 테이블에 걸려있는 트리거는 그대로 남아 계속 동작합니다. 트리거를 지우려면 다시 Exclusive MDL을 걸고 DROP TRIGGER를 해야 하는데, 이미 부하가 꽉 찬 상태에서는 이마저도 또 다른 장애의 불씨가 됩니다.

② GitHub gh-ost (GitHub's Online Schema Transmogrifier): 현대적 표준

GitHub 엔지니어링 팀은 pt-osc의 트리거가 유발하는 프로덕션 부하를 해결하기 위해 트리거를 전혀 사용하지 않는(Triggerless) gh-ost를 개발했습니다.

내부 작동 아키텍처

  1. Ghost 테이블 및 로그 테이블 생성: 변경할 스키마가 적용된 _orders_gho 테이블과, 작업 진행 상태 및 하트비트를 기록할 _orders_ghc 테이블을 생성합니다.
  2. 가짜 Replica로 위장 (Binlog Streamer): gh-ost 프로세스 자체가 대상 DB(또는 Replica DB)에 붙어 마치 자신이 또 하나의 Replica 서버인 것처럼 Binlog 스트림을 실시간으로 읽어 들입니다. (따라서 반드시 binlog_format=ROW여야 합니다.)
  3. 비동기 이벤트 적용 & Row Copy:
    • 백그라운드에서 PK 단위로 원본 데이터를 _orders_gho로 복사(Row Copy)합니다.
    • 동시에 원본 테이블에 발생한 INSERT/UPDATE/DELETE 내역을 Binlog에서 파싱하여 비동기적으로 _orders_gho에 반영합니다.
  4. 안전한 Cut-over (교체): 복사가 모두 끝나면 독자적인 2단계 잠금 및 원자적 RENAME 알고리즘을 통해 안전하게 테이블을 교체합니다.

gh-ost가 압도적으로 우월한 이유

  • 원본 트랜잭션과 완벽한 분리 (Decoupling): 트리거가 없으므로 애플리케이션이 수행하는 INSERT/UPDATE 트랜잭션에 추가적인 오버헤드나 락 경합을 전혀 주지 않습니다.
  • 진정한 의미의 일시 정지(Throttling): DB에 부하가 감지되어 gh-ost를 일시 정지(Throttle)시키면, Row Copy뿐만 아니라 Binlog 반영까지 즉시 멈춥니다. 그 순간 DB에는 아무런 부하도 가해지지 않는 순정 상태로 돌아갑니다. 부하가 풀리면 멈췄던 Binlog 포지션부터 다시 이어서 읽으면 됩니다.
  • 동적 제어 (Dynamic Control): 작업이 도는 도중에도 유닉스 소켓(Socket) 통신을 통해 실시간으로 청크 사이즈(chunk-size)나 최대 허용 지연 시간(max-lag-millis)을 재시작 없이 즉석에서 변경할 수 있습니다.

3. 한눈에 보는 3대 방식 완벽 비교표

실무 아키텍트 관점에서 세 가지 도구의 특성을 비교 정리한 표입니다.

비교 항목 MySQL Native Online DDL (INPLACE) pt-online-schema-change (pt-osc) gh-ost
변경분 동기화 방식 InnoDB 내부 Row Log 버퍼 MySQL Trigger (동기식) ROW 포맷 Binlog 파싱 (비동기식)
쓰기 트랜잭션 부하 낮음~보통 (I/O 경합은 심함) 높음 (모든 DML마다 트리거 추가 실행) 매우 낮음 (원본 DML과 완전히 분리됨)
복제 지연 (Replication Lag) 매우 취약 (Source 완료 후 Replica에서 재실행) 안전 (지연 감지 시 자동 대기) 매우 안전 (밀리초 단위 정밀 지연 제어)
실시간 부하 제어 (Throttling) 불가능 (시작하면 끝날 때까지 폭주) 반쪽짜리 (복사는 멈춰도 트리거는 계속 실행됨) 완벽 지원 (멈추면 DB 부하 즉시 0으로 수렴)
외래키 (Foreign Key) 지원 지원함 (제약 조건에 따라 다름) 지원함 (--alter-foreign-keys-method) 기본적 미지원 (외래키 사용 지양 환경에 적합)
사전 필수 조건 여유 디스크 공간 PK/UK 존재, 기존 트리거가 없어야 함 PK/UK 존재, binlog_format=ROW 필수

4. 실무 마이그레이션 명령어 레시피 및 꿀팁

실제 운영 환경에서 테이블 리빌드(공간 회수)나 인덱스/컬럼 추가 시 바로 복사해서 수정해 쓸 수 있는 실전 커맨드입니다.

① pt-osc 실무 실행 템플릿 (공간 회수 / OPTIMIZE 대체 예시)

 
pt-online-schema-change \
  --alter "ENGINE=InnoDB" \
  D=prod_db,t=service_access_log,h=10.0.0.10,u=admin,p='password' \
  --chunk-size=1000 \
  --chunk-time=0.5 \
  --max-lag=2 \
  --check-interval=1 \
  --max-load="Threads_running=50" \
  --critical-load="Threads_running=100" \
  --lock-wait-timeout=5 \
  --no-drop-old-table \
  --execute
  • --alter "ENGINE=InnoDB": 스키마 변경 없이 테이블만 새로 복사해서 단편화된 디스크 공간을 회수(Rebuild)할 때 사용하는 기법입니다.
  • --max-load / --critical-load: DB의 활성 스레드(Threads_running)가 50개를 넘으면 복사를 일시 중지하고, 100개를 넘으면 장애 예방을 위해 작업을 즉시 안전하게 취소(Abort)합니다.
  • --no-drop-old-table: (핵심 팁) 수백 GB짜리 구버전 테이블(_old)을 마지막에 DROP TABLE 할 때도 파일 시스템 부하가 걸릴 수 있습니다. 일단 삭제하지 않고 남겨둔 뒤, 새벽 시간대에 안전하게 지우거나 만일의 롤백 사태에 대비하는 것이 좋습니다.

② gh-ost 실무 실행 템플릿 (컬럼 및 인덱스 추가 예시)

gh-ost는 기본적으로 Replica에 연결하여 Binlog를 읽고 테스트를 수행할 수 있지만, AWS RDS나 Aurora 환경 등에서는 Source(Master)에 직접 연결하여 실행하는 --allow-on-master 모드를 많이 사용합니다.

 
gh-ost \
  --host="10.0.0.10" \
  --user="admin" \
  --password="password" \
  --database="prod_db" \
  --table="orders" \
  --alter="ADD COLUMN delivery_memo VARCHAR(255) NULL, ADD INDEX idx_created_at (created_at)" \
  --allow-on-master \
  --aliyun-rds=false \
  --assume-rbr \
  --chunk-size=1000 \
  --max-lag-millis=1500 \
  --max-load="Threads_running=50" \
  --critical-load="Threads_running=100" \
  --postpone-cut-over-flag-file="/tmp/ghost.postpone.flag" \
  --serve-socket-file="/tmp/ghost.orders.sock" \
  --execute

gh-ost 운영 꿀팁 2가지:

  1. --postpone-cut-over-flag-file 활용: 이 옵션을 주면 데이터 복사가 100% 끝났더라도 마지막 테이블 교체(Cut-over, RENAME)를 자동으로 하지 않고 대기합니다. 계속 Binlog만 따라가며 동기화 상태를 유지하고 있다가, 담당 엔지니어가 모니터링 화면을 켜놓고 /tmp/ghost.postpone.flag 파일을 지우는(rm) 바로 그 순간에 테이블을 교체합니다. 새벽에 복사를 걸어두고 출근해서 눈으로 보며 안전하게 마무리할 수 있는 최고의 기능입니다.
  2. 작업 중 실시간 속도 조절: 작업 도중 트래픽이 몰려 속도를 줄이고 싶다면, 프로세스를 끌 필요 없이 터미널에서 아래 명령어로 즉시 청크 사이즈를 줄일 수 있습니다.
    # 실행 중인 gh-ost의 청크 사이즈를 1000에서 200으로 즉시 축소
    echo "chunk-size=200" | nc -U /tmp/ghost.orders.sock
    
    # 현재 진행률 및 예상 남은 시간 상태 확인
    echo "status" | nc -U /tmp/ghost.orders.sock
    

5. 결론: 상황별 최적의 의사결정 가이드

현업에서 스키마 변경이나 테이블 최적화 요청이 들어왔을 때, 아래의 순서대로 판단하시면 장애 없는 완벽한 작업이 가능합니다.

  1. MySQL 8.0 이상이며 단순 컬럼 추가/삭제인가?
    • 무조건 Native Online DDL (ALGORITHM=INSTANT)을 사용하세요. 테이블 크기가 10TB라도 0.1초 만에 부하 없이 끝납니다.
  2. 테이블 크기가 10GB 미만이고 트래픽이 적은 새벽 시간대인가?
    • Native Online DDL (ALGORITHM=INPLACE, LOCK=NONE)로도 충분히 빠르고 안전하게 처리 가능합니다. 단, 실행 전 반드시 processlist를 확인해 롱 쿼리(장기 실행 트랜잭션)가 없는지 체크하세요.
  3. 수천만 건 이상(수십~수백 GB)의 핵심 테이블에 인덱스를 추가하거나, 컬럼 타입을 바꾸거나, 단편화된 공간을 회수(OPTIMIZE)해야 하는가?
    • 절대 Native DDL이나 OPTIMIZE TABLE을 직접 날리지 마세요.
    • 1순위 선택은 gh-ost입니다. 트리거 오버헤드가 없고 완벽한 스로틀링과 Cut-over 시점 제어가 가능하여 대규모 트래픽 환경에서 가장 안전합니다.
    • 만약 해당 테이블에 외래키(Foreign Key)가 걸려 있거나 Binlog 포맷이 STATEMENT라서 바꿀 수 없는 환경이라면, 그때 2순위로 pt-online-schema-change를 선택하시면 됩니다.

서비스를 몇 년간 운영하다 보면 로그성 테이블이나 이력, 정산 데이터가 수천만 건을 넘어 수억 건까지 쌓이는 시점이 반드시 찾아옵니다. 디스크 용량 알람이 울리기 시작하고, 백업 시간은 끝없이 길어지며, 결국 "오래된 데이터를 지워야겠다"는 결심을 하게 됩니다.

 

이때 가장 흔히 저지르는 치명적인 실수가 바로 아래와 같은 쿼리를 운영 DB에 직접 날리는 것입니다.

-- 절대 운영 환경에서 실행해서는 안 되는 쿼리
DELETE FROM service_Access_log WHERE created_at < '2025-01-01 00:00:00';

만약 이 조건에 해당하는 데이터가 5,000만 건이라면 어떤 일이 벌어질까요?

쿼리를 실행한 직후 DB CPU가 치솟고, 해당 테이블을 바라보는 모든 API가 타임아웃(Timeout)으로 죽어버리는 대형 장애로 이어집니다. 설령 몇 시간 만에 삭제가 끝났다 하더라도, 줄어들 줄 알았던 디스크 용량은 그대로인 황당한 상황을 마주하게 됩니다.

 

이번 글에서는 InnoDB 스토리지 엔진의 내부 아키텍처를 기반으로 왜 대량 DELETE가 문제를 일으키는지 파헤쳐 보고, 무중단 환경에서 수억 건의 데이터를 우아하고 안전하게 지워내는 3가지 실무 아카이빙/퍼지(Purge) 전략과 디스크 공간 회수(OPTIMIZE TABLE)의 함정까지 깊이 있게 다루어 보겠습니다.

 

 

1. 대규모 DELETE 문이 부르는 4가지 장애 (InnoDB 내부 원리)

MySQL의 InnoDB 엔진에서 DELETE는 데이터를 디스크에서 즉시 지우는 작업이 아닙니다. 트랜잭션의 ACID와 MVCC(다중 버전 동시성 제어)를 보장하기 위해 내부적으로 엄청난 비용을 치르는 무거운 작업입니다.

① Undo Log 폭발과 History List Length 증가

InnoDB는 DELETE가 실행되면 롤백(Rollback)과 다른 트랜잭션의 일관된 읽기(Consistent Read)를 위해 변경 전 데이터를 Undo Log에 기록합니다. 5,000만 건을 한 트랜잭션으로 지우면 5,000만 건 분량의 Undo Log가 생성됩니다.

  • 이 과정에서 트랜잭션이 끝날 때까지 Undo 세그먼트가 해제되지 않아 History List Length(HLL)가 기하급수적으로 치솟습니다.
  • HLL이 급증하면 다른 일반적인 SELECT 쿼리들도 과거 버전을 찾기 위해 긴 Undo 체인을 탐색해야 하므로 DB 전체의 읽기 성능이 급격히 저하됩니다.

② Buffer Pool 오염과 I/O 폭증

InnoDB는 디스크의 데이터 페이지(Page)를 직접 수정하지 않습니다. 반드시 메모리의 Buffer Pool로 페이지를 퍼 올린 뒤 수정(Dirty Page 생성)합니다.

  • 평소 조회되지도 않던 수년 전 과거 데이터를 지우기 위해 디스크에서 수많은 페이지를 읽어 들이면서, 정작 지금 서비스에 꼭 필요한 Hot Data들이 Buffer Pool에서 밀려나는(Eviction) 캐시 오염이 발생합니다.
  • 또한 변경된 내용을 Redo Log와 Binlog에 기록하느라 디스크 I/O가 한계치까지 치솟고, 복제(Replication) 환경에서는 Replica 서버가 이를 따라가지 못해 심각한 Replication Lag(복제 지연)이 발생합니다.

③ 광범위한 레코드 락(Record Lock)과 갭 락(Gap Lock) 점유

Если created_at 컬럼에 인덱스가 없다면? InnoDB는 클러스터드 인덱스(PK) 풀 스캔을 수행하며 스캔하는 모든 레코드에 X-Lock(배타적 잠금)을 걸어버립니다. 사실상 테이블 전체가 잠기는 것과 다름없습니다. 설령 인덱스가 있더라도 수천만 건의 인덱스 레코드와 그 사이의 간격(Gap Lock / Next-Key Lock)을 트랜잭션이 끝날 때까지 수십 분간 잡고 있게 되어, 다른 세션의 INSERT/UPDATE가 innodb_lock_wait_timeout에 걸려 줄줄이 실패하게 됩니다.

④ 가장 허무한 진실: 디스크 공간은 반환되지 않는다

수 시간의 고통 끝에 DELETE가 완료되었다고 가정해 봅시다. df -h로 서버 디스크를 확인해 보면 여유 공간이 단 1MB도 늘어나지 않은 것을 볼 수 있습니다. InnoDB가 실제 데이터 행을 지우는 방식은 해당 레코드 헤더에 '삭제 마킹(Delete-mark)'만 해두는 것입니다. 이후 백그라운드의 Purge Thread가 실제 레코드를 정리하더라도, 그 비어있는 공간은 OS로 반환되지 않고 .ibd 파일(테이블스페이스) 내부에 '재사용 가능한 빈 페이지(Fragmentation, 단편화)'로만 남게 됩니다.

2. 실무 대량 삭제(Purge) 3대 전략 비교

그렇다면 운영 중인 서비스에 영향을 주지 않고 대량의 데이터를 어떻게 삭제해야 할까요? 상황별 3가지 핵심 전략을 비교해 보겠습니다.

전략 구분 삭제 속도 서비스 영향도 (Lock/I/O) 디스크 공간 즉시 반환 여부 적용 난이도 및 사전 조건
1. Partition Drop 초고속 (수 밀리초~수 초) 거의 없음 (짧은 MDL만 발생) 즉시 반환됨 (OS 파일 삭제) 사전 파티셔닝 설계 필수 (PK 제약 존재)
2. Chunk 단위 삭제 (Script) 느림 (수 시간~수 일) 낮음 (Sleep 및 배치 사이즈 조절) 반환 안 됨 (단편화 발생) 별도 인프라 없이 쿼리/스크립트로 즉시 적용 가능
3. pt-archiver 활용 느림~보통 매우 낮음 (복제 지연 감시 가능) 반환 안 됨 (단편화 발생) Percona Toolkit 설치 필요, 가장 안전한 표준 툴

전략 1: 가장 완벽한 방법, 파티션 드롭 (Partition Drop)

주기적으로 삭제해야 하는 시계열(Time-series) 데이터나 로그성 테이블이라면, 설계 단계부터 Range Partitioning을 적용하는 것이 정석입니다.

파티셔닝된 테이블은 논리적으로는 하나의 테이블이지만, 물리적으로는 파티션마다 별도의 .ibd 데이터 파일을 가집니다. 따라서 특정 월의 데이터를 지울 때 DELETE가 아니라 ALTER TABLE ... DROP PARTITION을 사용합니다.

-- 2025년 1월 데이터 파티션을 물리적으로 즉시 삭제
ALTER TABLE service_access_log DROP PARTITION p202501;

 

왜 압도적으로 유리한가?

  1. Undo/Redo Log 미발생: DDL 작업으로서 파일 시스템 레벨에서 service_access_log#P#p202501.ibd 파일을 unlink(삭제)해 버립니다. 수억 건이 들어있어도 1초 안에 끝납니다.
  2. 디스크 공간 즉시 회수: 물리 파일이 삭제되므로 OS 디스크 용량이 그 즉시 확보됩니다. 단편화(Fragmentation) 걱정이 전혀 없습니다.

실무 주의사항:

파티션 테이블을 만들 때는 반드시 파티션 키(created_at)가 Primary Key(및 모든 Unique Key)의 일부로 포함되어야 합니다 (PRIMARY KEY (id, created_at)). 또한 DROP PARTITION 실행 순간에 아주 짧은 메타데이터 락(MDL, Metadata Lock)이 걸리므로, 수 초 이상 걸리는 롱 쿼리가 도는 시점만 피해서 실행하면 완벽합니다.

전략 2: 이미 파티셔닝이 안 되어 있다면? 청크(Chunk) 단위 쪼개 지우기

이미 단일 테이블에 수억 건이 쌓여 있는 상태에서 당장 데이터를 지워야 한다면, 트랜잭션의 크기를 작게 쪼개서(Chunking) 지워야 합니다.

이때 가장 흔히 하는 실수가 날짜 조건에 그냥 LIMIT만 붙여서 반복 실행하는 것입니다.

-- [안티 패턴] 조건절 인덱스 스캔 후 매번 정렬/탐색 비용 발생 가능
DELETE FROM service_access_log 
WHERE created_at < '2025-01-01 00:00:00' 
LIMIT 5000;

만약 created_at 인덱스가 없거나 카디널리티가 좋지 않다면 매 루프마다 막대한 스캔이 발생합니다. 가장 효율적인 청크 삭제는 클러스터드 인덱스(PK) 범위를 좁혀가며 삭제하는 것입니다.

Step 1. 삭제할 기준 시점의 최대 PK(ID) 값 확보

-- 2025년 1월 1일 이전 데이터의 가장 마지막 PK 값을 먼저 찾습니다.
SELECT MAX(id) FROM service_access_log WHERE created_at < '2025-01-01 00:00:00';
-- 결과 예시: 85,420,000

Step 2. PK 기반으로 끊어서 삭제 (Covering Index + Batch Loop)

PK를 기준으로 범위를 잘라 지우면 세컨더리 인덱스를 거치지 않고 곧바로 클러스터드 인덱스 리프 노드에 접근하므로 락 점유 시간이 극단적으로 짧아집니다.

-- 애플리케이션이나 쉘 스크립트에서 아래 쿼리를 반복 실행
DELETE FROM service_access_log 
WHERE id >= 1 AND id < 10000 
  AND created_at < '2025-01-01 00:00:00';
  • Batch Size: 한 번에 지우는 건수는 1,000건 ~ 5,000건 사이가 적당합니다. (한 번의 쿼리가 0.1초 이내에 끝나도록 유지)
  • Sleep Time: 매 루프 사이에 반드시 0.1초 ~ 0.5초의 Sleep(지연 시간)을 주어야 합니다. 이 짧은 틈 동안 밀려있던 일반 서비스 트랜잭션들이 처리되고, InnoDB의 Purge Thread가 Undo Log를 청소할 시간을 벌 수 있습니다.

전략 3: DBA들의 표준 무기, pt-archiver 활용하기

청크 삭제 스크립트를 직접 짜는 것도 좋지만, 운영 환경에서는 복제 지연(Replication Lag)이나 서버 부하 상태를 실시간으로 모니터링하며 속도를 조절해야 합니다. 이때 전 세계 MySQL DBA들이 표준처럼 사용하는 도구가 바로 Percona Toolkit의 pt-archiver입니다.

pt-archiver는 내부적으로 PK를 따라가며 작은 청크 단위로 데이터를 조회한 뒤, 삭제(--purge)하거나 다른 아카이브 테이블/파일로 이관 후 삭제하는 작업을 안전하게 수행합니다.

실무 추천 pt-archiver 명령어 템플릿

pt-archiver \
  --source h=10.0.0.10,D=prod_db,t=service_access_log,u=admin,p='password' \
  --where "created_at < '2025-01-01 00:00:00'" \
  --limit 2000 \
  --commit-each \
  --sleep 1 \
  --check-slave-lag h=10.0.0.11,D=prod_db,u=admin,p='password' \
  --max-lag 3 \
  --purge \
  --statistics \
  --progress 50000

핵심 옵션 해부:

  • --limit 2000 --commit-each: 한 번에 2,000건씩 가져와서 지우고 즉시 트랜잭션을 커밋합니다. Undo Log가 쌓일 틈을 주지 않습니다.
  • --Primary-key-only: (단순 삭제 시 매우 유용) 행 전체를 메모리에 올리지 않고 PK만 읽어서 삭제하므로 Buffer Pool 오염을 최소화합니다.
  • --check-slave-lag & --max-lag 3: 가장 강력한 기능입니다. 지정된 Replica(Slave) 서버의 복제 지연(Seconds_Behind_Master)을 실시간으로 체크하다가, 지연이 3초를 초과하면 지연이 해소될 때까지 알아서 삭제 작업을 일시 중지(Pause)합니다.

3. 삭제 후 : OPTIMIZE TABLE의 함정과 주의사항

청크 스크립트나 pt-archiver를 통해 무사히 5,000만 건을 지웠습니다. 하지만 앞서 설명했듯 .ibd 파일 크기는 줄어들지 않고 내부에 수많은 빈 구멍(단편화, Fragmentation)만 숭숭 뚫린 상태입니다.

얼마나 공간이 낭비되고 있는지는 아래 쿼리로 확인할 수 있습니다.

SELECT 
    TABLE_NAME,
    ROUND(DATA_LENGTH / 1024 / 1024 / 1024, 2) AS '데이터_크기(GB)',
    ROUND(INDEX_LENGTH / 1024 / 1024 / 1024, 2) AS '인덱스_크기(GB)',
    ROUND(DATA_FREE / 1024 / 1024 / 1024, 2) AS '단편화_여유공간(GB)'
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'prod_db' AND TABLE_NAME = 'service_access_log';

조회 결과 DATA_FREE가 수십~수백 GB로 나온다면, 이 공간을 OS로 돌려받기 위해 OPTIMIZE TABLE(내부적으로 ALTER TABLE ... ENGINE=InnoDB로 동작)을 실행하고 싶어집니다. 하지만 여기에는 반드시 알아야 할 3가지 치명적인 함정이 있습니다.

함정 1: 현재 테이블 크기만큼의 '여유 디스크 공간'이 추가로 필요하다

OPTIMIZE TABLE은 기존 파일을 제자리에서 줄이는 것이 아닙니다. 새로운 임시 .ibd 파일을 옆에 새로 만들고, 살아남은 데이터만 깔끔하게 복사해 넣은 뒤 기존 파일을 지우는 방식(Table Rebuild)으로 동작합니다.

  • 만약 현재 테이블 파일이 300GB이고 그중 150GB를 지웠다면, 작업 도중에는 새로 만들어지는 150GB 파일이 함께 존재해야 하므로 최소 150GB 이상의 여유 디스크 공간이 남아있어야 합니다. 디스크가 90% 이상 찬 상태에서 용량을 확보하겠다고 OPTIMIZE TABLE을 날리면 Disk Full로 DB가 즉사합니다.

함정 2: Online DDL이라도 '시작과 끝'에는 메타데이터 락(MDL)이 걸린다

MySQL 5.6 이후부터 OPTIMIZE TABLE은 Online DDL(Inplace 알고리즘)을 지원하여 작업 도중에도 DML(조회/입력/수정)이 가능합니다. 하지만 완벽한 무중단은 아닙니다.

  • 작업을 시작할 때와 마지막에 테이블을 교체(Rename)하는 순간에 배타적 메타데이터 락(Exclusive MDL)을 획득해야 합니다.
  • 만약 해당 테이블에 10초짜리 슬로우 쿼리가 돌고 있는 상태에서 OPTIMIZE가 마지막 단계에 진입하면, 슬로우 쿼리가 끝날 때까지 MDL을 대기하게 되고, 그 뒤로 들어오는 모든 일반 SELECT 쿼리들까지 MDL 대기 큐에 갇혀 커넥션 풀이 고갈되는 장애가 발생합니다.
  • 또한 작업 중에 발생하는 모든 DML은 innodb_online_alter_log_max_size(기본값 128MB)라는 버퍼에 쌓이는데, 쓰기 트래픽이 너무 많아 이 용량을 초과하면 수 시간 동안 돌던 OPTIMIZE TABLE이 에러를 뱉으며 롤백되어 버립니다.

함정 3: 굳이 OS로 공간을 반환해야 하는가? (발상의 전환)

가장 중요한 질문입니다. 만약 service_access_log 테이블에 앞으로도 매일 똑같은 속도로 새로운 로그가 INSERT될 예정이라면, 굳이 위험을 감수하며 OPTIMIZE TABLE을 할 필요가 없습니다. InnoDB는 파일 내부에 단편화된 빈 공간(DATA_FREE)이 있으면, 새로 들어오는 데이터를 디스크 파일을 늘리지 않고 그 빈 공간에 우선적으로 채워 넣기(재사용) 때문입니다. 즉, 며칠간 디스크 사용량이 더 이상 증가하지 않는 상태로 유지되므로 자연스럽게 용량 방어 효과를 얻을 수 있습니다.

그래도 꼭 공간을 회수해야 한다면?

네이티브 OPTIMIZE TABLE 대신 pt-online-schema-change나 gh-ost를 사용하여 빈 ALTER(--alter "ENGINE=InnoDB")를 수행하세요. 이 도구들은 복제 지연과 서버 부하를 감시하며 스로틀링(Throttling)을 걸어주기 때문에 훨씬 안전하게 테이블을 리빌드하고 디스크 공간을 회수할 수 있습니다.

4. 마무리하며: 대용량 삭제 실무 체크리스트

대용량 테이블의 데이터를 정리할 때는 아래의 의사결정 트리를 따르는 것이 가장 안전합니다.

  1. 신규 테이블 설계 시: 보관 주기(Retention)가 있는 데이터라면 무조건 날짜 기반 Range 파티셔닝을 적용하고, 스케줄러를 통해 DROP PARTITION으로 관리한다.
  2. 파티셔닝 없는 기존 테이블 삭제 시: 절대 단건 대규모 DELETE를 날리지 않는다. pt-archiver를 사용하거나 PK 범위 기반의 작은 청크(1,000~5,000건) + Sleep 조합으로 천천히 지운다.
  3. 삭제 후 디스크 공간 정리 시: 앞으로도 데이터가 계속 쌓이는 테이블이라면 OPTIMIZE TABLE을 생략하고 빈 공간을 재사용하게 둔다. 물리적 용량 회수가 반드시 필요한 경우에만 여유 디스크 공간을 확인한 뒤 트래픽 최저 시간대에 Online Schema Change 도구(gh-ost 등)를 활용해 리빌드한다.

 

+ Recent posts