Skip to content
Published on

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

Authors

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. 참고 자료