Skip to content

Split View: Flyway & DB 마이그레이션 완전 가이드 2025: 스키마 버전 관리, ORM 통합, 무중단 배포까지

✨ Learn with Quiz
|

Flyway & DB 마이그레이션 완전 가이드 2025: 스키마 버전 관리, ORM 통합, 무중단 배포까지

1. 왜 데이터베이스 마이그레이션이 필요한가

"내 로컬에서는 되는데" 문제

코드는 Git으로 완벽하게 버전 관리하면서, 데이터베이스 스키마는 수동으로 ALTER TABLE을 실행하고 있다면 심각한 문제가 존재합니다.

개발자 A: "users 테이블에 phone 컬럼 추가했어요"
개발자 B: "어? 제 로컬에는 없는데요?"
개발자 A: "슬랙에 공유했잖아요..."
개발자 C: "스테이징은요? 프로덕션은?"
(3시간 후 장애 발생)

이런 시나리오는 실제로 수많은 팀에서 반복됩니다. 코드와 스키마가 동기화되지 않으면 배포 시 장애가 발생하고, 롤백도 어렵습니다.

수동 ALTER TABLE의 공포

-- 금요일 오후 6시, 프로덕션에서 직접 실행...
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE orders MODIFY COLUMN status ENUM('pending','processing','shipped','delivered','cancelled');

-- 실수로 WHERE 없이...
UPDATE users SET role = 'admin';
-- Ctrl+C... 이미 늦었다

수동 관리의 문제점은 다음과 같습니다.

  • 누가, 언제, 무엇을 변경했는지 추적 불가
  • 환경(로컬/스테이징/프로덕션)마다 스키마가 다름
  • 롤백 절차가 없음
  • 코드 리뷰 없이 직접 실행
  • 실행 순서 보장이 안 됨

데이터베이스 버전 관리: Git과 같은 원칙

코드에 Git을 사용하는 것처럼, DB 스키마에도 버전 관리가 필요합니다.

Git으로 코드 관리          DB 마이그레이션으로 스키마 관리
------------------        ---------------------------
commit history      →     migration history
branch/merge        →     migration branching
code review (PR)    →     migration review
rollback (revert)   →     undo migration
CI/CD pipeline      →     migration in pipeline

핵심 원칙은 다음과 같습니다.

  1. 모든 스키마 변경은 마이그레이션 파일로 - 직접 SQL 실행 금지
  2. 마이그레이션은 코드와 함께 버전 관리 - Git 저장소에 포함
  3. 자동화된 실행 - CI/CD 파이프라인에서 실행
  4. 멱등성(Idempotency) - 같은 마이그레이션을 여러 번 실행해도 동일 결과
  5. 순서 보장 - 마이그레이션은 정해진 순서대로 실행

2. 마이그레이션 도구 비교

주요 도구 비교표

도구접근 방식언어/생태계강점약점
Flyway버전 기반 SQLJava/CLI단순, SQL 네이티브유료 기능 많음
Liquibase변경셋 XML/YAML/SQLJava/CLI롤백, diff설정 복잡
Prisma Migrate선언적 스키마TypeScript타입 안전, Shadow DBPrisma 종속
TypeORM코드 기반 마이그레이션TypeScript엔티티 자동 생성TypeORM 종속
Alembic버전 기반 PythonPythonSQLAlchemy 통합Python 전용
Django Migrations자동 감지PythonORM 완전 통합Django 전용
Knex.js코드 기반JavaScript경량기능 제한적
golang-migrate버전 기반 SQLGo/CLI경량, 다중 DB기능 최소화

선택 기준

프로젝트 기술 스택이 무엇인가?
├── Java/SpringFlyway 또는 Liquibase
│   ├── 단순함 선호 → Flyway
│   └── 롤백/diff 필요 → Liquibase
├── TypeScript/Node.js
│   ├── Prisma 사용 → Prisma Migrate
│   ├── TypeORM 사용 → TypeORM Migration
│   └── 경량 → Knex.js
├── Python
│   ├── DjangoDjango Migrations
│   └── Flask/FastAPIAlembic
├── Go → golang-migrate
└── 언어 무관, SQL 직접 → Flyway CLI

3. Flyway 심층 분석

3.1 Flyway 소개

Flyway는 가장 널리 사용되는 데이터베이스 마이그레이션 도구입니다. "단순함"을 핵심 철학으로 삼아, SQL 파일에 버전 번호를 붙여 순서대로 실행하는 직관적인 방식입니다.

flyway_migrations/
├── V1__Create_users_table.sql
├── V2__Create_orders_table.sql
├── V3__Add_email_to_users.sql
├── V4__Create_products_table.sql
├── R__Create_views.sql          # Repeatable
└── U3__Add_email_to_users.sql   # Undo (Teams)

3.2 설치 방법

CLI 설치 (macOS)

# Homebrew
brew install flyway

# 버전 확인
flyway --version
# Flyway Community Edition 10.x.x

Docker

docker run --rm \
  -v $(pwd)/sql:/flyway/sql \
  -v $(pwd)/conf:/flyway/conf \
  flyway/flyway:10-alpine \
  migrate

Maven 플러그인

<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>10.8.1</version>
    <configuration>
        <url>jdbc:postgresql://localhost:5432/mydb</url>
        <user>postgres</user>
        <password>secret</password>
        <locations>
            <location>filesystem:src/main/resources/db/migration</location>
        </locations>
    </configuration>
</plugin>

Gradle 플러그인

plugins {
    id 'org.flywaydb.flyway' version '10.8.1'
}

flyway {
    url = 'jdbc:postgresql://localhost:5432/mydb'
    user = 'postgres'
    password = 'secret'
    locations = ['filesystem:src/main/resources/db/migration']
}

3.3 네이밍 컨벤션

Flyway의 마이그레이션 파일 이름 규칙은 다음과 같습니다.

V{version}__{description}.sql       # Versioned Migration
R__{description}.sql                # Repeatable Migration
U{version}__{description}.sql       # Undo Migration (Teams)

규칙의 핵심 포인트는 다음과 같습니다.

  • 접두사: V(Versioned), R(Repeatable), U(Undo)
  • 버전: 숫자(1, 2, 3) 또는 날짜(20250325) 또는 타임스탬프
  • 구분자: 언더스코어 두 개(__)
  • 설명: 영문, 언더스코어로 구분
-- V1__Create_users_table.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- V2__Create_orders_table.sql
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
-- V3__Add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN profile_image_url TEXT;
-- R__Create_reporting_views.sql (매번 재실행됨)
CREATE OR REPLACE VIEW v_user_order_summary AS
SELECT
    u.id AS user_id,
    u.username,
    u.email,
    COUNT(o.id) AS order_count,
    COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username, u.email;

3.4 주요 커맨드

# 마이그레이션 실행
flyway migrate

# 현재 상태 확인
flyway info

# 마이그레이션 유효성 검증
flyway validate

# 데이터베이스 초기화 (주의: 모든 객체 삭제)
flyway clean

# 기존 DB에 Flyway 적용 (베이스라인)
flyway baseline

# 실패한 마이그레이션 복구
flyway repair

info 출력 예시

+-----------+---------+---------------------+------+---------------------+---------+----------+
| Category  | Version | Description         | Type | Installed On        | State   | Undoable |
+-----------+---------+---------------------+------+---------------------+---------+----------+
| Versioned | 1       | Create users table  | SQL  | 2025-03-20 10:15:30 | Success | No       |
| Versioned | 2       | Create orders table | SQL  | 2025-03-20 10:15:31 | Success | No       |
| Versioned | 3       | Add phone to users  | SQL  |                     | Pending | No       |
+-----------+---------+---------------------+------+---------------------+---------+----------+

3.5 설정 파일

flyway.conf (전통 방식)

# 데이터베이스 연결
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=postgres
flyway.password=secret

# 마이그레이션 위치
flyway.locations=filesystem:./sql,classpath:db/migration

# 스키마
flyway.schemas=public,app
flyway.defaultSchema=app

# 테이블
flyway.table=flyway_schema_history

# 인코딩
flyway.encoding=UTF-8

# 플레이스홀더
flyway.placeholders.env=production
flyway.placeholders.schema_name=app

# 검증
flyway.validateOnMigrate=true
flyway.validateMigrationNaming=true

# 기타
flyway.outOfOrder=false
flyway.baselineOnMigrate=false
flyway.cleanDisabled=true

flyway.toml (Flyway v10 신규 형식)

[environments.default]
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "secret"

[flyway]
locations = ["filesystem:./sql"]
defaultSchema = "app"
table = "flyway_schema_history"
validateOnMigrate = true
cleanDisabled = true

[flyway.placeholders]
env = "production"
schema_name = "app"

3.6 플레이스홀더와 환경별 설정

-- V5__Create_audit_table.sql
CREATE TABLE ${schema_name}.audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    operation VARCHAR(10) NOT NULL,
    old_data JSONB,
    new_data JSONB,
    changed_by VARCHAR(100),
    changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 환경별 파티션
-- ${env} 플레이스홀더 사용
COMMENT ON TABLE ${schema_name}.audit_log IS 'Audit log for ${env} environment';

환경별 설정 파일은 다음과 같습니다.

# flyway-dev.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb_dev
flyway.placeholders.env=development

# flyway-staging.conf
flyway.url=jdbc:postgresql://staging-db:5432/mydb
flyway.placeholders.env=staging

# flyway-prod.conf
flyway.url=jdbc:postgresql://prod-db:5432/mydb
flyway.placeholders.env=production
flyway.cleanDisabled=true
# 환경별 실행
flyway -configFiles=flyway-dev.conf migrate
flyway -configFiles=flyway-staging.conf migrate
flyway -configFiles=flyway-prod.conf migrate

3.7 콜백

Flyway는 마이그레이션 전후에 실행되는 콜백을 지원합니다.

콜백 파일 이름:
beforeMigrate.sql      - migrate 실행 전
beforeEachMigrate.sql  - 각 마이그레이션 전
afterEachMigrate.sql   - 각 마이그레이션 후
afterMigrate.sql       - migrate 완료 후
beforeClean.sql        - clean 실행 전
afterClean.sql         - clean 완료 후
beforeInfo.sql         - info 실행 전
afterInfo.sql          - info 완료 후
beforeValidate.sql     - validate 실행 전
afterValidate.sql      - validate 완료 후
-- afterMigrate.sql
-- 마이그레이션 완료 후 통계 뷰 갱신
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_stats;
ANALYZE users;
ANALYZE orders;

-- 마이그레이션 이력 로깅
INSERT INTO migration_audit_log (action, executed_at)
VALUES ('migration_completed', NOW());

3.8 Java 기반 마이그레이션

복잡한 로직이 필요한 경우 Java 코드로 마이그레이션을 작성할 수 있습니다.

// V6__Encrypt_user_emails.java
package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;

import java.sql.*;

public class V6__Encrypt_user_emails extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        Connection conn = context.getConnection();

        // 배치 처리로 대량 데이터 암호화
        String selectSql = "SELECT id, email FROM users WHERE encrypted_email IS NULL";
        String updateSql = "UPDATE users SET encrypted_email = ? WHERE id = ?";

        try (Statement select = conn.createStatement();
             PreparedStatement update = conn.prepareStatement(updateSql)) {

            ResultSet rs = select.executeQuery(selectSql);
            int batchSize = 0;

            while (rs.next()) {
                long id = rs.getLong("id");
                String email = rs.getString("email");
                String encrypted = encrypt(email);

                update.setString(1, encrypted);
                update.setLong(2, id);
                update.addBatch();

                if (++batchSize % 1000 == 0) {
                    update.executeBatch();
                }
            }

            if (batchSize % 1000 != 0) {
                update.executeBatch();
            }
        }
    }

    private String encrypt(String value) {
        // 실제 암호화 로직 구현
        return "ENC:" + value; // 예시
    }
}

3.9 Spring Boot 통합

# application.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    baseline-version: '0'
    validate-on-migrate: true
    out-of-order: false
    table: flyway_schema_history
    schemas:
      - public
    placeholders:
      env: ${SPRING_PROFILES_ACTIVE:dev}
    clean-disabled: true
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: postgres
    password: secret
// FlywayConfig.java
@Configuration
public class FlywayConfig {

    @Bean
    public FlywayMigrationStrategy flywayMigrationStrategy() {
        return flyway -> {
            // 마이그레이션 정보 출력
            MigrationInfo[] pending = flyway.info().pending();
            if (pending.length > 0) {
                System.out.println("Pending migrations: " + pending.length);
                for (MigrationInfo info : pending) {
                    System.out.println("  - " + info.getVersion()
                        + ": " + info.getDescription());
                }
            }
            flyway.migrate();
        };
    }
}

3.10 전체 프로젝트 예시 (PostgreSQL)

my-app/
├── src/main/resources/
│   └── db/
│       └── migration/
│           ├── V1__Create_schema.sql
│           ├── V2__Create_users.sql
│           ├── V3__Create_products.sql
│           ├── V4__Create_orders.sql
│           ├── V5__Add_indexes.sql
│           ├── V6__Create_audit_triggers.sql
│           └── R__Update_views.sql
├── flyway.toml
├── pom.xml
└── docker-compose.yml
# docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  flyway:
    image: flyway/flyway:10-alpine
    depends_on:
      - db
    volumes:
      - ./src/main/resources/db/migration:/flyway/sql
      - ./flyway.toml:/flyway/flyway.toml
    command: migrate
    environment:
      FLYWAY_URL: jdbc:postgresql://db:5432/myapp
      FLYWAY_USER: postgres
      FLYWAY_PASSWORD: secret

volumes:
  pgdata:

4. Liquibase 비교

4.1 Liquibase 변경 로그 형식

Liquibase는 XML, YAML, SQL, JSON 네 가지 형식을 지원합니다.

YAML 형식 (가장 읽기 쉬움)

# db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - changeSet:
      id: 1
      author: dev-team
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: username
                  type: varchar(50)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: email
                  type: varchar(255)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: created_at
                  type: timestamp with time zone
                  defaultValueComputed: NOW()
      rollback:
        - dropTable:
            tableName: users

  - changeSet:
      id: 2
      author: dev-team
      changes:
        - addColumn:
            tableName: users
            columns:
              - column:
                  name: phone
                  type: varchar(20)
      rollback:
        - dropColumn:
            tableName: users
            columnName: phone

SQL 형식

-- changeset dev-team:1
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- rollback DROP TABLE users;

-- changeset dev-team:2
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- rollback ALTER TABLE users DROP COLUMN phone;

4.2 Liquibase 주요 기능

# 마이그레이션 실행
liquibase update

# 롤백 (마지막 N개)
liquibase rollbackCount 1

# 롤백 (특정 태그까지)
liquibase rollback v1.0

# 현재 DB와 변경 로그 차이 확인
liquibase diff

# 기존 DB에서 변경 로그 자동 생성
liquibase generateChangeLog

# 상태 확인
liquibase status

4.3 Flyway vs Liquibase 선택 기준

Flyway 선택 시점:
  - SQL을 직접 작성하고 싶을 때
  - 단순한 워크플로우 선호
  - Spring Boot 생태계 사용 시
  - 팀이 SQL에 익숙할 때

Liquibase 선택 시점:
  - 롤백이 필수일 때
  - 다중 DB 벤더 지원 필요
  - 기존 DB 스키마를 변경 로그로 변환할 때
  - XML/YAML 기반 선언적 관리 선호 시
  - 세밀한 컨텍스트/조건부 실행 필요 시

5. ORM 마이그레이션 시스템

5.1 Prisma Migrate (TypeScript/Node.js)

스키마 정의

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  phone     String?
  posts     Post[]
  orders    Order[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int      @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")

  @@map("posts")
}

model Order {
  id         Int         @id @default(autoincrement())
  user       User        @relation(fields: [userId], references: [id])
  userId     Int         @map("user_id")
  totalAmount Decimal    @map("total_amount") @db.Decimal(10, 2)
  status     OrderStatus @default(PENDING)
  items      OrderItem[]
  createdAt  DateTime    @default(now()) @map("created_at")

  @@map("orders")
}

model OrderItem {
  id       Int   @id @default(autoincrement())
  order    Order @relation(fields: [orderId], references: [id])
  orderId  Int   @map("order_id")
  product  String
  quantity Int
  price    Decimal @db.Decimal(10, 2)

  @@map("order_items")
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

마이그레이션 명령어

# 개발 환경 마이그레이션 (생성 + 적용)
npx prisma migrate dev --name create_initial_schema

# 프로덕션 마이그레이션 적용
npx prisma migrate deploy

# 마이그레이션 초기화
npx prisma migrate reset

# 마이그레이션 상태 확인
npx prisma migrate status

# 스키마와 DB 차이 확인
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma

생성된 마이그레이션 파일

-- prisma/migrations/20250325_create_initial_schema/migration.sql
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED');

CREATE TABLE "users" (
    "id" SERIAL NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "phone" TEXT,
    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "posts" (
    "id" SERIAL NOT NULL,
    "title" TEXT NOT NULL,
    "content" TEXT,
    "published" BOOLEAN NOT NULL DEFAULT false,
    "author_id" INTEGER NOT NULL,
    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

ALTER TABLE "posts"
    ADD CONSTRAINT "posts_author_id_fkey"
    FOREIGN KEY ("author_id") REFERENCES "users"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE;

Shadow Database 개념

Prisma Migrate는 Shadow Database를 사용하여 마이그레이션의 정확성을 검증합니다.

1. 임시 Shadow DB 생성
2. 모든 기존 마이그레이션을 Shadow DB에 적용
3. 새 마이그레이션 SQL 생성
4. Shadow DB에 새 마이그레이션 적용 검증
5. Shadow DB 삭제

5.2 TypeORM 마이그레이션

엔티티 정의

// src/entities/User.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn, OneToMany
} from 'typeorm';
import { Order } from './Order';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 50, unique: true })
  username: string;

  @Column({ type: 'varchar', length: 255, unique: true })
  email: string;

  @Column({ type: 'varchar', length: 20, nullable: true })
  phone: string;

  @OneToMany(() => Order, order => order.user)
  orders: Order[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

마이그레이션 생성 및 실행

# 엔티티 변경 감지하여 마이그레이션 자동 생성
npx typeorm migration:generate -d src/data-source.ts src/migrations/AddPhoneToUser

# 빈 마이그레이션 생성
npx typeorm migration:create src/migrations/SeedAdminUser

# 마이그레이션 실행
npx typeorm migration:run -d src/data-source.ts

# 마지막 마이그레이션 롤백
npx typeorm migration:revert -d src/data-source.ts

자동 생성된 마이그레이션 예시

// src/migrations/1711234567890-AddPhoneToUser.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPhoneToUser1711234567890 implements MigrationInterface {
    name = 'AddPhoneToUser1711234567890';

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE "users" ADD "phone" varchar(20)`
        );
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE "users" DROP COLUMN "phone"`
        );
    }
}

5.3 Alembic (Python/SQLAlchemy)

초기 설정

# Alembic 설치
pip install alembic sqlalchemy psycopg2-binary

# 프로젝트 초기화
alembic init alembic
# alembic/env.py (핵심 부분)
from myapp.models import Base

target_metadata = Base.metadata

def run_migrations_online():
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
    )
    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            compare_type=True,
            compare_server_default=True,
        )
        with context.begin_transaction():
            context.run_migrations()

마이그레이션 생성 및 실행

# 자동 생성 (모델 변경 감지)
alembic revision --autogenerate -m "add phone to users"

# 수동 생성
alembic revision -m "seed initial data"

# 최신 버전으로 업그레이드
alembic upgrade head

# 특정 버전으로 업그레이드
alembic upgrade abc123

# 롤백 (한 단계)
alembic downgrade -1

# 현재 상태 확인
alembic current

# 마이그레이션 이력
alembic history

자동 생성된 마이그레이션

# alembic/versions/abc123_add_phone_to_users.py
"""add phone to users"""

from alembic import op
import sqlalchemy as sa

revision = 'abc123'
down_revision = 'prev456'
branch_labels = None
depends_on = None

def upgrade():
    op.add_column('users',
        sa.Column('phone', sa.String(20), nullable=True)
    )
    op.create_index('ix_users_phone', 'users', ['phone'])

def downgrade():
    op.drop_index('ix_users_phone', 'users')
    op.drop_column('users', 'phone')

5.4 Django 마이그레이션

# models.py
from django.db import models

class User(models.Model):
    username = models.CharField(max_length=50, unique=True)
    email = models.EmailField(unique=True)
    phone = models.CharField(max_length=20, blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'users'
# 마이그레이션 생성
python manage.py makemigrations

# 마이그레이션 적용
python manage.py migrate

# 마이그레이션 상태 확인
python manage.py showmigrations

# SQL 미리보기
python manage.py sqlmigrate myapp 0002

# 마이그레이션 스쿼시 (합치기)
python manage.py squashmigrations myapp 0001 0005

데이터 마이그레이션

# migrations/0003_populate_default_roles.py
from django.db import migrations

def create_roles(apps, schema_editor):
    Role = apps.get_model('myapp', 'Role')
    roles = ['admin', 'editor', 'viewer']
    for role_name in roles:
        Role.objects.get_or_create(name=role_name)

def remove_roles(apps, schema_editor):
    Role = apps.get_model('myapp', 'Role')
    Role.objects.filter(name__in=['admin', 'editor', 'viewer']).delete()

class Migration(migrations.Migration):
    dependencies = [
        ('myapp', '0002_create_role_table'),
    ]

    operations = [
        migrations.RunPython(create_roles, remove_roles),
    ]

6. 무중단 마이그레이션 전략

6.1 Expand-Contract 패턴 (가장 중요)

무중단 스키마 변경의 핵심 패턴입니다. 3단계로 나눠서 안전하게 변경합니다.

예시: username 컬럼을 first_name + last_name으로 분리

Phase 1 (Expand): 새 컬럼 추가, 양쪽 모두 쓰기
Phase 2 (Migrate): 데이터 이전, 읽기 전환
Phase 3 (Contract): 기존 컬럼 제거

Phase 1: Expand (확장)

-- Migration V10: Add new columns
ALTER TABLE users ADD COLUMN first_name VARCHAR(50);
ALTER TABLE users ADD COLUMN last_name VARCHAR(50);

-- 트리거로 양방향 동기화
CREATE OR REPLACE FUNCTION sync_user_names()
RETURNS TRIGGER AS $func$
BEGIN
    IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
        IF NEW.first_name IS NULL AND NEW.username IS NOT NULL THEN
            NEW.first_name := split_part(NEW.username, ' ', 1);
            NEW.last_name := split_part(NEW.username, ' ', 2);
        ELSIF NEW.username IS NULL AND NEW.first_name IS NOT NULL THEN
            NEW.username := NEW.first_name || ' ' || COALESCE(NEW.last_name, '');
        END IF;
    END IF;
    RETURN NEW;
END;
$func$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_user_names
    BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_user_names();
이 시점에서 배포: 새 코드는 first_name/last_name에 쓰기
이전 코드는 username에 쓰기 (트리거가 동기화)

Phase 2: Migrate (데이터 이전)

-- Migration V11: Backfill existing data
UPDATE users
SET first_name = split_part(username, ' ', 1),
    last_name = split_part(username, ' ', 2)
WHERE first_name IS NULL;

-- NOT NULL 제약 추가
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;

Phase 3: Contract (축소)

-- Migration V12: Remove old column (모든 코드가 전환된 후)
DROP TRIGGER IF EXISTS trg_sync_user_names ON users;
DROP FUNCTION IF EXISTS sync_user_names();
ALTER TABLE users DROP COLUMN username;

6.2 컬럼 이름 변경 (무중단)

컬럼 이름을 직접 변경하면 서비스가 중단됩니다. 안전한 방법은 다음과 같습니다.

단계 1: 새 컬럼 추가 + 트리거
단계 2: 데이터 복사
단계 3: 코드에서 새 컬럼 사용
단계 4: 이전 컬럼 제거

6.3 NOT NULL 제약 안전하게 추가

-- 위험: 전체 테이블 잠금
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- 안전: PostgreSQL 12+ CHECK 제약 사용
ALTER TABLE users ADD CONSTRAINT users_email_not_null
    CHECK (email IS NOT NULL) NOT VALID;

-- 별도 트랜잭션에서 검증 (AccessExclusiveLock 없이)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;

-- 그 후 NOT NULL 제약으로 전환
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_not_null;

6.4 대규모 테이블 ALTER TABLE

MySQL: gh-ost (GitHub Online Schema Change)

gh-ost \
  --host=db-primary \
  --database=myapp \
  --table=users \
  --alter="ADD COLUMN phone VARCHAR(20)" \
  --execute \
  --allow-on-master \
  --chunk-size=1000 \
  --max-load=Threads_running=25 \
  --critical-load=Threads_running=100

MySQL: pt-online-schema-change (Percona Toolkit)

pt-online-schema-change \
  --alter "ADD COLUMN phone VARCHAR(20)" \
  --host=db-primary \
  --user=admin \
  --ask-pass \
  --chunk-size=500 \
  --max-lag=1s \
  --execute \
  D=myapp,t=users

PostgreSQL: CREATE INDEX CONCURRENTLY

-- 위험: 테이블 잠금
CREATE INDEX idx_users_email ON users(email);

-- 안전: 동시 인덱스 생성 (잠금 없음)
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

-- 주의: 트랜잭션 내에서 사용 불가
-- Flyway에서 사용시 트랜잭션 비활성화 필요
-- Flyway에서 CONCURRENTLY 사용
-- V15__Add_index_concurrently.sql

-- Flyway 트랜잭션 비활성화 (파일 상단 주석)
-- flyway:executeInTransaction=false

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
ON users(email);

6.5 외래 키 안전하게 추가

-- 위험: 전체 테이블 스캔 + 잠금
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id);

-- 안전: NOT VALID로 추가 후 별도 검증
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id)
    NOT VALID;

ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;

7. 롤백 전략

7.1 Forward-Only vs 롤백 지원

Forward-Only (Flyway 기본):
  V1V2V3V4
  문제 발생 시 V5로 수정

Rollback 지원 (Liquibase, Flyway Teams):
  V1V2V3V4
  문제 발생 시 V3으로 되돌림

7.2 Flyway Undo 마이그레이션 (Teams 에디션)

-- U4__Remove_status_column.sql (V4의 Undo)
ALTER TABLE orders DROP COLUMN IF EXISTS status;
# Undo 실행
flyway undo

# 특정 버전까지 Undo
flyway undo -target=2

7.3 보상 마이그레이션 (Compensating Migration)

무료 버전에서 롤백이 필요한 경우 보상 마이그레이션을 작성합니다.

-- V4__Add_status_to_orders.sql (원래 마이그레이션)
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';

-- V5__Revert_status_from_orders.sql (보상 마이그레이션)
ALTER TABLE orders DROP COLUMN IF EXISTS status;

7.4 롤백하면 안 되는 경우

롤백 금지 시나리오:
  1. 컬럼 삭제 후 데이터 손실
     - DROP COLUMN 후에는 데이터 복구 불가
     - 반드시 백업 확인 후 삭제

  2. 데이터 변환 후 원본 손실
     - UPDATE로 데이터 형식 변경 후
     - 원본 데이터를 보존하지 않은 경우

  3. 대규모 데이터 마이그레이션
     - 수백만 건 데이터 이동 후
     - 롤백 시간이 너무 오래 걸림

  4. 외부 시스템 연동
     - API 스키마 변경 후 외부 시스템 적용
     - 롤백해도 외부 시스템은 되돌릴 수 없음

8. CI/CD 통합

8.1 GitHub Actions 파이프라인

# .github/workflows/db-migration.yml
name: Database Migration

on:
  push:
    branches: [main]
    paths:
      - 'db/migration/**'
  pull_request:
    paths:
      - 'db/migration/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Run Flyway Validate
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            validate

      - name: Run Flyway Info
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            info

      - name: Test Migration
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            migrate

      - name: Verify Schema
        run: |
          PGPASSWORD=testpass psql -h localhost -U postgres -d testdb -c "
            SELECT table_name FROM information_schema.tables
            WHERE table_schema = 'public'
            ORDER BY table_name;
          "

  deploy-staging:
    needs: validate
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Staging
        run: |
          docker run --rm \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=$STAGING_DB_URL \
            -user=$STAGING_DB_USER \
            -password=$STAGING_DB_PASSWORD \
            migrate
        env:
          STAGING_DB_URL: ${{ secrets.STAGING_DB_URL }}
          STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
          STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}

  deploy-production:
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Production
        run: |
          docker run --rm \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=$PROD_DB_URL \
            -user=$PROD_DB_USER \
            -password=$PROD_DB_PASSWORD \
            -validateOnMigrate=true \
            migrate
        env:
          PROD_DB_URL: ${{ secrets.PROD_DB_URL }}
          PROD_DB_USER: ${{ secrets.PROD_DB_USER }}
          PROD_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}

8.2 Blue-Green 배포와 DB 마이그레이션

시간 흐름 →

1. [Blue 활성] DB v1, App v1
2. [Blue 활성] DB v1 → v2 마이그레이션 (하위 호환)
3. [Blue 활성] Green에 App v2 배포
4. [BlueGreen 전환] 트래픽 전환
5. [Green 활성] Blue 정리

핵심: DB 마이그레이션은 반드시 하위 호환이어야 함
     이전 버전 코드가 새 스키마에서 동작해야 함

9. Kubernetes 환경

9.1 Init Container 방식

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      initContainers:
        - name: flyway-migrate
          image: flyway/flyway:10-alpine
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: password
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      containers:
        - name: myapp
          image: myapp:latest
          ports:
            - containerPort: 8080
      volumes:
        - name: migrations
          configMap:
            name: flyway-migrations

9.2 Kubernetes Job 방식

# k8s/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: flyway-migrate-v10
  labels:
    app: flyway-migration
spec:
  backoffLimit: 3
  activeDeadlineSeconds: 300
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: flyway
          image: flyway/flyway:10-alpine
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: password
            - name: FLYWAY_VALIDATE_ON_MIGRATE
              value: "true"
            - name: FLYWAY_CONNECT_RETRIES
              value: "5"
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      volumes:
        - name: migrations
          configMap:
            name: flyway-migrations

9.3 Helm Hooks

# helm/templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-flyway-migrate"
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: flyway
          image: "flyway/flyway:{{ .Values.flyway.version }}"
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              value: "jdbc:postgresql://{{ .Values.db.host }}:{{ .Values.db.port }}/{{ .Values.db.name }}"
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: "{{ .Values.db.secretName }}"
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: "{{ .Values.db.secretName }}"
                  key: password
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      volumes:
        - name: migrations
          configMap:
            name: "{{ .Release.Name }}-migrations"

9.4 동시 마이그레이션 처리 (잠금)

여러 Pod가 동시에 마이그레이션을 실행하는 것을 방지해야 합니다.

Flyway 내장 잠금:
  - flyway_schema_history 테이블에 잠금 메커니즘 내장
  - 첫 번째 인스턴스만 마이그레이션 실행
  - 나머지는 대기 후 스킵

추가 안전장치:
  - K8s Job (단일 Pod) 사용 권장
  - Init Container 방식은 Pod마다 시도 (Flyway가 잠금 처리)
  - DB Advisory Lock 활용
-- 커스텀 잠금 메커니즘 (Advisory Lock)
-- beforeMigrate.sql
SELECT pg_advisory_lock(12345);

-- afterMigrate.sql
SELECT pg_advisory_unlock(12345);

9.5 ArgoCD + Flyway 통합

# argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/myapp.git
    path: helm
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m
ArgoCD Sync 흐름:
1. Git 변경 감지
2. Helm hook (pre-upgrade)Flyway Job 실행
3. Flyway 마이그레이션 성공
4. 애플리케이션 Deployment 업데이트
5. ArgoCD 상태 동기화 완료

10. 베스트 프랙티스와 안티패턴

10.1 베스트 프랙티스

1. 하나의 마이그레이션 = 하나의 변경

좋은 예:
  V1__Create_users_table.sql
  V2__Add_email_index.sql
  V3__Create_orders_table.sql

나쁜 예:
  V1__Create_all_tables_and_indexes_and_data.sql

2. 적용된 마이그레이션은 절대 수정 금지

절대 하면 안 되는 것:
  - 이미 실행된 V1__Create_users.sql 파일 수정
  - Flyway는 체크섬으로 변경 감지 → 에러 발생

올바른 방법:
  - 새 마이그레이션 파일로 변경 (V5__Fix_users_table.sql)

3. 프로덕션 유사 데이터로 테스트

# 프로덕션 데이터 규모를 모방한 테스트
# 1000만 건 테이블에서 ALTER TABLE 테스트

# 테스트 데이터 생성
INSERT INTO users_test (username, email)
SELECT
    'user_' || generate_series,
    'user_' || generate_series || '@example.com'
FROM generate_series(1, 10000000);

# 마이그레이션 실행 시간 측정
\timing on
ALTER TABLE users_test ADD COLUMN phone VARCHAR(20);
# Time: 45123.456 ms (약 45초) - 프로덕션에서 45초 잠금!

4. 스키마 변경과 데이터 변경 분리

V10__Add_status_column.sql       # 스키마 변경
V11__Backfill_status_data.sql    # 데이터 변경
V12__Add_status_not_null.sql     # 제약 추가

5. 대규모 데이터 마이그레이션은 배치 처리

-- 나쁜 예: 한 번에 전체 업데이트
UPDATE users SET normalized_email = LOWER(email);

-- 좋은 예: 배치 처리
DO $block$
DECLARE
    batch_size INT := 10000;
    total_updated INT := 0;
    rows_affected INT;
BEGIN
    LOOP
        UPDATE users
        SET normalized_email = LOWER(email)
        WHERE id IN (
            SELECT id FROM users
            WHERE normalized_email IS NULL
            LIMIT batch_size
        );
        GET DIAGNOSTICS rows_affected = ROW_COUNT;
        total_updated := total_updated + rows_affected;
        RAISE NOTICE 'Updated % rows (total: %)', rows_affected, total_updated;
        EXIT WHEN rows_affected = 0;
        PERFORM pg_sleep(0.1);
    END LOOP;
END;
$block$;

10.2 안티패턴

안티패턴 1: 프로덕션에서 직접 DDL 실행
  → 반드시 마이그레이션 파일을 통해 실행

안티패턴 2: 환경별 다른 마이그레이션 파일
  → 동일한 마이그레이션을 모든 환경에서 실행 (플레이스홀더 활용)

안티패턴 3: 마이그레이션에 비즈니스 로직 포함
  → 마이그레이션은 스키마/데이터 변경만. 로직은 애플리케이션에서

안티패턴 4: 하위 호환성 무시
  → 무중단 배포 시 이전 코드와 호환되어야 함

안티패턴 5: 테스트 없이 프로덕션 배포
CI에서 반드시 테스트 DB로 검증

안티패턴 6: 롤백 계획 없이 배포
  → 모든 마이그레이션에 롤백/보상 전략 준비

안티패턴 7: 마이그레이션 도구 버전 미고정
  → lock file이나 명시적 버전 지정

11. 프로덕션 배포 체크리스트

배포 전 반드시 확인해야 할 항목입니다.

사전 검토:
  [ ] 마이그레이션 SQL 코드 리뷰 완료
  [ ] 하위 호환성 확인 (이전 코드와 호환)
  [ ] 프로덕션 유사 데이터로 테스트 완료
  [ ] 실행 시간 측정 (대규모 테이블 주의)
  [ ] 잠금 영향도 분석
  [ ] 롤백/보상 마이그레이션 준비

배포 시:
  [ ] DB 백업 완료 확인
  [ ] 모니터링 대시보드 확인 (CPU, 연결 수, 느린 쿼리)
  [ ] 유지보수 창 내 실행 (필요시)
  [ ] Flyway validate 먼저 실행
  [ ] 마이그레이션 실행 후 info 확인
  [ ] 애플리케이션 헬스체크 확인

배포 후:
  [ ] 스키마 변경 확인 (테이블, 인덱스, 제약)
  [ ] 애플리케이션 정상 동작 확인
  [ ] 성능 모니터링 (쿼리 성능 변화)
  [ ] 에러 로그 확인
  [ ]공유 (변경 내용, 영향도)

12. 퀴즈

Q1: Flyway 네이밍 컨벤션

Flyway에서 반복 실행(Repeatable) 마이그레이션 파일의 접두사는 무엇인가요?

정답: R__

Flyway 마이그레이션 파일 접두사는 다음과 같습니다.

  • V - Versioned Migration (한 번만 실행)
  • R - Repeatable Migration (파일이 변경될 때마다 재실행)
  • U - Undo Migration (Teams 에디션, 롤백용)

Repeatable 마이그레이션은 뷰, 스토어드 프로시저, 함수 등 매번 재생성이 필요한 객체에 사용됩니다.

Q2: Expand-Contract 패턴

무중단 스키마 변경에서 Expand-Contract 패턴의 3단계를 설명하세요.

정답:

  1. Expand (확장): 새 컬럼이나 테이블을 추가합니다. 이전 코드와 새 코드가 모두 동작할 수 있도록 합니다. 새 컬럼은 nullable로 추가합니다.

  2. Migrate (이전): 기존 데이터를 새 구조로 복사하거나 변환합니다. 이 단계에서 새 코드는 새 컬럼을 사용하도록 전환합니다.

  3. Contract (축소): 모든 코드가 새 구조를 사용하는 것이 확인된 후, 이전 컬럼이나 테이블을 제거합니다.

핵심은 각 단계가 별도의 배포로 이루어진다는 것입니다. 하나의 배포에서 모두 처리하면 안 됩니다.

Q3: Prisma Shadow Database

Prisma Migrate에서 Shadow Database의 역할은 무엇인가요?

정답:

Shadow Database는 Prisma Migrate가 마이그레이션의 정확성을 검증하기 위해 사용하는 임시 데이터베이스입니다.

동작 과정:

  1. 임시 데이터베이스를 생성합니다
  2. 모든 기존 마이그레이션 파일을 순서대로 적용합니다
  3. 현재 Prisma 스키마와 비교하여 차이를 감지합니다
  4. 새 마이그레이션 SQL을 생성합니다
  5. 생성된 SQL을 Shadow DB에 적용하여 검증합니다
  6. 임시 데이터베이스를 삭제합니다

이를 통해 마이그레이션 드리프트를 감지하고, 잘못된 마이그레이션 파일을 방지할 수 있습니다.

Q4: CI/CD에서 마이그레이션 테스트

CI/CD 파이프라인에서 DB 마이그레이션을 안전하게 테스트하는 방법은?

정답:

  1. 테스트 데이터베이스 사용: CI 환경에서 Docker 등으로 임시 DB를 생성합니다.

  2. Flyway validate 실행: 마이그레이션 파일의 유효성을 먼저 검증합니다.

  3. 전체 마이그레이션 적용: 빈 DB에서 모든 마이그레이션을 순서대로 실행합니다.

  4. 스키마 검증: 기대하는 테이블, 컬럼, 인덱스가 존재하는지 확인합니다.

  5. 프로덕션 유사 데이터로 성능 테스트: 대규모 데이터에서 마이그레이션 실행 시간과 잠금 영향을 측정합니다.

  6. 스테이징 환경에서 검증: 프로덕션 배포 전 스테이징에서 먼저 적용합니다.

  7. 승인 게이트: 프로덕션 배포 전 수동 승인 단계를 추가합니다.

Q5: 롤백 불가 시나리오

DB 마이그레이션에서 롤백하면 안 되는 상황 두 가지를 설명하세요.

정답:

  1. 컬럼 삭제 후 데이터 손실: DROP COLUMN으로 컬럼을 삭제한 마이그레이션은 롤백해도 데이터가 이미 사라져 복구할 수 없습니다. 반드시 백업을 먼저 확인해야 합니다.

  2. 비가역적 데이터 변환: 데이터를 변환(예: 이메일 해싱)한 후 원본 데이터를 보존하지 않은 경우, 롤백해도 원래 데이터를 복원할 수 없습니다.

추가:

  • 대규모 데이터 이동 후 롤백 시간이 서비스 허용 범위를 초과하는 경우
  • 외부 시스템과 연동된 변경으로 외부 시스템의 상태를 되돌릴 수 없는 경우

이런 상황에서는 Forward-Only 접근(보상 마이그레이션)이 더 안전합니다.


13. 참고 자료

Flyway & Database Migration Complete Guide 2025: Schema Versioning, ORM Integration, Zero-Downtime Deployment

1. Why Database Migration Matters

The "Works on My Machine" Problem with DB Schemas

Every team version-controls their code with Git, but how many apply the same discipline to database schemas? The answer is often a painful no.

Developer A: "I added a phone column to users table"
Developer B: "Huh? It's not on my local"
Developer A: "I shared it on Slack..."
Developer C: "What about staging? Production?"
(3 hours later, production incident)

This scenario repeats across countless teams. When code and schema get out of sync, deployments break and rollbacks become nightmares.

Manual ALTER TABLE Horror Stories

-- Friday 6 PM, directly on production...
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE orders MODIFY COLUMN status ENUM('pending','processing','shipped','delivered','cancelled');

-- Accidentally forgot WHERE clause...
UPDATE users SET role = 'admin';
-- Ctrl+C... too late

Problems with manual management:

  • No audit trail of who changed what and when
  • Schemas diverge across environments (local/staging/production)
  • No rollback procedure
  • No code review for schema changes
  • Execution order is not guaranteed

Version Control for Databases: Same Principle as Git for Code

Just as we use Git for code, databases need version control too.

Git for Code                DB Migration for Schemas
------------------         ---------------------------
commit history       →     migration history
branch/merge         →     migration branching
code review (PR)     →     migration review
rollback (revert)    →     undo migration
CI/CD pipeline       →     migration in pipeline

Core principles:

  1. All schema changes via migration files - no direct SQL execution
  2. Migrations version-controlled with code - included in Git repository
  3. Automated execution - run through CI/CD pipelines
  4. Idempotency - running the same migration multiple times yields the same result
  5. Order guarantee - migrations execute in defined sequence

2. Migration Approaches Comparison

Tool Comparison Matrix

ToolApproachLanguage/EcosystemStrengthsWeaknesses
FlywayVersion-based SQLJava/CLISimple, SQL-nativeMany paid features
LiquibaseChangeset XML/YAML/SQLJava/CLIRollback, diffComplex config
Prisma MigrateDeclarative schemaTypeScriptType-safe, Shadow DBPrisma-locked
TypeORMCode-based migrationTypeScriptAuto-generate from entityTypeORM-locked
AlembicVersion-based PythonPythonSQLAlchemy integrationPython only
Django MigrationsAuto-detectPythonFull ORM integrationDjango only
Knex.jsCode-basedJavaScriptLightweightLimited features
golang-migrateVersion-based SQLGo/CLILightweight, multi-DBMinimal features

Selection Criteria

What is your project's tech stack?
├── Java/SpringFlyway or Liquibase
│   ├── Prefer simplicity → Flyway
│   └── Need rollback/diff → Liquibase
├── TypeScript/Node.js
│   ├── Using PrismaPrisma Migrate
│   ├── Using TypeORMTypeORM Migration
│   └── LightweightKnex.js
├── Python
│   ├── DjangoDjango Migrations
│   └── Flask/FastAPIAlembic
├── Go → golang-migrate
└── Language-agnostic, raw SQLFlyway CLI

3. Flyway Deep Dive

3.1 Introduction to Flyway

Flyway is the most widely used database migration tool. Its core philosophy is simplicity: version-numbered SQL files executed in order.

flyway_migrations/
├── V1__Create_users_table.sql
├── V2__Create_orders_table.sql
├── V3__Add_email_column.sql
├── V4__Create_products_table.sql
├── R__Create_views.sql          # Repeatable
└── U3__Add_email_column.sql     # Undo (Teams)

3.2 Installation

CLI Installation (macOS)

# Homebrew
brew install flyway

# Verify version
flyway --version
# Flyway Community Edition 10.x.x

Docker

docker run --rm \
  -v $(pwd)/sql:/flyway/sql \
  -v $(pwd)/conf:/flyway/conf \
  flyway/flyway:10-alpine \
  migrate

Maven Plugin

<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>10.8.1</version>
    <configuration>
        <url>jdbc:postgresql://localhost:5432/mydb</url>
        <user>postgres</user>
        <password>secret</password>
        <locations>
            <location>filesystem:src/main/resources/db/migration</location>
        </locations>
    </configuration>
</plugin>

Gradle Plugin

plugins {
    id 'org.flywaydb.flyway' version '10.8.1'
}

flyway {
    url = 'jdbc:postgresql://localhost:5432/mydb'
    user = 'postgres'
    password = 'secret'
    locations = ['filesystem:src/main/resources/db/migration']
}

3.3 Naming Convention

Flyway migration file naming rules:

V{version}__{description}.sql       # Versioned Migration
R__{description}.sql                # Repeatable Migration
U{version}__{description}.sql       # Undo Migration (Teams)

Key points:

  • Prefix: V (Versioned), R (Repeatable), U (Undo)
  • Version: numeric (1, 2, 3) or date-based (20250325) or timestamp
  • Separator: double underscore (__)
  • Description: English words separated by underscores
-- V1__Create_users_table.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- V2__Create_orders_table.sql
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
-- V3__Add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN profile_image_url TEXT;
-- R__Create_reporting_views.sql (re-executed every time the file changes)
CREATE OR REPLACE VIEW v_user_order_summary AS
SELECT
    u.id AS user_id,
    u.username,
    u.email,
    COUNT(o.id) AS order_count,
    COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username, u.email;

3.4 Core Commands

# Execute migrations
flyway migrate

# Check current status
flyway info

# Validate migration integrity
flyway validate

# Drop all objects (CAUTION: destroys everything)
flyway clean

# Apply Flyway to existing database (baseline)
flyway baseline

# Repair failed migrations
flyway repair

Example info output

+-----------+---------+---------------------+------+---------------------+---------+----------+
| Category  | Version | Description         | Type | Installed On        | State   | Undoable |
+-----------+---------+---------------------+------+---------------------+---------+----------+
| Versioned | 1       | Create users table  | SQL  | 2025-03-20 10:15:30 | Success | No       |
| Versioned | 2       | Create orders table | SQL  | 2025-03-20 10:15:31 | Success | No       |
| Versioned | 3       | Add phone to users  | SQL  |                     | Pending | No       |
+-----------+---------+---------------------+------+---------------------+---------+----------+

3.5 Configuration

flyway.conf (traditional format)

# Database connection
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=postgres
flyway.password=secret

# Migration locations
flyway.locations=filesystem:./sql,classpath:db/migration

# Schemas
flyway.schemas=public,app
flyway.defaultSchema=app

# History table
flyway.table=flyway_schema_history

# Encoding
flyway.encoding=UTF-8

# Placeholders
flyway.placeholders.env=production
flyway.placeholders.schema_name=app

# Validation
flyway.validateOnMigrate=true
flyway.validateMigrationNaming=true

# Miscellaneous
flyway.outOfOrder=false
flyway.baselineOnMigrate=false
flyway.cleanDisabled=true

flyway.toml (Flyway v10 new format)

[environments.default]
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "secret"

[flyway]
locations = ["filesystem:./sql"]
defaultSchema = "app"
table = "flyway_schema_history"
validateOnMigrate = true
cleanDisabled = true

[flyway.placeholders]
env = "production"
schema_name = "app"

3.6 Placeholders and Environment-Specific Configuration

-- V5__Create_audit_table.sql
CREATE TABLE ${schema_name}.audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    operation VARCHAR(10) NOT NULL,
    old_data JSONB,
    new_data JSONB,
    changed_by VARCHAR(100),
    changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

COMMENT ON TABLE ${schema_name}.audit_log IS 'Audit log for ${env} environment';

Environment-specific config files:

# flyway-dev.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb_dev
flyway.placeholders.env=development

# flyway-staging.conf
flyway.url=jdbc:postgresql://staging-db:5432/mydb
flyway.placeholders.env=staging

# flyway-prod.conf
flyway.url=jdbc:postgresql://prod-db:5432/mydb
flyway.placeholders.env=production
flyway.cleanDisabled=true
# Run per environment
flyway -configFiles=flyway-dev.conf migrate
flyway -configFiles=flyway-staging.conf migrate
flyway -configFiles=flyway-prod.conf migrate

3.7 Callbacks

Flyway supports callbacks that run before and after migration operations.

Callback file names:
beforeMigrate.sql      - Before migrate
beforeEachMigrate.sql  - Before each migration
afterEachMigrate.sql   - After each migration
afterMigrate.sql       - After migrate completes
beforeClean.sql        - Before clean
afterClean.sql         - After clean completes
beforeValidate.sql     - Before validate
afterValidate.sql      - After validate completes
-- afterMigrate.sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_stats;
ANALYZE users;
ANALYZE orders;

INSERT INTO migration_audit_log (action, executed_at)
VALUES ('migration_completed', NOW());

3.8 Java-Based Migrations

For complex logic, you can write migrations in Java.

// V6__Encrypt_user_emails.java
package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.*;

public class V6__Encrypt_user_emails extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        Connection conn = context.getConnection();

        String selectSql = "SELECT id, email FROM users WHERE encrypted_email IS NULL";
        String updateSql = "UPDATE users SET encrypted_email = ? WHERE id = ?";

        try (Statement select = conn.createStatement();
             PreparedStatement update = conn.prepareStatement(updateSql)) {

            ResultSet rs = select.executeQuery(selectSql);
            int batchSize = 0;

            while (rs.next()) {
                long id = rs.getLong("id");
                String email = rs.getString("email");
                String encrypted = encrypt(email);

                update.setString(1, encrypted);
                update.setLong(2, id);
                update.addBatch();

                if (++batchSize % 1000 == 0) {
                    update.executeBatch();
                }
            }

            if (batchSize % 1000 != 0) {
                update.executeBatch();
            }
        }
    }

    private String encrypt(String value) {
        // Actual encryption logic here
        return "ENC:" + value; // Example placeholder
    }
}

3.9 Spring Boot Integration

# application.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    baseline-version: '0'
    validate-on-migrate: true
    out-of-order: false
    table: flyway_schema_history
    schemas:
      - public
    placeholders:
      env: ${SPRING_PROFILES_ACTIVE:dev}
    clean-disabled: true
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: postgres
    password: secret
// FlywayConfig.java
@Configuration
public class FlywayConfig {

    @Bean
    public FlywayMigrationStrategy flywayMigrationStrategy() {
        return flyway -> {
            MigrationInfo[] pending = flyway.info().pending();
            if (pending.length > 0) {
                System.out.println("Pending migrations: " + pending.length);
                for (MigrationInfo info : pending) {
                    System.out.println("  - " + info.getVersion()
                        + ": " + info.getDescription());
                }
            }
            flyway.migrate();
        };
    }
}

3.10 Full Working Example with PostgreSQL

my-app/
├── src/main/resources/
│   └── db/
│       └── migration/
│           ├── V1__Create_schema.sql
│           ├── V2__Create_users.sql
│           ├── V3__Create_products.sql
│           ├── V4__Create_orders.sql
│           ├── V5__Add_indexes.sql
│           ├── V6__Create_audit_triggers.sql
│           └── R__Update_views.sql
├── flyway.toml
├── pom.xml
└── docker-compose.yml
# docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  flyway:
    image: flyway/flyway:10-alpine
    depends_on:
      - db
    volumes:
      - ./src/main/resources/db/migration:/flyway/sql
      - ./flyway.toml:/flyway/flyway.toml
    command: migrate
    environment:
      FLYWAY_URL: jdbc:postgresql://db:5432/myapp
      FLYWAY_USER: postgres
      FLYWAY_PASSWORD: secret

volumes:
  pgdata:

4. Liquibase Comparison

4.1 Changelog Formats

Liquibase supports four formats: XML, YAML, SQL, and JSON.

YAML Format (most readable)

# db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - changeSet:
      id: 1
      author: dev-team
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: username
                  type: varchar(50)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: email
                  type: varchar(255)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: created_at
                  type: timestamp with time zone
                  defaultValueComputed: NOW()
      rollback:
        - dropTable:
            tableName: users

  - changeSet:
      id: 2
      author: dev-team
      changes:
        - addColumn:
            tableName: users
            columns:
              - column:
                  name: phone
                  type: varchar(20)
      rollback:
        - dropColumn:
            tableName: users
            columnName: phone

SQL Format

-- changeset dev-team:1
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- rollback DROP TABLE users;

-- changeset dev-team:2
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- rollback ALTER TABLE users DROP COLUMN phone;

4.2 Key Liquibase Features

# Run migrations
liquibase update

# Rollback last N changesets
liquibase rollbackCount 1

# Rollback to specific tag
liquibase rollback v1.0

# Diff current DB against changelog
liquibase diff

# Generate changelog from existing DB
liquibase generateChangeLog

# Check status
liquibase status

4.3 Flyway vs Liquibase Selection Guide

Choose Flyway when:
  - You want to write raw SQL directly
  - You prefer a simple workflow
  - You are in the Spring Boot ecosystem
  - Your team is comfortable with SQL

Choose Liquibase when:
  - Rollback support is mandatory
  - Multi-database vendor support is needed
  - You need to reverse-engineer existing DB into a changelog
  - You prefer XML/YAML declarative management
  - You need fine-grained context/conditional execution

5. ORM Migration Systems

5.1 Prisma Migrate (TypeScript/Node.js)

Schema Definition

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  phone     String?
  posts     Post[]
  orders    Order[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int      @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")

  @@map("posts")
}

model Order {
  id         Int         @id @default(autoincrement())
  user       User        @relation(fields: [userId], references: [id])
  userId     Int         @map("user_id")
  totalAmount Decimal    @map("total_amount") @db.Decimal(10, 2)
  status     OrderStatus @default(PENDING)
  items      OrderItem[]
  createdAt  DateTime    @default(now()) @map("created_at")

  @@map("orders")
}

model OrderItem {
  id       Int   @id @default(autoincrement())
  order    Order @relation(fields: [orderId], references: [id])
  orderId  Int   @map("order_id")
  product  String
  quantity Int
  price    Decimal @db.Decimal(10, 2)

  @@map("order_items")
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

Migration Commands

# Development migration (create + apply)
npx prisma migrate dev --name create_initial_schema

# Production migration (apply only)
npx prisma migrate deploy

# Reset database
npx prisma migrate reset

# Check migration status
npx prisma migrate status

# Diff schema vs database
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma

Generated Migration File

-- prisma/migrations/20250325_create_initial_schema/migration.sql
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED');

CREATE TABLE "users" (
    "id" SERIAL NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "phone" TEXT,
    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "posts" (
    "id" SERIAL NOT NULL,
    "title" TEXT NOT NULL,
    "content" TEXT,
    "published" BOOLEAN NOT NULL DEFAULT false,
    "author_id" INTEGER NOT NULL,
    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

ALTER TABLE "posts"
    ADD CONSTRAINT "posts_author_id_fkey"
    FOREIGN KEY ("author_id") REFERENCES "users"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE;

Shadow Database Concept

Prisma Migrate uses a Shadow Database to verify migration correctness:

1. Create temporary Shadow DB
2. Apply all existing migrations to Shadow DB
3. Generate new migration SQL
4. Verify new migration on Shadow DB
5. Delete Shadow DB

5.2 TypeORM Migrations

Entity Definition

// src/entities/User.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn, OneToMany
} from 'typeorm';
import { Order } from './Order';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 50, unique: true })
  username: string;

  @Column({ type: 'varchar', length: 255, unique: true })
  email: string;

  @Column({ type: 'varchar', length: 20, nullable: true })
  phone: string;

  @OneToMany(() => Order, order => order.user)
  orders: Order[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

Migration Generation and Execution

# Auto-generate migration from entity changes
npx typeorm migration:generate -d src/data-source.ts src/migrations/AddPhoneToUser

# Create empty migration
npx typeorm migration:create src/migrations/SeedAdminUser

# Run migrations
npx typeorm migration:run -d src/data-source.ts

# Revert last migration
npx typeorm migration:revert -d src/data-source.ts

Auto-Generated Migration Example

// src/migrations/1711234567890-AddPhoneToUser.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPhoneToUser1711234567890 implements MigrationInterface {
    name = 'AddPhoneToUser1711234567890';

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE "users" ADD "phone" varchar(20)`
        );
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE "users" DROP COLUMN "phone"`
        );
    }
}

5.3 Alembic (Python/SQLAlchemy)

Initial Setup

# Install Alembic
pip install alembic sqlalchemy psycopg2-binary

# Initialize project
alembic init alembic
# alembic/env.py (key section)
from myapp.models import Base

target_metadata = Base.metadata

def run_migrations_online():
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
    )
    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            compare_type=True,
            compare_server_default=True,
        )
        with context.begin_transaction():
            context.run_migrations()

Migration Generation and Execution

# Auto-generate (detect model changes)
alembic revision --autogenerate -m "add phone to users"

# Manual creation
alembic revision -m "seed initial data"

# Upgrade to latest
alembic upgrade head

# Upgrade to specific version
alembic upgrade abc123

# Rollback one step
alembic downgrade -1

# Check current state
alembic current

# View migration history
alembic history

Auto-Generated Migration

# alembic/versions/abc123_add_phone_to_users.py
"""add phone to users"""

from alembic import op
import sqlalchemy as sa

revision = 'abc123'
down_revision = 'prev456'
branch_labels = None
depends_on = None

def upgrade():
    op.add_column('users',
        sa.Column('phone', sa.String(20), nullable=True)
    )
    op.create_index('ix_users_phone', 'users', ['phone'])

def downgrade():
    op.drop_index('ix_users_phone', 'users')
    op.drop_column('users', 'phone')

5.4 Django Migrations

# models.py
from django.db import models

class User(models.Model):
    username = models.CharField(max_length=50, unique=True)
    email = models.EmailField(unique=True)
    phone = models.CharField(max_length=20, blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'users'
# Create migration
python manage.py makemigrations

# Apply migrations
python manage.py migrate

# Check migration status
python manage.py showmigrations

# Preview SQL
python manage.py sqlmigrate myapp 0002

# Squash migrations
python manage.py squashmigrations myapp 0001 0005

Data Migration

# migrations/0003_populate_default_roles.py
from django.db import migrations

def create_roles(apps, schema_editor):
    Role = apps.get_model('myapp', 'Role')
    roles = ['admin', 'editor', 'viewer']
    for role_name in roles:
        Role.objects.get_or_create(name=role_name)

def remove_roles(apps, schema_editor):
    Role = apps.get_model('myapp', 'Role')
    Role.objects.filter(name__in=['admin', 'editor', 'viewer']).delete()

class Migration(migrations.Migration):
    dependencies = [
        ('myapp', '0002_create_role_table'),
    ]

    operations = [
        migrations.RunPython(create_roles, remove_roles),
    ]

6. Zero-Downtime Migration Strategies

6.1 Expand-Contract Pattern (Most Important)

This is the essential pattern for zero-downtime schema changes. It splits the change into three safe phases.

Example: Splitting username into first_name + last_name

Phase 1 (Expand): Add new columns, write to both
Phase 2 (Migrate): Backfill data, switch reads
Phase 3 (Contract): Remove old column

Phase 1: Expand

-- Migration V10: Add new columns
ALTER TABLE users ADD COLUMN first_name VARCHAR(50);
ALTER TABLE users ADD COLUMN last_name VARCHAR(50);

-- Trigger for bidirectional sync
CREATE OR REPLACE FUNCTION sync_user_names()
RETURNS TRIGGER AS $func$
BEGIN
    IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
        IF NEW.first_name IS NULL AND NEW.username IS NOT NULL THEN
            NEW.first_name := split_part(NEW.username, ' ', 1);
            NEW.last_name := split_part(NEW.username, ' ', 2);
        ELSIF NEW.username IS NULL AND NEW.first_name IS NOT NULL THEN
            NEW.username := NEW.first_name || ' ' || COALESCE(NEW.last_name, '');
        END IF;
    END IF;
    RETURN NEW;
END;
$func$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_user_names
    BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_user_names();
Deploy at this point: new code writes to first_name/last_name
Old code writes to username (trigger syncs)

Phase 2: Migrate

-- Migration V11: Backfill existing data
UPDATE users
SET first_name = split_part(username, ' ', 1),
    last_name = split_part(username, ' ', 2)
WHERE first_name IS NULL;

-- Add NOT NULL constraint
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;

Phase 3: Contract

-- Migration V12: Remove old column (after all code has switched)
DROP TRIGGER IF EXISTS trg_sync_user_names ON users;
DROP FUNCTION IF EXISTS sync_user_names();
ALTER TABLE users DROP COLUMN username;

6.2 Column Rename Without Downtime

Directly renaming a column causes service interruption. The safe approach:

Step 1: Add new column + trigger
Step 2: Copy data
Step 3: Switch code to new column
Step 4: Remove old column

6.3 Safely Adding NOT NULL Constraints

-- Dangerous: full table lock
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- Safe: PostgreSQL 12+ CHECK constraint
ALTER TABLE users ADD CONSTRAINT users_email_not_null
    CHECK (email IS NOT NULL) NOT VALID;

-- Validate in separate transaction (no AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;

-- Then convert to NOT NULL constraint
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_not_null;

6.4 Large Table ALTER TABLE Strategies

MySQL: gh-ost (GitHub Online Schema Change)

gh-ost \
  --host=db-primary \
  --database=myapp \
  --table=users \
  --alter="ADD COLUMN phone VARCHAR(20)" \
  --execute \
  --allow-on-master \
  --chunk-size=1000 \
  --max-load=Threads_running=25 \
  --critical-load=Threads_running=100

MySQL: pt-online-schema-change (Percona Toolkit)

pt-online-schema-change \
  --alter "ADD COLUMN phone VARCHAR(20)" \
  --host=db-primary \
  --user=admin \
  --ask-pass \
  --chunk-size=500 \
  --max-lag=1s \
  --execute \
  D=myapp,t=users

PostgreSQL: CREATE INDEX CONCURRENTLY

-- Dangerous: table lock
CREATE INDEX idx_users_email ON users(email);

-- Safe: concurrent index creation (no lock)
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

-- Note: cannot be used inside a transaction
-- In Flyway, disable transaction for this migration
-- Using CONCURRENTLY in Flyway
-- V15__Add_index_concurrently.sql

-- Disable Flyway transaction (comment at top of file)
-- flyway:executeInTransaction=false

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
ON users(email);

6.5 Safely Adding Foreign Keys

-- Dangerous: full table scan + lock
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id);

-- Safe: add NOT VALID then validate separately
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id)
    NOT VALID;

ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;

7. Rollback Strategies

7.1 Forward-Only vs Rollback Support

Forward-Only (Flyway default):
  V1V2V3V4
  If V4 has issues → create V5 to fix

Rollback Support (Liquibase, Flyway Teams):
  V1V2V3V4
  If V4 has issues → rollback to V3

7.2 Flyway Undo Migrations (Teams Edition)

-- U4__Remove_status_column.sql (Undo for V4)
ALTER TABLE orders DROP COLUMN IF EXISTS status;
# Execute undo
flyway undo

# Undo to specific version
flyway undo -target=2

7.3 Compensating Migrations

For free-tier users who need rollback, write compensating migrations:

-- V4__Add_status_to_orders.sql (original migration)
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';

-- V5__Revert_status_from_orders.sql (compensating migration)
ALTER TABLE orders DROP COLUMN IF EXISTS status;

7.4 When NOT to Rollback

Do NOT rollback when:
  1. Column deletion caused data loss
     - DROP COLUMN cannot be recovered
     - Always verify backups before deletion

  2. Data transformation destroyed originals
     - UPDATE changed data format
     - Original data was not preserved

  3. Large-scale data migration
     - Millions of rows moved
     - Rollback time exceeds acceptable limits

  4. External system integration
     - API schema changes adopted by external consumers
     - Cannot rollback external systems

8. CI/CD Integration

8.1 GitHub Actions Pipeline

# .github/workflows/db-migration.yml
name: Database Migration

on:
  push:
    branches: [main]
    paths:
      - 'db/migration/**'
  pull_request:
    paths:
      - 'db/migration/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Run Flyway Validate
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            validate

      - name: Run Flyway Info
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            info

      - name: Test Migration
        run: |
          docker run --rm --network host \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=jdbc:postgresql://localhost:5432/testdb \
            -user=postgres \
            -password=testpass \
            migrate

      - name: Verify Schema
        run: |
          PGPASSWORD=testpass psql -h localhost -U postgres -d testdb -c "
            SELECT table_name FROM information_schema.tables
            WHERE table_schema = 'public'
            ORDER BY table_name;
          "

  deploy-staging:
    needs: validate
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Staging
        run: |
          docker run --rm \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=$STAGING_DB_URL \
            -user=$STAGING_DB_USER \
            -password=$STAGING_DB_PASSWORD \
            migrate
        env:
          STAGING_DB_URL: ${{ secrets.STAGING_DB_URL }}
          STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
          STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}

  deploy-production:
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Production
        run: |
          docker run --rm \
            -v $(pwd)/db/migration:/flyway/sql \
            flyway/flyway:10-alpine \
            -url=$PROD_DB_URL \
            -user=$PROD_DB_USER \
            -password=$PROD_DB_PASSWORD \
            -validateOnMigrate=true \
            migrate
        env:
          PROD_DB_URL: ${{ secrets.PROD_DB_URL }}
          PROD_DB_USER: ${{ secrets.PROD_DB_USER }}
          PROD_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}

8.2 Blue-Green Deployment with DB Migration

Timeline
1. [Blue active] DB v1, App v1
2. [Blue active] DB v1 → v2 migration (backward compatible)
3. [Blue active] Deploy App v2 to Green
4. [BlueGreen switch] Traffic cutover
5. [Green active] Decommission Blue

Key: DB migration MUST be backward compatible
     Old version code must work with new schema

9. Kubernetes Environment

9.1 Init Container Approach

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      initContainers:
        - name: flyway-migrate
          image: flyway/flyway:10-alpine
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: password
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      containers:
        - name: myapp
          image: myapp:latest
          ports:
            - containerPort: 8080
      volumes:
        - name: migrations
          configMap:
            name: flyway-migrations

9.2 Kubernetes Job Approach

# k8s/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: flyway-migrate-v10
  labels:
    app: flyway-migration
spec:
  backoffLimit: 3
  activeDeadlineSeconds: 300
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: flyway
          image: flyway/flyway:10-alpine
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: password
            - name: FLYWAY_VALIDATE_ON_MIGRATE
              value: "true"
            - name: FLYWAY_CONNECT_RETRIES
              value: "5"
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      volumes:
        - name: migrations
          configMap:
            name: flyway-migrations

9.3 Helm Hooks

# helm/templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-flyway-migrate"
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: flyway
          image: "flyway/flyway:{{ .Values.flyway.version }}"
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              value: "jdbc:postgresql://{{ .Values.db.host }}:{{ .Values.db.port }}/{{ .Values.db.name }}"
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef:
                  name: "{{ .Values.db.secretName }}"
                  key: username
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: "{{ .Values.db.secretName }}"
                  key: password
          volumeMounts:
            - name: migrations
              mountPath: /flyway/sql
      volumes:
        - name: migrations
          configMap:
            name: "{{ .Release.Name }}-migrations"

9.4 Handling Concurrent Migrations (Locking)

You must prevent multiple Pods from running migrations simultaneously.

Flyway built-in locking:
  - flyway_schema_history table has built-in lock mechanism
  - First instance runs the migration
  - Others wait and then skip

Additional safeguards:
  - K8s Job (single Pod) recommended
  - Init Container approach: each Pod attempts (Flyway handles locking)
  - DB Advisory Lock for extra safety
-- Custom locking mechanism (Advisory Lock)
-- beforeMigrate.sql
SELECT pg_advisory_lock(12345);

-- afterMigrate.sql
SELECT pg_advisory_unlock(12345);

9.5 ArgoCD + Flyway Integration

# argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/myapp.git
    path: helm
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m
ArgoCD Sync Flow:
1. Detect Git changes
2. Helm hook (pre-upgrade)Execute Flyway Job
3. Flyway migration succeeds
4. Application Deployment updates
5. ArgoCD sync complete

10. Best Practices and Anti-Patterns

10.1 Best Practices

1. One migration = one change

Good:
  V1__Create_users_table.sql
  V2__Add_email_index.sql
  V3__Create_orders_table.sql

Bad:
  V1__Create_all_tables_and_indexes_and_data.sql

2. Never modify applied migrations

Never do this:
  - Edit an already-executed V1__Create_users.sql
  - Flyway detects changes via checksum → error

Correct approach:
  - Create a new migration file (V5__Fix_users_table.sql)

3. Test migrations on production-like data

# Test with production-scale data
# Testing ALTER TABLE on a 10 million row table

# Generate test data
INSERT INTO users_test (username, email)
SELECT
    'user_' || generate_series,
    'user_' || generate_series || '@example.com'
FROM generate_series(1, 10000000);

# Measure migration execution time
\timing on
ALTER TABLE users_test ADD COLUMN phone VARCHAR(20);
# Time: 45123.456 ms (~45 seconds) - 45 seconds of locking in production!

4. Separate schema and data migrations

V10__Add_status_column.sql       # Schema change
V11__Backfill_status_data.sql    # Data change
V12__Add_status_not_null.sql     # Add constraint

5. Batch process large data migrations

-- Bad: update everything at once
UPDATE users SET normalized_email = LOWER(email);

-- Good: batch processing
DO $block$
DECLARE
    batch_size INT := 10000;
    total_updated INT := 0;
    rows_affected INT;
BEGIN
    LOOP
        UPDATE users
        SET normalized_email = LOWER(email)
        WHERE id IN (
            SELECT id FROM users
            WHERE normalized_email IS NULL
            LIMIT batch_size
        );
        GET DIAGNOSTICS rows_affected = ROW_COUNT;
        total_updated := total_updated + rows_affected;
        RAISE NOTICE 'Updated % rows (total: %)', rows_affected, total_updated;
        EXIT WHEN rows_affected = 0;
        PERFORM pg_sleep(0.1);
    END LOOP;
END;
$block$;

10.2 Anti-Patterns

Anti-pattern 1: Running DDL directly on production
Always go through migration files

Anti-pattern 2: Different migration files per environment
Same migrations in all environments (use placeholders)

Anti-pattern 3: Business logic in migrations
Migrations for schema/data changes only. Logic belongs in application

Anti-pattern 4: Ignoring backward compatibility
For zero-downtime deployment, old code must work with new schema

Anti-pattern 5: Deploying to production without testing
Always validate in CI with test database

Anti-pattern 6: Deploying without a rollback plan
Prepare rollback/compensating strategy for every migration

Anti-pattern 7: Not version-locking migration tools
Use lock files or explicit version pinning

11. Production Deployment Checklist

Items to verify before every deployment:

Pre-Deployment Review:
  [ ] Migration SQL code reviewed
  [ ] Backward compatibility verified (works with old code)
  [ ] Tested on production-like data
  [ ] Execution time measured (watch out for large tables)
  [ ] Lock impact analyzed
  [ ] Rollback/compensating migration prepared

During Deployment:
  [ ] Database backup confirmed
  [ ] Monitoring dashboard checked (CPU, connections, slow queries)
  [ ] Execute within maintenance window (if needed)
  [ ] Run flyway validate first
  [ ] Verify flyway info after migration
  [ ] Application health check confirmed

Post-Deployment:
  [ ] Schema changes verified (tables, indexes, constraints)
  [ ] Application functionality confirmed
  [ ] Performance monitoring (query performance changes)
  [ ] Error logs reviewed
  [ ] Team notified (changes, impact)

12. Quiz

Q1: Flyway Naming Convention

What is the prefix for Repeatable migrations in Flyway?

Answer: R__

Flyway migration file prefixes:

  • V - Versioned Migration (executed once)
  • R - Repeatable Migration (re-executed whenever the file changes)
  • U - Undo Migration (Teams edition, for rollback)

Repeatable migrations are used for objects that need to be recreated every time, such as views, stored procedures, and functions.

Q2: Expand-Contract Pattern

Describe the three phases of the Expand-Contract pattern for zero-downtime schema changes.

Answer:

  1. Expand: Add new columns or tables. Ensure both old and new code can operate. New columns should be nullable.

  2. Migrate: Copy or transform existing data to the new structure. At this stage, new code switches to using the new columns.

  3. Contract: After confirming all code uses the new structure, remove the old columns or tables.

The key insight is that each phase is a separate deployment. Never combine all phases into a single deployment.

Q3: Prisma Shadow Database

What is the purpose of the Shadow Database in Prisma Migrate?

Answer:

The Shadow Database is a temporary database that Prisma Migrate uses to verify migration correctness.

How it works:

  1. Creates a temporary database
  2. Applies all existing migration files in order
  3. Compares against the current Prisma schema to detect differences
  4. Generates new migration SQL
  5. Applies generated SQL to Shadow DB for verification
  6. Deletes the temporary database

This detects migration drift and prevents incorrect migration files.

Q4: CI/CD Migration Testing

How do you safely test DB migrations in a CI/CD pipeline?

Answer:

  1. Use a test database: Spin up a temporary DB in CI using Docker.

  2. Run Flyway validate: Verify migration file integrity first.

  3. Apply all migrations: Execute all migrations in order on an empty DB.

  4. Verify schema: Confirm expected tables, columns, and indexes exist.

  5. Performance test with production-like data: Measure migration execution time and lock impact on large datasets.

  6. Validate in staging: Apply to staging environment before production.

  7. Approval gates: Add manual approval step before production deployment.

Q5: When NOT to Rollback

Name two scenarios where you should NOT rollback a database migration.

Answer:

  1. Column deletion caused data loss: A DROP COLUMN migration cannot be rolled back because the data is already gone. Always verify backups before deletion.

  2. Irreversible data transformation: If data was transformed (e.g., email hashing) and original data was not preserved, rollback cannot restore the original values.

Additional cases:

  • Large-scale data migration where rollback time exceeds service-level tolerance
  • External system integration where the external system's state cannot be reverted

In these scenarios, a forward-only approach (compensating migrations) is safer.


13. References