카테고리 : MySQL/기술노트

gh-ost 원리: binlog 기반 섀도 테이블 마이그레이션과 전환 경계

gh-ost의 binlog 기반 변경 적용, 청크 복사, throttle과 cut-over 원리를 실제 검증하고 Aurora MySQL 적용 시 주의점을 정리한다.

저자: MySQL 기술 노트 작성: 2026.10.13 약 11분 6,546자
다운로드

대형 테이블의 스키마 변경에서 중요한 것은 작업 중 쓰기가 가능한지뿐만이 아니다. 부하가 높아졌을 때 추가 작업을 멈출 수 있는지, 복사 도중의 변경을 빠짐없이 반영하는지, 마지막 테이블 교체를 언제 실행할지까지 통제해야 한다. gh-ost는 이 문제를 원본 테이블의 트리거가 아니라 binlog를 읽는 외부 프로세스로 해결한다.

이 글은 gh-ost 1.1.11과 MySQL Community 8.0.46을 기준으로 내부 흐름과 운영 경계를 설명한다. 별도의 폐기용 MySQL에서 실제 마이그레이션도 실행한다. MySQL 8.4 또는 Aurora의 호환성·성능을 이 실험으로 보증하지는 않는다. 단순 컬럼 추가처럼 네이티브 ALGORITHM=INSTANT로 처리할 수 있는 변경이라면, 테이블 전체를 복사하는 도구보다 네이티브 DDL을 먼저 검토한다.

1. 섀도 테이블과 두 개의 데이터 흐름

섀도 테이블 마이그레이션(shadow table migration)은 새 구조의 테이블을 별도로 만들고 기존 데이터를 옮긴 다음, 마지막에 테이블 이름을 교환하는 방식이다. gh-ost는 보통 원본 이름에 따라 다음 객체를 사용한다.

객체 역할
osc_orders 애플리케이션이 계속 읽고 쓰는 원본
_osc_orders_gho 변경할 DDL을 먼저 적용하고 데이터를 채우는 ghost 테이블
_osc_orders_ghc heartbeat와 작업 상태를 전달하는 내부 changelog 테이블
_osc_orders_del 전환 후 남겨 둔 이전 원본; 전환 알고리즘에서는 같은 이름의 보조 객체도 사용

여기서 changelog라는 이름에 주의해야 한다. _ghc가 업무 행 변경을 트리거로 수집하는 큐인 것은 아니다. 업무 변경의 근거는 binlog이고, _ghc는 상태 신호를 같은 binlog 경로로 전달하는 데 사용된다.

flowchart TD
    A[애플리케이션 쓰기] --> O[원본 테이블]
    O --> B[ROW 형식 binlog]
    O --> C[공통 유일키 범위별 청크 복사]
    B --> S[gh-ost 이벤트 수신과 대상 필터링]
    S --> Q[변경 적용 대기열]
    C --> W[복사와 변경 적용 조정]
    Q --> W
    W --> G[새 구조의 ghost 테이블]
    H[heartbeat와 상태 신호] --> B
    G --> K[복사 완료와 변경 추종 확인]
    K --> L[원본 쓰기 차단과 마지막 변경 반영]
    L --> R[원자적 이름 교체]

두 흐름은 성격이 다르다. 청크 복사는 이미 존재하는 행의 범위를 채운다. binlog 흐름은 작업 중 발생한 INSERT, UPDATE, DELETE를 반영한다. 처음 읽은 키의 최댓값보다 큰 새 행도 binlog 경로를 통해 반영되어야 한다. 따라서 복사 진행률이 100%라는 사실만으로 전환 준비가 끝난 것은 아니다.

2. 트리거를 없애면 무엇이 달라지는가

트리거 기반 방식에서는 원본 DML과 추가 테이블 변경이 같은 트랜잭션 경로에 들어간다. 반면 gh-ost는 MySQL 복제 클라이언트처럼 binlog 이벤트를 요청하고, 대상 테이블의 행 이벤트를 해석해 ghost 테이블에 비동기로 적용한다. 애플리케이션 커밋은 해당 변경의 ghost 반영 완료를 기다리는 구조가 아니다.

이 분리는 부하를 없애는 것이 아니라 부하를 조절할 위치를 바꾼다. 원본 읽기, ghost 쓰기, 인덱스 유지, redo와 binlog 생성, 네트워크 수신은 여전히 필요하다. 원본과 ghost가 같은 서버에 있으면 I/O와 버퍼 풀도 경쟁한다. 다만 도구가 복사와 변경 적용을 멈출 수 있으므로 트리거 부하처럼 매 업무 쓰기에 추가 작업이 계속 붙지는 않는다.

공통 유일키가 필요한 이유

gh-ost는 변경 전후 테이블에 공통으로 존재하는 PRIMARY KEY 또는 적절한 UNIQUE KEY를 골라 범위를 순회하고 변경 대상 행을 식별한다. 안정적인 NOT NULL 유일키가 기본 전제다. nullable unique key를 강제로 허용하는 옵션은 NULL 값이 있는 행의 정합성을 해결해 주지 않는다.

청크 복사는 단순히 애플리케이션에서 SELECT한 결과를 나중에 무조건 덮어쓰는 작업이 아니다. gh-ost 1.1.11의 구현은 범위별 INSERT IGNORE ... SELECT와 InnoDB 공유 잠금 읽기를 사용하며, 복사 작업과 변경 이벤트 적용을 조정한다. 이미 반영된 행을 오래된 복사 내용으로 무조건 덮어쓰지 않도록 하는 것이 중요하다. 이를 “복사는 잠금을 전혀 잡지 않는다”거나 “binlog만 다시 실행하면 어떤 복사 순서도 안전하다”로 해석하면 안 된다.

외래키와 기존 트리거는 별개의 제약이다. 이 버전의 공식 요구사항에서는 둘 다 지원하지 않는다. 원본을 참조하는 외래키, 변경 후 추가될 유일성 조건, 타입 축소·문자열 변환도 사전에 검토해야 한다. 특히 유일키 추가 시 충돌하는 데이터를 IGNORE 계열 동작이 알아서 안전하게 처리한다고 기대하지 않는다.

3. 복제본을 경유해도 데이터 복사가 그곳으로 옮겨지는 것은 아니다

일반적인 경유 모드에서는 gh-ost가 복제본을 조사해 원본 서버를 찾고, 복제본의 binlog를 읽되 행 복사와 ghost 쓰기 및 cut-over는 원본 서버에서 수행한다. 따라서 복제본을 경유한다고 원본의 전체 복사 I/O까지 사라지는 것은 아니다.

복제본의 binlog에 원본 변경이 다시 기록되어야 하므로 log_replica_updates 계열 설정과 ROW 이벤트 생성 조건이 중요하다. 복제 필터로 업무 테이블이나 내부 상태 신호가 빠지지 않아야 하고, 양쪽의 테이블 정의도 같아야 한다. 도구가 읽는 복제본의 지연은 ghost 변경 반영 지연에도 영향을 준다.

원본에 직접 연결하려면 --allow-on-master로 의도를 명시한다. 이번 검증은 복제 토폴로지 효과를 배제하기 위해 이 모드를 사용했다. 여기서 옵션의 master는 도구가 사용하는 실제 이름이므로 그대로 표기한다.

binlog 조건을 먼저 확인한다

다음 SQL은 현재 서버의 설정을 조회한다. 도구를 실행할 서버에서는 log_bin=1, binlog_format=ROW, binlog_row_image=FULL을 확인해야 한다. gh-ost 1.1.11의 공식 요구사항은 FULL row image이며, MINIMAL을 같은 조건으로 취급하지 않는다.

SELECT @@global.log_bin AS log_bin,
       @@global.binlog_format AS binlog_format,
       @@global.binlog_row_image AS binlog_row_image;

실행 결과(MySQL 8.0.x):

mysql> SELECT @@global.log_bin AS log_bin,
    ->        @@global.binlog_format AS binlog_format,
    ->        @@global.binlog_row_image AS binlog_row_image;

+---------+---------------+------------------+
| log_bin | binlog_format | binlog_row_image |
+---------+---------------+------------------+
|       0 | ROW           | FULL             |
+---------+---------------+------------------+

위 출력은 설정값 중심으로 발췌했다. 실행 결과는 진단 SQL용 인스턴스에서 수집했다. 이 인스턴스는 binlog를 끈 상태이므로 log_bin=0이며, 이 상태로 gh-ost를 실행할 수는 없다. 아래 실제 마이그레이션 실험에는 binlog를 켜고 ROW/FULL로 설정한 별도 인스턴스를 사용했다. Aurora에서는 ROW라는 조회값만 보고 binlog 활성화까지 단정하면 안 된다.

권한도 두 경로로 나눠 확인한다. 대상 스키마의 DDL/DML 및 테이블 잠금 권한과 전역 binlog 수신·복제 상태 조회 권한이 필요하다. 운영 계정에는 공식 버전별 요구 권한만 부여하고, 접속 정보는 권한을 제한한 설정 파일로 전달한다. --assume-rbr는 서버를 ROW로 바꾸는 옵션이 아니라 이미 준비된 조건을 전제로 불필요한 변경 시도를 피하는 옵션이다.

4. 실제 검증: 복사 중 중단과 변경 추종

검증 대상은 외래키와 트리거가 없는 InnoDB 테이블이다. 초기 데이터는 id=1..30000, amount=id*10, note는 100글자의 x로 구성했다. 다음 SQL로 같은 데이터를 준비할 수 있다. 폐기용 스키마에서만 실행하며, 이어지는 gh-ost 실험에 사용하도록 테이블은 남겨 둔다.

SET SESSION cte_max_recursion_depth = 30000;
CREATE TABLE osc_orders (
    id BIGINT NOT NULL PRIMARY KEY,
    amount INT NOT NULL,
    note VARCHAR(120) NOT NULL
) ENGINE=InnoDB;
INSERT INTO osc_orders (id, amount, note)
WITH RECURSIVE seq(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM seq WHERE n < 30000
)
SELECT n, n * 10, RPAD('x', 100, 'x') FROM seq;
SELECT COUNT(*) AS row_count, MIN(id) AS min_id, MAX(id) AS max_id
FROM osc_orders;
SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, NULLABLE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'osc_orders'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;

실행 결과(MySQL 8.0.x):

mysql> SET SESSION cte_max_recursion_depth = 30000;

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE osc_orders (
    ->     id BIGINT NOT NULL PRIMARY KEY,
    ->     amount INT NOT NULL,
    ->     note VARCHAR(120) NOT NULL
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO osc_orders (id, amount, note)
    -> WITH RECURSIVE seq(n) AS (
    ->     SELECT 1
    ->     UNION ALL
    ->     SELECT n + 1 FROM seq WHERE n < 30000
    -> )
    -> SELECT n, n * 10, RPAD('x', 100, 'x') FROM seq;

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

mysql> SELECT COUNT(*) AS row_count, MIN(id) AS min_id, MAX(id) AS max_id
    -> FROM osc_orders;

+-----------+--------+--------+
| row_count | min_id | max_id |
+-----------+--------+--------+
|     30000 |      1 |  30000 |
+-----------+--------+--------+
1 row in set (0.03 sec)

mysql> SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, NULLABLE
    -> FROM information_schema.STATISTICS
    -> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'osc_orders'
    -> ORDER BY INDEX_NAME, SEQ_IN_INDEX;

+------------+------------+-------------+----------+
| INDEX_NAME | NON_UNIQUE | COLUMN_NAME | NULLABLE |
+------------+------------+-------------+----------+
| PRIMARY    |          0 | id          |          |
+------------+------------+-------------+----------+
1 row in set (0.00 sec)

실행 모드와 제어 파일

다음은 외부 네트워크를 차단한 폐기용 컨테이너 내부에서 사용한 실행 명령이다. 이 실험의 root 연결은 격리된 테스트 전용이며, 운영 실행 예제로 가져가지 않는다. 운영에서는 별도 계정·인증 설정·TLS·실측 부하 임계값으로 바꿔야 한다. --execute는 실제 변경을 수행한다. 운영 후보 작업은 먼저 이를 제외한 noop 검사를 실행하되, noop 성공을 전체 복사나 cut-over 검증으로 간주하지 않는다.

touch /tmp/hold-cutover
gh-ost \
  --host=127.0.0.1 --port=3306 --user=root \
  --database=mysql_tech_note --table=osc_orders \
  --alter="ADD COLUMN migration_tag INT NOT NULL DEFAULT 7" \
  --allow-on-master --assume-rbr \
  --chunk-size=100 --dml-batch-size=10 --nice-ratio=30 \
  --max-load=Threads_running=50 \
  --critical-load=Threads_running=100 --max-lag-millis=3000 \
  --throttle-flag-file=/tmp/pause-copy \
  --postpone-cut-over-flag-file=/tmp/hold-cutover \
  --serve-socket-file=/tmp/ghost.sock \
  --verbose --execute

nice-ratio=30은 중간 상태를 관측하기 위해 복사 사이의 휴지를 길게 둔 실험 설정이다. 나머지 수치 역시 운영 권장값이나 성능 기준이 아니다. 컬럼 추가 자체는 이 환경에서 네이티브 INSTANT 후보지만, 여기서는 gh-ost의 전체 동작을 관찰하기 위한 변경으로 선택했다.

별도 세션에서 다음 순서로 검증했다.

  1. ghost 테이블에 일부 행이 복사된 것을 확인한 뒤 /tmp/pause-copy를 생성했다.
  2. throttle 상태를 확인하고 원본의 100행을 갱신하고 다른 100행을 삭제한 뒤, 초기 최댓값보다 큰 id=40001 행을 추가했다.
  3. throttle 중 ghost의 행 수가 유지되는지 확인하고 pause 파일을 제거했다.
  4. 복사가 끝나도 /tmp/hold-cutover는 유지하여 교체를 보류했다. 이 단계에서 원본·ghost의 모든 키와 공유 컬럼 값을 양방향으로 비교했다.
  5. hold 파일을 제거해 교체를 허용했다. 도구 종료 코드, 새 컬럼, 전체 행의 기대값을 검사했다.
  6. 전환 후 새 테이블만 다시 갱신하여 이전 원본이 더 이상 동기화되지 않는지 확인했다.

검증 결과는 다음과 같다.

확인 항목 실제 관측·검증 결과
사용 버전 MySQL 8.0.46 / gh-ost 1.1.11
초기 원본 행 수 30,000행
throttle 중 ghost 행 수 1,500행; 원본 변경 후에도 유지
작업 중 원본 트리거 수 0개
원본의 동시 변경 UPDATE 100행, DELETE 100행, INSERT 1행
재개 후 도구의 변경 적용 표시 Applied: 201
전환 직전 양방향 공유 컬럼 비교 차이 0행
전환 직후 전체 기대값 대조 29,901행 모두 일치
새 컬럼 전체 행의 migration_tag=7 확인
도구 종료 코드 0

전환 후 id=1의 amount를 새 테이블에서 1 증가시킨 뒤 두 테이블을 조회한 실제 결과다. 다른 행은 생략한 표가 아니라 이 키 한 건만 조회했다.

mysql> SELECT n.amount AS current_amount, o.amount AS old_amount
    -> FROM osc_orders n JOIN _osc_orders_del o USING(id)
    -> WHERE n.id=1;
+----------------+------------+
| current_amount | old_amount |
+----------------+------------+
|             16 |         15 |
+----------------+------------+
1 row in set (0.00 sec)

전체 행 비교는 행 수만 맞춘 검사가 아니다. 전환 전에는 누락·추가 키와 공유 컬럼 값 차이를 확인했고, 전환 후에는 정렬된 전체 행을 독립적으로 계산한 기대 데이터와 대조했다. 이번 시험은 정상적인 중단·재개와 전환을 검증한 것이며, 네트워크 단절, 프로세스 비정상 종료, 장기 MDL 대기, Aurora failover까지 재현한 것은 아니다.

5. throttle과 cut-over 보류는 서로 다르다

상태 복사·변경 적용 운영 의미
정상 진행 청크 복사와 binlog 변경 적용을 조정 부하·진행률·지연을 함께 관측
throttle ghost 행 복사와 변경 적용을 중단 업무 쓰기는 계속되므로 따라잡을 변경이 쌓임
cut-over 보류 복사는 끝났고 업무 변경 추종은 계속 담당자가 전환 시점을 선택하는 대기 상태
cut-over 원본 쓰기를 막고 마지막 이벤트를 반영한 뒤 교체 짧더라도 쓰기 지연이 발생할 수 있음

throttle 중에도 heartbeat 등 소량의 제어 쓰기는 남을 수 있다. 따라서 “도구가 어떤 SQL도 실행하지 않는다”는 뜻은 아니다. 반대로 cut-over 보류 파일은 일반적인 부하 차단 장치가 아니다. 변경 적용이 계속되므로 이를 throttle 대용으로 사용하지 않는다.

또한 오래 멈출수록 안전한 것은 아니다. 도구가 다시 읽어야 할 binlog가 삭제되면 변경 추종을 이어갈 수 없다. 보존 기간에는 실행 시간뿐 아니라 중단 가능 시간과 재추종 여유도 포함해야 한다. 큰 트랜잭션이나 높은 변경률 때문에 변경 처리 속도가 유입 속도를 계속 밑돌면 청크 크기를 줄이는 것만으로 해결되지 않는다.

상태 출력의 Backlog는 도구 내부 대기열이지, 아직 수신하지 않은 모든 binlog까지 포함한 업무 변경 총량이 아니다. Applied, streamer 위치, heartbeat 지연, 복제본 지연, 작업 상태를 함께 읽는다. 추정 행 수에 기반한 진행률과 ETA도 완료 약속으로 사용하지 않는다.

6. cut-over는 이름 교체만의 문제가 아니다

비동기 방식에서는 원본 쓰기를 멈춘 순간에도 아직 ghost에 적용하지 않은 이벤트가 남을 수 있다. 이 상태에서 원자적 RENAME TABLE만 실행하면 부족한 데이터를 가진 새 테이블로 업무를 넘기는 오류가 생긴다.

기본 cut-over 알고리즘은 연결과 잠금 순서를 조정해 원본 쓰기를 막고, 남은 이벤트와 제어 신호를 확인한 다음 원자적 이름 교체를 완료한다. 별도 연결의 교체 요청과 sentry 테이블을 이용해 준비 전에 교체가 진행되지 않도록 한다. 구체적인 잠금 구현을 수동 rename 몇 줄로 대체하지 않는 이유다.

원자적 교체는 무잠금·무지연과 동의어가 아니다. 장기 트랜잭션이 원본의 metadata lock을 보유하면 전환 대기와 뒤따르는 업무 요청 적체가 발생할 수 있다. 승인 직전에는 장기 트랜잭션, performance_schema.metadata_locks의 대기 관계, 연결 수와 서비스 지연을 확인한다. 이번 실험은 MDL 장애를 주입하지 않았으므로 전환 지연의 상한을 제시하지 않는다.

전환 후 남겨 둔 _del 테이블도 자동 롤백 장치가 아니다. 새 테이블로 들어간 쓰기는 이전 테이블에 반영되지 않는다. 따라서 단순 역방향 rename은 전환 이후 쓰기를 잃을 수 있다. 업무 쓰기를 시작하기 전 취소와 시작한 뒤 복구를 분리하고, 후자는 데이터 보정·역변환 또는 백업 복구 계획과 함께 설계해야 한다.

프로세스가 종료된 뒤 남은 ghost 테이블을 보고 무조건 이어서 실행할 수 있다고 생각해서도 안 된다. 살아 있는 작업의 pause/resume과 프로세스 재시작 후 복구는 다르다. 이전 작업의 상태와 원본 이름을 확인하기 전에 자동 삭제 옵션을 붙여 재실행하지 않는다.

7. Aurora MySQL에서는 접속 경로와 버전이 먼저다

Aurora reader는 Community MySQL의 일반 binlog 복제본과 동일하지 않다. AWS 문서는 binlog에 접근할 수 있는 인스턴스를 primary(writer)로 한정한다. 같은 클러스터의 reader endpoint를 “binlog를 읽는 복제본”으로 넣는 구성을 그대로 적용하지 않는다. writer 직접 모드와 별도 binlog 복제 토폴로지를 구분하고, failover로 writer 신원이 바뀌면 진행 중 작업을 어떻게 중단·재검증할지 계획한다.

binlog 활성화는 DB cluster parameter group과 재부팅 적용 절차를 확인해야 한다. binlog_format=ROW 조회값만으로는 충분하지 않다. AWS 문서는 파라미터 그룹에서 binlog를 OFF로 설정한 경우에도 데이터베이스의 형식 조회값이 ROW로 보일 수 있음을 설명한다. 실제 log_bin과 로그 수신 가능 여부를 함께 검증한다.

오래된 gh-ost RDS 문서의 aurora_enable_repl_bin_log_filtering=0 안내를 최신 Aurora에 그대로 적용해서는 안 된다. 현재 AWS 문서에 따르면 이 설정은 Aurora MySQL 3에서 제거되었고, binlog filtering은 버전 3과 8.4에서 항상 활성화되어 있다. 이는 reader로 전송되는 복제 메시지의 binlog 정보 최적화에 관한 설명이며, writer의 업무 binlog가 모두 없어졌다는 뜻도 아니다.

따라서 Aurora에서는 엔진 minor version, gh-ost 릴리스, writer 접근, ROW/FULL 설정, 계정 권한, 내부 상태 신호의 수신과 cut-over를 복구 가능한 별도 환경에서 검증한다. 이번 로컬 Community 실험을 Aurora의 실제 실행·지원 확인으로 확대하지 않는다.

8. 실행 승인과 완료 판정 체크리스트

gh-ost의 핵심은 트리거가 없다는 사실 하나가 아니다. 업무 쓰기와 마이그레이션 적용을 분리하고, 그 사이의 시간차를 마지막 전환 경계까지 관리하는 것이다. 다음 단계에서는 도구별 기능 목록보다 MDL, 변경 추종 지연, 데이터 정합성, 전환 후 복구라는 공통 기준으로 온라인 스키마 변경 방식을 비교해야 한다.

참고 자료