Split View: Flyway 완벽 가이드 — DB 마이그레이션 자동화의 모든 것
Flyway 완벽 가이드 — DB 마이그레이션 자동화의 모든 것
들어가며
프로덕션 서버에 직접 접속해서 ALTER TABLE을 실행한 적이 있나요? "어제 스테이징에 반영한 스키마 변경이 프로덕션에는 안 되어 있어서 장애가 났습니다" 같은 보고를 받은 적이 있나요?
데이터베이스 스키마를 수동으로 관리하는 것은 애플리케이션 코드를 Git 없이 관리하는 것과 다를 바 없습니다. 스키마 변경 이력을 추적할 수 없고, 환경 간 불일치가 발생하며, 롤백도 불가능합니다.
DB 마이그레이션 도구는 이 문제를 해결합니다. 이 글에서는 가장 널리 사용되는 DB 마이그레이션 도구인 Flyway를 깊이 있게 다룹니다.
1. DB 마이그레이션이란
1.1 왜 필요한가
데이터베이스 마이그레이션이란 DB 스키마의 변경사항을 체계적으로 관리하고, 버전별로 순차 적용하는 프로세스입니다.
수동 관리의 문제점:
- 어떤 변경이 어느 환경에 적용되었는지 추적 불가
- 개발/스테이징/프로덕션 환경 간 스키마 불일치
- 여러 개발자가 동시에 스키마를 변경하면 충돌 발생
- 실수로 잘못된 DDL을 실행하면 롤백이 어려움
- 감사(Audit) 추적이 불가능
1.2 버전 관리의 중요성
애플리케이션 코드는 Git으로 버전 관리하면서 DB 스키마는 왜 관리하지 않을까요? DB 마이그레이션 도구는 스키마 변경을 코드로 관리하여, 누가 언제 어떤 변경을 했는지 추적할 수 있게 합니다.
app-code v1.0 → v1.1 → v1.2 → v2.0
↓ ↓ ↓ ↓
db-schema V1 → V2 → V3 → V4
이렇게 애플리케이션 버전과 DB 스키마 버전을 함께 관리하면, 어떤 시점의 코드가 어떤 스키마를 기대하는지 명확해집니다.
2. Flyway vs Liquibase vs Alembic
| 항목 | Flyway | Liquibase | Alembic |
|---|---|---|---|
| 언어 | Java | Java | Python |
| 스키마 정의 | SQL 파일 기반 | XML/YAML/JSON/SQL | Python 코드 |
| 학습 곡선 | 낮음 | 중간 | 중간 |
| DB 지원 | 20개 이상 | 50개 이상 | SQLAlchemy 지원 DB |
| 롤백 | 유료(Teams), 또는 수동 | 내장(auto-rollback) | 내장(downgrade) |
| Spring Boot 통합 | 네이티브 지원 | 네이티브 지원 | 해당 없음 |
| 가격 | Community 무료 | Community 무료 | 완전 무료 |
| 철학 | SQL 중심, 단순함 | 추상화 레이어 | ORM 연동 |
선택 기준:
- Flyway: SQL을 직접 작성하고 싶고, 단순한 것을 선호하는 팀. Java/Spring Boot 프로젝트에 최적
- Liquibase: DB 벤더 독립적인 스키마 관리가 필요한 팀. 자동 롤백이 중요한 경우
- Alembic: Python/SQLAlchemy 기반 프로젝트
3. Flyway 설치
3.1 CLI 설치
# macOS
brew install flyway
# Linux (tar.gz)
wget -qO- https://download.red-gate.com/maven/release/com/redgate/flyway/flyway-commandline/10.x/flyway-commandline-10.x-linux-x64.tar.gz | tar xz
sudo ln -s $(pwd)/flyway-10.x/flyway /usr/local/bin
# 버전 확인
flyway --version
3.2 Docker
docker run --rm flyway/flyway:10 -url=jdbc:postgresql://host:5432/mydb -user=admin -password=secret migrate
Docker Compose에서 사용하기:
services:
flyway:
image: flyway/flyway:10
command: migrate
volumes:
- ./sql:/flyway/sql
- ./conf:/flyway/conf
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: mydb
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d mydb"]
interval: 5s
timeout: 5s
retries: 5
3.3 Maven/Gradle
<!-- Maven pom.xml -->
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>10.15.0</version>
<configuration>
<url>jdbc:postgresql://localhost:5432/mydb</url>
<user>admin</user>
<password>secret</password>
</configuration>
</plugin>
// Gradle build.gradle
plugins {
id "org.flywaydb.flyway" version "10.15.0"
}
flyway {
url = 'jdbc:postgresql://localhost:5432/mydb'
user = 'admin'
password = 'secret'
}
3.4 Spring Boot 통합
Spring Boot에서는 의존성만 추가하면 자동으로 마이그레이션이 실행됩니다.
<!-- pom.xml -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: '0'
4. 핵심 개념
4.1 마이그레이션 파일 네이밍
Flyway는 파일 이름 규칙으로 마이그레이션의 유형, 버전, 설명을 구분합니다.
V2__add_email_column.sql
│ │ └── 설명 (언더스코어 2개로 구분)
│ └── 버전 번호
└── 유형 (V=Versioned, U=Undo, R=Repeatable)
네이밍 규칙:
- 버전 구분자: 언더스코어 2개(
__) - 단어 구분: 언더스코어 1개(
_) - 버전 번호: 숫자, 점(
.), 언더스코어 사용 가능 - 예시:
V1__init.sql,V1.1__add_index.sql,V2_1__create_orders.sql
4.2 flyway_schema_history 테이블
Flyway는 어떤 마이그레이션이 적용되었는지 flyway_schema_history 테이블에 기록합니다.
SELECT installed_rank, version, description, checksum, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;
installed_rank | version | description | checksum | installed_on | success
----------------+---------+-----------------------+-------------+----------------------+---------
1 | 1 | init | 1234567890 | 2026-04-01 10:00:00 | t
2 | 2 | add email column | -987654321 | 2026-04-05 14:30:00 | t
3 | 3 | create orders table | 1122334455 | 2026-04-10 09:15:00 | t
4.3 체크섬
Flyway는 각 마이그레이션 파일의 체크섬(CRC32)을 계산하여 기록합니다. 한번 적용된 마이그레이션 파일을 수정하면 체크섬이 변경되어 다음 실행 시 오류가 발생합니다.
이는 마이그레이션의 불변성을 보장합니다. 한번 적용된 마이그레이션은 절대 수정하지 말고, 새로운 마이그레이션을 추가해야 합니다.
5. 마이그레이션 유형
5.1 Versioned Migration (V)
가장 기본적인 마이그레이션으로, 버전 번호 순서대로 한 번만 실행됩니다.
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) 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_username ON users(username);
5.2 Undo Migration (U) - Teams Edition
Versioned Migration의 역방향을 정의합니다. Flyway Teams(유료) 기능입니다.
-- U1__create_users_table.sql
DROP TABLE IF EXISTS users;
5.3 Repeatable Migration (R)
버전 번호가 없으며, 파일 내용이 변경될 때마다 재실행됩니다. 뷰, 함수, 프로시저 등의 관리에 적합합니다.
-- R__refresh_user_statistics_view.sql
CREATE OR REPLACE VIEW user_statistics AS
SELECT
u.id,
u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
MAX(o.created_at) AS last_order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username;
5.4 Callback
마이그레이션 라이프사이클의 특정 시점에 실행되는 스크립트입니다.
sql/
beforeMigrate.sql -- 마이그레이션 시작 전
afterMigrate.sql -- 마이그레이션 완료 후
beforeEachMigrate.sql -- 각 마이그레이션 파일 실행 전
afterEachMigrate.sql -- 각 마이그레이션 파일 실행 후
beforeValidate.sql -- 유효성 검사 전
활용 예시 -- 마이그레이션 후 통계 갱신:
-- afterMigrate.sql
ANALYZE;
SELECT schemaname, tablename, last_analyze
FROM pg_stat_user_tables
WHERE schemaname = 'public';
6. Flyway 명령어
6.1 migrate
미적용 마이그레이션을 순서대로 실행합니다.
flyway migrate
6.2 info
현재 마이그레이션 상태를 보여줍니다.
flyway info
+-----------+---------+---------------------+----------+---------------------+----------+----------+
| Category | Version | Description | Type | Installed On | State | Undoable |
+-----------+---------+---------------------+----------+---------------------+----------+----------+
| Versioned | 1 | init | SQL | 2026-04-01 10:00:00 | Success | No |
| Versioned | 2 | add email column | SQL | 2026-04-05 14:30:00 | Success | No |
| Versioned | 3 | create orders table | SQL | | Pending | No |
+-----------+---------+---------------------+----------+---------------------+----------+----------+
6.3 validate
적용된 마이그레이션의 체크섬과 파일 시스템의 체크섬을 비교합니다.
flyway validate
CI/CD 파이프라인에서 마이그레이션을 실행하기 전에 validate를 먼저 실행하는 것이 좋습니다.
6.4 repair
실패한 마이그레이션 기록을 정리합니다.
flyway repair
주요 동작:
- 실패한 마이그레이션 기록을 flyway_schema_history에서 제거
- 적용된 마이그레이션의 체크섬을 현재 파일 기준으로 재계산
6.5 clean
주의: 모든 객체를 삭제합니다. 프로덕션에서 절대 사용 금지!
flyway clean
개발 환경에서 깨끗한 상태에서 다시 시작할 때 사용합니다.
Flyway 10부터는 flyway.cleanDisabled=false를 명시적으로 설정해야 합니다.
6.6 baseline
기존 DB에 Flyway를 도입할 때 사용합니다.
flyway baseline -baselineVersion=5 -baselineDescription="existing schema"
이미 V1~V5까지의 스키마가 적용된 DB에 Flyway를 도입하면, baseline을 V5로 설정하고 V6부터 새 마이그레이션을 추가합니다.
7. 설정 파일
7.1 flyway.conf
# flyway.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=admin
flyway.password=secret
flyway.schemas=public
flyway.locations=filesystem:sql
flyway.baselineOnMigrate=true
flyway.baselineVersion=0
flyway.connectRetries=3
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=true
7.2 Spring Boot (application.yml)
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: admin
password: secret
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: '0'
clean-disabled: true
validate-on-migrate: true
out-of-order: false
connect-retries: 3
table: flyway_schema_history
7.3 환경별 설정
Spring Boot에서는 프로파일별로 설정을 분리합니다.
# application-dev.yml
spring:
flyway:
clean-disabled: false
locations: classpath:db/migration,classpath:db/seed
# application-prod.yml
spring:
flyway:
clean-disabled: true
locations: classpath:db/migration
validate-on-migrate: true
8. 실전 예제
8.1 V1 -- 사용자 테이블 생성
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_users_username UNIQUE (username)
);
COMMENT ON TABLE users IS '사용자 정보';
COMMENT ON COLUMN users.username IS '로그인 아이디';
8.2 V2 -- 이메일 컬럼 제약조건 추가
-- V2__add_email_unique_constraint.sql
-- 기존 중복 데이터 정리
DELETE FROM users a
USING users b
WHERE a.id > b.id
AND a.email = b.email
AND a.email IS NOT NULL;
-- NOT NULL 제약조건 추가
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- UNIQUE 인덱스 추가
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users(email);
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE USING INDEX idx_users_email;
8.3 V3 -- 주문 테이블 생성
-- V3__create_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
order_number VARCHAR(20) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'KRW',
shipping_address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
COMMENT ON TABLE orders IS '주문 정보';
8.4 R -- 뷰 갱신
-- R__refresh_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 total_orders,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
COALESCE(AVG(o.total_amount), 0) AS avg_order_amount,
MAX(o.created_at) AS last_order_at
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status != 'CANCELLED'
GROUP BY u.id, u.username, u.email;
CREATE OR REPLACE VIEW v_daily_order_stats AS
SELECT
DATE(created_at) AS order_date,
COUNT(*) AS order_count,
SUM(total_amount) AS daily_revenue,
AVG(total_amount) AS avg_order_value,
COUNT(DISTINCT user_id) AS unique_customers
FROM orders
WHERE status NOT IN ('CANCELLED')
GROUP BY DATE(created_at);
8.5 프로젝트 디렉토리 구조
src/main/resources/
db/
migration/
V1__create_users_table.sql
V2__add_email_unique_constraint.sql
V3__create_orders_table.sql
V4__create_order_items_table.sql
V5__add_user_profile_fields.sql
R__refresh_views.sql
R__update_functions.sql
seed/
V100__insert_test_users.sql
V101__insert_test_orders.sql
9. 팀 협업 전략
9.1 브랜치별 마이그레이션 충돌
여러 개발자가 동시에 마이그레이션을 작성하면 버전 번호가 충돌할 수 있습니다.
타임스탬프 기반 버전 번호 사용:
V20260412_001__feature_a_create_table.sql (개발자 A)
V20260412_002__feature_b_add_column.sql (개발자 B)
또는 outOfOrder 허용:
spring:
flyway:
out-of-order: true
이 옵션을 켜면 V3 이후에 V2.5가 추가되어도 실행됩니다. 하지만 팀 내에서 명확한 규칙을 정하는 것이 우선입니다.
9.2 네이밍 컨벤션
팀에서 합의할 사항:
# 방법 1: 순차 번호
V1__create_users.sql
V2__add_index.sql
# 방법 2: 날짜 기반
V20260412.1__create_users.sql
V20260412.2__add_index.sql
# 방법 3: 티켓 번호 포함
V1__JIRA_123_create_users.sql
V2__JIRA_456_add_index.sql
9.3 코드 리뷰 체크리스트
마이그레이션 PR 리뷰 시 확인 사항:
- 파일 이름 규칙을 따르는가
- 기존 마이그레이션 파일을 수정하지 않았는가
- 대용량 테이블에 락을 유발하는 DDL은 없는가
- 롤백 스크립트가 준비되어 있는가
- 인덱스 생성 시 CONCURRENTLY 옵션을 사용했는가
10. CI/CD 연동
10.1 GitHub Actions
name: Database Migration
on:
push:
branches: [main]
paths:
- 'src/main/resources/db/migration/**'
jobs:
migrate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Run Flyway Validate
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
validate
- name: Run Flyway Migrate
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
migrate
- name: Run Flyway Info
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
info
10.2 Kubernetes Init Container
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
initContainers:
- name: flyway-migrate
image: flyway/flyway:10
args: ["migrate"]
env:
- name: FLYWAY_URL
value: "jdbc:postgresql://postgres-service:5432/mydb"
- name: FLYWAY_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: FLYWAY_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
volumeMounts:
- name: migration-scripts
mountPath: /flyway/sql
containers:
- name: app
image: my-app:latest
volumes:
- name: migration-scripts
configMap:
name: flyway-migrations
11. 무중단 마이그레이션
11.1 위험한 DDL 작업
프로덕션 환경에서 주의해야 할 작업들:
| 작업 | 위험도 | 이유 |
|---|---|---|
| NOT NULL 추가 | 높음 | 전체 테이블 스캔 필요 |
| 컬럼 타입 변경 | 높음 | 전체 테이블 리라이트 |
| 인덱스 생성 | 중간 | 테이블 락 (CONCURRENTLY 미사용 시) |
| 컬럼 추가 (기본값 없음) | 낮음 | 메타데이터만 변경 |
| 컬럼 추가 (기본값 있음) | 중간 | PostgreSQL 11 이전에는 전체 리라이트 |
11.2 Expand-Contract 패턴
컬럼 이름을 변경하는 안전한 방법:
Phase 1 -- Expand (확장):
-- V10__expand_add_new_column.sql
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);
-- 기존 데이터 복사
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- 양쪽 컬럼에 쓰도록 트리거 설정
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS '
BEGIN
IF TG_OP = ''INSERT'' OR TG_OP = ''UPDATE'' THEN
IF NEW.name IS DISTINCT FROM OLD.name THEN
NEW.full_name := NEW.name;
END IF;
IF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.name := NEW.full_name;
END IF;
END IF;
RETURN NEW;
END;
' LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_user_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();
Phase 2 -- 애플리케이션 배포:
새 코드는 full_name 컬럼을 사용하도록 변경합니다.
Phase 3 -- Contract (축소):
-- V11__contract_remove_old_column.sql
DROP TRIGGER IF EXISTS trg_sync_user_name ON users;
DROP FUNCTION IF EXISTS sync_user_name();
ALTER TABLE users DROP COLUMN name;
11.3 대용량 테이블 인덱스 추가
-- V12__add_index_concurrently.sql
-- Flyway에서 CONCURRENTLY를 사용하려면 트랜잭션을 비활성화해야 합니다
-- flyway.conf에서 설정: flyway.postgresql.transactional.lock=false
-- 또는 Spring Boot에서 별도의 non-transactional migration 사용
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at_status
ON orders(created_at, status);
12. 트러블슈팅
12.1 체크섬 불일치
증상: Migration checksum mismatch 오류
원인: 이미 적용된 마이그레이션 파일이 수정됨
해결:
# 방법 1: repair로 체크섬 갱신 (파일 변경이 의도적인 경우)
flyway repair
# 방법 2: 원본 파일 복구 (실수로 변경한 경우)
git checkout -- src/main/resources/db/migration/V1__init.sql
12.2 실패한 마이그레이션 복구
증상: 마이그레이션이 중간에 실패하여 DB 상태가 불일치
# 1. 실패 상태 확인
flyway info
# 2. 실패한 마이그레이션 기록 제거
flyway repair
# 3. SQL 파일 수정 후 재실행
flyway migrate
PostgreSQL은 DDL이 트랜잭션을 지원하므로 실패 시 자동 롤백됩니다. 하지만 MySQL은 DDL이 암묵적으로 커밋되므로 수동 복구가 필요할 수 있습니다.
12.3 baseline 활용
기존 프로젝트에 Flyway를 도입하는 경우:
# 1. 현재 스키마를 V1으로 덤프
pg_dump --schema-only mydb > V1__baseline.sql
# 2. baseline 설정
flyway baseline -baselineVersion=1
# 3. 이후 마이그레이션은 V2부터 작성
12.4 흔한 실수들
실수 1: 적용된 마이그레이션 파일 수정
- 항상 새로운 마이그레이션을 추가하세요
실수 2: 버전 번호 건너뛰기 (V1, V3, V5)
- Flyway는 기본적으로 순차 번호를 기대합니다
실수 3: 개발 환경에서 clean 후 프로덕션 설정 복사
- 환경별로 설정 파일을 분리하세요
실수 4: 마이그레이션에 DML과 DDL 혼합
- 가능하면 분리하세요. DDL 실패 시 DML도 함께 롤백될 수 있습니다
13. 모범 사례
13.1 롤백 전략
Flyway Community에서는 자동 Undo가 없으므로, 수동 롤백 스크립트를 준비합니다.
sql/
migration/
V5__add_payment_table.sql
rollback/
V5__rollback_add_payment_table.sql
-- V5__rollback_add_payment_table.sql (수동 실행용)
DROP TABLE IF EXISTS payments;
DELETE FROM flyway_schema_history WHERE version = '5';
13.2 데이터 마이그레이션
스키마 변경과 데이터 변경은 별도의 마이그레이션으로 분리합니다.
-- V6__add_role_column.sql (DDL)
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'USER';
-- V7__migrate_admin_roles.sql (DML)
UPDATE users SET role = 'ADMIN' WHERE username IN ('admin', 'superadmin');
UPDATE users SET role = 'MODERATOR' WHERE username IN ('mod1', 'mod2');
13.3 시드 데이터
개발/테스트 환경에서만 사용할 시드 데이터는 별도 디렉토리로 관리합니다.
# application-dev.yml
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/seed
-- db/seed/V1000__seed_test_users.sql
INSERT INTO users (username, email, password_hash, role) VALUES
('testuser1', 'test1@example.com', 'hashed_pw_1', 'USER'),
('testuser2', 'test2@example.com', 'hashed_pw_2', 'USER'),
('testadmin', 'admin@example.com', 'hashed_pw_3', 'ADMIN')
ON CONFLICT (username) DO NOTHING;
13.4 전체 체크리스트
마이그레이션 작성 시 확인할 사항:
- 이전에 적용된 마이그레이션을 수정하지 않았는가
- 버전 번호가 기존 마이그레이션과 충돌하지 않는가
- 프로덕션 대용량 테이블에 대한 ALTER TABLE에 락 영향을 검토했는가
- 인덱스는 CONCURRENTLY 옵션으로 생성하는가
- 롤백 스크립트가 준비되어 있는가
- 데이터 마이그레이션과 스키마 마이그레이션이 분리되어 있는가
- 환경별 설정이 올바른가
마무리
Flyway는 단순하면서도 강력한 DB 마이그레이션 도구입니다. SQL 파일 기반의 접근 방식은 DBA와 개발자 모두에게 친숙하며, Spring Boot와의 네이티브 통합은 Java 생태계에서 특히 빛을 발합니다.
핵심 원칙을 정리하면:
- 마이그레이션은 불변이다: 한번 적용된 파일은 절대 수정하지 않는다
- 항상 전진한다: 문제가 있으면 새 마이그레이션으로 수정한다
- 환경을 분리한다: dev, staging, prod 설정을 명확히 구분한다
- 팀 규칙을 정한다: 네이밍, 리뷰 프로세스, 롤백 전략을 합의한다
- CI/CD에 통합한다: 수동 실행은 실수의 시작이다
DB 스키마 관리도 결국 소프트웨어 엔지니어링의 일부입니다. 코드처럼 버전 관리하고, 테스트하고, 자동화하세요.
The Complete Guide to Flyway -- Automating Database Migrations
Introduction
Have you ever SSH-ed into a production server and ran ALTER TABLE directly? Have you ever received an incident report saying, "The schema change we applied to staging yesterday was missing from production"?
Managing database schemas manually is no different from managing application code without Git. You cannot track schema change history, environments drift apart, and rollbacks become impossible.
Database migration tools solve this problem. In this article, we take an in-depth look at Flyway, one of the most widely adopted DB migration tools.
1. What Is Database Migration
1.1 Why Is It Needed
Database migration is the process of systematically managing schema changes and applying them sequentially by version.
Problems with manual management:
- Cannot track which changes have been applied to which environment
- Schema drift between dev/staging/production
- Conflicts when multiple developers change the schema concurrently
- Accidental DDL execution is hard to roll back
- No audit trail
1.2 The Importance of Version Control
We version-control application code with Git, so why not the database schema? A DB migration tool manages schema changes as code, making it possible to track who changed what and when.
app-code v1.0 -> v1.1 -> v1.2 -> v2.0
| | | |
db-schema V1 -> V2 -> V3 -> V4
When application versions and DB schema versions are managed together, it becomes clear which schema a given code version expects.
2. Flyway vs Liquibase vs Alembic
| Criteria | Flyway | Liquibase | Alembic |
|---|---|---|---|
| Language | Java | Java | Python |
| Schema definition | SQL file-based | XML/YAML/JSON/SQL | Python code |
| Learning curve | Low | Medium | Medium |
| DB support | 20+ | 50+ | SQLAlchemy-supported DBs |
| Rollback | Paid (Teams) or manual | Built-in (auto-rollback) | Built-in (downgrade) |
| Spring Boot integration | Native | Native | N/A |
| Pricing | Community free | Community free | Fully free |
| Philosophy | SQL-centric, simple | Abstraction layer | ORM integration |
Selection criteria:
- Flyway: For teams that prefer writing raw SQL and value simplicity. Ideal for Java/Spring Boot projects
- Liquibase: For teams that need vendor-agnostic schema management. When auto-rollback is important
- Alembic: For Python/SQLAlchemy-based projects
3. Installing Flyway
3.1 CLI Installation
# macOS
brew install flyway
# Linux (tar.gz)
wget -qO- https://download.red-gate.com/maven/release/com/redgate/flyway/flyway-commandline/10.x/flyway-commandline-10.x-linux-x64.tar.gz | tar xz
sudo ln -s $(pwd)/flyway-10.x/flyway /usr/local/bin
# Check version
flyway --version
3.2 Docker
docker run --rm flyway/flyway:10 -url=jdbc:postgresql://host:5432/mydb -user=admin -password=secret migrate
Using with Docker Compose:
services:
flyway:
image: flyway/flyway:10
command: migrate
volumes:
- ./sql:/flyway/sql
- ./conf:/flyway/conf
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: mydb
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d mydb"]
interval: 5s
timeout: 5s
retries: 5
3.3 Maven/Gradle
<!-- Maven pom.xml -->
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>10.15.0</version>
<configuration>
<url>jdbc:postgresql://localhost:5432/mydb</url>
<user>admin</user>
<password>secret</password>
</configuration>
</plugin>
// Gradle build.gradle
plugins {
id "org.flywaydb.flyway" version "10.15.0"
}
flyway {
url = 'jdbc:postgresql://localhost:5432/mydb'
user = 'admin'
password = 'secret'
}
3.4 Spring Boot Integration
With Spring Boot, simply adding the dependency triggers automatic migration on startup.
<!-- pom.xml -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: '0'
4. Core Concepts
4.1 Migration File Naming
Flyway uses file naming conventions to distinguish migration type, version, and description.
V2__add_email_column.sql
| | +-- Description (separated by double underscores)
| +-- Version number
+-- Type (V=Versioned, U=Undo, R=Repeatable)
Naming rules:
- Version separator: double underscore (
__) - Word separator: single underscore (
_) - Version number: digits, dots (
.), underscores allowed - Examples:
V1__init.sql,V1.1__add_index.sql,V2_1__create_orders.sql
4.2 The flyway_schema_history Table
Flyway records which migrations have been applied in the flyway_schema_history table.
SELECT installed_rank, version, description, checksum, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;
installed_rank | version | description | checksum | installed_on | success
----------------+---------+-----------------------+-------------+----------------------+---------
1 | 1 | init | 1234567890 | 2026-04-01 10:00:00 | t
2 | 2 | add email column | -987654321 | 2026-04-05 14:30:00 | t
3 | 3 | create orders table | 1122334455 | 2026-04-10 09:15:00 | t
4.3 Checksums
Flyway calculates a checksum (CRC32) for each migration file and records it. If an already-applied migration file is modified, the checksum will change and an error will be raised on the next run.
This guarantees migration immutability. Once a migration has been applied, never modify it -- always add a new migration.
5. Migration Types
5.1 Versioned Migration (V)
The most basic migration type, executed once in version-number order.
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) 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_username ON users(username);
5.2 Undo Migration (U) -- Teams Edition
Defines the reverse of a Versioned Migration. This is a Flyway Teams (paid) feature.
-- U1__create_users_table.sql
DROP TABLE IF EXISTS users;
5.3 Repeatable Migration (R)
Has no version number and re-executes whenever the file content changes. Ideal for managing views, functions, and procedures.
-- R__refresh_user_statistics_view.sql
CREATE OR REPLACE VIEW user_statistics AS
SELECT
u.id,
u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
MAX(o.created_at) AS last_order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username;
5.4 Callbacks
Scripts that execute at specific points in the migration lifecycle.
sql/
beforeMigrate.sql -- Before migration starts
afterMigrate.sql -- After migration completes
beforeEachMigrate.sql -- Before each migration file
afterEachMigrate.sql -- After each migration file
beforeValidate.sql -- Before validation
Example -- refreshing statistics after migration:
-- afterMigrate.sql
ANALYZE;
SELECT schemaname, tablename, last_analyze
FROM pg_stat_user_tables
WHERE schemaname = 'public';
6. Flyway Commands
6.1 migrate
Executes pending migrations in order.
flyway migrate
6.2 info
Displays the current migration status.
flyway info
+-----------+---------+---------------------+----------+---------------------+----------+----------+
| Category | Version | Description | Type | Installed On | State | Undoable |
+-----------+---------+---------------------+----------+---------------------+----------+----------+
| Versioned | 1 | init | SQL | 2026-04-01 10:00:00 | Success | No |
| Versioned | 2 | add email column | SQL | 2026-04-05 14:30:00 | Success | No |
| Versioned | 3 | create orders table | SQL | | Pending | No |
+-----------+---------+---------------------+----------+---------------------+----------+----------+
6.3 validate
Compares the checksums of applied migrations against the files on disk.
flyway validate
It is best practice to run validate before migrate in CI/CD pipelines.
6.4 repair
Cleans up failed migration records.
flyway repair
Key behaviors:
- Removes failed migration entries from flyway_schema_history
- Recalculates checksums for applied migrations based on current files
6.5 clean
Warning: Drops all objects. Never use in production!
flyway clean
Used in development to start from a clean state.
Since Flyway 10, you must explicitly set flyway.cleanDisabled=false.
6.6 baseline
Used when introducing Flyway to an existing database.
flyway baseline -baselineVersion=5 -baselineDescription="existing schema"
If a database already has schemas V1 through V5 applied, set the baseline to V5 and start adding new migrations from V6.
7. Configuration
7.1 flyway.conf
# flyway.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=admin
flyway.password=secret
flyway.schemas=public
flyway.locations=filesystem:sql
flyway.baselineOnMigrate=true
flyway.baselineVersion=0
flyway.connectRetries=3
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=true
7.2 Spring Boot (application.yml)
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: admin
password: secret
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: '0'
clean-disabled: true
validate-on-migrate: true
out-of-order: false
connect-retries: 3
table: flyway_schema_history
7.3 Environment-Specific Configuration
With Spring Boot, separate configurations by profile.
# application-dev.yml
spring:
flyway:
clean-disabled: false
locations: classpath:db/migration,classpath:db/seed
# application-prod.yml
spring:
flyway:
clean-disabled: true
locations: classpath:db/migration
validate-on-migrate: true
8. Practical Examples
8.1 V1 -- Create Users Table
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_users_username UNIQUE (username)
);
COMMENT ON TABLE users IS 'User information';
COMMENT ON COLUMN users.username IS 'Login ID';
8.2 V2 -- Add Email Unique Constraint
-- V2__add_email_unique_constraint.sql
-- Clean up existing duplicates
DELETE FROM users a
USING users b
WHERE a.id > b.id
AND a.email = b.email
AND a.email IS NOT NULL;
-- Add NOT NULL constraint
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- Add UNIQUE index
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users(email);
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE USING INDEX idx_users_email;
8.3 V3 -- Create Orders Table
-- V3__create_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
order_number VARCHAR(20) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'KRW',
shipping_address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
COMMENT ON TABLE orders IS 'Order information';
8.4 R -- Refresh Views
-- R__refresh_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 total_orders,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
COALESCE(AVG(o.total_amount), 0) AS avg_order_amount,
MAX(o.created_at) AS last_order_at
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status != 'CANCELLED'
GROUP BY u.id, u.username, u.email;
CREATE OR REPLACE VIEW v_daily_order_stats AS
SELECT
DATE(created_at) AS order_date,
COUNT(*) AS order_count,
SUM(total_amount) AS daily_revenue,
AVG(total_amount) AS avg_order_value,
COUNT(DISTINCT user_id) AS unique_customers
FROM orders
WHERE status NOT IN ('CANCELLED')
GROUP BY DATE(created_at);
8.5 Project Directory Structure
src/main/resources/
db/
migration/
V1__create_users_table.sql
V2__add_email_unique_constraint.sql
V3__create_orders_table.sql
V4__create_order_items_table.sql
V5__add_user_profile_fields.sql
R__refresh_views.sql
R__update_functions.sql
seed/
V100__insert_test_users.sql
V101__insert_test_orders.sql
9. Team Collaboration Strategies
9.1 Branch-Based Migration Conflicts
When multiple developers write migrations concurrently, version numbers can collide.
Use timestamp-based version numbers:
V20260412_001__feature_a_create_table.sql (Developer A)
V20260412_002__feature_b_add_column.sql (Developer B)
Or enable outOfOrder:
spring:
flyway:
out-of-order: true
With this option, a V2.5 added after V3 will still be executed. However, establishing clear team conventions should come first.
9.2 Naming Conventions
Things the team should agree on:
# Approach 1: Sequential numbers
V1__create_users.sql
V2__add_index.sql
# Approach 2: Date-based
V20260412.1__create_users.sql
V20260412.2__add_index.sql
# Approach 3: Include ticket number
V1__JIRA_123_create_users.sql
V2__JIRA_456_add_index.sql
9.3 Code Review Checklist
Items to check when reviewing migration PRs:
- Does the filename follow the naming convention
- Were any existing migration files modified
- Are there DDL operations that cause locks on large tables
- Are rollback scripts prepared
- Is CONCURRENTLY used for index creation
10. CI/CD Integration
10.1 GitHub Actions
name: Database Migration
on:
push:
branches: [main]
paths:
- 'src/main/resources/db/migration/**'
jobs:
migrate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Run Flyway Validate
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
validate
- name: Run Flyway Migrate
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
migrate
- name: Run Flyway Info
run: |
docker run --rm --network host \
-v $(pwd)/src/main/resources/db/migration:/flyway/sql \
flyway/flyway:10 \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=test \
-password=test \
info
10.2 Kubernetes Init Container
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
initContainers:
- name: flyway-migrate
image: flyway/flyway:10
args: ["migrate"]
env:
- name: FLYWAY_URL
value: "jdbc:postgresql://postgres-service:5432/mydb"
- name: FLYWAY_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: FLYWAY_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
volumeMounts:
- name: migration-scripts
mountPath: /flyway/sql
containers:
- name: app
image: my-app:latest
volumes:
- name: migration-scripts
configMap:
name: flyway-migrations
11. Zero-Downtime Migrations
11.1 Dangerous DDL Operations
Operations that require caution in production:
| Operation | Risk | Reason |
|---|---|---|
| Adding NOT NULL | High | Requires full table scan |
| Changing column type | High | Full table rewrite |
| Creating index | Medium | Table lock (without CONCURRENTLY) |
| Adding column (no default) | Low | Metadata-only change |
| Adding column (with default) | Medium | Full rewrite before PostgreSQL 11 |
11.2 The Expand-Contract Pattern
A safe way to rename a column:
Phase 1 -- Expand:
-- V10__expand_add_new_column.sql
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);
-- Copy existing data
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- Set up trigger to write to both columns
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS '
BEGIN
IF TG_OP = ''INSERT'' OR TG_OP = ''UPDATE'' THEN
IF NEW.name IS DISTINCT FROM OLD.name THEN
NEW.full_name := NEW.name;
END IF;
IF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.name := NEW.full_name;
END IF;
END IF;
RETURN NEW;
END;
' LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_user_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();
Phase 2 -- Deploy Application:
Deploy new code that uses the full_name column.
Phase 3 -- Contract:
-- V11__contract_remove_old_column.sql
DROP TRIGGER IF EXISTS trg_sync_user_name ON users;
DROP FUNCTION IF EXISTS sync_user_name();
ALTER TABLE users DROP COLUMN name;
11.3 Adding Indexes on Large Tables
-- V12__add_index_concurrently.sql
-- To use CONCURRENTLY in Flyway, you must disable the transaction
-- In flyway.conf: flyway.postgresql.transactional.lock=false
-- Or use a separate non-transactional migration in Spring Boot
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at_status
ON orders(created_at, status);
12. Troubleshooting
12.1 Checksum Mismatch
Symptom: Migration checksum mismatch error
Cause: An already-applied migration file was modified
Resolution:
# Option 1: Repair to update checksum (if the change was intentional)
flyway repair
# Option 2: Restore original file (if the change was accidental)
git checkout -- src/main/resources/db/migration/V1__init.sql
12.2 Recovering from Failed Migrations
Symptom: A migration failed mid-execution, leaving the DB in an inconsistent state
# 1. Check failure status
flyway info
# 2. Remove failed migration record
flyway repair
# 3. Fix SQL file and re-run
flyway migrate
PostgreSQL supports transactional DDL, so failures are automatically rolled back. However, MySQL implicitly commits DDL, which may require manual recovery.
12.3 Using Baseline
When introducing Flyway to an existing project:
# 1. Dump current schema as V1
pg_dump --schema-only mydb > V1__baseline.sql
# 2. Set baseline
flyway baseline -baselineVersion=1
# 3. Write new migrations starting from V2
12.4 Common Mistakes
Mistake 1: Modifying an already-applied migration file
- Always add a new migration instead
Mistake 2: Skipping version numbers (V1, V3, V5)
- Flyway expects sequential numbering by default
Mistake 3: Copying production config after running clean in dev
- Separate configuration files by environment
Mistake 4: Mixing DML and DDL in a single migration
- Separate them when possible. If DDL fails, the DML may be rolled back as well
13. Best Practices
13.1 Rollback Strategy
Since Flyway Community does not support automatic Undo, prepare manual rollback scripts.
sql/
migration/
V5__add_payment_table.sql
rollback/
V5__rollback_add_payment_table.sql
-- V5__rollback_add_payment_table.sql (for manual execution)
DROP TABLE IF EXISTS payments;
DELETE FROM flyway_schema_history WHERE version = '5';
13.2 Data Migration
Separate schema changes from data changes into different migrations.
-- V6__add_role_column.sql (DDL)
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'USER';
-- V7__migrate_admin_roles.sql (DML)
UPDATE users SET role = 'ADMIN' WHERE username IN ('admin', 'superadmin');
UPDATE users SET role = 'MODERATOR' WHERE username IN ('mod1', 'mod2');
13.3 Seed Data
Manage seed data for dev/test environments in a separate directory.
# application-dev.yml
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/seed
-- db/seed/V1000__seed_test_users.sql
INSERT INTO users (username, email, password_hash, role) VALUES
('testuser1', 'test1@example.com', 'hashed_pw_1', 'USER'),
('testuser2', 'test2@example.com', 'hashed_pw_2', 'USER'),
('testadmin', 'admin@example.com', 'hashed_pw_3', 'ADMIN')
ON CONFLICT (username) DO NOTHING;
13.4 Complete Checklist
Items to verify when writing migrations:
- You have not modified any previously applied migrations
- The version number does not conflict with existing migrations
- You have reviewed lock impact for ALTER TABLE on large production tables
- Indexes are created with the CONCURRENTLY option
- Rollback scripts are prepared
- Data migrations and schema migrations are separated
- Environment-specific configuration is correct
Conclusion
Flyway is a simple yet powerful DB migration tool. Its SQL file-based approach is familiar to both DBAs and developers, and its native integration with Spring Boot shines especially in the Java ecosystem.
Key principles in summary:
- Migrations are immutable: Never modify an applied migration file
- Always move forward: If there is a problem, fix it with a new migration
- Separate environments: Clearly distinguish dev, staging, and prod configurations
- Establish team conventions: Agree on naming, review processes, and rollback strategies
- Integrate with CI/CD: Manual execution is the beginning of mistakes
Database schema management is part of software engineering. Version-control it, test it, and automate it -- just like code.