- Published on
Zero-Downtime 데이터베이스 마이그레이션 완전 해부 — Expand-Contract, gh-ost, pt-osc, CONCURRENTLY, Logical Replication
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — "토요일 새벽 3시"라는 유산
2015년 즈음까지 DBA의 삶:
"다음 주 토요일 새벽 3시, 30분간 서비스 점검이 있습니다."
점검의 실체는 대부분 ALTER TABLE. 10억 행 테이블에 컬럼 추가 한 번에 테이블 락 → 전 사용자 대기 → 비즈니스 손실.
2025년 현재 넷플릭스, 깃허브, 우버 같은 회사들은 다운타임 0로 같은 작업을 한다. 기법은 하나하나 단순하지만 조합과 원칙이 있다. 이 글에서는:
- Expand-Contract 패턴 — 스키마 변경의 정석
- Online DDL: gh-ost, pt-online-schema-change 원리
- PostgreSQL의 CONCURRENTLY와 락 단계
- Logical Replication으로 메이저 버전 업그레이드
- Dual-Write 함정과 CDC 기반 이주
- NoSQL 이주 (MongoDB, DynamoDB)
- Foreign Key, NOT NULL, 인덱스 추가의 안전한 순서
- 수십억 행 테이블 실전 사례
- 체크리스트와 롤백 전략
이전 글 Feature Flag와 Progressive Delivery에서 "코드 롤아웃"을 점진적으로 한다고 했다. 이 글은 "스키마 롤아웃"을 점진적으로 하는 이야기다.
1. 왜 스키마 변경이 어려운가
DB의 본질적 제약
- 락(Lock) — 테이블 구조를 바꾸려면 일반적으로 다른 트랜잭션을 막아야
- 데이터 이동 — 컬럼 타입 변경, 인덱스 추가는 실제 데이터 재작성
- 포맷 호환성 — 앱 코드가 기대하는 스키마와 DB 스키마가 일치해야
- 단일 진실 소스 — 스키마가 바뀌면 롤백이 데이터 손실
MySQL vs PostgreSQL vs 현대
- MySQL 5.6 이전: 거의 모든 DDL이 테이블 락
- MySQL 5.6+ Online DDL: 일부 가능, 하지만 큰 테이블에선 여전히 부담
- PostgreSQL: 9.x부터 다수 DDL이 짧은 락만 (여전히 주의 필요)
- CockroachDB/TiDB/YugaByte: Online DDL이 기본 설계
문제의 정의
"Zero-downtime DDL"이란:
- 서비스 무정지 (요청 실패 없음)
- 지연 허용 가능한 수준 (P99 몇 ms 상승은 OK)
- 롤백 가능 (중간에 멈출 수 있어야)
- 진척 관측 가능
2. Expand-Contract 패턴 — 스키마 변경의 교본
모든 zero-downtime 스키마 변경은 6단계 패턴을 따른다.
6단계
1. Expand — 새 스키마 추가 (옛 것 유지)
2. Migrate — 데이터 이관 또는 이중 쓰기
3. Dual-read — 앱이 양쪽 읽되 우선순위로
4. Switchover — 앱이 새 스키마만 씀
5. Wait — 모든 읽기가 새 쪽으로 안정
6. Contract — 옛 스키마 제거
예: 컬럼 이름 변경 username → login_name
단순 시도 (망함): ALTER TABLE users RENAME COLUMN username TO login_name → 순간 앱이 username 참조하는 코드가 깨짐.
Expand-Contract:
- Expand:
ALTER TABLE users ADD COLUMN login_name VARCHAR(...)(nullable) - Migrate:
UPDATE users SET login_name = username WHERE login_name IS NULL(배치) - Dual-write: 앱이 양쪽 컬럼 모두 저장
- Dual-read: 앱이
login_name우선, 없으면username - Switchover: 앱이
username쓰기 중단,login_name만 - Contract:
ALTER TABLE users DROP COLUMN username(몇 주 뒤)
각 단계는 며칠 ~ 몇 주 간격. 서두르면 롤백 불가.
핵심 원칙
- 코드 배포와 스키마 변경은 항상 호환
- 모든 단계가 롤백 가능
- Expand는 빨리, Contract는 천천히
3. MySQL Online DDL — 네이티브 한계
INSTANT Algorithm (5.6+)
특정 변경은 메타데이터만 변경, 데이터 복사 X:
- 컬럼 추가 (마지막 위치)
- 컬럼 기본값 변경
- 파티션 변경 일부
ALTER TABLE users ADD COLUMN last_login TIMESTAMP NULL, ALGORITHM=INSTANT;
주의: 컬럼 추가가 마지막 위치에만 INSTANT. 중간 삽입은 풀 복사.
INPLACE Algorithm
온라인이지만 데이터 재작성:
ALTER TABLE users ADD INDEX idx_email(email), ALGORITHM=INPLACE, LOCK=NONE;
10억 행 테이블에서 몇 시간 소요. 락은 없지만 부하는 상당.
COPY Algorithm
풀 복사 + 락. 옛 방식. 회피 권장.
한계
Online DDL도 대규모 운영에선 한계:
- 바이너리 로그 증가량 폭증
- 리플리카 지연
- 크래시 시 복구 복잡
→ 외부 도구 사용이 표준.
4. gh-ost — GitHub의 Online Schema Change
왜 만들었나
GitHub이 MySQL 환경에서 pt-osc(Percona Toolkit)의 한계를 겪음:
- 트리거 기반이라 메인 테이블 부하 추가
- 외래 키와 호환 안 됨
- 리플리카 지연 모니터링 약함
gh-ost의 핵심 아이디어
바이너리 로그(binlog)를 구독해서 변경 반영. 트리거 없음.
[원본 테이블] [그림자 테이블]
↓ ↑
(binlog 이벤트) (배치 복사)
↓ ↑
↓───────► [gh-ost 프로세스] ─────────┘
│
▼
(cut-over)
플로우
- gh-ost가
_users_gho라는 그림자 테이블 생성 (새 스키마) - 원본 테이블의 row를 배치로 복사 (속도 제어 가능)
- 동시에 binlog를 읽어 실시간 변경을 그림자에 반영
- 모든 데이터 동기화 완료 시 cut-over:
- 원본을
_users_old로 rename - 그림자를
users로 rename - 원자적 (두 rename이 한 트랜잭션)
- 원본을
- 작업 완료.
_users_old는 나중에 제거.
장점
- 트리거 없음 → 성능 예측 가능
- 리플리카에서 읽기 → 원본 부하 최소
- 일시 중지 가능
- 자동 조절 (리플리카 지연 감지 시 속도 줄임)
- throttle 파일 기반 수동 제어
한계
- 외래 키 있는 테이블은 조심 (옵션으로 가능)
- MySQL에만 (PostgreSQL 비지원)
- 바이너리 로그 포맷 ROW 필요
5. pt-online-schema-change — Percona의 고전
기본 원리
gh-ost와 비슷하지만 트리거 기반.
- 그림자 테이블 생성
- 원본에 트리거 3개 설치 (INSERT/UPDATE/DELETE)
- 트리거가 그림자에도 동일 변경 적용
- 배치로 기존 row를 복사
- cut-over로 rename
- 트리거 제거
장점 / 단점
장점:
- 성숙 (2010년대 초부터), 수많은 실전
- MariaDB 등 다양한 MySQL 포크 지원
단점:
- 트리거가 원본 부하 추가 (쓰기 2배)
- 일시 중지가 까다로움
- 외래 키 처리 까다로움
선택 기준
- 안정성 검증 최대 → pt-osc
- 성능 영향 최소 → gh-ost
- MariaDB → pt-osc (gh-ost 호환 한정)
6. PostgreSQL의 마이그레이션 기법
CONCURRENTLY — 인덱스의 친구
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
- 테이블 쓰기 허용하며 인덱스 구축
- 느림 (2~3배)
- 중단되면
INVALID인덱스로 남음 → 재시도
주의: CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서. ORM 마이그레이션이 트랜잭션을 기본 감싸면 실패.
락 레벨 이해
PostgreSQL DDL별 락 강도:
ACCESS EXCLUSIVE— 최강, 모든 읽기/쓰기 차단 (예:ALTER TABLE RENAME)SHARE ROW EXCLUSIVE— 쓰기 차단 허용, 옛 데이터 복사 (CREATE INDEX)SHARE UPDATE EXCLUSIVE— 가볍 (CREATE INDEX CONCURRENTLY)ACCESS SHARE— 읽기 수준
락을 이해해야 "이 DDL이 운영에 안전한가"를 판단 가능.
NOT NULL 추가의 함정
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
→ 이것만으로 전 테이블 스캔 + ACCESS EXCLUSIVE 락.
안전 패턴 (PostgreSQL 12+):
-- 1. 체크 제약 NOT VALID로 추가 (빠름)
ALTER TABLE users ADD CONSTRAINT users_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
-- 2. 검증 (긴 락 없이 스캔)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- 3. NOT NULL 정식 설정 (12+에선 이미 검증된 제약 재활용)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- 4. 체크 제약 제거
ALTER TABLE users DROP CONSTRAINT users_email_not_null;
외래 키 추가
-- 1. 제약을 NOT VALID로 추가 (빠름, 기존 데이터 무시)
ALTER TABLE posts ADD CONSTRAINT posts_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
-- 2. 기존 row 검증 (락 약함)
ALTER TABLE posts VALIDATE CONSTRAINT posts_user_fk;
타입 변경
-- 나쁜: INTEGER → BIGINT, 전체 재작성, 강한 락
ALTER TABLE users ALTER COLUMN id TYPE BIGINT;
-- 좋은: Expand-Contract
-- 1. 새 컬럼 추가
ALTER TABLE users ADD COLUMN id_new BIGINT;
-- 2. 트리거로 dual-write
-- 3. 배치 복사
-- 4. 스왑
7. Logical Replication으로 메이저 업그레이드
전통 pg_upgrade 한계
pg_upgrade는 빠르지만 짧은 다운타임 필요. 큰 DB는 확장/테스트 부담.
Logical Replication 방식
- 새 버전 DB 준비 (예: 11 → 15)
- 옛 버전에서 logical publication 생성
- 새 버전에서 subscription 생성, 초기 복사
- WAL 변경을 실시간으로 새 버전에 스트리밍
- 애플리케이션을 새 버전으로 전환
- 옛 버전 폐기
다운타임: 전환 순간 수 초(DNS/연결 풀 갱신).
함정
- Logical Replication은 DDL 미복제 → 스키마는 양쪽 수동 동기화
- Sequence 값 → 수동 이관
- Large objects → 수동 이관
- 복제 충돌 해결 필요
도구
- pglogical — 3rd party, 양방향 복제 가능
- pg_dump + pg_restore — 여전히 정석 (작은 DB)
- AWS DMS — AWS 환경에서 편리
8. Dual-Write의 함정과 CDC
순진한 Dual-Write
앱이 A DB와 B DB에 동시에 쓴다:
def save(data):
db_a.insert(data)
db_b.insert(data)
문제:
- A 성공, B 실패 → 불일치
- 트랜잭션 X (서로 다른 DB)
- 순서 꼬임
CDC 기반 이주
CDC (Change Data Capture): 소스 DB의 변경 스트림을 구독해 대상에 반영.
도구:
- Debezium — Kafka Connect 기반. MySQL/Postgres/Mongo 등 지원
- Maxwell's Daemon — MySQL
- AWS DMS — 관리형
- Flink CDC — 실시간 스트림
플로우:
[소스 DB] ──► (binlog/WAL) ──► [Debezium] ──► [Kafka] ──► [대상 DB]
이점:
- 트랜잭션 일관성 유지 (소스의 commit 순서 보존)
- 실시간 대상 동기화
- 여러 대상에 fan-out 가능
9. NoSQL 이주
MongoDB — 버전 업 또는 샤드 재구성
- Rolling upgrade — Replica Set 멤버를 하나씩 업그레이드
- Live migration with mongomirror — 클러스터 간 데이터 이전
- Atlas Live Migration — 관리형
DynamoDB — 스키마 변경 자체가 드뭄
- 스키마리스라 컬럼 추가는 앱 수준
- 인덱스 변경: GSI 추가는 백그라운드 생성, 삭제는 즉시
- 대용량 이주: AWS DMS + DynamoDB Streams
Cassandra — 확장이 주 요구
- 새 노드 추가 시 토큰 재분배
- 스키마 변경은 schema disagreement 주의
10. 실전 사례 — 10억 행 테이블 타입 변경
Shopify 2020년 사례
- 주문 테이블
id컬럼: INT (2^31 한계 임박) → BIGINT - 8억 행, MySQL
전략:
id_big BIGINT컬럼 추가 (기본값 없음)- 트리거로 새 insert의 id를
id_big에도 자동 채움 - 배치 작업으로 기존 row의
id_big채움 (수 주) - 애플리케이션을
id_big읽기로 전환 - 앱이
id쓰기 중단 - 3개월 뒤
idDROP
총 소요: 6개월. 운영 영향 사실상 0.
GitHub 2016년 사례
- 이슈 테이블을 새 스키마로 이전
- gh-ost 도입 계기
- 초기 pt-osc로 시도 → 리플리카 지연 폭증 → gh-ost 자체 개발
Stripe 2019년 사례
- MongoDB 3.6 → 4.0 버전 업
- Change Streams로 무정지 전환
- A/B 테스트로 5% 트래픽 먼저 새 클러스터로
11. 도구 생태계
스키마 마이그레이션 프레임워크
- Flyway (Java 친화, 모든 DB) — SQL 기반, 버전 관리
- Liquibase (엔터프라이즈) — XML/YAML DSL, 롤백 스크립트
- Alembic (Python/SQLAlchemy)
- Sequelize migrations (Node.js)
- Atlas — 선언적 스키마 관리, 다중 DB
- Skeema (MySQL) — git 기반 스키마 관리
Online DDL 도구
- gh-ost — MySQL, GitHub
- pt-online-schema-change — MySQL, Percona
- pg-osc — PostgreSQL (신흥)
- pgroll — PostgreSQL, Xata — 선언적 expand-contract
CDC
- Debezium, Maxwell, AWS DMS, Fivetran, Airbyte
12. 롤백 전략
원칙: 모든 단계가 롤백 가능해야
1. Expand → 역순 DROP
2. Migrate → 반대 방향 배치
3. Dual-write → 한 쪽 쓰기 중단
4. Switchover → 플래그로 읽기 전환
5. Wait → 그냥 기다리기
6. Contract → DROP한 건 백업에서 복원
Contract 이후 롤백은 불가. 이 때문에 Contract는 수 주 유예 후 실행.
Feature Flag와 결합
읽기 경로를 Feature Flag로 감싸면:
if flag("use_new_schema"):
return db.select(new_schema)
else:
return db.select(old_schema)
즉각 롤백 가능 (이전 글 Feature Flag와 완전 연계).
13. 관측성 — 마이그레이션 중 무엇을 봐야 하나
핵심 지표
- 쿼리 레이턴시 — 평소 대비 50% 이상 증가하면 즉시 중단
- 락 대기 시간 — PostgreSQL
pg_stat_activity, MySQLSHOW ENGINE INNODB STATUS - 리플리카 지연 — 마이그레이션 중 폭증하는 전형적 지표
- 에러율 — 제약 위반, 타임아웃
- 배치 진척 — ETA와 처리 속도
Alerting
- lag > 60s → SLACK
- lag > 300s → PAGE
- error_rate > 1% → 자동 중단 (kill switch)
14. 함정과 안티패턴
함정 1: "우리는 작아서 락 걸려도 돼"
현재 10만 행이지만 반 년 뒤 1억 행이라면? 마이그레이션 절차를 초기부터 연습.
함정 2: Schema 변경을 앱 배포와 동시에
앱이 옛 스키마와 새 스키마 모두 호환해야 한다. 한 번에 갈 수 없다. Expand-Contract 단계 사이에 앱 배포 여러 번.
함정 3: 대규모 UPDATE 한 방에
UPDATE users SET login_name = username;
→ 10억 행 단일 트랜잭션, WAL 폭증, 리플리카 지연. 배치로 LIMIT 10000씩.
함정 4: 배치의 너무 빠른 속도
배치를 빨리 돌려 빨리 끝내고 싶은 충동 → DB 과부하. Throttle 필수.
함정 5: 외래 키 무시
FK가 있는 테이블 구조 변경 시 카스케이드 이슈. 사전 검토 + 테스트.
함정 6: Primary Key 변경
PK는 데이터 물리 배치에 영향. 어떤 엔진에선 거의 재구성. Expand-Contract + 매우 긴 기간.
함정 7: 캐시 일관성
마이그레이션 중 Redis에 옛 스키마 캐시가 남음. 명시적 무효화.
함정 8: 스키마 버전과 앱 버전의 동기화 실패
배포는 롤링인데 스키마는 순간 바뀜 → 구 버전 앱이 신 스키마에 쿼리. 항상 하위 호환.
15. 실전 체크리스트 12가지
- Expand-Contract 원칙 고수 — 단계 절대 건너뛰지 않기
- 스키마 변경 리뷰 템플릿 — 락 수준, 예상 시간, 롤백 명시
- Staging에서 실제 데이터 크기로 먼저 — "작은 테스트"는 믿지 말기
- 외부 도구 활용 — gh-ost, pt-osc, pgroll 등
- CONCURRENTLY를 기억 — PostgreSQL 인덱스 필수
- NOT NULL/FK는 2단계 (NOT VALID → VALIDATE)
- 배치 UPDATE는 Throttle
- 관측성 알림 설정 — 락 대기, 리플리카 지연
- Feature Flag로 읽기 경로 래핑
- Contract는 수 주 유예 후
- 런북 필수 — 누가 봐도 실행 가능하게
- Game Day로 롤백 연습 — 이전 글 Chaos Engineering과 연결
다음 글 예고 — Event-Driven Architecture와 Kafka 내부
DB 이주가 "데이터의 모양을 바꾸는" 이야기라면, Event-Driven Architecture는 "데이터를 흘려보내는" 이야기다. 다음 글에서는:
- Kafka 내부 구조 — 파티션, 리더, ISR, Log Segment
- Exactly-Once Semantics — 어떻게 가능한가
- Transactional Outbox — DB와 이벤트의 일관성
- Event Sourcing vs CDC vs Event Notification
- Kafka Streams, Flink, ksqlDB — 스트림 처리 3대장
- Schema Registry와 Avro/Protobuf/JSON Schema
- 실전 지연 SLA 달성 — 100ms 이하
- Dead Letter Queue와 에러 처리
- Pulsar, Redpanda 같은 Kafka 호환 대안
Kafka를 쓴다는 곳은 많지만 내부가 어떻게 돌아가는지 설명할 수 있는 사람은 드물다. 다음 글에서 그 미스터리를 해체한다.
"비동기 시스템은 동기 시스템보다 강하지만, 이해하기 힘들다. 이해하지 못하면 디버깅도 못한다."