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](/blog/culture/2026-04-15-feature-flag-progressive-delivery-dark-launch-canary-rollout-launchdarkly-unleash-openfeature-deep-dive-guide-2025)에서 "코드 롤아웃"을 점진적으로 한다고 했다. 이 글은 "스키마 롤아웃"을 점진적으로 하는 이야기다.

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 — 옛 스키마 제거

예: 컬럼 이름 변경 `username` → `login_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](/blog/culture/2026-04-15-feature-flag-progressive-delivery-dark-launch-canary-rollout-launchdarkly-unleash-openfeature-deep-dive-guide-2025)와 완전 연계).

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](/blog/culture/2026-04-15-chaos-engineering-netflix-simian-army-litmus-chaos-mesh-fis-game-day-principles-deep-dive-guide-2025)과 연결

다음 글 예고 — 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