Skip to content

✍️ 필사 모드: Zero-Downtime 데이터베이스 마이그레이션 완전 해부 — Expand-Contract, gh-ost, pt-osc, CONCURRENTLY, Logical Replication

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — "토요일 새벽 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의 본질적 제약

  1. 락(Lock) — 테이블 구조를 바꾸려면 일반적으로 다른 트랜잭션을 막아야
  2. 데이터 이동 — 컬럼 타입 변경, 인덱스 추가는 실제 데이터 재작성
  3. 포맷 호환성 — 앱 코드가 기대하는 스키마와 DB 스키마가 일치해야
  4. 단일 진실 소스 — 스키마가 바뀌면 롤백이 데이터 손실

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"이란:

  1. 서비스 무정지 (요청 실패 없음)
  2. 지연 허용 가능한 수준 (P99 몇 ms 상승은 OK)
  3. 롤백 가능 (중간에 멈출 수 있어야)
  4. 진척 관측 가능

2. Expand-Contract 패턴 — 스키마 변경의 교본

모든 zero-downtime 스키마 변경은 6단계 패턴을 따른다.

6단계

1. Expand — 새 스키마 추가 (옛 것 유지)
2. Migrate — 데이터 이관 또는 이중 쓰기
3. Dual-read — 앱이 양쪽 읽되 우선순위로
4. Switchover — 앱이 새 스키마만 씀
5. Wait — 모든 읽기가 새 쪽으로 안정
6. Contract — 옛 스키마 제거

예: 컬럼 이름 변경 usernamelogin_name

단순 시도 (망함): ALTER TABLE users RENAME COLUMN username TO login_name → 순간 앱이 username 참조하는 코드가 깨짐.

Expand-Contract:

  1. Expand: ALTER TABLE users ADD COLUMN login_name VARCHAR(...) (nullable)
  2. Migrate: UPDATE users SET login_name = username WHERE login_name IS NULL (배치)
  3. Dual-write: 앱이 양쪽 컬럼 모두 저장
  4. Dual-read: 앱이 login_name 우선, 없으면 username
  5. Switchover: 앱이 username 쓰기 중단, login_name
  6. 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)

플로우

  1. gh-ost가 _users_gho라는 그림자 테이블 생성 (새 스키마)
  2. 원본 테이블의 row를 배치로 복사 (속도 제어 가능)
  3. 동시에 binlog를 읽어 실시간 변경을 그림자에 반영
  4. 모든 데이터 동기화 완료 시 cut-over:
    • 원본을 _users_old로 rename
    • 그림자를 users로 rename
    • 원자적 (두 rename이 한 트랜잭션)
  5. 작업 완료. _users_old는 나중에 제거.

장점

  • 트리거 없음 → 성능 예측 가능
  • 리플리카에서 읽기 → 원본 부하 최소
  • 일시 중지 가능
  • 자동 조절 (리플리카 지연 감지 시 속도 줄임)
  • throttle 파일 기반 수동 제어

한계

  • 외래 키 있는 테이블은 조심 (옵션으로 가능)
  • MySQL에만 (PostgreSQL 비지원)
  • 바이너리 로그 포맷 ROW 필요

5. pt-online-schema-change — Percona의 고전

기본 원리

gh-ost와 비슷하지만 트리거 기반.

  1. 그림자 테이블 생성
  2. 원본에 트리거 3개 설치 (INSERT/UPDATE/DELETE)
  3. 트리거가 그림자에도 동일 변경 적용
  4. 배치로 기존 row를 복사
  5. cut-over로 rename
  6. 트리거 제거

장점 / 단점

장점:

  • 성숙 (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 방식

  1. 새 버전 DB 준비 (예: 11 → 15)
  2. 옛 버전에서 logical publication 생성
  3. 새 버전에서 subscription 생성, 초기 복사
  4. WAL 변경을 실시간으로 새 버전에 스트리밍
  5. 애플리케이션을 새 버전으로 전환
  6. 옛 버전 폐기

다운타임: 전환 순간 수 초(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

전략:

  1. id_big BIGINT 컬럼 추가 (기본값 없음)
  2. 트리거로 새 insert의 id를 id_big에도 자동 채움
  3. 배치 작업으로 기존 row의 id_big 채움 (수 주)
  4. 애플리케이션을 id_big 읽기로 전환
  5. 앱이 id 쓰기 중단
  6. 3개월 뒤 id DROP

총 소요: 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, MySQL SHOW 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가지

  1. Expand-Contract 원칙 고수 — 단계 절대 건너뛰지 않기
  2. 스키마 변경 리뷰 템플릿 — 락 수준, 예상 시간, 롤백 명시
  3. Staging에서 실제 데이터 크기로 먼저 — "작은 테스트"는 믿지 말기
  4. 외부 도구 활용 — gh-ost, pt-osc, pgroll 등
  5. CONCURRENTLY를 기억 — PostgreSQL 인덱스 필수
  6. NOT NULL/FK는 2단계 (NOT VALID → VALIDATE)
  7. 배치 UPDATE는 Throttle
  8. 관측성 알림 설정 — 락 대기, 리플리카 지연
  9. Feature Flag로 읽기 경로 래핑
  10. Contract는 수 주 유예 후
  11. 런북 필수 — 누가 봐도 실행 가능하게
  12. 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를 쓴다는 곳은 많지만 내부가 어떻게 돌아가는지 설명할 수 있는 사람은 드물다. 다음 글에서 그 미스터리를 해체한다.

"비동기 시스템은 동기 시스템보다 강하지만, 이해하기 힘들다. 이해하지 못하면 디버깅도 못한다."

현재 단락 (1/264)

2015년 즈음까지 DBA의 삶:

작성 글자: 0원문 글자: 8,882작성 단락: 0/264