From ecc
PostgreSQL, MySQL 및 주요 ORM(Prisma, Drizzle, Kysely, Django, TypeORM, golang-migrate)을 아우르는 스키마 변경, 데이터 마이그레이션, 롤백 및 제로 다운타임 배포를 위한 데이터베이스 마이그레이션 모범 사례입니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
프로덕션 시스템을 위한 안전하고 가역적인 데이터베이스 스키마 변경 가이드입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
프로덕션 시스템을 위한 안전하고 가역적인 데이터베이스 스키마 변경 가이드입니다.
마이그레이션을 적용하기 전 확인 사항:
-- 좋음: Nullable 컬럼, 락 없음
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 없이 모델에서 제거한 뒤,
-- 다음 배포 시점에서 실제로 삭제합니다.
-- 나쁨: 하나의 트랜잭션에서 모든 행을 업데이트 (테이블 락 유발)
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(),
});
# 설정 파일 초기화 (kysely.config.ts)
kysely init
# 새로운 마이그레이션 파일 생성
kysely migrate make add_user_avatar
# 보류 중인 모든 마이그레이션 적용
kysely migrate latest
# 마지막 마이그레이션 롤백
kysely migrate down
# 마이그레이션 상태 확인
kysely migrate list
// migrations/2024_01_15_001_create_user_profile.ts
import { type Kysely, sql } from 'kysely'
// 중요: 현재 스키마 타입에 의존하지 않도록 Kysely<any>를 사용하세요.
// 마이그레이션은 특정 시점의 상태를 보존해야 합니다.
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')
.on('user_profile')
.column('avatar_url')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_profile').execute()
}
import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
// ESM 전용 — CJS는 __dirname 직접 사용 가능
import { fileURLToPath } from 'url'
const migrationFolder = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'./migrations',
)
// `db`는 Kysely<any> 데이터베이스 인스턴스입니다.
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
// 경고: 개발 환경에서만 활성화하세요. 타임스탬프 순서 검증을 건너뛰어
// 환경 간 스키마 차이(drift)를 유발할 수 있습니다.
// allowUnorderedMigrations: true,
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`마이그레이션 "${it.migrationName}" 성공적으로 실행됨`)
} else if (it.status === 'Error') {
console.error(`마이그레이션 "${it.migrationName}" 실행 실패`)
}
})
if (error) {
console.error('마이그레이션 실패', error)
process.exit(1)
}
# 모델 변경 사항으로부터 마이그레이션 생성
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를 건드리지 않음
),
]
# 마이그레이션 쌍(UP/DOWN) 생성
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
-- 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;
중요한 프로덕션 변경 시에는 확장-축소(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 사용 |
| 스키마와 데이터를 한 파일에 처리 | 롤백 어려움, 트랜잭션 길어짐 | 마이그레이션을 각각 분리 |
| 코드 제거 전 컬럼 삭제 | 삭제된 컬럼 참조로 인한 앱 에러 | 코드 먼저 제거 후 다음 배포 시 삭제 |