--- id: db-migration-safety title: DB Migration Safety — Zero-downtime 전환 패턴 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, migration, zero-downtime, postgres, vibe-coding] tech_stack: { language: "SQL / Postgres / MySQL", applicable_to: ["Backend"] } applied_in: [] aliases: [expand-contract, blue-green schema, online migration, lock-free] --- # DB Migration Safety > **Expand → Migrate → Contract** 3단계가 정답. 한 번에 schema + 코드 둘 다 못 바꾼다 — 둘이 동시에 deploy 안 되니까. NOT NULL 추가 / column rename / type change 가 가장 위험. ## 📖 핵심 개념 - **Expand**: 새 schema 추가. 옛 코드는 옛 schema 만 사용. 양립 가능. - **Migrate**: 코드를 새 schema 로 이전. dual-write 또는 backfill. - **Contract**: 옛 schema 제거. - 각 단계는 별도 deploy. 사이에 모니터링 시간. ## 💻 코드 패턴 ### Column rename `old_name → new_name` #### Expand ```sql ALTER TABLE users ADD COLUMN new_name TEXT; -- Trigger: old_name 변경 시 new_name 동기화 CREATE OR REPLACE FUNCTION sync_name() RETURNS trigger AS $$ BEGIN NEW.new_name := COALESCE(NEW.new_name, NEW.old_name); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_sync_name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE sync_name(); -- Backfill (낮 시간대 batch) UPDATE users SET new_name = old_name WHERE new_name IS NULL; ``` #### Migrate (코드 deploy) - 코드: 읽기는 `COALESCE(new_name, old_name)`, 쓰기는 둘 다. #### Contract ```sql DROP TRIGGER trg_sync_name ON users; DROP FUNCTION sync_name(); ALTER TABLE users DROP COLUMN old_name; ``` ### NOT NULL 추가 — 큰 테이블 ```sql -- ❌ 직접 NOT NULL — 대형 테이블 long lock ALTER TABLE orders ALTER COLUMN status SET NOT NULL; -- ✅ 단계별 -- 1) DEFAULT + NOT VALID CHECK ALTER TABLE orders ADD CONSTRAINT orders_status_chk CHECK (status IS NOT NULL) NOT VALID; -- 2) 기존 행 backfill UPDATE orders SET status = 'pending' WHERE status IS NULL; -- 3) constraint validate (read lock 만) ALTER TABLE orders VALIDATE CONSTRAINT orders_status_chk; -- 4) 진짜 NOT NULL (Postgres 12+ — fast path) ALTER TABLE orders ALTER COLUMN status SET NOT NULL; ALTER TABLE orders DROP CONSTRAINT orders_status_chk; ``` ### Index 추가 — CONCURRENTLY (Postgres) ```sql CREATE INDEX CONCURRENTLY idx_users_email ON users(email); -- write block 안 함. 단 transaction 안에서는 못 씀. ``` ### 큰 테이블 backfill — batch ```sql -- 한 번에 1000행씩 DO $$ DECLARE batch INT := 1000; BEGIN LOOP WITH ids AS ( SELECT id FROM orders WHERE new_field IS NULL LIMIT batch FOR UPDATE SKIP LOCKED ) UPDATE orders SET new_field = compute(id) WHERE id IN (SELECT id FROM ids); EXIT WHEN NOT FOUND; PERFORM pg_sleep(0.1); END LOOP; END$$; ``` ## 🤔 의사결정 기준 | 변경 | 안전 | |---|---| | Column 추가 (nullable) | ✅ 즉시 | | Column 추가 + DEFAULT (Postgres 11+) | ✅ — fast path | | NOT NULL 추가 | expand-contract | | Column rename | expand-contract | | Type change (varchar → text) | 보통 안전 — but 큰 테이블이면 점검 | | Type change (int → bigint) | expand-contract | | Drop column | expand-contract — 코드 먼저 안 쓰게 | | Index 추가 | CONCURRENTLY | | FK 추가 | NOT VALID + VALIDATE | ## ❌ 안티패턴 - **schema 변경과 코드 변경 한 deploy**: 둘이 정확히 동시 시작 못 함. 한 쪽만 적용된 짧은 시간 = 사고. - **online migration 없는 큰 테이블 ALTER**: 수십 분 lock → 다운타임. - **rollback 계획 없음**: 새 코드 + 새 schema deploy 후 문제 → 옛 코드 + 새 schema 인스턴스 발생 → 사고. expand-contract 면 자연 롤백. - **production 에 직접 SQL**: history / review 없음. migration tool (Flyway / Prisma migrate / golang-migrate / dbmate) 사용. - **migration 안에 비즈니스 로직**: 한 트랜잭션에 무거운 변환. 별도 backfill batch. - **trigger 가 영구 남음**: contract 단계에서 trigger 제거 잊음. - **dev 에서만 테스트**: 데이터 양이 다름. staging with prod-size dataset 검증. ## 🤖 LLM 활용 힌트 - "schema 변경 = expand-migrate-contract 3 deploy" 강제. - 큰 테이블 ALTER 는 항상 단계별 + CONCURRENTLY 권장. ## 🔗 관련 문서 - [[DB_Connection_Pool]] - [[Optimistic_Concurrency_Control]]