| name | database-migrations |
| description | PostgreSQL, MySQL 및 주요 ORM(Prisma, Drizzle, Kysely, Django, TypeORM, golang-migrate)을 아우르는 스키마 변경, 데이터 마이그레이션, 롤백 및 제로 다운타임 배포를 위한 데이터베이스 마이그레이션 모범 사례입니다. |
| origin | ECC |
데이터베이스 마이그레이션 패턴
프로덕션 시스템을 위한 안전하고 가역적인 데이터베이스 스키마 변경 가이드입니다.
활성화 시점
- 데이터베이스 테이블을 생성하거나 수정할 때
- 컬럼 또는 인덱스를 추가/제거할 때
- 데이터 마이그레이션(백필, 변환)을 실행할 때
- 제로 다운타임 스키마 변경을 계획할 때
- 새로운 프로젝트를 위한 마이그레이션 도구를 설정할 때
핵심 원칙
- 모든 변경은 마이그레이션으로 관리한다 — 프로덕션 데이터베이스를 수동으로 수정하지 마세요.
- 프로덕션에서 마이그레이션은 정방향으로만 진행한다 — 롤백 시에도 새로운 정방향 마이그레이션을 사용합니다.
- 스키마와 데이터 마이그레이션을 분리한다 — 하나의 마이그레이션 파일에 DDL과 DML을 섞지 마세요.
- 프로덕션 규모의 데이터로 테스트한다 — 100개 행에서 작동하던 것이 1,000만 개 행에서는 락(lock)을 유발할 수 있습니다.
- 배포된 마이그레이션은 불변(Immutable)이다 — 프로덕션에서 이미 실행된 마이그레이션 파일은 절대 수정하지 마세요.
마이그레이션 안전 체크리스트
마이그레이션을 적용하기 전 확인 사항:
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(),
});
Kysely (TypeScript/Node.js)
워크플로 (kysely-ctl)
kysely init
kysely migrate make add_user_avatar
kysely migrate latest
kysely migrate down
kysely migrate list
마이그레이션 파일
import { type Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_profile')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('avatar_url', 'text')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute()
await db.schema
.createIndex('idx_user_profile_avatar')
.()
.()
.()
}
(): <> {
db..().()
}
프로그래매틱 마이그레이터(Programmatic Migrator)
import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
const migrationFolder = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'./migrations',
)
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`마이그레이션 "${it.migrationName}" 성공적으로 실행됨`)
} else (it. === ) {
.()
}
})
(error) {
.(, error)
process.()
}
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;
제로 다운타임 마이그레이션 전략
중요한 프로덕션 변경 시에는 확장-축소(expand-contract) 패턴을 따르세요:
1단계: 확장(EXPAND)
- 새 컬럼/테이블 추가 (nullable 또는 기본값 포함)
- 배포: 애플리케이션이 이전 및 신규 컬럼 모두에 쓰기 작업 수행
- 기존 데이터 백필
2단계: 이전(MIGRATE)
- 배포: 애플리케이션이 신규 컬럼에서 읽고, 두 컬럼 모두에 쓰기 수행
- 데이터 일관성 검증
3단계: 축소(CONTRACT)
- 배포: 애플리케이션이 신규 컬럼만 사용
- 별도의 마이그레이션으로 이전 컬럼/테이블 삭제
타임라인 예시
1일차: 새 status 컬럼 추가 (nullable)
1일차: 앱 v2 배포 — status와 new_status 모두에 쓰기 수행
2일차: 기존 행에 대해 백필 마이그레이션 실행
3일차: 앱 v3 배포 — new_status에서만 읽기 수행
7일차: 이전 status 컬럼을 삭제하는 마이그레이션 실행
안티패턴
| 안티패턴 | 실패 이유 | 권장 접근법 |
|---|
| 프로덕션에서 수동 SQL 실행 | 감사 추적 불가, 재현 불가능 | 항상 마이그레이션 파일 사용 |
| 배포된 마이그레이션 수정 | 환경 간 차이 유발 | 대신 새로운 마이그레이션 생성 |
| 기본값 없는 NOT NULL 추가 | 테이블 락 유발, 모든 행 재작성 | Nullable로 추가 후 백필하고 제약 조건 추가 |
| 큰 테이블의 인라인 인덱스 | 인덱스 생성 중 쓰기 차단 | CREATE INDEX CONCURRENTLY 사용 |
| 스키마와 데이터를 한 파일에 처리 | 롤백 어려움, 트랜잭션 길어짐 | 마이그레이션을 각각 분리 |
| 코드 제거 전 컬럼 삭제 | 삭제된 컬럼 참조로 인한 앱 에러 | 코드 먼저 제거 후 다음 배포 시 삭제 |