스키마 변경, 데이터 마이그레이션, 롤백 및 PostgreSQL, MySQL 및 주요 ORM(Prisma, Drizzle, Django, TypeORM, golang-migrate)을 통한 무중단 배포를 위한 데이터베이스 마이그레이션 모범 사례. 데이터베이스 스키마 변경을 계획하거나 구현할 때 사용하세요.
프로덕션 시스템을 위한 안전하고 가역적인 데이터베이스 스키마 변경 가이드입니다.
마이그레이션을 적용하기 전에 다음 사항을 확인하세요:
-- 좋음: Null 허용 컬럼, 락 발생 없음
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- 좋음: 기본값이 포함된 컬럼 (Postgres 11+ 버전은 즉시 적용되며 재작성 안 함)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- 나쁨: 기존 테이블에 기본값 없이 NOT NULL 추가 (전체 데이터 재작성 필요)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- 이 작업은 테이블을 잠그고 모든 행을 재작성합니다.
-- 나쁨: 대용량 테이블에서 쓰기 작업을 차단함
CREATE INDEX idx_users_email ON users (email);
-- 좋음: 비차단 방식, 병렬 쓰기 허용
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- 참고: CONCURRENTLY는 트랜잭션 블록 안에서 실행될 수 없습니다.
-- 대부분의 마이그레이션 도구는 이를 위해 특별한 처리가 필요합니다.
프로덕션에서 직접 이름을 변경하지 마세요. 확장-수축(expand-contract) 패턴을 사용하세요:
-- 1단계: 새 컬럼 추가 (마이그레이션 001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- 2단계: 데이터 백필 (마이그레이션 002, 데이터 마이그레이션)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- 3단계: 애플리케이션 코드가 두 컬럼 모두 읽고 쓰도록 업데이트
-- 애플리케이션 배포
-- 4단계: 이전 컬럼 쓰기 중단 후 삭제 (마이그레이션 003)
ALTER TABLE users DROP COLUMN username;
-- 1단계: 애플리케이션에서 해당 컬럼에 대한 모든 참조 제거
-- 2단계: 컬럼 참조가 제거된 애플리케이션 배포
-- 3단계: 다음 마이그레이션에서 컬럼 삭제
ALTER TABLE orders DROP COLUMN legacy_status;
-- Django의 경우: SeparateDatabaseAndState를 사용하여 모델에서는 제거하되
-- DROP COLUMN SQL은 생성하지 않도록 하고, 다음 마이그레이션에서 실제 삭제 수행
-- 나쁨: 하나의 트랜잭션에서 모든 행 업데이트 (테이블 잠금)
UPDATE users SET normalized_email = LOWER(email);
-- 좋음: 진행 상황을 포함한 배치 업데이트
DO $$
DECLARE
batch_size INT := 10000;
rows_updated 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
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RAISE NOTICE 'Updated % rows', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
# 스키마 변경 사항으로부터 마이그레이션 생성
npx prisma migrate dev --name add_user_avatar
# 프로덕션에 대기 중인 마이그레이션 적용
npx prisma migrate deploy
# 데이터베이스 초기화 (개발용)
npx prisma migrate reset
# 스키마 변경 후 클라이언트 생성
npx prisma generate
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
@@map("users")
@@index([email])
}
Prisma가 표현할 수 없는 작업(병렬 인덱스 생성, 데이터 백필 등):
# 빈 마이그레이션을 생성하고 SQL을 수동으로 편집
npx prisma migrate dev --create-only --name add_email_index
-- migrations/20240115_add_email_index/migration.sql
-- Prisma는 CONCURRENTLY를 생성할 수 없으므로 수동으로 작성합니다.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
# 스키마 변경 사항으로부터 마이그레이션 생성
npx drizzle-kit generate
# 마이그레이션 적용
npx drizzle-kit migrate
# 스키마 직접 푸시 (개발 전용, 마이그레이션 파일 없음)
npx drizzle-kit push
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
# 모델 변경 사항으로부터 마이그레이션 생성
python manage.py makemigrations
# 마이그레이션 적용
python manage.py migrate
# 마이그레이션 상태 확인
python manage.py showmigrations
# 커스텀 SQL을 위한 빈 마이그레이션 생성
python manage.py makemigrations --empty app_name -n description
from django.db import migrations
def backfill_display_names(apps, schema_editor):
User = apps.get_model("accounts", "User")
batch_size = 5000
users = User.objects.filter(display_name="")
while users.exists():
batch = list(users[:batch_size])
for user in batch:
user.display_name = user.username
User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size)
def reverse_backfill(apps, schema_editor):
pass # 데이터 마이그레이션, 역방향 작업 불필요
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_add_display_name")]
operations = [
migrations.RunPython(backfill_display_names, reverse_backfill),
]
데이터베이스에서 컬럼을 즉시 삭제하지 않고 Django 모델에서만 제거하기:
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[], # 아직 DB는 건드리지 않음
),
]
# 마이그레이션 쌍 생성
migrate create -ext sql -dir migrations -seq add_user_avatar
# 대기 중인 모든 마이그레이션 적용
migrate -path migrations -database "$DATABASE_URL" up
# 마지막 마이그레이션 롤백
migrate -path migrations -database "$DATABASE_URL" down 1
# 특정 버전 강제 설정 (Dirty 상태 해결용)
migrate -path migrations -database "$DATABASE_URL" force VERSION
-- migrations/000003_add_user_avatar.up.sql
ALTER TABLE users ADD COLUMN avatar_url TEXT;
CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL;
-- migrations/000003_add_user_avatar.down.sql
DROP INDEX IF EXISTS idx_users_avatar;
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
중요한 프로덕션 변경 사항의 경우, 확장-수축 패턴을 따르세요:
1단계: 확장 (EXPAND)
- 새 컬럼/테이블 추가 (Null 허용 또는 기본값 포함)
- 배포: 앱에서 이전 것과 새 것에 모두 씀 (Dual-write)
- 기존 데이터 백필
2단계: 마이그레이션 (MIGRATE)
- 배포: 앱에서 새 것을 읽고, 두 곳 모두에 씀
- 데이터 일관성 검증
3단계: 수축 (CONTRACT)
- 배포: 앱에서 새 것만 사용
- 별도의 마이그레이션으로 이전 컬럼/테이블 삭제
1일차: 마이그레이션으로 new_status 컬럼 추가 (Null 허용)
1일차: 앱 v2 배포 — status와 new_status 모두에 씀
2일차: 기존 행에 대해 백필 마이그레이션 실행
3일차: 앱 v3 배포 — new_status에서만 읽음
7일차: 마이그레이션으로 이전 status 컬럼 삭제
| 안티 패턴 | 실패 원인 | 더 나은 접근 방식 |
|---|---|---|
| 프로덕션에서 수동 SQL 실행 | 감사 기록 부재, 재현 불가 | 항상 마이그레이션 파일 사용 |
| 배포된 마이그레이션 수정 | 환경 간 정합성 어긋남 | 대신 새로운 마이그레이션 생성 |
| 기본값 없이 NOT NULL 추가 | 테이블 잠금, 모든 행 재작성 | Null 허용으로 추가 후 백필하고 제약 조건 추가 |
| 큰 테이블에 인라인 인덱스 | 빌드 중 쓰기 차단 | CREATE INDEX CONCURRENTLY 사용 |
| 스키마와 데이터를 한 번에 | 롤백 어려움, 긴 트랜잭션 | 마이그레이션 분리 |
| 코드 제거 전 컬럼 삭제 | 삭제된 컬럼 참조로 인한 앱 에러 | 코드 먼저 제거 후 다음 배포 시 삭제 |