| name | database-migrations |
| description | 스키마 변경, 데이터 마이그레이션, 롤백 및 PostgreSQL, MySQL 및 주요 ORM(Prisma, Drizzle, Django, TypeORM, golang-migrate)을 통한 무중단 배포를 위한 데이터베이스 마이그레이션 모범 사례. 데이터베이스 스키마 변경을 계획하거나 구현할 때 사용하세요.
|
| metadata | {"origin":"ECC"} |
데이터베이스 마이그레이션 패턴 (Database Migration Patterns)
프로덕션 시스템을 위한 안전하고 가역적인 데이터베이스 스키마 변경 가이드입니다.
활성화 시기
- 데이터베이스 테이블 생성 또는 수정 시
- 컬럼 또는 인덱스 추가/삭제 시
- 데이터 마이그레이션(백필, 변환) 실행 시
- 무중단 스키마 변경 계획 시
- 새 프로젝트의 마이그레이션 도구 설정 시
핵심 원칙
- 모든 변경은 마이그레이션이다 — 절대로 프로덕션 데이터베이스를 수동으로 변경하지 마세요.
- 프로덕션 마이그레이션은 정방향으로만 진행한다 — 롤백 시에도 새로운 정방향 마이그레이션을 사용하세요.
- 스키마 마이그레이션과 데이터 마이그레이션을 분리한다 — DDL과 DML을 하나의 마이그레이션에 섞지 마세요.
- 프로덕션 규모의 데이터로 마이그레이션을 테스트한다 — 100개 행에서 잘 작동하는 마이그레이션이 1,000만 개 행에서는 테이블을 잠글 수 있습니다.
- 배포된 마이그레이션은 불변이다 — 이미 프로덕션에서 실행된 마이그레이션 파일은 절대로 수정하지 마세요.
마이그레이션 안전 체크리스트
마이그레이션을 적용하기 전에 다음 사항을 확인하세요:
PostgreSQL 패턴
안전하게 컬럼 추가하기
ALTER TABLE users ADD COLUMN avatar_url TEXT;
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
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);
컬럼 이름 변경하기 (무중단 방식)
프로덕션에서 직접 이름을 변경하지 마세요. 확장-수축(expand-contract) 패턴을 사용하세요:
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = username WHERE display_name IS NULL;
ALTER TABLE users DROP COLUMN username;
안전하게 컬럼 삭제하기
ALTER TABLE orders DROP COLUMN legacy_status;
대용량 데이터 마이그레이션
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 $$;
Prisma (TypeScript/Node.js)
워크플로우
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])
}
커스텀 SQL 마이그레이션
Prisma가 표현할 수 없는 작업(병렬 인덱스 생성, 데이터 백필 등):
npx prisma migrate dev --create-only --name add_email_index
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
Drizzle (TypeScript/Node.js)
워크플로우
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(),
});
Django (Python)
워크플로우
python manage.py makemigrations
python manage.py migrate
python manage.py showmigrations
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),
]
SeparateDatabaseAndState
데이터베이스에서 컬럼을 즉시 삭제하지 않고 Django 모델에서만 제거하기:
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[],
),
]
golang-migrate (Go)
워크플로우
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
migrate -path migrations -database "$DATABASE_URL" force VERSION
마이그레이션 파일
ALTER TABLE users ADD COLUMN avatar_url TEXT;
CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL;
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 컬럼 삭제
안티 패턴 (Anti-Patterns)
| 안티 패턴 | 실패 원인 | 더 나은 접근 방식 |
|---|
| 프로덕션에서 수동 SQL 실행 | 감사 기록 부재, 재현 불가 | 항상 마이그레이션 파일 사용 |
| 배포된 마이그레이션 수정 | 환경 간 정합성 어긋남 | 대신 새로운 마이그레이션 생성 |
| 기본값 없이 NOT NULL 추가 | 테이블 잠금, 모든 행 재작성 | Null 허용으로 추가 후 백필하고 제약 조건 추가 |
| 큰 테이블에 인라인 인덱스 | 빌드 중 쓰기 차단 | CREATE INDEX CONCURRENTLY 사용 |
| 스키마와 데이터를 한 번에 | 롤백 어려움, 긴 트랜잭션 | 마이그레이션 분리 |
| 코드 제거 전 컬럼 삭제 | 삭제된 컬럼 참조로 인한 앱 에러 | 코드 먼저 제거 후 다음 배포 시 삭제 |
이 스킬을 사용하는 시점
- 데이터베이스 스키마 변경 계획 시
- 무중단 마이그레이션 구현 시
- 마이그레이션 도구 설정 시
- 마이그레이션 문제 해결 시
- 마이그레이션 PR(Pull Request) 리뷰 시