기본 원칙
Zero Downtime
무중단 배포호환성 유지
Backward Compatible
하위 호환성점진적 변경
Rollback Ready
롤백 가능항상 준비
Test First
테스트 우선스테이징 검증
무중단 배포 패턴
패턴 1: Expand-Contract
새 구조 추가 → 마이그레이션 → 구 구조 제거 Step 1: Expand (확장) - 새 컬럼 추가복사
// Migration 1: 새 컬럼 추가 (nullable)
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.string("email_verified", 20).nullable(); // 새 컬럼
});
}
// 애플리케이션: 양쪽 모두 쓰기
await knex("users").update({
is_verified: true,
email_verified: "verified", // 새 컬럼에도 쓰기
});
복사
// Migration 2: 데이터 이전
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
UPDATE users
SET email_verified = CASE
WHEN is_verified THEN 'verified'
ELSE 'unverified'
END
WHERE email_verified IS NULL
`);
}
복사
// Migration 3: 구 컬럼 삭제
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.dropColumns("is_verified"); // 구 컬럼 제거
});
}
Expand-Contract 장점:
- 무중단 배포 가능
- 언제든지 롤백 가능
- 데이터 손실 없음
패턴 2: 점진적 마이그레이션
복사
// Phase 1: 새 컬럼 추가 (nullable)
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.jsonb("settings").nullable();
});
}
// Phase 2: 애플리케이션 배포 (settings 사용 시작)
// Phase 3: 데이터 마이그레이션 (백그라운드)
async function migrateSettings() {
const users = await knex("users")
.whereNull("settings")
.limit(100);
for (const user of users) {
await knex("users")
.where("id", user.id)
.update({
settings: { theme: "light", language: "ko" },
});
}
}
// Phase 4: NOT NULL 제약 추가
export async function up(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TABLE users ALTER COLUMN settings SET NOT NULL`
);
}
위험한 변경 피하기
❌ 위험: 직접 컬럼 삭제
복사
// 🚨 위험: 즉시 데이터 손실
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.dropColumns("old_field");
});
}
✅ 안전: 단계적 삭제
복사
// Step 1: 컬럼을 nullable로 변경
export async function up(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TABLE users ALTER COLUMN old_field DROP NOT NULL`
);
}
// Step 2: 애플리케이션에서 사용 중지
// Step 3: 일정 기간 후 삭제
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.dropColumns("old_field");
});
}
❌ 위험: NOT NULL 즉시 추가
복사
// 🚨 위험: 기존 null 데이터 에러
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.string("phone", 20).notNullable(); // 기존 row는 null
});
}
✅ 안전: nullable → 마이그레이션 → NOT NULL
복사
// Step 1: nullable로 추가
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.string("phone", 20).nullable();
});
}
// Step 2: 기본값 설정
await knex("users")
.whereNull("phone")
.update({ phone: "000-0000-0000" });
// Step 3: NOT NULL 추가
export async function up(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TABLE users ALTER COLUMN phone SET NOT NULL`
);
}
데이터 타입 변경
안전한 타입 변경 순서
복사
// Step 1: 새 컬럼 추가
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.decimal("price_decimal", 10, 2).nullable();
});
}
// Step 2: 데이터 복사
await knex.raw(`
UPDATE products
SET price_decimal = price::decimal(10,2)
`);
// Step 3: 애플리케이션 변경 (price_decimal 사용)
// Step 4: 구 컬럼 삭제
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.dropColumns("price");
});
// 컬럼 이름 변경
await knex.raw(
`ALTER TABLE products RENAME COLUMN price_decimal TO price`
);
}
인덱스 전략
동시성 인덱스 생성
복사
// ❌ 잘못됨: 테이블 잠금
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.index(["email"], "users_email_index");
});
}
// ✅ 올바름: CONCURRENTLY 사용
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE INDEX CONCURRENTLY users_email_index
ON users(email)
`);
}
CONCURRENTLY 주의사항:
- 트랜잭션 외부에서 실행
- 시간이 더 오래 걸림
- 실패 시 INVALID 인덱스 남음
인덱스 생성 모니터링
복사
-- 진행 상황 확인
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE indexrelname = 'users_email_index';
-- INVALID 인덱스 확인
SELECT *
FROM pg_indexes
WHERE indexdef LIKE '%INVALID%';
외래 키 전략
대용량 테이블 FK 추가
복사
// Step 1: NOT VALID 제약 추가 (빠름)
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
ALTER TABLE employees
ADD CONSTRAINT fk_employees_user_id
FOREIGN KEY (user_id)
REFERENCES users(id)
NOT VALID
`);
}
// Step 2: 제약 검증 (백그라운드)
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
ALTER TABLE employees
VALIDATE CONSTRAINT fk_employees_user_id
`);
}
NOT VALID + VALIDATE 장점:
- 기존 데이터 검증 스킵 (빠름)
- 새 데이터만 즉시 검증
- 나중에 백그라운드로 전체 검증
대용량 데이터 마이그레이션
Batch 처리
복사
export async function up(knex: Knex): Promise<void> {
const batchSize = 1000;
let offset = 0;
while (true) {
const users = await knex("users")
.whereNull("normalized_email")
.limit(batchSize)
.offset(offset);
if (users.length === 0) break;
for (const user of users) {
await knex("users")
.where("id", user.id)
.update({
normalized_email: user.email.toLowerCase(),
});
}
offset += batchSize;
console.log(`Processed ${offset} users`);
}
}
백그라운드 워커
복사
// migration에서는 컬럼만 추가
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("users", (table) => {
table.string("normalized_email").nullable();
});
}
// 별도 스크립트로 데이터 마이그레이션
// scripts/migrate-emails.ts
async function migrateEmails() {
const queue = new Queue("email-migration");
const users = await knex("users").whereNull("normalized_email");
for (const user of users) {
await queue.add({
userId: user.id,
email: user.email,
});
}
}
배포 전략
Blue-Green Deployment
Rolling Deployment
복사
// Version N: 구 스키마 호환
app.get("/users/:id", async (req, res) => {
const user = await knex("users")
.select("id", "email", "is_verified") // 구 컬럼
.where("id", req.params.id)
.first();
res.json(user);
});
// Migration: 새 컬럼 추가
// Version N+1: 신구 스키마 모두 호환
app.get("/users/:id", async (req, res) => {
const user = await knex("users")
.select("id", "email", "is_verified", "email_verified") // 양쪽 모두
.where("id", req.params.id)
.first();
res.json({
...user,
verified: user.email_verified || (user.is_verified ? "verified" : "unverified"),
});
});
// Migration: 구 컬럼 삭제
// Version N+2: 새 스키마만
app.get("/users/:id", async (req, res) => {
const user = await knex("users")
.select("id", "email", "email_verified") // 새 컬럼만
.where("id", req.params.id)
.first();
res.json(user);
});
테스트 전략
Migration 테스트
복사
// migration.test.ts
describe("20251220143200_alter_users_add1", () => {
beforeEach(async () => {
await knex.migrate.latest();
});
afterEach(async () => {
await knex.migrate.rollback(null, true);
});
test("up: phone 컬럼 추가", async () => {
const hasColumn = await knex.schema.hasColumn("users", "phone");
expect(hasColumn).toBe(true);
});
test("down: phone 컬럼 제거", async () => {
await knex.migrate.down();
const hasColumn = await knex.schema.hasColumn("users", "phone");
expect(hasColumn).toBe(false);
});
test("reversibility: up → down → up", async () => {
await knex.migrate.down();
await knex.migrate.up();
const hasColumn = await knex.schema.hasColumn("users", "phone");
expect(hasColumn).toBe(true);
});
});
데이터 마이그레이션 테스트
복사
test("데이터 마이그레이션", async () => {
// Given: 구 스키마 데이터
await knex("users").insert([
{ email: "[email protected]", is_verified: true },
{ email: "[email protected]", is_verified: false },
]);
// When: Migration 실행
await knex.migrate.up();
// Then: 데이터 검증
const users = await knex("users").select("*");
expect(users[0].email_verified).toBe("verified");
expect(users[1].email_verified).toBe("unverified");
});
모니터링
Migration 성능 추적
복사
export async function up(knex: Knex): Promise<void> {
const startTime = Date.now();
await knex.schema.alterTable("users", (table) => {
table.string("phone", 20).nullable();
});
const duration = Date.now() - startTime;
// 로깅
console.log(`Migration completed in ${duration}ms`);
// 메트릭 전송
await sendMetric("migration.duration", duration, {
migration: "alter_users_add1",
});
}
롱 러닝 쿼리 모니터링
복사
-- 실행 중인 Migration 쿼리 확인
SELECT
pid,
now() - query_start AS duration,
state,
query
FROM pg_stat_activity
WHERE state = 'active'
AND query LIKE 'ALTER TABLE%'
ORDER BY duration DESC;
체크리스트
Migration 실행 전
- 스테이징 환경 테스트 완료
- 프로덕션 백업 완료
- 롤백 계획 수립
- 다운타임 계획 (필요시)
- 팀 공지
- 모니터링 준비
Migration 실행 중
- 에러 로그 모니터링
- 성능 메트릭 확인
- 애플리케이션 헬스체크
- 사용자 피드백 확인
Migration 실행 후
- 데이터 정합성 검증
- 애플리케이션 기능 테스트
- 성능 확인
- 문서 업데이트
- 롤백 테스트 (스테이징)
실전 사례
사례 1: 대용량 테이블 컬럼 추가
복사
// 100M 행 테이블에 인덱스 컬럼 추가
// Step 1: 컬럼 추가 (1초)
await knex.schema.alterTable("posts", (table) => {
table.integer("user_id").nullable();
});
// Step 2: 백그라운드로 데이터 채우기 (30분)
// 별도 워커에서 배치 처리
// Step 3: 인덱스 생성 CONCURRENTLY (15분)
await knex.raw(`
CREATE INDEX CONCURRENTLY posts_user_id_idx
ON posts(user_id)
`);
// Step 4: NOT NULL 제약 (1초)
await knex.raw(`
ALTER TABLE posts
ALTER COLUMN user_id SET NOT NULL
`);
사례 2: 컬럼 이름 변경
복사
// Step 1: 새 컬럼 추가
await knex.schema.alterTable("products", (table) => {
table.string("product_name", 200).nullable();
});
// Step 2: 데이터 복사
await knex.raw(`
UPDATE products
SET product_name = name
`);
// Step 3: 애플리케이션 배포 (product_name 사용)
// Step 4: 구 컬럼 삭제
await knex.schema.alterTable("products", (table) => {
table.dropColumns("name");
});
