- Published on
대용량 테이블 온라인 스키마 변경 — gh-ost, pt-online-schema-change, 네이티브 Online DDL
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — ALTER 한 줄이 장애가 되는 순간
- 왜 대용량 ALTER가 위험한가
- MySQL 네이티브 Online DDL — 어디까지 되나
- pt-online-schema-change — 트리거 기반 접근
- gh-ost — 바이너리 로그 기반 접근
- PostgreSQL의 온라인 DDL
- 청크 백필 — 데이터를 천천히 채우기
- 스로틀링과 컷오버
- 롤백 — 되돌릴 계획을 먼저 세운다
- 마이그레이션 도구와의 통합
- 실전 함정 모음
- 운영 체크리스트
- 마치며
- 참고 자료
들어가며 — ALTER 한 줄이 장애가 되는 순간
작은 테이블에서는 누구나 자신 있게 ALTER TABLE을 칩니다. 컬럼 하나 추가하는 건 1초도 안 걸리고, 인덱스 하나 만드는 것도 순식간입니다. 그런데 같은 명령을 수억 행짜리 주문 테이블에 거는 순간 이야기가 완전히 달라집니다. 명령은 몇 분에서 몇 시간 동안 끝나지 않고, 그동안 테이블에 쓰기 락이 걸려 애플리케이션은 타임아웃을 토하기 시작합니다. 운영 채널에는 5xx 알림이 쏟아지고, 누군가는 황급히 Ctrl-C를 누르지만 이미 늦었습니다.
이 글은 바로 그 순간을 피하기 위한 이야기입니다. 대용량 테이블의 스키마를 서비스 중단 없이 바꾸는 방법, 즉 온라인 스키마 변경(online schema change)을 다룹니다. MySQL의 네이티브 Online DDL이 어디까지 해주고 어디서 막히는지, 그 한계를 메우기 위해 등장한 pt-online-schema-change와 gh-ost가 각각 어떤 원리로 동작하는지, PostgreSQL은 어떤 다른 도구를 제공하는지를 ASCII 다이어그램과 실제 명령 예시로 풀어봅니다. 마지막으로 청크 백필, 스로틀링, 컷오버, 롤백 같은 운영 디테일과 실전 체크리스트까지 정리합니다.
결론을 먼저 말하면 이렇습니다. 대용량 테이블의 DDL은 "명령 한 줄"이 아니라 "운영 절차"로 다뤄야 합니다. 도구를 아는 것만으로는 부족하고, 부하 제어와 컷오버 전략, 그리고 되돌릴 계획까지 함께 설계해야 합니다.
왜 대용량 ALTER가 위험한가
대용량 테이블에 직접 ALTER를 걸 때 발생하는 위험은 크게 세 가지입니다.
첫째, 락(lock)입니다. 많은 DDL은 테이블을 재구성하는 동안 메타데이터 락이나 쓰기 락을 잡습니다. 락이 잡히는 동안 들어오는 쓰기 트랜잭션은 대기열에 쌓이고, 대기가 길어지면 커넥션 풀이 고갈되어 읽기 쿼리까지 함께 멈춥니다. 한 테이블의 DDL이 데이터베이스 전체의 장애로 번지는 전형적인 경로입니다.
둘째, 복제 지연(replication lag)입니다. 프라이머리에서 오래 걸린 DDL은 복제본에서도 똑같이 오래 걸립니다. 특히 MySQL의 전통적인 복제는 DDL을 직렬로 적용하기 때문에, 프라이머리에서 30분 걸린 ALTER가 복제본에 그대로 30분의 지연을 만듭니다. 읽기 트래픽을 복제본으로 분산하던 서비스라면 이 지연 동안 오래된 데이터를 읽거나 복제본 라우팅이 깨집니다.
셋째, 디스크와 부하입니다. 테이블을 새로 쓰는 방식의 DDL은 원본 크기만큼의 추가 디스크를 일시적으로 요구합니다. 100GB 테이블이면 변경 중 추가로 100GB가 필요할 수 있습니다. 동시에 대량의 I/O와 CPU를 소비해 같은 서버의 다른 쿼리 성능을 떨어뜨립니다.
+-------------------------------------------------------------+
| 직접 ALTER TABLE의 위험 경로 |
| |
| ALTER 시작 |
| | |
| v |
| [메타데이터/쓰기 락 획득] |
| | |
| +--> 쓰기 트랜잭션 대기열에 적체 |
| | | |
| | v |
| | 커넥션 풀 고갈 --> 읽기까지 타임아웃 |
| | |
| +--> 복제본에 동일 DDL 직렬 적용 --> 복제 지연 폭증 |
| | |
| +--> 대량 I/O/CPU --> 동일 서버 다른 쿼리 성능 저하 |
| |
| => 한 테이블의 변경이 DB 전체 장애로 확산 |
+-------------------------------------------------------------+
MySQL 네이티브 Online DDL — 어디까지 되나
MySQL 5.6부터 도입된 Online DDL은 많은 변경을 테이블 락 없이 처리할 수 있게 해줬습니다. 핵심은 ALGORITHM과 LOCK이라는 두 절(clause)입니다.
ALGORITHM에는 세 가지 값이 있습니다. COPY는 테이블을 통째로 복사하는 옛 방식이고, INPLACE는 테이블을 복사하지 않고 제자리에서 변경하는 방식, INSTANT는 데이터 딕셔너리의 메타데이터만 바꾸고 즉시 끝나는 방식입니다. MySQL 8.0부터 INSTANT 알고리즘이 추가되면서 컬럼 추가 같은 일부 작업은 정말로 한순간에 끝나게 됐습니다.
LOCK 절은 변경 중 허용할 동시성 수준을 지정합니다. NONE은 읽기와 쓰기 모두 허용, SHARED는 읽기만 허용, EXCLUSIVE는 둘 다 차단입니다.
-- INSTANT: 8.0+, 컬럼 추가가 즉시 끝남 (메타데이터만 변경)
ALTER TABLE orders
ADD COLUMN coupon_code VARCHAR(32) NULL,
ALGORITHM=INSTANT;
-- INPLACE + LOCK=NONE: 테이블 복사 없이, 읽기/쓰기 허용하며 인덱스 생성
ALTER TABLE orders
ADD INDEX idx_customer_created (customer_id, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
여기서 중요한 운영 습관이 하나 있습니다. ALGORITHM과 LOCK을 명시적으로 적는 것입니다. 만약 지정한 알고리즘으로 처리할 수 없는 변경이라면 MySQL은 에러를 내고 멈춥니다. 명시하지 않으면 MySQL이 알아서 COPY 방식으로 떨어져 테이블 락을 걸 수 있는데, ALGORITHM=INPLACE, LOCK=NONE을 적어두면 그런 위험한 폴백을 사전에 차단할 수 있습니다.
네이티브 Online DDL의 한계
네이티브 Online DDL은 강력하지만 만능이 아닙니다. 실무에서 부딪히는 한계는 다음과 같습니다.
INSTANT 알고리즘이 지원하는 작업은 제한적입니다. 컬럼 추가, 컬럼 기본값 변경, 컬럼 이름 변경 등 일부에 한정됩니다. PRIMARY KEY 변경, 컬럼 타입 변경, 문자셋 변경 같은 무거운 작업은 여전히 INPLACE나 COPY를 거쳐야 합니다.
INPLACE라 하더라도 변경 자체는 시간이 오래 걸립니다. LOCK=NONE이면 그동안 쓰기는 허용되지만, 변경이 끝나는 순간 짧은 메타데이터 락이 필요하고, 그 시점에 오래 실행 중인 트랜잭션이 있으면 락 대기가 길어질 수 있습니다.
가장 큰 한계는 부하 제어와 중단 가능성입니다. 네이티브 DDL은 일단 시작하면 중간에 부드럽게 멈추거나 진행 속도를 조절하기 어렵습니다. 복제 지연이 심해져도 알아서 속도를 줄여주지 않습니다. 바로 이 지점에서 외부 도구가 필요해집니다.
+----------------------------------------------------------+
| MySQL 네이티브 Online DDL 알고리즘 선택 |
| |
| 변경 종류 추천 알고리즘 테이블 복사 |
| ---------------------- ------------ ---------- |
| 컬럼 추가(끝) INSTANT 없음 |
| 컬럼 기본값 변경 INSTANT 없음 |
| 세컨더리 인덱스 추가 INPLACE 없음 |
| 컬럼 타입 변경 COPY 있음 |
| PRIMARY KEY 변경 COPY 있음 |
| 문자셋 변경 COPY 있음 |
+----------------------------------------------------------+
pt-online-schema-change — 트리거 기반 접근
Percona Toolkit의 pt-online-schema-change(줄여서 pt-osc)는 트리거를 이용해 온라인 변경을 구현합니다. 핵심 아이디어는 "새 구조의 빈 테이블을 만들고, 원본의 데이터를 천천히 복사하면서, 그동안의 변경 사항은 트리거로 따라잡는다"입니다.
동작 순서는 다음과 같습니다. 먼저 원본 테이블과 같은 구조의 새 테이블을 만들고 거기에 원하는 ALTER를 적용합니다. 그다음 원본 테이블에 INSERT, UPDATE, DELETE 트리거를 걸어 앞으로 들어오는 모든 변경이 새 테이블에도 반영되도록 합니다. 이어서 원본의 기존 데이터를 작은 청크 단위로 새 테이블에 복사합니다. 복사가 모두 끝나면 원본과 새 테이블의 이름을 원자적으로 맞바꿉니다(RENAME). 마지막으로 트리거와 옛 테이블을 정리합니다.
+--------------------------------------------------------------+
| pt-online-schema-change 동작 (트리거 기반) |
| |
| 1) 새 테이블 _orders_new 생성 + 원하는 ALTER 적용 |
| |
| 2) 원본 orders 에 트리거 설치 |
| INSERT/UPDATE/DELETE --> _orders_new 로 전파 |
| |
| 애플리케이션 쓰기 |
| | |
| v |
| [ orders ] --(트리거)--> [ _orders_new ] |
| ^ |
| | 3) 기존 행을 청크 단위로 복사 |
| +--- 백필: 1000행씩 INSERT ... SELECT |
| |
| 4) 원자적 RENAME: orders <-> _orders_new 맞바꿈 |
| |
| 5) 트리거 제거 + 옛 테이블 _orders_old 삭제 |
+--------------------------------------------------------------+
실제 명령은 이렇게 생겼습니다. 변경 내용은 alter 옵션에 ADD COLUMN 같은 절만 적습니다. ALTER TABLE 키워드는 도구가 알아서 붙입니다.
pt-online-schema-change \
--alter "ADD COLUMN coupon_code VARCHAR(32) NULL" \
--host=db-primary.internal \
--user=migrator \
--ask-pass \
--chunk-size=1000 \
--max-load "Threads_running=50" \
--critical-load "Threads_running=120" \
--max-lag=2 \
--check-slave-lag=db-replica.internal \
D=shop,t=orders \
--execute
여기서 주목할 옵션들이 있습니다. chunk-size는 한 번에 복사할 행 수를 정하고, max-load는 서버의 동시 실행 스레드 수가 일정 수준을 넘으면 복사를 잠시 멈춰 부하를 조절합니다. critical-load는 그보다 심각한 임계치로, 넘으면 작업 자체를 중단합니다. max-lag와 check-slave-lag는 복제 지연을 감시해, 복제본이 뒤처지면 백필을 늦춰 지연이 회복되기를 기다립니다.
트리거 기반 방식의 장단점
장점은 별도의 바이너리 로그 접근 권한 없이도 동작한다는 점, 그리고 오래 검증된 안정적인 도구라는 점입니다. 단점은 트리거가 원본 테이블의 쓰기 경로에 직접 끼어든다는 데 있습니다. 모든 INSERT, UPDATE, DELETE가 트리거를 한 번 더 거치므로 쓰기 지연이 늘어납니다. 또 이미 트리거가 있는 테이블에는 사용하기 까다롭고, 외래 키가 걸린 테이블은 추가 처리가 필요합니다. 무엇보다 컷오버 시점의 RENAME이 짧지만 분명한 락 구간을 만든다는 점을 기억해야 합니다.
gh-ost — 바이너리 로그 기반 접근
GitHub이 만든 gh-ost는 트리거를 전혀 쓰지 않습니다. 대신 MySQL의 바이너리 로그(binlog)를 읽어 변경을 따라잡습니다. gh-ost 자신이 복제본인 척 프라이머리에 붙어 binlog 이벤트를 스트림으로 받고, 그 이벤트를 새 테이블(고스트 테이블)에 반영합니다.
동작은 이렇습니다. 먼저 원본과 같은 구조의 고스트 테이블 _orders_gho를 만들고 ALTER를 적용합니다. 동시에 gh-ost는 binlog를 구독해 원본 테이블에 들어오는 모든 변경 이벤트를 받아 고스트 테이블에 적용합니다. 그와 별개로 원본의 기존 행을 청크 단위로 복사하는 백필을 진행합니다. binlog 적용과 백필이 따라잡으면 컷오버를 수행해 테이블 이름을 맞바꿉니다.
+--------------------------------------------------------------+
| gh-ost 동작 (바이너리 로그 기반, 트리거 없음) |
| |
| 애플리케이션 쓰기 |
| | |
| v |
| [ orders ] ---> MySQL binlog |
| ^ | |
| | v |
| | gh-ost (복제본인 척 binlog 구독) |
| | | |
| | +-------+-------+ |
| | | | |
| | binlog 이벤트 적용 기존 행 백필 (청크) |
| | | | |
| | v v |
| | [ _orders_gho 고스트 테이블 ] |
| | |
| +--- 4) 컷오버: orders <-> _orders_gho 맞바꿈 |
+--------------------------------------------------------------+
실제 명령 예시입니다.
gh-ost \
--host=db-primary.internal \
--user=migrator \
--password=secret \
--database=shop \
--table=orders \
--alter="ADD COLUMN coupon_code VARCHAR(32) NULL" \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--critical-load="Threads_running=120" \
--max-lag-millis=1500 \
--throttle-control-replicas="db-replica.internal" \
--allow-on-master \
--postpone-cut-over-flag-file=/tmp/ghost.postpone \
--execute
gh-ost의 강력한 운영 기능 중 하나는 실행 중 상호작용입니다. 유닉스 소켓이나 패널을 통해 실행 중에 명령을 보낼 수 있습니다. 예를 들어 백필 속도를 늦추거나, 스로틀을 직접 걸거나, 컷오버를 미루거나 당길 수 있습니다.
# 실행 중 스로틀 강제 (백필 일시 정지)
echo "throttle" | nc -U /tmp/gh-ost.shop.orders.sock
# 스로틀 해제
echo "no-throttle" | nc -U /tmp/gh-ost.shop.orders.sock
# 청크 사이즈 실시간 변경
echo "chunk-size=200" | nc -U /tmp/gh-ost.shop.orders.sock
postpone-cut-over-flag-file은 특히 유용합니다. 이 플래그 파일이 존재하는 동안에는 백필이 모두 끝나도 컷오버를 하지 않고 대기합니다. 덕분에 백필이라는 무거운 작업은 낮 시간에 미리 끝내두고, 실제 테이블 교체는 트래픽이 적은 시간에 파일 하나를 지우는 것으로 트리거할 수 있습니다.
pt-osc와 gh-ost 비교
두 도구의 차이를 표로 정리하면 다음과 같습니다.
| 항목 | pt-online-schema-change | gh-ost |
|---|---|---|
| 변경 추적 방식 | 트리거 | 바이너리 로그 |
| 원본 쓰기 경로 영향 | 트리거로 직접 개입 | 개입 없음(비동기) |
| 추가 부하 위치 | 프라이머리 쓰기 시점 | gh-ost 프로세스 |
| 실행 중 제어 | 제한적 | 소켓으로 동적 제어 |
| 컷오버 연기 | 어려움 | 플래그 파일로 지원 |
| 외래 키 처리 | 옵션으로 일부 지원 | 직접 지원은 제한적 |
| 검증 성숙도 | 매우 오래됨 | GitHub 대규모 운영 검증 |
요약하면, 쓰기 부하가 매우 높은 테이블이나 컷오버 타이밍을 정밀하게 제어하고 싶다면 gh-ost가 유리합니다. 반대로 binlog 접근이 어렵거나 매우 단순한 변경이라면 pt-osc가 더 간편할 수 있습니다.
PostgreSQL의 온라인 DDL
PostgreSQL은 MySQL과 다른 모델을 가집니다. 많은 DDL이 이미 트랜잭션 안에서 동작하고, MVCC 덕분에 일부 변경은 본질적으로 덜 침습적입니다. 하지만 PostgreSQL에도 고유한 함정이 있고, 그것을 피하기 위한 전용 구문이 있습니다.
CREATE INDEX CONCURRENTLY
PostgreSQL에서 일반 CREATE INDEX는 테이블에 쓰기 락을 걸어 인덱스가 만들어지는 동안 모든 쓰기를 막습니다. 대용량 테이블에서는 치명적입니다. 해법은 CONCURRENTLY 옵션입니다. 이 옵션을 쓰면 쓰기를 막지 않고 인덱스를 만듭니다. 대신 테이블을 두 번 스캔하므로 더 느리고, 트랜잭션 블록 안에서는 쓸 수 없습니다.
-- 쓰기를 막지 않고 인덱스 생성 (트랜잭션 블록 밖에서 실행)
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at);
-- 실패해 INVALID 상태로 남은 인덱스가 있는지 확인
SELECT indexrelid::regclass AS index_name
FROM pg_index
WHERE indisvalid = false;
-- INVALID 인덱스는 동일하게 CONCURRENTLY로 제거
DROP INDEX CONCURRENTLY idx_orders_customer_created;
CONCURRENTLY는 실패할 경우 INVALID 상태의 인덱스를 남기는데, 이건 쿼리에 사용되지 않으면서 디스크만 차지하므로 반드시 찾아내 정리해야 합니다.
lock_timeout으로 락 대기 폭주 막기
PostgreSQL에서 ALTER TABLE은 짧더라도 ACCESS EXCLUSIVE 락을 요구하는 경우가 많습니다. 문제는 이 락을 얻으려고 기다리는 동안, 그 뒤에 줄을 선 다른 쿼리들도 함께 막힌다는 점입니다. 락 대기가 락 큐를 만들고, 락 큐가 장애를 만듭니다.
이를 막는 핵심은 lock_timeout을 짧게 설정하는 것입니다. 락을 일정 시간 안에 얻지 못하면 DDL 자체가 깔끔하게 실패하게 만들어, 다른 쿼리들이 무한정 막히는 사태를 방지합니다. 그다음 재시도하면 됩니다.
-- 락을 3초 안에 못 얻으면 이 DDL은 실패시킨다
SET lock_timeout = '3s';
-- 11+ 에서 NOT NULL 없는 컬럼 추가는 기본적으로 즉시 (테이블 재작성 없음)
ALTER TABLE orders ADD COLUMN coupon_code VARCHAR(32);
SET lock_timeout = '3s';
-- DEFAULT 가 있는 컬럼 추가도 11+ 에서는 메타데이터만 변경되어 빠름
ALTER TABLE orders ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
PostgreSQL 11부터는 기본값이 있는 컬럼 추가도 전체 테이블을 재작성하지 않고 카탈로그에 기본값을 기록하는 방식으로 빠르게 처리됩니다. 이건 큰 개선이었습니다. 다만 기존 컬럼에 NOT NULL 제약을 새로 거는 작업은 여전히 전체 스캔이 필요하므로, 뒤에서 다룰 검증 분리 기법을 써야 합니다.
제약 추가를 NOT VALID로 쪼개기
외래 키나 CHECK 제약을 대용량 테이블에 한 번에 거는 것도 위험합니다. PostgreSQL은 이를 두 단계로 쪼갤 수 있습니다. 먼저 NOT VALID로 제약을 추가하면 기존 행은 검증하지 않고 앞으로 들어오는 행에만 적용합니다. 그다음 한가한 시간에 VALIDATE CONSTRAINT로 기존 행을 검증하는데, 이 검증은 약한 락만 잡아 쓰기를 막지 않습니다.
-- 1단계: 기존 행 검증을 건너뛰고 제약 추가 (짧은 락)
ALTER TABLE order_items
ADD CONSTRAINT fk_order
FOREIGN KEY (order_id) REFERENCES orders (id)
NOT VALID;
-- 2단계: 한가할 때 기존 행 검증 (쓰기를 막지 않는 약한 락)
ALTER TABLE order_items
VALIDATE CONSTRAINT fk_order;
청크 백필 — 데이터를 천천히 채우기
스키마를 바꾼 뒤에는 보통 새 컬럼에 값을 채워야 합니다. 여기서 흔한 실수가 단일 UPDATE 한 방으로 수억 행을 갱신하려는 것입니다. 이 거대 트랜잭션은 긴 락, 거대한 언두/WAL, 복제 지연을 동시에 불러옵니다. 정석은 작은 청크로 쪼개 반복하는 것입니다.
-- 잘못된 예: 수억 행을 한 트랜잭션으로 (절대 금지)
-- UPDATE orders SET coupon_code = 'NONE' WHERE coupon_code IS NULL;
-- 올바른 예: PK 범위로 청크를 끊어 반복
-- (애플리케이션/스크립트 루프에서 batch_start 를 1000씩 증가)
UPDATE orders
SET coupon_code = 'NONE'
WHERE id >= 1000000 AND id < 1001000
AND coupon_code IS NULL;
백필 루프는 보통 다음 원칙을 따릅니다. 청크 크기를 적당히(수백에서 수천 행) 잡고, 각 청크 사이에 짧은 휴식을 둡니다. 그리고 매 청크마다 복제 지연을 확인해, 지연이 임계치를 넘으면 백필을 잠시 멈췄다가 회복되면 재개합니다. 이렇게 하면 백필이 운영 트래픽을 압도하지 않습니다.
// 의사 코드 수준의 청크 백필 루프 (복제 지연 감시 포함)
func backfill(db *sql.DB, maxID, batch int) error {
for start := 0; start < maxID; start += batch {
for replicationLagSeconds(db) > 2 {
time.Sleep(500 * time.Millisecond) // 지연이 회복될 때까지 대기
}
_, err := db.Exec(
"UPDATE orders SET coupon_code = 'NONE' "+
"WHERE id >= ? AND id < ? AND coupon_code IS NULL",
start, start+batch,
)
if err != nil {
return err
}
time.Sleep(50 * time.Millisecond) // 청크 사이 짧은 휴식
}
return nil
}
스로틀링과 컷오버
스로틀링 — 부하를 보면서 속도 조절
온라인 스키마 변경의 핵심 안전장치는 스로틀링입니다. 스로틀링이란 서버의 부하나 복제 지연을 실시간으로 관찰하면서, 위험 신호가 보이면 백필 속도를 자동으로 늦추거나 멈추는 것을 말합니다. pt-osc의 max-load와 max-lag, gh-ost의 max-load와 max-lag-millis가 모두 이 기능을 담당합니다.
좋은 스로틀 설정은 두 가지 임계치를 둡니다. 하나는 부드러운 임계치로, 넘으면 속도를 늦춥니다(pt-osc의 max-load). 다른 하나는 비상 임계치로, 넘으면 작업을 아예 멈춥니다(pt-osc의 critical-load). 복제 지연도 마찬가지로 감시 대상에 넣어, 복제본이 뒤처지기 시작하면 백필을 늦춰 지연이 누적되지 않게 합니다.
컷오버 — 가장 위험한 순간
컷오버는 새 테이블과 원본 테이블의 이름을 맞바꾸는 순간입니다. 백필과 변경 추적이 모두 끝난 뒤에 일어나며, 보통 매우 짧지만 이 순간만큼은 락이 필요합니다. 컷오버가 위험한 이유는, 만약 이 시점에 오래 실행 중인 트랜잭션이 테이블을 붙잡고 있으면 이름 교체가 그 트랜잭션을 기다리며 락 큐를 만들기 때문입니다.
그래서 컷오버는 트래픽이 적고, 긴 트랜잭션이 없는 시간대를 골라 수행하는 것이 좋습니다. gh-ost의 postpone-cut-over-flag-file이 바로 이 목적을 위한 장치입니다. 무거운 백필은 미리 끝내두고, 컷오버라는 짧고 민감한 순간만 사람이 직접 고른 타이밍에 실행하는 것입니다.
+--------------------------------------------------------------+
| 컷오버 타임라인 (이상적인 경우) |
| |
| 백필 진행 ===============================> (낮 시간, 스로틀) |
| | |
| [컷오버 대기, 플래그 파일] |
| | |
| 트래픽 적은 시간 -----------------------> [컷오버 실행] |
| | 짧은 락 |
| v |
| orders <-> ghost 맞바꿈 |
| | |
| 옛 테이블 정리 |
+--------------------------------------------------------------+
롤백 — 되돌릴 계획을 먼저 세운다
온라인 스키마 변경에서 롤백은 단계마다 의미가 다릅니다. 컷오버 이전이라면 롤백은 쉽습니다. 새 테이블(고스트 테이블 또는 _new 테이블)을 그냥 버리고 트리거를 제거하면 원본은 한 번도 바뀐 적이 없으므로 깔끔하게 원상복구됩니다. gh-ost나 pt-osc를 중간에 멈추는 것이 이 단계의 롤백입니다.
컷오버 이후라면 이야기가 다릅니다. 이미 이름이 바뀌어 새 구조가 운영 중이므로, 되돌리려면 반대 방향의 변경을 다시 적용해야 합니다. 그래서 진짜 안전한 마이그레이션은 스키마 변경을 애플리케이션 배포와 분리하고, 역방향으로도 호환되는 단계로 쪼갭니다.
대표적인 패턴이 확장-수축(expand-contract)입니다. 컬럼 이름을 바꾸는 경우를 예로 들면, 먼저 새 컬럼을 추가하고(확장), 애플리케이션이 두 컬럼을 모두 쓰게 한 뒤, 데이터를 백필하고, 읽기를 새 컬럼으로 옮기고, 마지막으로 옛 컬럼을 제거(수축)합니다. 각 단계가 독립적으로 배포되고 각 단계마다 되돌릴 수 있으므로, 어느 시점에 문제가 생겨도 안전하게 멈출 수 있습니다.
+--------------------------------------------------------------+
| 확장-수축(expand-contract) 패턴 |
| |
| 1) 확장: 새 컬럼 추가 (옛 컬럼 그대로 유지) |
| 2) 이중 쓰기: 앱이 옛+새 컬럼 둘 다 기록 |
| 3) 백필: 기존 행의 새 컬럼을 청크로 채움 |
| 4) 읽기 전환: 앱이 새 컬럼을 읽도록 배포 |
| 5) 수축: 안정화 후 옛 컬럼 제거 |
| |
| => 각 단계가 독립 배포 + 단계별 롤백 가능 |
+--------------------------------------------------------------+
마이그레이션 도구와의 통합
온라인 스키마 변경은 보통 마이그레이션 관리 도구와 함께 운영됩니다. Flyway, Liquibase, golang-migrate 같은 도구는 마이그레이션의 버전과 적용 순서를 관리하지만, 기본적으로는 SQL을 그대로 실행합니다. 즉 대용량 테이블 변경에서는 이 도구들이 직접 ALTER를 거는 대신, gh-ost나 pt-osc를 호출하는 방식으로 감싸 쓰는 경우가 많습니다.
핵심은 두 가지를 분리해 생각하는 것입니다. 하나는 "어떤 변경을 어떤 순서로 적용했는가"라는 버전 관리이고, 다른 하나는 "그 변경을 어떻게 안전하게 실행하는가"라는 실행 전략입니다. 마이그레이션 도구는 전자를, 온라인 DDL 도구는 후자를 담당합니다. 안전한 컬럼 추가처럼 가벼운 변경은 마이그레이션 도구가 직접 실행하고, 무거운 테이블 재작성은 온라인 DDL 도구에 위임하는 식으로 나누면 깔끔합니다.
실전 함정 모음
현장에서 반복적으로 마주치는 함정들을 정리합니다.
첫째, 디스크 용량을 잊는 것입니다. 테이블을 재작성하는 모든 방식은 일시적으로 원본 크기만큼의 추가 디스크를 요구합니다. 변경 전 반드시 여유 공간을 확인하세요. 변경 도중 디스크가 가득 차면 가장 나쁜 시점에 작업이 죽습니다.
둘째, 외래 키를 무시하는 것입니다. 트리거 기반 도구와 binlog 기반 도구 모두 외래 키가 걸린 테이블에서 추가 처리가 필요합니다. 자식 테이블의 외래 키가 원본 테이블을 가리키고 있다면, 이름 교체 과정에서 외래 키가 깨지지 않도록 도구의 외래 키 옵션을 반드시 검토해야 합니다.
셋째, 긴 트랜잭션을 방치하는 것입니다. 컷오버 직전에 오래 실행 중인 트랜잭션이 있으면 컷오버가 그 트랜잭션을 기다리며 락 큐를 만듭니다. 변경 전에 오래 도는 쿼리나 미커밋 트랜잭션이 없는지 확인하세요.
넷째, 스로틀 임계치를 너무 느슨하게 잡는 것입니다. 부하가 보일 때 멈추라고 설정해야 하는데, 임계치가 너무 높으면 이미 서비스가 느려진 뒤에야 스로틀이 걸립니다. 처음에는 보수적으로 낮게 잡고 관찰하면서 올리는 편이 안전합니다.
다섯째, 리허설 없이 운영에 바로 거는 것입니다. 같은 크기의 스테이징 데이터에서 한 번 돌려보면 소요 시간, 디스크 사용량, 부하 영향을 미리 가늠할 수 있습니다. 운영에서 처음 돌리는 것은 무모합니다.
여섯째, PostgreSQL에서 CONCURRENTLY의 INVALID 인덱스를 청소하지 않는 것입니다. 실패한 CONCURRENTLY는 조용히 쓸모없는 인덱스를 남기므로, 작업 후 반드시 INVALID 인덱스를 점검하고 정리하세요.
운영 체크리스트
대용량 테이블 온라인 스키마 변경을 실행하기 전에 다음을 점검하세요.
[ ] 변경을 가벼운 변경(INSTANT/즉시)과 무거운 변경(재작성)으로 분류했는가
[ ] 가벼운 변경은 네이티브 DDL/마이그레이션 도구로 직접 처리 가능한가
[ ] 무거운 변경에 gh-ost 또는 pt-osc 중 무엇을 쓸지 정했는가
[ ] 원본 크기만큼의 추가 디스크 여유가 확보되었는가
[ ] 외래 키/트리거가 걸린 테이블인지 확인하고 옵션을 준비했는가
[ ] 스로틀 임계치(부하/복제 지연)를 보수적으로 설정했는가
[ ] 복제본 지연 감시 대상을 도구에 지정했는가
[ ] 컷오버를 트래픽 적은 시간대로 계획했는가(연기 플래그 활용)
[ ] 컷오버 직전 긴 트랜잭션/미커밋 트랜잭션이 없음을 확인했는가
[ ] 롤백 절차(컷오버 전/후)를 문서화했는가
[ ] 동일 규모 스테이징에서 리허설을 마쳤는가
[ ] PostgreSQL이라면 lock_timeout 설정과 INVALID 인덱스 점검 계획이 있는가
[ ] 확장-수축으로 애플리케이션 배포와 스키마 변경을 분리했는가
마치며
대용량 테이블의 스키마 변경은 데이터베이스 운영에서 가장 긴장되는 작업 중 하나입니다. 하지만 그 긴장의 정체는 분명합니다. 락, 복제 지연, 디스크, 그리고 되돌릴 수 없음입니다. 이 네 가지를 각각 어떻게 다룰지 미리 설계해두면, 무서웠던 작업이 절차로 바뀝니다.
핵심을 다시 정리하면 이렇습니다. 가벼운 변경은 네이티브 Online DDL과 INSTANT 알고리즘으로 빠르게 끝내고, 무거운 변경은 gh-ost나 pt-osc 같은 도구에 위임해 청크 백필과 스로틀링으로 부하를 제어합니다. PostgreSQL이라면 CONCURRENTLY와 lock_timeout, NOT VALID 분리 같은 고유 기법을 적극 활용합니다. 그리고 무엇보다, 컷오버를 인간이 통제하는 짧은 순간으로 만들고, 확장-수축으로 언제든 멈출 수 있는 마이그레이션을 설계하는 것입니다.
도구는 거들 뿐입니다. 안전한 마이그레이션을 만드는 건 결국 절차와 리허설, 그리고 되돌릴 계획입니다.
참고 자료
- MySQL 8.0 Reference Manual — Online DDL Operations
- MySQL 8.0 Reference Manual — Online DDL Overview
- Percona Toolkit — pt-online-schema-change
- gh-ost — GitHub Online Schema Migrations for MySQL
- PostgreSQL Documentation — CREATE INDEX (CONCURRENTLY)
- PostgreSQL Documentation — ALTER TABLE
- PostgreSQL Documentation — Client Connection Defaults (lock_timeout)
- Amazon RDS — Working with online DDL operations
- Flyway Documentation
- Liquibase Documentation
- golang-migrate