Back

무중단 PostgreSQL 스키마 마이그레이션: 프로덕션 DB를 멈추지 않고 바꾸는 완전 가이드

새벽 2시. 팀이 5천만 행짜리 테이블에 NOT NULL 컬럼 추가하는 마이그레이션을 배포했어요. ACCESS EXCLUSIVE 락이 걸리면서 그 테이블 쿼리가 전부 막혀버렸죠. API 응답 시간은 50ms에서 30초로 치솟고, 온콜 폰은 터지고, 고객들은 에러 페이지 스크린샷을 트위터에 올리고 있어요.

ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT 하나로 프로덕션이 작살날 수 있다는 걸, 이렇게 처참하게 배운 거예요.

이 가이드는 그 교훈을 몸으로 겪지 않아도 되게 쓴 거예요. PostgreSQL 스키마를 앱 안 내리고 바꾸는 패턴, 도구, 함정을 전부 정리했어요. 이론이 아니라 수억 행 테이블에서 실전 검증된 전략들이에요.

스키마 마이그레이션이 위험한 이유

대부분의 개발자가 DB 마이그레이션을 그냥 코드 배포처럼 취급해요. ALTER TABLE 쓰고, 배포할 때 돌리고, 끝. 개발 환경에서 users 테이블이 50행이면 잘 돌아가죠. 근데 프로덕션에서 5천만 행이면? 같은 마이그레이션이 앱 전체를 멈춰버려요.

왜 그럴까요?

PostgreSQL의 락 시스템

DDL 문은 전부 락을 걸어요. 근데 문제는 락 자체가 아니라 락 큐예요. DDL이 ACCESS EXCLUSIVE 락을 요청했는데 바로 못 얻으면(활성 쿼리가 충돌하는 락을 잡고 있으니까), 큐에 들어가서 대기해요. 그 사이에 그 테이블에 뭐든 락이 필요한 새 쿼리까지 전부 뒤에 줄을 서요.

재앙의 타임라인:

00:00  SELECT 쿼리 실행 중 (ACCESS SHARE 락 보유)
00:01  ALTER TABLE이 ACCESS EXCLUSIVE 락 요청 → 큐에서 대기
00:02  새 SELECT 도착 → ALTER TABLE 뒤에 줄 섬
00:03  새 SELECT 도착 → ALTER TABLE 뒤에 줄 섬
00:04  새 SELECT 도착 → ALTER TABLE 뒤에 줄 섬
  ...  모든 쿼리가 큐에 쌓임. 앱이 얼어붙음.
00:30  원래 SELECT 끝남 → ALTER TABLE이 락 획득
00:31  ALTER TABLE 실행 (큰 테이블이면 몇 분 걸림)
02:00  ALTER TABLE 완료 → 밀린 쿼리들이 드디어 실행

ALTER TABLE 딱 하나로 2분짜리 장애가 터진 거예요. 트래픽 많은 테이블이면 커넥션 풀 고갈, 앱 크래시, 전체 시스템 연쇄 장애까지 번지고요.

죽이는 오퍼레이션들

모든 스키마 변경이 똑같이 위험한 건 아니에요. 리스크 매트릭스로 정리하면:

오퍼레이션락 타입위험도5천만 행 기준 소요시간
ADD COLUMN (nullable, 기본값 없음)ACCESS EXCLUSIVE🟢 낮음밀리초
ADD COLUMN ... DEFAULT (PG 11+)ACCESS EXCLUSIVE🟢 낮음밀리초
ADD COLUMN ... NOT NULL DEFAULT (PG 11+)ACCESS EXCLUSIVE🟡 중간밀리초 (단, 락 큐 위험)
DROP COLUMNACCESS EXCLUSIVE🟢 낮음밀리초
ALTER COLUMN TYPEACCESS EXCLUSIVE🔴 위험수 분~수 시간 (전체 테이블 리라이트)
ADD INDEXSHARE 락🔴 위험수 분 (쓰기 차단)
ADD INDEX CONCURRENTLY락 없음🟢 낮음수 분 (비차단)
ADD NOT NULL CONSTRAINTACCESS EXCLUSIVE🔴 위험수 분 (전체 테이블 스캔)
ADD FOREIGN KEYSHARE ROW EXCLUSIVE🔴 위험수 분 (전체 테이블 스캔)

🔴 표시된 것들이 장애를 일으키는 녀석들이에요. 이걸 어떻게 안전하게 하는지 알아볼게요.

Expand-Contract 패턴

Expand-Contract 패턴(병렬 변경이라고도 해요)은 무중단 마이그레이션의 기본 전략이에요. 아이디어는 간단해요. 한 번에 깨지는 변경을 절대 하지 마세요. 여러 개의 안 깨지는 단계로 나누는 거예요.

┌─────────────────────────────────────────────────────────────┐
│              EXPAND-CONTRACT 패턴                            │
│                                                              │
│  Phase 1: EXPAND (확장)                                     │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 기존 것 옆에 새 컬럼/테이블 추가                   │       │
│  │ 기존과 새 것 둘 다에 쓰는 코드 배포                │       │
│  │ 기존 읽기 코드는 그대로 동작                       │       │
│  └──────────────────────────────────────────────────┘       │
│                         │                                    │
│                         ▼                                    │
│  Phase 2: MIGRATE (마이그레이트)                             │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 기존 데이터를 새 컬럼/테이블로 백필                │       │
│  │ 데이터 정합성 검증                                 │       │
│  │ 읽기를 새 컬럼/테이블로 전환                       │       │
│  └──────────────────────────────────────────────────┘       │
│                         │                                    │
│                         ▼                                    │
│  Phase 3: CONTRACT (축소)                                   │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 기존 컬럼/테이블 제거                              │       │
│  │ 호환 코드 정리                                     │       │
│  │ 클린업                                            │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

가장 흔하고 위험한 마이그레이션에 이걸 적용해 볼게요.

예시: 컬럼 이름 바꾸기

users.full_nameusers.display_name으로 바꾸고 싶어요. 단순히 ALTER TABLE users RENAME COLUMN full_name TO display_name 하면 full_name을 참조하는 모든 쿼리가 그 순간 터져요.

Step 1: Expand: 새 컬럼 추가

-- 배포 1: 새 컬럼 추가 (즉시, 리라이트 없음) ALTER TABLE users ADD COLUMN display_name TEXT; -- 두 컬럼을 동기화하는 트리거 생성 CREATE OR REPLACE FUNCTION sync_display_name() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN IF NEW.display_name IS NULL AND NEW.full_name IS NOT NULL THEN NEW.display_name := NEW.full_name; ELSIF NEW.full_name IS NULL AND NEW.display_name IS NOT NULL THEN NEW.full_name := NEW.display_name; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_display_name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION sync_display_name();

Step 2: Backfill: 기존 데이터 복사

-- 장기 트랜잭션을 피하기 위해 배치로 백필 DO $$ DECLARE batch_size INT := 10000; rows_updated INT; BEGIN LOOP UPDATE users SET display_name = full_name WHERE id IN ( SELECT id FROM users WHERE display_name IS NULL AND full_name IS NOT NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; RAISE NOTICE 'Updated % rows', rows_updated; PERFORM pg_sleep(0.1); -- 부하를 줄이기 위한 잠깐의 멈춤 COMMIT; END LOOP; END $$;

Step 3: 읽기 전환: 앱 코드가 display_name에서 읽도록 변경

Step 4: Contract: 기존 컬럼과 트리거 제거

-- 배포 3: 모든 코드가 display_name을 읽은 후 DROP TRIGGER trigger_sync_display_name ON users; DROP FUNCTION sync_display_name(); ALTER TABLE users DROP COLUMN full_name;

배포를 한 번에 끝내는 대신 네 번 나눠서 하는 거죠. 대신 다운타임은 제로.

안전한 인덱스 생성

큰 테이블에 인덱스를 만드는 건 프로덕션 장애의 가장 흔한 원인 중 하나예요. 일반 CREATE INDEX는 SHARE 락을 잡아서 인덱스 빌드가 끝날 때까지 모든 쓰기(INSERT, UPDATE, DELETE)를 차단해요.

반드시 CONCURRENTLY 쓰세요

-- ❌ 위험: 모든 쓰기를 차단 CREATE INDEX idx_users_email ON users(email); -- ✅ 안전: 비차단 CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

CREATE INDEX CONCURRENTLY는 쓰기 차단 없이 인덱스를 만들어요. 어떻게 가능하냐면, 테이블을 두 번 스캔해요. 첫 번째 스캔으로 초기 인덱스를 잡고, 두 번째 스캔에서 그 사이에 생긴 변경분을 잡아줘요.

CONCURRENTLY의 함정들

반드시 알아야 할 주의사항이 있어요:

1. 조용히 실패할 수 있어요. CREATE INDEX CONCURRENTLY가 에러를 만나면(예: 유니크 제약 위반), INVALID 인덱스를 남겨놔요. 반드시 검증하세요:

-- 생성 후 유효하지 않은 인덱스 확인 SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'idx_users_email'; -- 유효성 확인 SELECT pg_index.indisvalid FROM pg_index JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'idx_users_email';

인덱스가 유효하지 않으면 삭제하고 다시 시도:

DROP INDEX CONCURRENTLY IF EXISTS idx_users_email; -- CREATE INDEX CONCURRENTLY 재시도

2. 트랜잭션 안에서 실행할 수 없어요. CREATE INDEX CONCURRENTLYBEGIN...COMMIT 블록으로 감쌀 수 없어요. 대부분의 마이그레이션 도구가 각 마이그레이션을 기본적으로 트랜잭션으로 감싸기 때문에, 동시 인덱스 생성 시에는 이걸 끄셔야 해요.

3. 더 오래 걸려요. 테이블을 두 번 스캔하고 배타 락을 안 잡으니까, 일반 CREATE INDEX보다 2~3배 느려요. 1억 행 테이블이면 30분 이상 걸릴 수 있어요. 계획을 세우세요.

NOT NULL 제약을 안전하게 추가하기

기존 컬럼에 NOT NULL 제약을 추가하는 건 겉으로는 간단해 보이지만 위험해요. PostgreSQL이 NULL 값이 없는지 전체 테이블을 스캔해야 하고, 그 동안 ACCESS EXCLUSIVE 락을 잡아요.

안전한 패턴

-- Step 1: NOT VALID로 CHECK 제약 추가 (즉시, 스캔 없음) ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID; -- Step 2: 별도 트랜잭션에서 제약 검증 -- 테이블을 스캔하지만 SHARE UPDATE EXCLUSIVE 락만 잡음 (읽기/쓰기 허용) ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; -- Step 3 (선택): 정식 NOT NULL 제약으로 변환 -- PostgreSQL 12+에서 유효한 CHECK 제약이 있으면 -- NOT NULL 추가는 즉시 (테이블 스캔 불필요) ALTER TABLE users ALTER COLUMN email SET NOT NULL; -- Step 4: 중복된 CHECK 제약 삭제 ALTER TABLE users DROP CONSTRAINT users_email_not_null;

원리가 뭐냐면요, NOT VALID가 PostgreSQL한테 "새로 들어오는 행은 이 제약 지켜. 근데 기존 행은 아직 건드리지 마"라고 알려주는 거예요. 그 다음에 VALIDATE CONSTRAINT로 기존 행을 따로 검증하는데, 이때 잡는 락이 약해서 읽기/쓰기를 안 막아요.

외래 키를 안전하게 추가하기

외래 키 생성도 조용한 킬러예요. 기본적으로 ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY는 참조하는 쪽과 참조되는 쪽 테이블 둘 다 락을 잡고 스캔해요.

-- ❌ 위험: 검증 중 양쪽 테이블 락 ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id); -- ✅ 안전: 2단계 접근 -- Step 1: 검증 없이 제약 추가 (즉시) ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- Step 2: 별도로 검증 (약한 락) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;

CHECK 제약 때랑 똑같은 NOT VALID 트릭이에요. 새 행은 바로 검증하고, 기존 행은 약한 락으로 따로 검증하는 거죠.

컬럼 타입 변경하기

컬럼 타입을 바꾸면(예: INT에서 BIGINT, VARCHAR(50)에서 VARCHAR(255)) 보통 전체 테이블 리라이트가 필요해요. 수백만 행 테이블이면 몇 분 동안 다운타임이 발생해요.

Expand-Contract 접근법

-- Step 1: 원하는 타입으로 새 컬럼 추가 ALTER TABLE orders ADD COLUMN amount_v2 BIGINT; -- Step 2: 동기화 트리거 생성 CREATE OR REPLACE FUNCTION sync_amount_v2() RETURNS TRIGGER AS $$ BEGIN NEW.amount_v2 := NEW.amount; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_amount_v2 BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION sync_amount_v2(); -- Step 3: 백필 (배치) UPDATE orders SET amount_v2 = amount WHERE id BETWEEN 1 AND 1000000; -- ... 모든 범위에 대해 반복 -- Step 4: 앱 코드를 amount_v2 사용으로 전환 -- Step 5: 정리 DROP TRIGGER trigger_sync_amount_v2 ON orders; DROP FUNCTION sync_amount_v2(); ALTER TABLE orders DROP COLUMN amount; ALTER TABLE orders RENAME COLUMN amount_v2 TO amount;

예외: VARCHAR 길이 늘리기

일부 컬럼 타입 변경은 테이블 리라이트가 필요 없어서 실제로 안전해요:

-- ✅ 안전: VARCHAR 길이 늘리기 (리라이트 불필요) ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255); -- 늘리는 건만 안전, 줄이는 건 불안전! -- ✅ 안전: VARCHAR 제한 완전 제거 ALTER TABLE users ALTER COLUMN name TYPE TEXT; -- ✅ 안전: VARCHAR를 TEXT로 변경 -- TEXT와 VARCHAR는 PostgreSQL에서 동일하게 저장

Lock Timeout: 안전 장치

모든 마이그레이션에 락 타임아웃을 설정하세요. 없으면 마이그레이션이 락을 무한정 기다리면서 뒤에 오는 모든 쿼리를 줄 세워요.

-- 이 세션에 5초 락 타임아웃 설정 SET lock_timeout = '5s'; -- 마이그레이션 실행 ALTER TABLE users ADD COLUMN bio TEXT; -- 5초 내에 락을 얻지 못하면 -- 무한 대기 대신 에러를 발생시킴

마이그레이션 스크립트에서 항상 락 타임아웃을 설정하세요:

-- 모든 마이그레이션 파일 상단에 SET lock_timeout = '5s'; SET statement_timeout = '30s'; -- 여기에 마이그레이션 DDL ALTER TABLE users ADD COLUMN bio TEXT; -- 안전을 위해 리셋 RESET lock_timeout; RESET statement_timeout;

재시도 로직

락 타임아웃을 쓰면 재시도 로직이 필요해요. 오래 실행 중인 쿼리가 충돌하는 락을 잡고 있어서 마이그레이션이 실패할 수 있어요. 괜찮아요. 잠깐 쉬고 재시도하면 돼요:

async function safeMigrate(sql: string, maxRetries = 5): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await db.query('SET lock_timeout = \'5s\''); await db.query(sql); console.log(`Migration succeeded on attempt ${attempt}`); return; } catch (error) { if (error.code === '55P03' && attempt < maxRetries) { // 락 타임아웃 - 대기 후 재시도 console.log( `Lock timeout on attempt ${attempt}, ` + `retrying in ${attempt * 2}s...` ); await sleep(attempt * 2000); } else { throw error; } } } }

배치 백필: 데이터 이동의 기술

수백만 개의 기존 행을 업데이트해야 할 때(예: 새 컬럼 백필), 하나의 UPDATE로 하면 위험해요. 거대한 단일 트랜잭션이 만들어져서:

  1. 영향받는 모든 행에 행 레벨 락을 잡고
  2. 대량의 WAL(Write-Ahead Log)을 생성하고
  3. 레플리케이션 지연을 일으키고
  4. 테이블 블로트를 만들어요 (VACUUM이 처리해야 할 데드 튜플)

배치 패턴

-- 10,000행씩 청크로 백필 DO $$ DECLARE batch_size INT := 10000; last_id BIGINT := 0; max_id BIGINT; rows_updated INT; BEGIN SELECT MAX(id) INTO max_id FROM orders; WHILE last_id < max_id LOOP UPDATE orders SET amount_cents = amount * 100 WHERE id > last_id AND id <= last_id + batch_size AND amount_cents IS NULL; GET DIAGNOSTICS rows_updated = ROW_COUNT; last_id := last_id + batch_size; RAISE NOTICE 'Processed up to id %, updated % rows', last_id, rows_updated; -- 리플리카가 따라잡고 -- autovacuum이 데드 튜플을 처리할 시간을 줌 PERFORM pg_sleep(0.05); -- 각 배치를 별도로 커밋 COMMIT; END LOOP; END $$;

백필 모니터링

백필이 돌아가는 동안 이 지표들을 모니터링하세요:

-- 레플리케이션 랙 확인 (읽기 레플리카에 중요) SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes FROM pg_stat_replication; -- 테이블 블로트 확인 (쌓이는 데드 튜플) SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders'; -- 충돌할 수 있는 장기 실행 쿼리 확인 SELECT pid, now() - query_start AS duration, state, left(query, 100) AS query_preview FROM pg_stat_activity WHERE state != 'idle' AND query_start < now() - interval '30 seconds' ORDER BY duration DESC;

프로덕션용 마이그레이션 도구

pgroll: 무중단 마이그레이션 도구

pgroll은 무중단 변경을 위해 특별히 설계된 스키마 마이그레이션 도구예요. Expand-Contract 패턴을 자동으로 구현해요:

{ "name": "add_display_name", "operations": [ { "rename_column": { "table": "users", "from": "full_name", "to": "display_name" } } ] }

pgroll이 확장 단계(뷰, 트리거, 임시 컬럼 생성)와 마이그레이션 단계, 축소 단계를 자동으로 처리해요. 버전별 스키마 뷰를 만들어서 이전/이후 앱 버전이 공존할 수 있게 해 줘요.

Reshape

Reshape도 비슷한 철학을 따라요. 선언적이고 무중단인 마이그레이션을 자동 Expand-Contract로 처리:

[[actions]] type = "alter_column" table = "users" column = "full_name" [actions.changes] name = "display_name"

sqitch + 커스텀 스크립트

더 세밀한 제어가 필요한 팀이라면, sqitch와 커스텀 스크립트 조합이 가벼운 대안이에요:

# sqitch 워크플로우 sqitch add rename-user-column \ -n "Rename full_name to display_name (phase 1: expand)" sqitch deploy sqitch verify

프레임워크별 도구

프레임워크도구무중단 지원
Railsstrong_migrations gem위험한 오퍼레이션 차단, 안전한 대안 제안
Djangodjango-pg-zero-downtime-migrations락 타임아웃 및 안전 패턴 추가
Laravel내장 없음수동 패턴 필요
Node.js/TypeScriptgraphile-migrate, node-pg-migrate좋은 제어, 수동 패턴
Gogoose, atlasAtlas는 안전 검사가 있는 선언적 마이그레이션

마이그레이션 전 체크리스트

프로덕션에서 마이그레이션을 실행하기 전에:

1. 활성 락과 장기 실행 쿼리 확인

-- 대상 테이블에서 5분 이상 실행 중인 쿼리 종료 SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE query ILIKE '%your_table%' AND state != 'idle' AND query_start < now() - interval '5 minutes';

2. 프로덕션 규모 데이터셋에서 테스트

개발 DB에서만 마이그레이션을 테스트하지 마세요. 프로덕션 스케일 데이터로 스테이징 환경을 만드세요.

3. 타임아웃 설정

SET lock_timeout = '5s'; SET statement_timeout = '30m'; -- 긴 백필용

4. 롤백 계획 준비

모든 마이그레이션에는 테스트된 롤백이 있어야 해요:

-- migration.sql ALTER TABLE users ADD COLUMN bio TEXT; -- rollback.sql ALTER TABLE users DROP COLUMN IF EXISTS bio;

5. 배포 중 모니터링

-- 실시간 락 경합 감시 SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query, now() - blocked.query_start AS blocked_duration FROM pg_stat_activity blocked JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid JOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype AND blocked_locks.database = blocking_locks.database AND blocked_locks.relation = blocking_locks.relation AND blocked_locks.pid != blocking_locks.pid JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid WHERE NOT blocked_locks.granted;

실전 마이그레이션 플레이북

가장 흔한 프로덕션 마이그레이션을 위한 복붙 가능한 플레이북이에요:

플레이북 1: 기본값이 있는 필수 컬럼 추가

-- Step 1: nullable 컬럼 추가 (즉시) SET lock_timeout = '5s'; ALTER TABLE users ADD COLUMN subscription_tier TEXT; -- Step 2: 기존 행 백필 DO $$ DECLARE batch_size INT := 5000; rows_updated INT; BEGIN LOOP UPDATE users SET subscription_tier = 'free' WHERE subscription_tier IS NULL AND id IN ( SELECT id FROM users WHERE subscription_tier IS NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; COMMIT; PERFORM pg_sleep(0.05); END LOOP; END $$; -- Step 3: NOT NULL 제약 안전하게 추가 ALTER TABLE users ADD CONSTRAINT users_subscription_tier_not_null CHECK (subscription_tier IS NOT NULL) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT users_subscription_tier_not_null; -- Step 4: NOT NULL 설정 (유효한 CHECK 제약이 있으면 즉시) ALTER TABLE users ALTER COLUMN subscription_tier SET NOT NULL; -- Step 5: 향후 행을 위해 기본값 설정 ALTER TABLE users ALTER COLUMN subscription_tier SET DEFAULT 'free'; -- Step 6: CHECK 제약 정리 ALTER TABLE users DROP CONSTRAINT users_subscription_tier_not_null;

플레이북 2: 큰 테이블에 인덱스 생성

-- Step 1: 인덱스를 concurrently로 생성 SET statement_timeout = '0'; CREATE INDEX CONCURRENTLY idx_orders_customer_created ON orders(customer_id, created_at DESC); -- Step 2: 인덱스 유효성 검증 DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_index i JOIN pg_class c ON i.indexrelid = c.oid WHERE c.relname = 'idx_orders_customer_created' AND i.indisvalid = true ) THEN RAISE EXCEPTION 'Index is invalid! Drop and retry.'; END IF; END $$;

플레이북 3: 테이블 교체 (전체 스키마 변경)

-- 개별 컬럼의 expand-contract가 의미 없을 정도로 -- 변경이 광범위한 경우 -- Step 1: 원하는 스키마로 새 테이블 생성 CREATE TABLE users_v2 ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, display_name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, subscription_tier TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Step 2: 새 테이블에 인덱스 생성 CREATE INDEX idx_users_v2_email ON users_v2(email); CREATE INDEX idx_users_v2_created ON users_v2(created_at); -- Step 3: 배치로 데이터 복사 (백필 패턴과 동일) -- Step 4: 기존 테이블에 트리거를 만들어 새 행을 -- users_v2로 동기화 -- Step 5: 테이블을 원자적으로 스왑 BEGIN; ALTER TABLE users RENAME TO users_old; ALTER TABLE users_v2 RENAME TO users; COMMIT; -- Step 6: 시퀀스, 외래 키 등 업데이트 -- Step 7: 검증 기간 후 기존 테이블 삭제

흔한 실수와 피하는 법

실수 1: 피크 트래픽 중에 마이그레이션 실행

스키마 마이그레이션은 트래픽이 제일 적을 때 돌리세요. "안전한" 마이그레이션도 동시 접속이 적으면 훨씬 부드러워요.

실수 2: 롤백을 테스트하지 않기

마이그레이션 롤백은 반드시 테스트해야 해요. "그냥 컬럼 드랍하면 돼"는 데이터 날리는 롤백이에요. 롤백할 때 데이터를 살려야 하는지 꼭 생각해보세요.

실수 3: ORM을 잊어버리기

ORM이 컬럼명으로 SQL을 생성할 수 있어요. Expand-Contract를 쓸 때, ORM 버전이 전환 상태(이전/이후 컬럼 둘 다 있는 상태)를 처리할 수 있는지 확인하세요.

실수 4: 레플리케이션 랙 무시

읽기 레플리카 쓰면 스키마 변경이 레플리케이션으로 전파돼요. 1천만 행을 쓰는 백필은 레플리케이션 랙을 심하게 볼려서, 읽기 레플리카가 낡은 데이터를 돌려줄 수 있어요.

해결: 백필 중 pg_stat_replication을 모니터링하고 랙이 임계값을 넘으면 스로틀링:

async function throttledBackfill(batchSize: number) { while (true) { const lag = await getReplicationLag(); if (lag > MAX_LAG_BYTES) { console.log(`Replication lag ${lag} bytes, pausing...`); await sleep(5000); continue; } const updated = await updateBatch(batchSize); if (updated === 0) break; await sleep(50); // 기본 스로틀 } }

실수 5: 앱 코드와 마이그레이션을 동시에 배포

앱 코드와 마이그레이션은 별도 단계로 배포하세요. 마이그레이션 먼저 배포하고, 성공을 확인한 다음, 새 스키마를 사용하는 코드를 배포하세요. 이 분리가 안전한 롤백의 핵심이에요.

마이그레이션 안전 체크리스트

프로덕션 마이그레이션 전 매번:

  • 락 타임아웃 설정 (SET lock_timeout = '5s')
  • 긴 오퍼레이션에 statement 타임아웃 설정
  • 프로덕션 규모 데이터셋에서 마이그레이션 테스트 완료
  • 롤백 스크립트 작성 및 테스트 완료
  • 동시 배포나 유지보수 창 없음
  • 레플리케이션 랙 모니터링 활성화
  • 백필은 배치 업데이트 사용 (단일 UPDATE가 아닌)
  • 인덱스는 CONCURRENTLY로 생성
  • NOT NULL 제약은 CHECK + VALIDATE 패턴으로 추가
  • 외래 키는 NOT VALID + VALIDATE로 추가
  • 앱 코드가 이전 스키마와 하위 호환
  • 온콜 엔지니어에게 마이그레이션 알림
  • 가능하면 트래픽이 가장 적은 시간대

스키마 마이그레이션이 무서울 필요 없어요. 여기 나온 패턴들은 하루 수백만 요청 처리하는 DB에서 검증된 것들이에요. 핵심은 간단해요. 깨지는 변경을 한 번에 때리지 마세요. 먼저 확장, 나중에 축소. 타임아웃 걸고, 배치로 백필하고, 전부 모니터링하세요.

DB는 앱의 심장이에요. 스키마 변경은 심장 수술하듯 다루세요. 면밀한 계획, 정밀한 실행, 끊임없는 모니터링.

PostgreSQLdatabasemigrationszero-downtimeschema changesDevOpsbackendproductionexpand-contractdeploy

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요