데이터베이스 마이그레이션 전략 — 무중단으로 스키마를 변경하고 DB를 교체하는 법
서비스가 운영 중인 데이터베이스의 스키마를 바꾸는 일은 달리는 기차 위에서 바퀴를 교체하는 것과 같다. 기차를 멈추면(서비스 중단) 간단하지만, 대부분의 서비스는 그 선택지가 없다. 무중단으로 스키마를 변경하는 기법, DB 자체를 교체하는 전략, 그리고 변경 이력을 코드로 관리하는 방법을 처음부터 짚어본다.
마이그레이션이 왜 어려운가
스키마 변경이 까다로운 이유는 동시성 때문이다. 마이그레이션이 진행되는 동안에도 애플리케이션은 쿼리를 보낸다. 대용량 테이블에 컬럼 하나를 추가하는 ALTER TABLE은 MySQL에서 테이블 잠금을 유발해 수십 분 동안 해당 테이블에 쓰기가 불가능해질 수 있다. 이 시간이 바로 다운타임이다.
세 가지 위험이 공존한다.
- 잠금 경합: DDL이 테이블 잠금을 획득하는 동안 기존 DML 트랜잭션이 대기하거나, 반대로 긴 트랜잭션이 DDL을 막는다.
- 데이터 정합성: 스키마 변경 중 들어온 데이터가 새 구조와 구 구조 중 어디에 기록되는지 불명확해진다.
- 롤백 불가: 대용량 테이블의 DDL이 절반쯤 진행됐을 때 실패하면 원상 복구가 오래 걸리거나 불가능하다.
스키마 버전 관리: Flyway와 Liquibase
스키마 변경을 코드 파일로 관리하면 팀 전체가 같은 스키마 상태를 공유하고, 환경마다(dev/staging/prod) 동일한 마이그레이션을 재현할 수 있다.
Flyway
Flyway는 SQL 파일 또는 Java 파일 형태의 마이그레이션 스크립트를 버전 번호 순서로 실행한다. 실행 이력은 flyway_schema_history 테이블에 기록하며, 이미 실행된 스크립트는 다시 실행하지 않는다.
db/migration/
V1__create_users_table.sql
V2__add_email_to_users.sql
V3__create_orders_table.sql
V3.1__add_orders_index.sql
파일명 규칙: V{버전}__{설명}.sql. 버전은 정수 또는 소수점이며 사전 순서가 아닌 숫자 순서로 비교한다.
-- V2__add_email_to_users.sql
ALTER TABLE users
ADD COLUMN email VARCHAR(255) NULL,
ADD COLUMN email_verified_at DATETIME NULL;
CREATE INDEX idx_users_email ON users (email);
// Spring Boot 설정 (application.yml)
// spring:
// flyway:
// enabled: true
// locations: classpath:db/migration
// baseline-on-migrate: true # 기존 DB에 처음 적용 시
// validate-on-migrate: true # 실행 전 체크섬 검증
// out-of-order: false # 버전 순서 강제
// 프로그래밍 방식으로 실행
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
return flyway -> {
flyway.repair(); // 실패한 마이그레이션 이력 정리
flyway.migrate(); // 마이그레이션 실행
};
}
체크섬 검증이 Flyway의 핵심 안전장치다. 이미 실행된 파일을 수정하면 다음 기동 시 체크섬 불일치로 실패한다. 기존 마이그레이션을 절대 수정하지 말고, 새 버전 파일을 추가하는 방식으로만 변경한다.
Liquibase
Liquibase는 XML, YAML, JSON, SQL 형식으로 변경 이력(changeset)을 관리한다. 각 changeset은 id와 author로 식별하며, DB 종류에 따라 다른 SQL을 생성할 수 있어 멀티 DB 환경에 유리하다.
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 2
author: devteam
changes:
- addColumn:
tableName: users
columns:
- column:
name: email
type: varchar(255)
constraints:
nullable: true
- createIndex:
indexName: idx_users_email
tableName: users
columns:
- column:
name: email
- changeSet:
id: 3
author: devteam
preConditions:
- onFail: MARK_RAN # 조건 불충족 시 실행 건너뜀
- tableExists:
tableName: orders
changes:
- addColumn:
tableName: orders
columns:
- column:
name: discount_amount
type: decimal(10,2)
defaultValueNumeric: 0
Liquibase는 rollback 블록을 changeset에 함께 정의해 되돌리기를 지원한다. 단, 일부 변경(DROP TABLE 등)은 되돌릴 수 없으므로 롤백 스크립트 작성에 주의가 필요하다.
무중단 스키마 변경: Expand-Contract 패턴
Expand-Contract(또는 Parallel Change) 패턴은 스키마 변경을 여러 단계로 나눠 구 버전 애플리케이션과 신 버전 애플리케이션이 동시에 동작할 수 있도록 한다.
graph LR
E[1-Expand 확장] --> M[2-Migrate 전환] --> C[3-Contract 축소]
컬럼 이름을 user_name에서 username으로 바꾸는 예시를 단계별로 살펴본다.
1단계 Expand (확장): 새 컬럼을 추가하고, 애플리케이션이 구 컬럼과 신 컬럼 모두에 쓰도록 배포한다.
-- 새 컬럼 추가 (NULL 허용, 기존 데이터 영향 없음)
ALTER TABLE users ADD COLUMN username VARCHAR(100) NULL;
// 애플리케이션: 구/신 컬럼 동시 쓰기
public void updateUser(User user) {
jdbcTemplate.update(
"UPDATE users SET user_name = ?, username = ? WHERE id = ?",
user.getName(), user.getName(), user.getId()
);
}
2단계 Migrate (전환): 기존 데이터를 신 컬럼으로 복사한다.
-- 기존 데이터 배치 복사 (한 번에 전체 실행하면 잠금 위험)
UPDATE users SET username = user_name
WHERE username IS NULL
LIMIT 10000;
-- 위 쿼리를 username IS NULL인 행이 없을 때까지 반복
-- 백필 완료 후 NOT NULL 제약 추가
ALTER TABLE users MODIFY COLUMN username VARCHAR(100) NOT NULL;
3단계 Contract (축소): 애플리케이션이 신 컬럼만 읽도록 배포하고, 구 컬럼을 제거한다.
-- 구 컬럼 제거
ALTER TABLE users DROP COLUMN user_name;
이 패턴의 핵심은 각 단계 사이에 배포와 검증이 들어가며, 어느 단계에서든 롤백이 안전하다는 점이다.
pt-online-schema-change: 대용량 테이블 DDL
일반 ALTER TABLE이 위험한 이유는 테이블 전체를 잠그기 때문이다. 1억 건 테이블에 컬럼 하나를 추가하면 MySQL은 내부적으로 새 테이블을 만들어 데이터를 복사한다. 이 복사가 끝날 때까지 해당 테이블의 모든 쓰기가 대기한다. 복사 속도가 초당 100만 건이라도 1억 건이면 100초, 즉 약 1분 40초의 다운타임이 발생한다.
Percona Toolkit의 pt-online-schema-change(pt-osc)는 이 문제를 달리는 기차 위에서 선로를 교체하는 방식으로 해결한다. 기차를 멈추지 않고도 선로(스키마)를 바꾼다. 동작 원리는 다음과 같다.
graph LR
O[원본 테이블] -->|복사| N[새 테이블]
N -->|트리거 동기화| O
N -->|이름 교체| F[완료]
- 새 구조로 빈 테이블(
_tablename_new)을 생성한다. - 원본 테이블에 INSERT/UPDATE/DELETE 트리거를 설치해 변경 사항을 새 테이블에 실시간 반영한다.
- 원본 데이터를 청크(chunk) 단위로 새 테이블에 복사한다.
- 복사 완료 후 원본과 새 테이블을 원자적으로 이름 교체(RENAME)한다.
- 트리거와 원본 테이블(이제 이름이
_tablename_old)을 정리한다.
# 사용 예시: orders 테이블에 컬럼 추가
pt-online-schema-change \
--host=db-primary \
--user=admin \
--password=secret \
--database=myapp \
--table=orders \
--alter="ADD COLUMN discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0" \
--chunk-size=1000 \
--max-lag=1 \ # Replica 복제 지연이 1초 초과 시 일시 중단
--check-interval=1 \
--critical-load="Threads_running:100" \ # 서버 부하 임계치
--execute
# 실제 실행 전 --dry-run으로 검증
pt-online-schema-change \
... \
--dry-run
--max-lag 옵션이 중요하다. 복사 속도를 줄여 Replica 복제 지연을 제어하므로, 운영 중 Replica에 의존하는 읽기 트래픽에 미치는 영향을 최소화한다.
주의사항: pt-osc는 트리거를 사용하므로 이미 트리거가 있는 테이블에는 추가 설정이 필요하다. MySQL 5.7+에서는 트리거 중첩이 제한적으로 허용되지만, gh-ost가 더 나은 대안이다.
gh-ost: GitHub이 만든 무잠금 DDL 도구
gh-ost(GitHub Online Schema Transmogrifier)는 트리거 대신 binlog 스트리밍을 이용해 변경을 동기화한다. 트리거 없이 동작하므로 pt-osc의 트리거 제약이 없다.
graph LR
O[원본 테이블] -->|binlog 읽기| G[gh-ost]
G -->|쓰기| N[새 테이블]
# gh-ost 실행 예시
gh-ost \
--user="admin" \
--password="secret" \
--host="db-primary" \
--database="myapp" \
--table="orders" \
--alter="ADD COLUMN discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0" \
--max-lag-millis=1500 \ # 복제 지연 임계치 (ms)
--chunk-size=1000 \
--max-load="Threads_running=50" \
--switch-to-rbr \ # binlog를 ROW 포맷으로 전환
--allow-master-master \ # Master-Master 구성 허용
--cut-over=default \ # atomic cut-over 방식
--exact-rowcount \
--concurrent-rowcount \
--default-retries=120 \
--execute
# 실행 중 일시 중단 (Unix socket 사용)
echo "throttle" > /tmp/gh-ost.myapp.orders.sock
# 재개
echo "no-throttle" > /tmp/gh-ost.myapp.orders.sock
gh-ost의 특징은 실행 중 동적 제어다. 소켓 파일을 통해 실행 중에도 청크 크기 변경, 일시 중단, 재개가 가능해 피크 타임에 속도를 낮추고 한가한 시간에 속도를 높일 수 있다.
대용량 테이블 DDL 전략 비교
| 방법 | 잠금 | 트리거 필요 | 특징 |
|---|---|---|---|
| 일반 ALTER TABLE | 전체 잠금 | 없음 | 소규모 테이블만 |
| pt-osc | 없음(RENAME 순간 제외) | 필요 | 검증된 도구, 트리거 제약 |
| gh-ost | 없음(cut-over 순간 제외) | 없음(binlog) | 동적 제어, GitHub 표준 |
| MySQL 8.0 Instant DDL | 없음 | 없음 | 컬럼 추가만 지원, 즉각 완료 |
MySQL 8.0의 ALGORITHM=INSTANT는 컬럼 추가(기본값 있음), 컬럼 순서 변경(가상) 등 일부 DDL을 메타데이터 변경만으로 즉각 처리한다.
-- MySQL 8.0 Instant DDL (메타데이터만 변경, 잠금 없음)
ALTER TABLE orders
ADD COLUMN discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
ALGORITHM=INSTANT;
-- 지원 여부 확인 후 폴백
ALTER TABLE orders
ADD COLUMN notes TEXT NULL,
ALGORITHM=INSTANT; -- 실패 시 ALGORITHM=INPLACE 시도
DB 교체 마이그레이션: MySQL에서 PostgreSQL로
DB 엔진 자체를 교체하는 마이그레이션은 단순 스키마 변경보다 훨씬 복잡하다. 두 DB가 동시에 운영되는 기간 동안 데이터 정합성을 유지해야 한다.
이중 쓰기(Dual Write) 패턴
graph LR
App[애플리케이션] -->|쓰기| M[MySQL]
App -->|쓰기| PG[PostgreSQL]
애플리케이션이 두 DB 모두에 쓰고, 읽기는 검증이 완료된 비율로 점진적으로 전환한다.
@Service
public class OrderService {
private final MySqlOrderRepository mysqlRepo;
private final PostgresOrderRepository postgresRepo;
private final MigrationFeatureFlag migrationFlag;
public Order save(Order order) {
// 항상 MySQL에 쓰기 (primary)
Order saved = mysqlRepo.save(order);
// PostgreSQL에도 비동기 쓰기 (검증용)
if (migrationFlag.isDualWriteEnabled()) {
try {
postgresRepo.save(order);
} catch (Exception e) {
// 실패 로그만 남기고 MySQL 결과 반환 (PostgreSQL은 secondary)
log.error("PostgreSQL dual write failed: {}", e.getMessage());
}
}
return saved;
}
public Order findById(Long id) {
if (migrationFlag.isPostgresReadEnabled()) {
// 일부 트래픽을 PostgreSQL로 전환 (A/B 방식)
Order pgResult = postgresRepo.findById(id);
Order mysqlResult = mysqlRepo.findById(id);
// 결과 비교 및 불일치 로깅
if (!pgResult.equals(mysqlResult)) {
log.warn("Data mismatch for order {}", id);
}
return mysqlResult; // 아직 MySQL이 기준
}
return mysqlRepo.findById(id);
}
}
CDC 기반 마이그레이션 (Change Data Capture)
Debezium 같은 CDC 도구는 MySQL binlog를 읽어 변경 이벤트를 Kafka로 발행하고, 소비자가 PostgreSQL에 적용한다. 애플리케이션 코드 수정 없이 DB 레벨에서 동기화할 수 있다.
graph LR
M[MySQL binlog] --> D[Debezium]
D --> K[Kafka]
K --> PG[PostgreSQL]
// Debezium MySQL Connector 설정 예시
{
"name": "mysql-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql-host",
"database.port": "3306",
"database.user": "debezium",
"database.password": "secret",
"database.server.id": "184054",
"topic.prefix": "dbserver1",
"database.include.list": "myapp",
"schema.history.internal.kafka.topic": "schema-changes.myapp",
"schema.history.internal.kafka.bootstrap.servers": "kafka:9092",
"include.schema.changes": "true"
}
}
CDC 방식의 장점은 초기 스냅샷과 이후 변경분 모두를 자동으로 처리한다는 점이다. 스냅샷 단계에서 전체 테이블을 한 번 복사하고, 이후 binlog 이벤트를 실시간 반영한다.
전환 전략: 트래픽을 점진적으로 이전하기
이중 쓰기나 CDC로 두 DB가 동기화된 상태에서 트래픽을 전환하는 방법이다.
블루-그린 DB 전환
graph LR
LB[로드 밸런서] -->|100%| Blue[MySQL]
LB -.->|0%→100%| Green[PostgreSQL]
- 준비: CDC로 PostgreSQL을 MySQL과 동기화 상태로 유지한다.
- 전환: 로드 밸런서 또는 애플리케이션 설정을 변경해 트래픽을 PostgreSQL로 전환한다.
- 검증: 전환 후 에러율, 응답 시간, 데이터 정합성을 모니터링한다.
- 롤백: 문제 발생 시 로드 밸런서를 다시 MySQL로 돌린다. MySQL은 여전히 이중 쓰기로 최신 상태를 유지하고 있다.
# 피처 플래그로 DB 전환 제어 (예: LaunchDarkly, Unleash)
db:
primary: mysql # 현재 기준 DB
read-replica: postgresql # 읽기 전환 비율
read-replica-weight: 10 # 10% 트래픽을 PostgreSQL로
카나리 전환
전체 사용자 중 1~5%만 새 DB를 사용하도록 라우팅하고, 에러가 없으면 점진적으로 비율을 높인다. 특정 사용자 ID 범위나 요청 헤더를 기반으로 분기한다.
롤백 전략
마이그레이션 실패 시 빠르게 되돌릴 수 있어야 한다.
스키마 롤백
Flyway는 기본적으로 롤백을 지원하지 않는다. Flyway Teams(유료) 또는 Liquibase의 rollback 블록을 사용하거나, 수동으로 역방향 마이그레이션 파일을 준비한다.
-- V2__add_email_to_users.sql의 롤백 스크립트
-- U2__undo_add_email_to_users.sql (수동 관리)
ALTER TABLE users
DROP COLUMN email,
DROP COLUMN email_verified_at;
DROP INDEX idx_users_email ON users;
실무에서 안전한 롤백을 위한 원칙이 있다.
- 컬럼 삭제는 Expand-Contract의 마지막 단계(여러 배포 후)에만 실행한다.
- 컬럼 이름 변경은 직접
RENAME COLUMN대신 신규 컬럼 추가 → 데이터 복사 → 구 컬럼 삭제 순서로 진행한다. - NOT NULL 제약은 데이터 백필이 100% 완료된 것을 확인 후 추가한다.
DB 교체 롤백
이중 쓰기 기간에 MySQL이 항상 Primary 상태를 유지하면, PostgreSQL 전환 후 문제 발생 시 MySQL로 즉시 되돌릴 수 있다. 단, PostgreSQL 전환 후 MySQL로의 역방향 CDC도 설정해 두면 전환 기간 동안 PostgreSQL에 쓰인 데이터가 MySQL에도 반영된다.
데이터 정합성 검증
마이그레이션 중 두 DB의 데이터가 일치하는지 주기적으로 검증해야 한다.
# 두 DB 간 카운트 및 체크섬 비교 (Python 예시)
import hashlib
def verify_table_consistency(mysql_conn, pg_conn, table, id_range):
"""지정 ID 범위의 행 수와 체크섬 비교"""
mysql_cursor = mysql_conn.cursor()
pg_cursor = pg_conn.cursor()
query = f"""
SELECT COUNT(*), COALESCE(SUM(CRC32(CONCAT_WS('|', id, user_id, amount, status))), 0)
FROM {table}
WHERE id BETWEEN {id_range[0]} AND {id_range[1]}
"""
# MySQL CRC32 기반 체크섬
mysql_cursor.execute(query)
mysql_count, mysql_checksum = mysql_cursor.fetchone()
# PostgreSQL 동등 쿼리
pg_query = f"""
SELECT COUNT(*),
COALESCE(SUM(('x' || md5(CONCAT_WS('|', id::text, user_id::text,
amount::text, status)))::bit(32)::int), 0)
FROM {table}
WHERE id BETWEEN {id_range[0]} AND {id_range[1]}
"""
pg_cursor.execute(pg_query)
pg_count, pg_checksum = pg_cursor.fetchone()
if mysql_count != pg_count or mysql_checksum != pg_checksum:
print(f"MISMATCH in {table} [{id_range[0]}-{id_range[1]}]: "
f"MySQL({mysql_count}, {mysql_checksum}) vs "
f"PG({pg_count}, {pg_checksum})")
return False
return True
pt-table-checksum은 MySQL 복제 환경에서 Primary와 Replica 간 데이터 정합성을 검증하는 데 특화된 도구다.
pt-table-checksum \
--host=mysql-primary \
--user=admin \
--password=secret \
--databases=myapp \
--tables=orders
# 불일치 행 수정
pt-table-sync \
--execute \
h=mysql-primary,D=myapp,t=orders \
h=mysql-replica
CI/CD 파이프라인에서 마이그레이션 자동화
# .github/workflows/deploy.yml 예시
jobs:
migrate-and-deploy:
steps:
- name: Run Flyway Migration (Staging)
run: |
flyway -url=jdbc:mysql://staging-db:3306/myapp \
-user=$ \
-password=$ \
-locations=filesystem:db/migration \
migrate
- name: Verify Migration
run: |
flyway -url=jdbc:mysql://staging-db:3306/myapp \
... \
info
# 현재 버전과 기대 버전 비교
- name: Deploy Application
# 마이그레이션 성공 후 애플리케이션 배포
run: kubectl rollout restart deployment/myapp
- name: Run Migration Tests
# 마이그레이션 후 통합 테스트 실행
run: ./gradlew integrationTest
마이그레이션과 애플리케이션 배포 순서가 중요하다. 마이그레이션 먼저, 배포 나중이 원칙이다. 새 스키마에서 구 애플리케이션이 동작할 수 있도록 하위 호환성을 유지해야 한다. Expand-Contract 패턴이 이를 자연스럽게 보장한다.
극한 시나리오
시나리오 1: 마이그레이션 중 Primary DB 장애
gh-ost로 1억 건 테이블의 DDL을 70% 진행하던 중 Primary DB가 장애를 맞았다. Replica가 새 Primary로 승격됐는데, gh-ost의 작업 상태는 어떻게 되는가.
gh-ost는 binlog를 기반으로 동기화하므로 새 Primary에 접속해 중단 지점부터 재시작할 수 있다. 단, _orders_new 임시 테이블이 새 Primary에 복제되어 있어야 한다.
# gh-ost는 새 Primary에서 재실행 가능
# _orders_new 테이블이 Replica에 복제됐는지 확인
SHOW TABLES LIKE '_orders%';
# 재실행 시 기존 임시 테이블 재사용
gh-ost \
--host=new-primary \
... \
--initially-drop-ghost-table=false \ # 기존 임시 테이블 유지
--execute
반면 pt-osc는 트리거를 사용하는데, 트리거는 Replica에 복제되지 않는다. 새 Primary에서 트리거를 다시 설치해야 하므로 처음부터 재실행하거나 수동 조정이 필요하다.
시나리오 2: 마이그레이션 중 데이터 정합성 깨짐
CDC 기반 MySQL → PostgreSQL 마이그레이션 중 일부 UPDATE 이벤트가 Kafka 처리 지연으로 순서가 뒤바뀌어 PostgreSQL에 구 버전 데이터가 저장됐다. 이미 PostgreSQL로 읽기 트래픽 30%를 전환한 상태다.
대응 절차는 다음과 같다.
- 즉각 트래픽 롤백: PostgreSQL 읽기 트래픽을 0%로 되돌린다.
- 범위 파악: 마지막 정상 확인 시점 이후 불일치 레코드 범위를 특정한다.
- 재동기화: 해당 범위를 MySQL 기준으로 PostgreSQL에 강제 덮어쓴다.
-- 불일치 범위 특정 (pt-table-checksum 또는 자체 스크립트)
-- 마지막 CDC 체크포인트 확인
SELECT MAX(updated_at) FROM orders WHERE updated_at < '2026-05-15 10:00:00';
-- MySQL 기준으로 PostgreSQL 강제 동기화 (pg_upsert)
INSERT INTO orders (id, user_id, amount, status, updated_at)
SELECT id, user_id, amount, status, updated_at
FROM mysql_fdw_orders
WHERE updated_at BETWEEN '2026-05-15 09:50:00' AND '2026-05-15 10:10:00'
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
amount = EXCLUDED.amount,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at;
- 원인 분석: Kafka 파티션 수 부족 또는 동일 key에 대한 병렬 처리가 원인이라면,
orders토픽의 파티셔닝 키를order_id로 설정해 같은 주문의 이벤트가 항상 같은 파티션에서 순서대로 처리되도록 변경한다.
시나리오 3: 스키마 변경 중 서비스 배포와 충돌
Flyway 마이그레이션이 운영 DB에서 수십 초가 걸리는 동안, Kubernetes rolling update로 새 Pod가 기동됐다. 새 Pod는 마이그레이션 완료를 기다리지 않고 이미 기동된 상태라면 구 스키마로 쿼리를 실행해 에러가 난다.
해결책: initContainer에서 Flyway를 실행하고 완료된 후에만 메인 컨테이너가 기동되도록 한다.
# k8s deployment 예시
spec:
initContainers:
- name: flyway-migrate
image: flyway/flyway:latest
command:
- flyway
- -url=jdbc:mysql://db:3306/myapp
- -user=$(DB_USER)
- -password=$(DB_PASSWORD)
- migrate
env:
- name: DB_USER
valueFrom: { secretKeyRef: { name: db-secret, key: user } }
containers:
- name: myapp
# initContainer 완료 후 기동됨
단, 여러 Pod가 동시에 롤링 업데이트되면 여러 initContainer가 동시에 Flyway를 실행할 수 있다. Flyway는 flyway_schema_history 테이블에 LOCK을 사용해 동시 실행을 막으므로 한 Pod만 마이그레이션을 실행하고 나머지는 대기 후 완료된 버전을 확인한다.
댓글