스키마 마이그레이션 배포 전략: 하위 호환 DDL과 애플리케이션 순서
MySQL 스키마 변경에서 구·신 애플리케이션의 공존, 이중 쓰기, 백필, 읽기 전환과 컬럼 제거 순서를 검증하고 롤백 경계를 정리한다.
컬럼 이름을 바꾸는 작업은 DDL 한 문장으로 끝날 수 있다. 그러나 운영 서비스에서는 이전 버전의 웹 서버, 배치, 메시지 소비자와 새 버전이 동시에 같은 테이블을 사용한다. 데이터베이스가 새 정의를 정상적으로 반영했더라도 구버전의 SQL이 실패하거나, 더 위험하게는 성공한 쓰기가 새 컬럼에 반영되지 않을 수 있다.
스키마 마이그레이션의 배포 단위는 DDL 파일 하나가 아니라 스키마·애플리케이션·데이터 상태의 조합이다. 이 글은 이메일 컬럼을 교체하는 작은 실험으로 확장 → 호환 코드 배포 → 데이터 이행 → 읽기·쓰기 전환 → 축소의 순서와 되돌릴 수 있는 경계를 설명한다. 기본 대상은 MySQL 8.0 이상이다. 실제 SQL 검증은 폐기용 Community MySQL에서 수행했으며, 다중 서버 배포·대용량 부하·Aurora 장애 전환을 재현한 결과는 아니다.
1. 하위 호환은 DDL의 속성이 아니라 사용 계약이다
하위 호환(backward compatibility)이란 새 스키마에서도 기존 애플리케이션의 읽기와 쓰기가 약속된 의미로 동작하는 것이다. 단순히 SQL 오류가 없다는 뜻보다 넓다.
| 변경 | 실행 가능성 외에 확인할 계약 |
|---|---|
| NULL 허용 컬럼 추가 | 기존 INSERT의 컬럼 목록 명시, SELECT 결과를 위치로 매핑하는 코드 여부 |
| 기본값 없는 NOT NULL 컬럼 추가 | 기존 INSERT가 새 값을 공급하는지, 암묵적 보정값을 업무상 허용하는지 |
| 컬럼 이름 변경·제거 | 웹 서버뿐 아니라 배치·뷰·프로시저·CDC 소비자에 이전 이름이 남아 있는지 |
| 길이·타입·문자셋 변경 | 값 범위, 정렬·비교 의미, 드라이버 타입 매핑, 인덱스 길이 |
| UNIQUE 제약 추가 | 기존 중복뿐 아니라 구버전의 중복 생성 경로도 차단했는지 |
NULL 허용 컬럼을 추가해도 INSERT INTO t VALUES (...)처럼 전체 컬럼 개수에 의존하는 쓰기는 깨질 수 있다. SELECT *의 결과 순서나 필드 수를 고정한 역직렬화 코드도 마찬가지다. “추가만 했으므로 안전하다”는 판단 전에 실제 SQL 계약을 확인해야 한다.
또한 온라인 DDL은 가용성 기법이고, 하위 호환 배포는 의미 보존 기법이다. ALGORITHM=INSTANT로 처리되는 변경도 구버전 SQL을 깨뜨릴 수 있고, 완전히 호환되는 컬럼 추가도 metadata lock(MDL) 대기 때문에 연결을 적체시킬 수 있다.
2. 확장과 축소 사이에 명시적인 통과 조건을 둔다
예제의 기존 컬럼은 email, 새 컬럼은 contact_email이다. 값의 의미와 자료형은 동일하며, 실제 주소 변환·정규화 규칙은 추가하지 않는다.
| 코드 버전 | 읽기 | 쓰기 | 허용 조건 |
|---|---|---|---|
| V0 기존 코드 | email만 변경 | 확장 후에도 동작하지만 새 컬럼을 오래된 상태로 만들 수 있음 | |
| V1 호환 코드 | 초기에는 email, 검증 후 contact_email 우선 | 두 컬럼을 같은 트랜잭션에서 변경 | 두 컬럼이 모두 존재해야 함 |
| V2 새 코드 | contact_email | contact_email만 변경 | 새 컬럼 검증 완료, 기존 컬럼의 필수 입력 제약 해제 |
V1의 읽기 기능 플래그는 처음에는 기존 컬럼을 향하게 둔다. V0 쓰기 경로가 모두 사라진 뒤 최종 보정과 검증을 완료해야 새 컬럼 우선 읽기를 켠다. 이후 V1과 V2가 공존하는 동안에는 새 컬럼이 기준 데이터이며, 기존 컬럼에서 새 컬럼으로 복사하는 백필을 다시 실행하면 안 된다.
flowchart TD
A["기존 스키마와 V0"] --> B["확장: 새 NULL 허용 컬럼 추가"]
B --> C["V1 배포: 기존 값 읽기와 이중 쓰기"]
C --> D["구버전 쓰기 종료 확인과 최종 백필"]
D --> E{"누락·불일치가 없고 구버전 재진입도 차단했는가"}
E -->|아니요| C
E -->|예| F["V1 읽기를 새 컬럼 우선으로 전환"]
F --> G["제약 조정 후 V2 배포: 새 컬럼만 사용"]
G --> H["V1 종료와 롤백 보존 기간 확인"]
H --> I["축소: 기존 컬럼 제거"]
구버전 종료의 증거는 배포 도구의 완료 표시만으로 충분하지 않다. 연결을 유지한 오래된 프로세스, 재시작 대기 중인 작업, 장시간 트랜잭션, 예약 배치, 재시도 큐와 수동 운영 스크립트까지 확인한다. 오래된 이미지를 자동으로 다시 띄우는 설정도 차단해야 한다.
3. 확장 단계: 기존 쓰기를 깨지 않고 새 자리를 만든다
다음 SQL은 빈 실험용 스키마에서 위에서 아래 순서로 실행한다. 같은 테이블을 다음 절에서 계속 사용한다. 실제 운영 테이블에 그대로 적용하는 배포 스크립트가 아니다. 주소는 설명용 example.test 도메인을 사용한다. 실행 환경은 MySQL 8.0.46이며, 아래 결과는 반복되는 SQL 명령문을 ...로 줄이고 세션 설정·트랜잭션 시작/커밋 출력만 생략한 발췌다. 결과 표의 행과 열은 생략하지 않았다.
CREATE TABLE migration_customer (
id BIGINT NOT NULL PRIMARY KEY,
email VARCHAR(120) NOT NULL
) ENGINE=InnoDB;
INSERT INTO migration_customer (id, email) VALUES
(1, 'a@example.test'),
(2, 'b@example.test'),
(3, 'c@example.test');
SET SESSION lock_wait_timeout = 5;
ALTER TABLE migration_customer
ADD COLUMN contact_email VARCHAR(120) NULL,
ALGORITHM=INSTANT;
INSERT INTO migration_customer (id, email)
VALUES (4, 'd@example.test');
SELECT id, email, contact_email
FROM migration_customer ORDER BY id;
실행 결과(MySQL 8.0.x):
mysql> CREATE TABLE migration_customer ...;
Query OK, 0 rows affected (0.01 sec)
mysql> INSERT INTO migration_customer ...;
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0
mysql> ALTER TABLE migration_customer ...;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> INSERT INTO migration_customer (id, email) VALUES (4, 'd@example.test');
Query OK, 1 row affected (0.00 sec)
mysql> SELECT id, email, contact_email FROM migration_customer ORDER BY id;
+----+----------------+---------------+
| id | email | contact_email |
+----+----------------+---------------+
| 1 | a@example.test | NULL |
| 2 | b@example.test | NULL |
| 3 | c@example.test | NULL |
| 4 | d@example.test | NULL |
+----+----------------+---------------+
4 rows in set (0.00 sec)
기존 INSERT가 컬럼 목록을 명시하므로 새 컬럼을 몰라도 정상적으로 들어간다. 반면 새 컬럼은 기존 행과 신규 V0 행 모두 NULL이다. DDL 성공이 데이터 이행 완료를 의미하지 않는다는 첫 번째 경계다.
ALGORITHM=INSTANT는 허용되지 않는 상황에서 더 비싼 알고리즘으로 조용히 전환하지 않도록 명시했다. MySQL 8.0의 세부 버전과 테이블 조건에 따라 지원 범위가 다르다. 이 예제처럼 끝에 단순 컬럼을 추가하는 경우에도 실제 서버에서 사전 시험해야 한다. lock_wait_timeout은 이 세션의 MDL 대기 한도를 줄이는 설정이지 전체 ALTER 실행 시간 제한이나 행 잠금 대기 제한이 아니다. SQL fence를 별도 연결로 실행한다면 세션 설정은 다음 연결에 유지되지 않는다.
4. 백필의 함정: NULL이 없다고 최신 데이터인 것은 아니다
운영에서는 V1의 이중 쓰기를 먼저 배포하고 백필을 진행할 수 있다. 그러나 V0가 남아 있는 공존 구간의 백필은 최종 동기화를 보장하지 않는다. 다음은 그 순서를 단일 연결에서 명시적으로 재현한다.
UPDATE migration_customer
SET contact_email = email
WHERE id > 0 AND id <= 4 AND contact_email IS NULL;
UPDATE migration_customer
SET email = 'b-old-writer@example.test'
WHERE id = 2;
SELECT COUNT(*) AS total_rows,
SUM(contact_email IS NULL) AS missing_rows,
SUM(NOT (contact_email <=> email)) AS mismatch_rows
FROM migration_customer;
SELECT id, email, contact_email,
COALESCE(contact_email, email) AS fallback_read
FROM migration_customer WHERE id = 2;
실행 결과(MySQL 8.0.x):
mysql> UPDATE migration_customer ...;
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4 Changed: 4 Warnings: 0
mysql> UPDATE migration_customer SET email = 'b-old-writer@example.test' WHERE id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT COUNT(*) ...;
+------------+--------------+---------------+
| total_rows | missing_rows | mismatch_rows |
+------------+--------------+---------------+
| 4 | 0 | 1 |
+------------+--------------+---------------+
1 row in set (0.00 sec)
mysql> SELECT id, ...;
+----+---------------------------+----------------+----------------+
| id | email | contact_email | fallback_read |
+----+---------------------------+----------------+----------------+
| 2 | b-old-writer@example.test | b@example.test | b@example.test |
+----+---------------------------+----------------+----------------+
1 row in set (0.00 sec)
첫 UPDATE는 백필이고 두 번째 UPDATE는 남아 있는 V0의 쓰기다. 검증 결과에서 NULL은 없어도 두 컬럼의 불일치는 남는다. COALESCE(contact_email, email)도 새 컬럼이 NULL이 아니면 기존 컬럼의 최신 값을 선택하지 않는다. 따라서 fallback 읽기만으로 구버전의 미러링되지 않은 쓰기를 해결할 수 없다.
대용량 백필은 기본키 범위를 나눠 짧은 트랜잭션으로 실행한다. 완료한 상한 키, 오류와 재시도 횟수를 기록하고 부하·redo·복제 지연을 보며 속도를 조절한다. 시작 시점의 최대 키만 처리했다면 그 이후 INSERT는 누가 새 컬럼을 채우는지 별도로 보장해야 한다. NULL만 갱신하는 방식은 재실행 비용을 줄이지만, 이미 채워진 값의 노후화는 탐지하지 못한다.
원본 값을 애플리케이션으로 읽었다가 한참 후 새 컬럼에 쓰는 백필은 중간 변경을 덮어쓸 수 있다. 이 예제의 단일 UPDATE는 잠근 행의 값을 같은 문장에서 복사한다. 외부 변환이 필요하면 변경 버전 비교, 조건부 UPDATE, 재시도 등 별도의 경쟁 제어가 필요하다. 두 컬럼을 서로 다른 요청이나 커밋으로 쓰는 것도 이중 쓰기의 원자성을 보장하지 못한다.
5. 기준 데이터 전환: 구버전을 멈춘 뒤 최종 보정한다
다음 단계의 전제는 V0 쓰기 종료다. 실험에서는 앞 절의 V0 UPDATE 이후 더 이상 구버전 쓰기를 실행하지 않는 방식으로 이 순서를 모델링한다. 실제 배포 시스템에서 구버전 접근을 차단하는 장치를 시험한 것은 아니다.
UPDATE migration_customer
SET contact_email = email
WHERE NOT (contact_email <=> email);
START TRANSACTION;
UPDATE migration_customer
SET email = 'a-v1@example.test',
contact_email = 'a-v1@example.test'
WHERE id = 1;
INSERT INTO migration_customer (id, email, contact_email)
VALUES (5, 'e@example.test', 'e@example.test');
COMMIT;
SELECT id, email, contact_email
FROM migration_customer ORDER BY id;
SELECT SUM(contact_email IS NULL) AS missing_rows,
SUM(NOT (contact_email <=> email)) AS mismatch_rows
FROM migration_customer;
실행 결과(MySQL 8.0.x):
mysql> UPDATE migration_customer SET contact_email = email WHERE NOT (contact_email <=> email);
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE migration_customer ...;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> INSERT INTO migration_customer ...;
Query OK, 1 row affected (0.00 sec)
mysql> SELECT id, email, contact_email FROM migration_customer ORDER BY id;
+----+---------------------------+---------------------------+
| id | email | contact_email |
+----+---------------------------+---------------------------+
| 1 | a-v1@example.test | a-v1@example.test |
| 2 | b-old-writer@example.test | b-old-writer@example.test |
| 3 | c@example.test | c@example.test |
| 4 | d@example.test | d@example.test |
| 5 | e@example.test | e@example.test |
+----+---------------------------+---------------------------+
5 rows in set (0.00 sec)
mysql> SELECT SUM(contact_email ...;
+--------------+---------------+
| missing_rows | mismatch_rows |
+--------------+---------------+
| 0 | 0 |
+--------------+---------------+
1 row in set (0.00 sec)
NULL-safe 비교 연산자 <=>를 사용해 NULL과 비NULL의 차이까지 검출한다. 이 단계까지는 email이 기준 데이터이므로 기존 값으로 보정한다. 단, 일반 문자열 비교는 컬럼의 collation을 따른다. 바이트 단위 보존이 계약이라면 이 비교만으로 대소문자·문자 표현 차이까지 검증했다고 주장해서는 안 된다.
구버전 종료, 보정 완료, 업무 의미 검증을 확인한 다음 V1의 읽기를 새 컬럼 우선으로 전환한다. 일부 트래픽에서 반환값을 비교하되 이메일 같은 실제 개인정보를 로그에 그대로 남기지 않는다. NULL·불일치 건수 0이라는 관측 하나만으로 승인하지 말고, 그 상태를 깨뜨릴 writer가 다시 들어오지 못하는지도 확인한다.
예제의 두 값은 동일한 의미이므로 직접 비교한다. 실제 타입 변경이나 분할·합성 변환에서는 허용 범위, 반올림, 시간대, 중복, NULL 의미에 대한 별도 검증식을 정의해야 한다.
6. 새 코드 배포 전에 기존 제약도 확인한다
새 코드가 contact_email만 쓰도록 바뀌어도 기존 email NOT NULL이 남아 있으면 INSERT가 실패할 수 있다. 별도 부정 테스트에서 엄격 모드로 새 컬럼만 지정한 INSERT는 제약 완화 전에 오류 1364로 실패했고, 아래 제약 전환 후에는 반대로 기존 컬럼만 지정한 INSERT가 오류 1364로 실패했다. 컬럼 제거를 뒤로 미루는 것만으로는 충분하지 않다. 여기서는 기존 컬럼을 NULL 허용으로 완화하고, 검증된 새 컬럼은 NOT NULL로 강화한 다음 V2의 쓰기를 실행한다.
SET SESSION lock_wait_timeout = 5;
SET SESSION sql_mode = 'TRADITIONAL';
ALTER TABLE migration_customer
MODIFY COLUMN email VARCHAR(120) NULL,
MODIFY COLUMN contact_email VARCHAR(120) NOT NULL,
ALGORITHM=INPLACE, LOCK=NONE;
INSERT INTO migration_customer (id, contact_email)
VALUES (6, 'f@example.test');
UPDATE migration_customer
SET contact_email = 'a-v2@example.test'
WHERE id = 1;
SELECT id, email, contact_email
FROM migration_customer WHERE id IN (1, 6) ORDER BY id;
SELECT COLUMN_NAME, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'migration_customer'
AND COLUMN_NAME IN ('email', 'contact_email')
ORDER BY ORDINAL_POSITION;
실행 결과(MySQL 8.0.x):
mysql> ALTER TABLE migration_customer ...;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> INSERT INTO migration_customer (id, contact_email) VALUES (6, 'f@example.test');
Query OK, 1 row affected (0.00 sec)
mysql> UPDATE migration_customer SET contact_email = 'a-v2@example.test' WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT id, email, contact_email FROM migration_customer WHERE id IN (1, 6) ORDER BY id;
+----+-------------------+-------------------+
| id | email | contact_email |
+----+-------------------+-------------------+
| 1 | a-v1@example.test | a-v2@example.test |
| 6 | NULL | f@example.test |
+----+-------------------+-------------------+
2 rows in set (0.00 sec)
mysql> SELECT COLUMN_NAME, ...;
+---------------+-------------+
| COLUMN_NAME | IS_NULLABLE |
+---------------+-------------+
| email | YES |
| contact_email | NO |
+---------------+-------------+
2 rows in set (0.00 sec)
NULL 허용 여부 변경은 이 예제에서 INPLACE로 실행하지만 테이블을 재구축한다. LOCK=NONE도 전체 과정에서 MDL이 전혀 없다는 뜻이 아니다. 작은 실험의 성공·소요 시간을 대형 운영 테이블의 비용으로 환산해서는 안 된다. NOT NULL 전환은 잔존 NULL뿐 아니라 작업 도중 들어오는 쓰기의 계약도 만족해야 한다.
MODIFY COLUMN은 유지할 타입·길이·기본값·주석 등의 속성을 정확히 다시 지정해야 한다. 예제의 단순한 정의를 복잡한 운영 컬럼에 복사하면 의도하지 않은 속성 손실이 생길 수 있다. 세션 sql_mode 지정 역시 실험 조건을 고정하기 위한 것이며 운영 표준 모드를 덮어쓰라는 권고가 아니다.
V2 쓰기 이후 기존 컬럼은 NULL이거나 과거 값일 수 있다. 이 시점에 “불일치가 생겼다”며 email에서 contact_email로 백필을 재실행하면 최신 데이터를 손상시킨다. 반대로 현재 값을 기존 컬럼으로 다시 동기화하려면 역변환 가능성과 충돌 정책을 검증해야 한다.
V1로 애플리케이션을 되돌릴 수 있는 기간에도 새 컬럼 우선 읽기 플래그는 유지해야 한다. 기존 값만 읽는 V0로 즉시 되돌릴 수 있는 것은 아니다.
7. 축소는 별도 배포이며 롤백 경계를 넘는다
기존 컬럼은 V2 전환 직후 제거하지 않는다. V1의 이중 쓰기 경로, 예전 작업 재시도, 보고서와 외부 연동이 모두 사라지고 정해진 관찰·보존 기간을 지난 뒤 별도 변경으로 제거한다.
SET SESSION lock_wait_timeout = 5;
ALTER TABLE migration_customer
DROP COLUMN email,
ALGORITHM=INPLACE, LOCK=NONE;
SELECT id, contact_email
FROM migration_customer ORDER BY id;
SELECT COUNT(*) AS legacy_column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'migration_customer'
AND COLUMN_NAME = 'email';
DROP TABLE migration_customer;
실행 결과(MySQL 8.0.x):
mysql> ALTER TABLE migration_customer DROP COLUMN email, ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> SELECT id, contact_email FROM migration_customer ORDER BY id;
+----+---------------------------+
| id | contact_email |
+----+---------------------------+
| 1 | a-v2@example.test |
| 2 | b-old-writer@example.test |
| 3 | c@example.test |
| 4 | d@example.test |
| 5 | e@example.test |
| 6 | f@example.test |
+----+---------------------------+
6 rows in set (0.00 sec)
mysql> SELECT COUNT(*) ...;
+---------------------+
| legacy_column_count |
+---------------------+
| 0 |
+---------------------+
1 row in set (0.00 sec)
mysql> DROP TABLE migration_customer;
Query OK, 0 rows affected (0.00 sec)
끝의 DROP TABLE은 실험 정리용이다. 여기서는 MySQL 8.0의 더 넓은 세부 버전 범위를 고려해 컬럼 제거에 INPLACE를 명시했다. 지원되는 버전·테이블에서 INSTANT 제거를 선택할 수 있지만, 실제 알고리즘 선택은 재구축 비용과 instant DDL 제약을 함께 확인해서 결정한다.
컬럼 제거 후에는 V1의 이중 쓰기 SQL도 더 이상 유효하지 않다. 따라서 이 단계 이후의 애플리케이션 롤백 후보는 새 스키마를 이해하는 버전이어야 한다. 빈 컬럼을 다시 추가하는 것은 값·제약·주변 의존성을 복구하는 것과 다르다.
| 중단 시점 | 우선 대응 | 피해야 할 대응 |
|---|---|---|
| 확장만 완료 | 새 컬럼을 둔 채 이전 코드 유지 | 즉시 역방향 DDL로 불필요한 MDL 유발 |
| 백필·V1 공존 중 | 기존 컬럼 읽기 유지, 백필 중단과 원인 조사 | 불일치 상태에서 새 읽기 활성화 |
| V2 쓰기 시작 후 | 새 컬럼을 기준으로 유지, 호환 V1 롤백 검토 | 과거 컬럼으로 무조건 덮어쓰기 |
| 기존 컬럼 제거 후 | 수정 배포 또는 검증된 데이터 복구 절차 | 구버전 바이너리만 다시 배포 |
8. Atomic DDL은 배포 전체의 ROLLBACK이 아니다
MySQL의 atomic DDL은 지원되는 DDL의 데이터 사전·스토리지 엔진·binlog 변경이 장애 시 일관되게 완료되거나 취소되도록 하는 성질이다. 여러 DDL과 업무 DML을 사용자 트랜잭션 하나로 감싼다는 뜻이 아니다. 일반적인 ALTER TABLE은 암묵적 커밋 경계를 만든다.
CREATE TABLE migration_commit_demo (
id INT PRIMARY KEY,
value_n INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO migration_commit_demo VALUES (1, 10);
START TRANSACTION;
UPDATE migration_commit_demo SET value_n = 20 WHERE id = 1;
ALTER TABLE migration_commit_demo
ADD COLUMN extra_n INT NULL, ALGORITHM=INSTANT;
ROLLBACK;
SELECT id, value_n, extra_n FROM migration_commit_demo;
DROP TABLE migration_commit_demo;
실행 결과(MySQL 8.0.x):
mysql> CREATE TABLE migration_commit_demo ...;
Query OK, 0 rows affected (0.01 sec)
mysql> INSERT INTO migration_commit_demo VALUES (1, 10);
Query OK, 1 row affected (0.00 sec)
mysql> UPDATE migration_commit_demo SET value_n = 20 WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> ALTER TABLE migration_commit_demo ADD COLUMN extra_n INT NULL, ALGORITHM=INSTANT;
Query OK, 0 rows affected (0.00 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT id, value_n, extra_n FROM migration_commit_demo;
+----+---------+---------+
| id | value_n | extra_n |
+----+---------+---------+
| 1 | 20 | NULL |
+----+---------+---------+
1 row in set (0.00 sec)
mysql> DROP TABLE migration_commit_demo;
Query OK, 0 rows affected (0.00 sec)
ROLLBACK 뒤에도 변경된 값과 추가 컬럼이 남는지 확인한다. 이 실험은 암묵적 커밋을 검증하며, 서버 비정상 종료 시 atomic DDL의 crash recovery를 시험한 것은 아니다.
배포 관리 테이블에 완료 상태를 기록하더라도 DDL과 그 기록을 사용자 트랜잭션 하나로 묶었다고 생각하면 안 된다. DDL 성공 직후 프로세스가 종료되면 이력 기록이 누락될 수 있다. 재시도는 “이력 행이 없다”만 보고 같은 DDL을 다시 실행하지 말고 실제 컬럼 정의와 인덱스 상태를 함께 대조해야 한다. 여러 애플리케이션 인스턴스가 시작할 때 각자 마이그레이션을 실행하기보다 실행 주체를 하나로 제한하고 실패 시 재조정 절차를 마련한다.
9. Aurora와 복제 환경에서는 전환 범위를 넓혀 본다
Aurora MySQL에서도 구·신 코드의 컬럼 계약과 기준 데이터 전환 문제는 사라지지 않는다. AWS 문서는 Aurora MySQL 버전 3과 8.4의 instant DDL, 버전 2의 Fast DDL을 구분한다. 버전 2 전용 설명을 버전 3에 그대로 적용하거나 모든 ALTER가 instant로 끝난다고 가정해서는 안 된다.
DDL과 데이터 이행은 writer에서 통제하고, reader 연결을 사용하는 코드의 적용 시점과 가시성을 별도로 확인한다. Community binlog replica, Aurora reader, 리전 간 복제를 같은 적용·복구 모델로 취급하지 않는다. 읽기 경로에서 실제 새 컬럼 조회와 업무 값 확인을 완료한 뒤 기능을 켜는 것이 단순한 writer의 DDL 성공보다 강한 조건이다. 장애 전환 뒤에도 마이그레이션 이력과 실제 스키마를 재대조한다.
이 글의 실험에서는 binlog 복제, Aurora reader 전파, 장애 전환과 실제 애플리케이션 드라이버 동작을 시험하지 않았다. 관리형 스냅샷이나 PITR이 있더라도 즉시 애플리케이션 롤백을 제공하는 것은 아니다. 별도 복원 환경의 생성, endpoint 전환, 복원 시점 이후 쓰기의 처리까지 포함한 복구 계획이 필요하다.
10. 배포 승인 체크리스트
마무리
안전한 스키마 변경은 “DDL을 먼저 할까, 애플리케이션을 먼저 할까”라는 한 번의 순서 선택이 아니다. 추가는 구버전이 견딜 수 있게 먼저 하고, 새 동작은 데이터가 준비된 뒤 켜며, 제거는 이전 의존성이 사라진 뒤 실행한다. 특히 백필 완료, 기준 데이터 전환, 롤백 가능 기간을 서로 다른 상태로 관리해야 한다.
앞선 온라인 스키마 변경 도구의 선택이 복사·동기화·전환 비용을 다뤘다면, 이번 배포 전략은 그 도구 위에서 서비스 의미를 보존하는 절차다. 이후 제약 조건과 데이터 타입 변경에서도 같은 호환성 행렬과 통과 조건을 확장해 적용할 수 있다.