서비스가 배포된 뒤에도 스키마는 계속 바뀝니다. 변경 이력을 코드처럼 관리하지 않으면 개발·스테이징·운영 DB가 서로 다른 상태가 됩니다. 마이그레이션 버전 관리는 팀이 안전하게 DB 구조를 진화시키는 기본 장치입니다.
스키마 변경을 버전 관리 시스템(Git)에 커밋된 코드로 관리하면 팀 전체가 동일한 순서로 변경사항을 적용할 수 있습니다. 이 모듈에서는 주요 마이그레이션 도구의 동작 방식과 운영 환경에서 안전하게 스키마를 변경하는 전략을 배웁니다.
- 1마이그레이션이 필요한 이유와 수동 관리의 문제점
- 2Flyway — SQL 파일 기반 버전 관리
- 3Liquibase — 플랫폼 독립적인 changeset 방식
- 4Flyway vs Liquibase 비교
- 5Prisma migrate — ORM 통합 마이그레이션
- 6무중단 마이그레이션의 Expand-Contract 패턴
마이그레이션 — Flyway, Liquibase, 스키마 버전 관리
운영 중인 서비스에서 데이터베이스 스키마를 변경하는 일은 가장 위험한 작업 중 하나입니다. 컬럼 하나를 잘못 삭제하거나 잠금 없이 인덱스를 추가했다가 수분간 서비스가 중단될 수 있습니다. DDL 변경은 대부분 롤백이 불가능하고, 실행 중 테이블 락이 걸리며, 구버전 애플리케이션과의 호환성 문제를 일으킵니다. 마이그레이션 도구는 이러한 변경 이력을 코드로 관리하고 재현 가능하게 만들어 줍니다.
마이그레이션 도구 — Flyway와 Liquibase 비교
새 개발자가 합류해서 로컬 개발환경을 세팅합니다. git clone까지는 잘 됐는데 앱이 실행되지 않습니다. 테이블 컬럼이 맞지 않는다는 에러입니다. 시니어한테 물어보니 "지난주에 컬럼 추가했어요, 직접 ALTER하세요"라고 합니다. 문서도 없고, 언제 무엇이 바뀌었는지도 모릅니다. 마이그레이션 도구 없이 스키마를 관리하면 팀 규모가 커질수록 이런 혼란이 반복됩니다.

마이그레이션 없이 스키마를 관리하면 생기는 일
팀에서 마이그레이션 도구 없이 스키마를 변경하면 다음과 같은 문제가 발생합니다.
- 환경 불일치: 개발자 A는 컬럼을 추가했지만 개발자 B는 모름
- 재현 불가: 새 개발자가 합류했을 때 정확한 스키마 상태를 알 수 없음
- 배포 사고: 운영 DB에 변경사항 적용 순서가 잘못되어 데이터 손상
- 롤백 불가: 어떤 변경이 언제 이루어졌는지 추적이 어려움
Flyway vs Liquibase 비교
두 도구 모두 마이그레이션 이력을 DB 테이블에 저장하고 체크섬으로 변경 여부를 감지합니다. 선택 기준은 팀의 SQL 친숙도와 멀티 DBMS 지원 필요 여부입니다.
| 항목 | Flyway | Liquibase |
|---|---|---|
| 파일 형식 | 주로 SQL | XML, YAML, JSON, SQL |
| 학습 곡선 | 낮음 | 중간 |
| 롤백 | 수동 SQL 작성 | changeset에 rollback 내장 가능 |
| DBMS 추상화 | 낮음 | 높음 (플랫폼 독립) |
| 체크섬 검증 | 강제 | 선택적 |
| 이력 테이블 | flyway_schema_history | DATABASECHANGELOG |
| 기업 지원 | 유료 플랜 있음 | 유료 플랜 있음 |
| 적합한 환경 | SQL 중심, 단순한 팀 | 멀티 DBMS, 복잡한 변경 관리 |
Flyway — SQL 파일 기반 버전 관리
Flyway는 가장 단순하고 직관적인 마이그레이션 도구입니다. SQL 파일에 버전 번호를 매겨 관리하며, flyway_schema_history 테이블에 실행 이력과 체크섬을 기록합니다.
파일 명명 규칙은 V{버전}__{설명}.sql 형식이며, R__ 접두사 파일은 내용이 변경될 때마다 재실행됩니다(뷰, 함수 재정의에 유용).
migrations/
├── V1__create_users_table.sql
├── V2__add_email_to_users.sql
├── V3__create_orders_table.sql
├── V3.1__add_order_status_index.sql
└── R__create_user_summary_view.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
실행 완료 또는 조회 결과가 표시됩니다.
- 적용 버전—마이그레이션 이력 테이블에 새 버전이 기록됐는지 확인합니다.
- 락 시간—DDL 실행 중 운영 쿼리가 오래 대기하지 않는지 봅니다.
- 롤백 경로—되돌릴 수 없는 변경은 별도 배포 단계로 분리됐는지 점검합니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users ADD COLUMN email VARCHAR(255);
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
Flyway가 flyway_schema_history 테이블에 기록한 이력을 조회하면 다음과 같습니다. 체크섬 컬럼을 통해 이미 적용된 파일이 수정되었는지 감지합니다.
SELECT version, description, checksum, success, installed_on
FROM flyway_schema_history
ORDER BY installed_rank;
한 번 적용된 마이그레이션 파일을 수정하면 Flyway는 체크섬 불일치를 감지하고 실행을 즉시 중단합니다. 이미 적용된 파일은 절대 수정하지 않고 새 버전 파일을 추가해야 합니다.
Spring Boot 통합은 application.yml에 다음을 추가하면 애플리케이션 시작 시 자동으로 마이그레이션을 적용합니다.
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
팀원이 이미 운영 DB에 적용된 V3__create_orders_table.sql을 로컬에서 수정하면, 다음 배포 시 Flyway가 체크섬 불일치를 감지하고 전체 마이그레이션 실행을 중단합니다. 운영 서버에서 마이그레이션이 실패하면 애플리케이션 시작도 실패합니다.
해결: 이미 적용된 마이그레이션 파일은 절대 수정하지 않습니다. 변경이 필요하면 새 버전 파일(V4__)을 추가합니다. Git 브랜치 보호 규칙에 "기존 마이그레이션 파일 수정 금지" 규칙을 추가하고, PR 리뷰 체크리스트에 포함시키는 것이 좋습니다.
Liquibase — XML/YAML 기반 changeset 방식
Liquibase는 XML, YAML, JSON, SQL 형식으로 변경사항을 표현할 수 있습니다. 각 changeset에 rollback 블록을 함께 정의할 수 있어, Flyway 대비 롤백 전략을 구조적으로 관리할 수 있습니다.
databaseChangeLog:
- changeSet:
id: 1
author: alice
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
- column:
name: name
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: created_at
type: TIMESTAMP WITH TIME ZONE
defaultValueComputed: NOW()
constraints:
nullable: false
- changeSet:
id: 2
author: bob
changes:
- addColumn:
tableName: users
columns:
- column:
name: email
type: VARCHAR(255)
- addUniqueConstraint:
tableName: users
columnNames: email
constraintName: users_email_unique
rollback:
- dropColumn:
tableName: users
columnName: email
Prisma Migrate
Prisma는 ORM과 마이그레이션이 통합된 방식을 제공합니다. prisma migrate dev로 개발 환경에서 마이그레이션 파일을 생성하고, prisma migrate deploy로 운영 환경에 적용합니다.
npx prisma migrate dev --name add_email_to_users
npx prisma migrate deploy
npx prisma migrate status
위 명령으로 생성된 마이그레이션 파일의 내용은 다음과 같습니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE "users" ADD COLUMN "email" TEXT;
ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE ("email");
무중단 마이그레이션 전략 — 컬럼 추가/삭제를 안전하게
배포 시간이 새벽 2시입니다. 컬럼 이름을 변경하는 마이그레이션을 실행했는데 5분이 지나도 끝나지 않습니다. 그 사이 서비스 API가 타임아웃으로 응답하지 않습니다. 수백만 건의 대용량 테이블에서 ALTER TABLE이 잠금을 잡고 있었던 겁니다. 결국 마이그레이션을 강제 중단했고 데이터 정합성이 깨졌습니다. DDL 변경은 올바른 순서와 패턴 없이 운영 환경에 적용하면 장애로 이어집니다.

DDL 변경의 위험성
운영 환경에서 DDL(CREATE, ALTER, DROP) 변경은 세 가지 위험을 가집니다. 첫째, ALTER TABLE은 대용량 테이블에서 수분간 테이블 잠금을 유발합니다. 둘째, 컬럼 삭제나 타입 변경은 롤백이 불가능합니다. 셋째, 구버전 애플리케이션이 삭제된 컬럼을 참조하면 즉시 오류가 발생합니다. 이 세 가지 문제를 모두 해결하는 패턴이 Expand-Contract입니다.
ALTER TABLE users DROP COLUMN username을 운영 DB에 실행했는데, 아직 구버전 애플리케이션 인스턴스가 username 컬럼을 참조하고 있어 즉시 500 오류가 발생했습니다. DDL 롤백은 불가능하므로 데이터를 복구하려면 백업에서 복원해야 합니다.
해결: 컬럼 삭제는 절대 단독으로 실행하지 않습니다. Expand-Contract 패턴의 3단계(새 컬럼 추가 → 코드 배포 → 구 컬럼 삭제)를 반드시 준수하고, 각 단계 사이에 최소 한 번의 전체 배포 주기를 가져야 합니다.
Expand-Contract 패턴
운영 중인 서비스에서 스키마를 변경할 때는 Expand-Contract(확장-수축) 패턴을 사용합니다. 이 패턴은 하나의 변경을 3단계로 나눠서 서비스 중단 없이 진행합니다.
시나리오: users.username 컬럼을 users.display_name으로 이름 변경
3단계로 나눕니다. 1단계에서는 새 컬럼을 추가하면서 기존 컬럼은 그대로 유지합니다. 그다음 애플리케이션 코드를 display_name에 쓰고 username에서 읽는 호환 로직으로 배포합니다. 2단계에서는 기존 데이터를 새 컬럼으로 이전하고, display_name만 사용하는 코드로 전환 배포합니다. 3단계에서는 구 컬럼이 더 이상 참조되지 않음을 확인한 뒤 삭제합니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);
UPDATE users SET display_name = username WHERE display_name IS NULL;
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users DROP COLUMN username;
NOT NULL 컬럼 추가 시 주의사항
기존 데이터가 있는 테이블에 NOT NULL 컬럼을 즉시 추가하면 기존 행이 NULL 상태가 되어 제약 위반 오류가 발생합니다. 올바른 방법은 3단계로 나누는 것입니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE orders ADD COLUMN shipping_address TEXT;
UPDATE orders SET shipping_address = '주소 미등록' WHERE shipping_address IS NULL;
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE orders ALTER COLUMN shipping_address SET NOT NULL;
PostgreSQL 11 이상에서는 DEFAULT 값이 있는 NOT NULL 컬럼을 즉시 추가해도 테이블 재작성 없이 처리됩니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE orders
ADD COLUMN shipping_address TEXT NOT NULL DEFAULT '주소 미등록';
대용량 테이블 인덱스 CONCURRENTLY
일반 CREATE INDEX는 테이블 전체 잠금을 걸어 수백만 건 이상의 테이블에서는 수분간 서비스가 중단될 수 있습니다. 운영 환경에서는 항상 CONCURRENTLY 옵션을 사용합니다.
CONCURRENTLY는 트랜잭션 블록 내에서 사용할 수 없으므로 BEGIN/COMMIT 밖에서 단독으로 실행해야 합니다. 실패 시 INVALID 상태의 인덱스가 남을 수 있으며, 이 경우 DROP INDEX CONCURRENTLY로 정리한 후 재시도합니다.
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
DROP INDEX CONCURRENTLY idx_orders_user_id;
Flyway에서 무중단 마이그레이션 — 배치 업데이트 예시
컬럼을 추가한 뒤 기존 데이터를 채울 때 한 번에 전체 행을 업데이트하면 테이블 잠금이 발생할 수 있습니다. 아래는 1,000건씩 나눠서 처리하는 배치 방식입니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users ADD COLUMN tier VARCHAR(20);
DO $$
DECLARE
batch_size INT := 1000;
updated INT;
BEGIN
LOOP
UPDATE users
SET tier = 'standard'
WHERE id IN (
SELECT id FROM users
WHERE tier IS NULL
ORDER BY id
LIMIT batch_size
);
GET DIAGNOSTICS updated = ROW_COUNT;
EXIT WHEN updated = 0;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users ALTER COLUMN tier SET NOT NULL;
ALTER TABLE users ALTER COLUMN tier SET DEFAULT 'standard';
팀이 GitHub Actions 또는 Jenkins 파이프라인에서 Flyway를 사용할 때, 마이그레이션 단계를 애플리케이션 배포 단계와 분리합니다. 일반적인 파이프라인은 "1단계: flyway migrate 실행 → 2단계: 헬스 체크 → 3단계: 앱 롤링 배포" 순서입니다. Expand-Contract 패턴을 따르면 마이그레이션 파일이 먼저 적용된 상태에서도 구버전 앱이 정상 동작하므로 롤링 배포 중 오류가 발생하지 않습니다. 반대로 컬럼 삭제처럼 하위 호환이 깨지는 변경은 반드시 앱 배포가 100% 완료된 뒤 별도 마이그레이션으로 실행해야 합니다.
체크리스트: 운영 마이그레이션 전 확인 사항
| 항목 | 확인 |
|---|---|
| 마이그레이션이 트랜잭션으로 감싸져 있는가? | 실패 시 롤백 보장 |
| 대용량 테이블 인덱스에 CONCURRENTLY 사용했는가? | 잠금 방지 |
| NOT NULL 추가 전 데이터를 채웠는가? | 오류 방지 |
| 스테이징 환경에서 먼저 테스트했는가? | 실행 시간 파악 |
| 롤백 계획이 있는가? | 사고 대비 |
| 마이그레이션 실행 중 모니터링 준비했는가? | 이상 감지 |
다음 모듈에서는 PostgreSQL의 JSONB 비정형 데이터 처리와 전문 검색(Full-text Search) 기능을 다룹니다.