LabHub

블로그

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

들어가며 — "토요일 새벽 3시"라는 유산

2015년 즈음까지 DBA의 삶:

"다음 주 토요일 새벽 3시, 30분간 서비스 점검이 있습니다."

점검의 실체는 대부분 ALTER TABLE. 10억 행 테이블에 컬럼 추가 한 번에 테이블 락 → 전 사용자 대기 → 비즈니스 손실.

2025년 현재 넷플릭스, 깃허브, 우버 같은 회사들은 다운타임 0로 같은 작업을 한다. 기법은 하나하나 단순하지만 조합과 원칙이 있다. 이 글에서는:

이전 글 Feature Flag와 Progressive Delivery에서 "코드 롤아웃"을 점진적으로 한다고 했다. 이 글은 "스키마 롤아웃"을 점진적으로 하는 이야기다.


1. 왜 스키마 변경이 어려운가

DB의 본질적 제약

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

MySQL vs PostgreSQL vs 현대

문제의 정의

"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 (몇 주 뒤)

각 단계는 며칠 ~ 몇 주 간격. 서두르면 롤백 불가.

핵심 원칙


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는 나중에 제거.

장점

한계


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

기본 원리

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

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

장점 / 단점

장점:

단점:

선택 기준


6. PostgreSQL의 마이그레이션 기법

CONCURRENTLY — 인덱스의 친구

CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

주의: CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서. ORM 마이그레이션이 트랜잭션을 기본 감싸면 실패.

락 레벨 이해

PostgreSQL DDL별 락 강도:

락을 이해해야 "이 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/연결 풀 갱신).

함정

도구


8. Dual-Write의 함정과 CDC

순진한 Dual-Write

앱이 A DB와 B DB에 동시에 쓴다:

def save(data):
    db_a.insert(data)
    db_b.insert(data)

문제:

CDC 기반 이주

CDC (Change Data Capture): 소스 DB의 변경 스트림을 구독해 대상에 반영.

도구:

플로우:

[소스 DB] ──► (binlog/WAL) ──► [Debezium] ──► [Kafka] ──► [대상 DB]

이점:


9. NoSQL 이주

MongoDB — 버전 업 또는 샤드 재구성

DynamoDB — 스키마 변경 자체가 드뭄

Cassandra — 확장이 주 요구


10. 실전 사례 — 10억 행 테이블 타입 변경

Shopify 2020년 사례

전략:

  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년 사례

Stripe 2019년 사례


11. 도구 생태계

스키마 마이그레이션 프레임워크

Online DDL 도구

CDC


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. 관측성 — 마이그레이션 중 무엇을 봐야 하나

핵심 지표

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를 쓴다는 곳은 많지만 내부가 어떻게 돌아가는지 설명할 수 있는 사람은 드물다. 다음 글에서 그 미스터리를 해체한다.

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

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다