서비스가 배포된 뒤에도 스키마는 계속 바뀝니다. 변경 이력을 코드처럼 관리하지 않으면 개발·스테이징·운영 DB가 서로 다른 상태가 됩니다. 마이그레이션 버전 관리는 팀이 안전하게 DB 구조를 진화시키는 기본 장치입니다.
스키마 변경을 버전 관리 시스템(Git)에 커밋된 코드로 관리하면 팀 전체가 동일한 순서로 변경사항을 적용할 수 있습니다. 이 모듈에서는 주요 마이그레이션 도구의 동작 방식과 운영 환경에서 안전하게 스키마를 변경하는 전략을 배웁니다.
- 1마이그레이션이 필요한 이유와 수동 관리의 문제점을 설명할 수 있다
- 2Flyway로 SQL 파일 기반 스키마 버전 관리를 구성할 수 있다
- 3Liquibase의 플랫폼 독립적인 changeset 방식을 적용할 수 있다
- 4Flyway와 Liquibase를 비교해 상황에 맞는 도구를 선택할 수 있다
- 5Prisma migrate로 ORM 통합 마이그레이션을 실행할 수 있다
- 6Expand-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.sqlV2__add_email_to_users.sqlV3__create_orders_table.sqlV3.1__add_order_status_index.sqlR__create_user_summary_view.sql(반복 실행)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
실행 완료 또는 조회 결과가 표시됩니다.
- 먼저 flyway_schema_history에서 installed_rank 순서를 확인합니다. 버전 번호가 V1 → V2 → V3 순서대로 기록돼 있지 않다면 실행 순서가 어긋난 것입니다.
- success 컬럼 값을 확인합니다. false인 행이 있으면 해당 마이그레이션이 실패한 채 dirty 상태로 남아 있습니다 — 다음 flyway migrate 실행 시 전체가 중단됩니다.
- checksum 컬럼을 기억해 두세요. 같은 버전의 파일을 수정하면 다음 실행 시 체크섬 불일치 오류가 납니다 — checksum이 변경됐다면 파일이 이미 적용된 후 수정된 것입니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, 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");
flyway migrate 한 번이 실제로 하는 일 — 이력 대조부터 기록까지 5단계
flyway migrate(또는 liquibase update) 한 번이면 팀 전원의 DB가 같은 스키마로 맞춰집니다. 그런데 도구는 "이 DB에 무엇이 이미 적용됐고 무엇을 더 실행해야 하는지"를 어떻게 알까요? 비밀은 도구가 DB 안에 만들어 두는 적용 이력 테이블과, 매 실행마다 그 이력과 파일을 대조하는 5단계 절차에 있습니다. 이 흐름을 알면 체크섬 오류·부분 적용·버전 충돌이 어느 단계에서 나는지 바로 짚을 수 있습니다.
[CI / 앱 시작] flyway migrate
│
① 버전 파일 스캔·정렬 (migrations/ 의 V1__, V2__, V3__ … 을 버전 번호 순으로 나열)
│
② 적용 이력 테이블 조회 (flyway_schema_history 에서 '어디까지 적용됐는지' + 각 파일 체크섬 읽기)
│
③ 미적용분만 순차 실행 (이력에 없는 버전만, 낮은 버전 → 높은 버전 순으로 DDL 실행)
│ → 각 파일을 트랜잭션으로 감싸 실행(DDL 트랜잭션 지원 DB)
│
④ 이력 기록 (성공한 파일마다 version · checksum · success=true 를 이력에 추가)
│
⑤ 실패 시 중단 (한 파일이 실패하면 그 지점에서 멈춤 → 이후 버전 미적용, 실패 행은 dirty)
▼
[결과] 개발·스테이징·운영이 같은 순서로 같은 스키마에 수렴
각 단계에서 무슨 일이 일어나고, 어디서 어긋나나:
| 단계 | 하는 일 | 여기서 어긋나면(증상) |
|---|---|---|
| ① 파일 스캔·정렬 | migrations/의 버전 파일을 버전 번호 순으로 나열 | 두 사람이 같은 번호(V5__)를 각자 만들면 순서 충돌·중복 버전 → 머지 때 번호 재조정 필요 |
| ② 이력 대조 | 이력 테이블에서 적용된 버전과 각 파일 체크섬을 읽음 | 이미 적용된 파일을 나중에 수정 → 체크섬 불일치로 실행 거부(적용분은 수정 금지, 새 파일 추가) |
| ③ 미적용분 순차 실행 | 이력에 없는 버전만 낮은→높은 순으로 DDL 실행 | V2가 만든 컬럼을 V4가 참조하는데 순서가 깨지면 실패 → 마이그레이션은 앞 버전에 의존 |
| ④ 이력 기록 | 성공한 파일의 version·checksum·success를 기록 | 이 기록이 있어야 다음 실행이 "미적용분만" 이어서 적용 — 환경 간 스키마 일치의 근거 |
| ⑤ 실패 시 중단 | 실패 지점에서 멈추고 실패 행을 dirty로 남김 | 중간 파일이 실패하면 부분 적용 상태 → dirty 정리(repair) 후 재실행. Flyway는 down 자동 롤백이 없어 되돌리기 SQL을 직접 준비 |
즉 마이그레이션 도구의 핵심은 ②이력 대조와 ④이력 기록입니다 — "어디까지 적용됐는지"를 DB가 스스로 기억하기에 개발·스테이징·운영이 같은 순서로 수렴합니다. 그래서 사고는 대개 코드가 아니라 규율에서 납니다: 적용된 파일 수정(②체크섬), 팀 간 버전 번호 중복(①), down을 안 만들어 되돌릴 수 없는 상태(⑤). 문제가 생기면 flyway_schema_history의 success 컬럼과 체크섬부터 보면 대부분 원인이 잡힙니다.
무중단 마이그레이션 전략 — 컬럼 추가/삭제를 안전하게
배포 시간이 새벽 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;
기존 행이 있는 테이블에 NOT NULL을 한 번에 거는 게 왜 위험한지 직접 부딪힙니다. 즉시 SET NOT NULL이 기존 NULL 행 때문에 거부되는 걸 본 뒤, 추가→백필→제약의 3단계로 통과시킵니다.
-- (실패) 기존 행 shipping_address가 NULL인 상태에서 바로 NOT NULL
ALTER TABLE orders ALTER COLUMN shipping_address SET NOT NULL;
-- (안전) 1) nullable로 컬럼 추가 2) 기존 행 백필 3) 그제서야 NOT NULL
ALTER TABLE orders ADD COLUMN shipping_address TEXT; -- 1
UPDATE orders SET shipping_address = '주소 미등록'
WHERE shipping_address IS NULL; -- 2
ALTER TABLE orders ALTER COLUMN shipping_address SET NOT NULL; -- 3
-- 백필 누락 검증: 0이어야 3단계가 성공한다
SELECT count(*) FROM orders WHERE shipping_address IS NULL;
-- 즉시 SET NOT NULL
ERROR: column "shipping_address" of relation "orders" contains null values
-- 3단계 후 검증
count
-------
0
ALTER TABLE orders ALTER COLUMN shipping_address SET NOT NULL;- 즉시 SET NOT NULL에서 'contains null values' 에러가 나는지 본다 — 나야 정상이다(기존 행이 NULL이라 제약을 못 건다는 증거)
- 3단계 직전 SELECT count(*) ... IS NULL 이 0인지 반드시 확인 — 0이 아니면 백필이 덜 된 것이고, 이 상태로 SET NOT NULL하면 다시 실패한다
- 대용량 테이블이면 2)번 UPDATE가 전체 행을 잠글 수 있다 — 배치(WHERE id BETWEEN ...)로 나눠 백필하는지 점검
- PostgreSQL 11+는 'ADD COLUMN ... NOT NULL DEFAULT ...' 한 줄이 테이블 재작성 없이 끝난다 — 신규 컬럼이면 3단계 대신 이 방식을 우선 고려
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 추가 전 데이터를 채웠는가? | 오류 방지 |
| 스테이징 환경에서 먼저 테스트했는가? | 실행 시간 파악 |
| 롤백 계획이 있는가? | 사고 대비 |
| 마이그레이션 실행 중 모니터링 준비했는가? | 이상 감지 |
심화 — 백필 UPDATE의 청구서: dead tuple·autovacuum·복제 지연
심화: '한 방 UPDATE'가 왜 위험한가 — 배치가 선택이 아닌 이유
앞의 배치 백필 예제는 한 번에 다 바꾸지 말고 수천 건씩이라고 했습니다. 왜 그래야 하는지 한 단계 더 들어가 보면, 마이그레이션이 락을 안 잡아도 다른 방식으로 서비스를 흔들 수 있음을 알게 됩니다.
- UPDATE는 행을 덮어쓰지 않습니다: PostgreSQL은 MVCC라 한 행을 UPDATE하면 새 버전을 쓰고 옛 버전을 dead tuple로 남깁니다. 5천만 행을 한 UPDATE로 바꾸면 5천만 개의 dead tuple이 한꺼번에 생깁니다. 테이블은 순식간에 부풀고, 이후 순차 스캔이 죽은 공간까지 읽느라 느려집니다.
- 긴 트랜잭션은 autovacuum을 무력화합니다: 그 거대한 UPDATE가 도는 동안(그리고 커밋될 때까지) xmin horizon이 묶여 autovacuum이 dead tuple을 회수하지 못합니다. 끝난 뒤에도 autovacuum이 수천만 행을 정리하느라 몇 시간 CPU·I/O를 잡아, 그 시간 내내 운영 쿼리와 자원을 다툽니다.
- 복제본이 뒤처집니다: 대량 변경은 그만큼의 WAL을 폭발적으로 만들고, 복제본은 이를 대체로 직렬로 재생합니다. 그 결과 read replica가 몇 분씩 지연돼, 쓰고 바로 읽기가 복제본으로 가는 요청에서 stale read가 번집니다(Master-Slave 복제(Replication) 구축과 DB 고가용성(HA) 아키텍처). 마이그레이션 하나가 읽기 경로 전체의 신선도를 흔드는 것입니다.
- 그래서 배치로 나눕니다: 수천 건씩 끊어 커밋하면 트랜잭션이 짧아 xmin horizon이 자주 풀리고, autovacuum이 사이사이 dead tuple을 회수하며, WAL과 복제 지연이 완만해집니다. 배치 사이 짧은 pg_sleep은 복제본과 autovacuum이 따라올 여유를 줍니다. 느리게 하는 게 아니라 흔들지 않게 하는 것입니다.
핵심은, 마이그레이션의 위험이 락만이 아니라는 점입니다. 락을 피해도 dead tuple·autovacuum·복제 지연이라는 청구서가 뒤따라오며, 배치는 그 청구서를 잘게 쪼개는 장치입니다.
상황: 신규 컬럼을 채우려고 대용량 테이블 전체를 한 문장의 UPDATE로 백필했습니다. 마이그레이션 자체는 성공으로 끝났는데, 이후 복제본을 보는 화면들이 옛 데이터를 보여주고 원본 DB의 그 테이블 조회도 눈에 띄게 느려졌습니다.
원인: 한 방 UPDATE가 수천만 개의 dead tuple을 만들어 테이블이 급팽창했고, 그 큰 변경이 만든 WAL을 복제본이 직렬로 재생하느라 수 분씩 뒤처졌습니다. 동시에 autovacuum이 방대한 dead tuple을 정리하며 자원을 잡아 조회까지 느려졌습니다. 락은 없었지만 MVCC·복제·autovacuum 쪽으로 비용이 청구된 것입니다.
진단: pg_stat_user_tables에서 그 테이블의 n_dead_tup 급증과 테이블 크기 증가를 확인합니다. 복제 지연 지표가 UPDATE 시점부터 치솟았는지 봅니다. autovacuum이 그 테이블에 장시간 돌고 있는지 pg_stat_activity로 확인하면 그림이 맞아떨어집니다.
해결: 대량 백필은 처음부터 배치로 나눠 수천 건씩 커밋하고 사이에 짧은 대기를 둬, dead tuple 회수·WAL·복제 지연을 완만하게 만듭니다. 이미 팽창했다면 한적한 시간에 VACUUM으로 공간을 회수합니다. 복제 지연에 민감한 쓰고 바로 읽기 요청은 백필 동안 주 인스턴스로 보내고, 대규모 백필은 트래픽이 낮은 시간대에 예약합니다(대량 데이터 고성능 처리 패턴 Bulk Insert·Update·Delete).
명령어·구문 빠른 참조
이 모듈에서 다룬 마이그레이션 도구 명령과 무중단 DDL 구문을 실전 예와 함께 모았습니다.
| 구문/명령 | 용도 | 예 |
|---|---|---|
Flyway V{n}__desc.sql | 버전 번호 붙은 마이그레이션 파일 | V2__add_email_to_users.sql (적용 후 수정 금지) |
Flyway R__desc.sql | 내용 바뀔 때마다 재실행(뷰·함수) | R__create_user_summary_view.sql |
flyway_schema_history 조회 | 적용 이력·체크섬·성공 여부 확인 | SELECT version, checksum, success FROM flyway_schema_history ORDER BY installed_rank; |
prisma migrate dev / deploy | 개발 생성 / 운영 적용 | npx prisma migrate deploy, ... migrate status |
ALTER TABLE ADD COLUMN | 컬럼 추가(1단계는 nullable로) | ALTER TABLE users ADD COLUMN display_name VARCHAR(100); |
UPDATE ... WHERE ... IS NULL | 기존 행 백필(2단계) | UPDATE users SET display_name = username WHERE display_name IS NULL; |
ALTER COLUMN ... SET NOT NULL | 백필 후 제약 추가(3단계) | 즉시 NOT NULL은 기존 NULL 행 때문에 실패 |
ADD COLUMN ... NOT NULL DEFAULT | PG 11+ 테이블 재작성 없는 컬럼 추가 | ... ADD COLUMN tier TEXT NOT NULL DEFAULT 'standard'; |
CREATE INDEX CONCURRENTLY | 운영 테이블 락 없이 인덱스 생성 | 트랜잭션 블록 밖에서 단독 실행 |
DROP INDEX CONCURRENTLY | 실패로 남은 INVALID 인덱스 정리 | DROP INDEX CONCURRENTLY idx_orders_user_id; |
배치 백필 DO $$ ... LOOP | 대량 UPDATE를 나눠 커밋 | LIMIT 1000 + GET DIAGNOSTICS + PERFORM pg_sleep(0.1) |
백필 검증 SELECT count(*) | NULL 잔여 0 확인 후 제약 적용 | SELECT count(*) FROM orders WHERE shipping_address IS NULL; |
| Expand-Contract 3단계 | 무중단 스키마 변경 순서 | 새 컬럼 추가 → 코드 배포 → 구 컬럼 삭제 |
관련 모듈로 더 깊이:
- Prisma, JPA, TypeORM, SQLAlchemy의 성능 차이와 올바른 사용법 — ORM이 생성하는 마이그레이션과 수동 SQL 마이그레이션의 경계
- 계층형 댓글 구조, 다대다 태그 시스템, Soft Delete 구현 — Expand-Contract처럼 변경에 강한 스키마를 처음부터 설계하는 법
- 대량 데이터 고성능 처리 패턴 Bulk Insert·Update·Delete — 대용량 테이블 마이그레이션 시 잠금 없이 데이터를 채우는 패턴
다음 모듈에서는 PostgreSQL의 JSONB 비정형 데이터 처리와 전문 검색(Full-text Search) 기능을 다룹니다.