
무중단 DB 마이그레이션: 운영 중 스키마 변경의 공포
운영 중인 테이블에 컬럼을 추가했다가 서비스가 5분간 멈췄다. 무중단으로 스키마를 변경하는 전략을 배운 이야기.

운영 중인 테이블에 컬럼을 추가했다가 서비스가 5분간 멈췄다. 무중단으로 스키마를 변경하는 전략을 배운 이야기.
DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

금요일 오후 5시. "이 컬럼 하나만 추가하면 되는데..." 싶었다. 유저 테이블에 email_verified 컬럼을 추가하는 간단한 작업이었다. Prisma 스키마 수정하고, npx prisma migrate deploy 실행. 그리고 5분간 서비스가 멈췄다.
Slack에 에러 알림이 쏟아졌다. "Database connection timeout", "Query timeout", "503 Service Unavailable". 무슨 일이 일어난 건지 몰랐다. 단순히 컬럼 하나 추가한 것뿐인데, 100만 개의 로우를 가진 테이블이 통째로 잠겼다. ALTER TABLE이 실행되는 동안 모든 읽기/쓰기 작업이 대기 상태에 빠진 것이다.
그날 이후 깨달았다. 운영 DB에서 스키마 변경은 코드 배포보다 위험하다. 코드는 롤백하면 되지만, DB는 락이 걸리는 순간 전체 서비스가 멈춘다. 무중단 마이그레이션은 선택이 아니라 필수였다.
문제의 핵심은 테이블 락이었다. PostgreSQL에서 특정 DDL 작업은 테이블 전체에 ACCESS EXCLUSIVE 락을 건다. 이 락이 걸리면:
내가 실행한 마이그레이션은 이랬다:
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT false;
이 한 줄이 100만 개 로우를 전부 업데이트했다. DEFAULT 값을 설정하면서 각 로우에 false 값을 쓰는 동안 테이블이 잠긴 것이다. 마치 도서관에서 모든 책을 재정렬하는 동안 출입문을 잠그는 것과 같았다.
처음엔 이해가 안 됐다. "컬럼 하나 추가하는 게 왜 이렇게 오래 걸려?" 하지만 DB 관점에서 보니 당연했다. 각 로우에 물리적으로 새 데이터를 써야 하고, 그 동안 다른 작업이 끼어들면 데이터 일관성이 깨진다. 그래서 락을 거는 것이다.
여기서 핵심 깨달음: DB 마이그레이션은 점진적으로 진행해야 한다. 한 번에 바꾸려고 하면 락이 걸리고, 서비스가 멈춘다.
무중단 마이그레이션의 핵심은 Expand-Contract 패턴이다. 마치 다리를 건너갈 때 한쪽 발을 내딛고 나서 다른 발을 떼듯이, 새로운 스키마를 추가하고 기존 것을 제거하는 과정을 단계적으로 진행한다.
잘못된 방법 (한 번에 변경):-- ❌ 위험: 테이블 락 발생
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT false;
올바른 방법 (3단계로 분리):
-- 1단계: nullable로 컬럼 추가 (락 최소화)
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN;
-- 2단계: 배치 작업으로 기존 데이터 채우기 (애플리케이션 레벨)
UPDATE users
SET email_verified = false
WHERE email_verified IS NULL
LIMIT 1000; -- 배치 단위로 처리
-- 3단계: NOT NULL 제약조건 추가 (모든 데이터가 채워진 후)
ALTER TABLE users
ALTER COLUMN email_verified SET NOT NULL;
-- 4단계: 기본값 설정 (메타데이터만 변경)
ALTER TABLE users
ALTER COLUMN email_verified SET DEFAULT false;
첫 번째 단계에서는 nullable로 추가하기 때문에 PostgreSQL이 메타데이터만 수정한다. 실제 로우를 건드리지 않아 락이 거의 발생하지 않는다. 그 다음 배치로 데이터를 채우면서 서비스는 계속 돌아간다.
이 패턴의 핵심은 물리적 변경과 논리적 변경을 분리하는 것이다. 마치 건물을 리모델링할 때 임시 가벽을 세워두고 한쪽씩 공사하는 것처럼.
인덱스 생성도 위험한 작업이다. 일반적인 CREATE INDEX는 테이블 전체를 스캔하면서 SHARE 락을 건다. 읽기는 되지만 쓰기가 막힌다.
잘못된 방법:-- ❌ 위험: 쓰기 작업 블로킹
CREATE INDEX idx_users_email ON users(email);
올바른 방법:
-- ✅ 안전: 동시 접근 허용
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CONCURRENTLY 키워드를 쓰면 인덱스 생성이 2배 더 오래 걸리지만, 서비스는 멈추지 않는다. 트레이드오프가 명확하다. 시간을 주고 안정성을 산다.
주의할 점: CONCURRENTLY는 트랜잭션 안에서 사용할 수 없다. Prisma 같은 ORM을 쓴다면 raw SQL로 직접 실행해야 한다:
// Prisma에서 CONCURRENTLY 인덱스 생성
await prisma.$executeRawUnsafe(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email)'
);
컬럼 이름을 바꾸는 것도 간단해 보이지만 위험하다. RENAME COLUMN은 메타데이터만 바꾸니까 빠르지만, 문제는 애플리케이션 코드다. 코드가 배포되기 전에 컬럼 이름이 바뀌면 즉시 에러가 난다.
-- 1단계: 새 컬럼 추가
ALTER TABLE users ADD COLUMN full_name TEXT;
-- 2단계: 기존 데이터 복사 (배치)
UPDATE users SET full_name = name WHERE full_name IS NULL;
// 3단계: 애플리케이션 코드에서 두 컬럼 모두 쓰기
await prisma.user.update({
where: { id },
data: {
name: newName, // 기존 컬럼
full_name: newName, // 새 컬럼
},
});
-- 4단계: 애플리케이션 배포 완료 후, 기존 컬럼 읽기 중단
-- (코드에서 full_name만 읽도록 수정)
-- 5단계: 기존 컬럼 쓰기 중단
-- (코드에서 name 제거)
-- 6단계: 기존 컬럼 삭제
ALTER TABLE users DROP COLUMN name;
이 과정은 며칠에 걸쳐 진행된다. 급할 것 없다. 안정성이 속도보다 중요하다. 마치 케이블 하나하나를 옮기면서 시스템을 이전하는 것처럼.
컬럼을 삭제하는 것도 역순으로 진행한다:
-- 마지막 단계에서만 실행
ALTER TABLE users DROP COLUMN old_column;
DROP COLUMN은 PostgreSQL에서 메타데이터만 변경하므로 빠르지만, 롤백이 불가능하다. 그래서 충분히 기다린다. 만약 문제가 생기면 코드를 롤백해서 다시 읽기/쓰기를 시작할 수 있어야 한다.
Supabase를 쓰면서 정착한 워크플로우:
# 1. 로컬에서 마이그레이션 작성
supabase migration new add_email_verified
# 2. SQL 파일 편집 (무중단 전략 적용)
# supabase/migrations/20260205_add_email_verified.sql
-- nullable로 컬럼 추가
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN;
-- 인덱스는 CONCURRENTLY
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_verified
ON users(email_verified)
WHERE email_verified = true;
# 3. 로컬에서 테스트
supabase db reset
# 4. Staging에 배포
supabase db push --db-url $STAGING_DB_URL
# 5. Production에 배포 (심호흡 후)
supabase db push --db-url $PRODUCTION_DB_URL
핵심은 staging 환경에서 먼저 테스트하는 것이다. Production 데이터를 복제해서 마이그레이션을 돌려보고, 락이 얼마나 오래 걸리는지 측정한다.
마이그레이션이 실패했을 때를 대비한 롤백 스크립트를 항상 준비한다:
-- 20260205_add_email_verified_rollback.sql
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email_verified;
ALTER TABLE users DROP COLUMN IF EXISTS email_verified;
Supabase나 Prisma는 자동 롤백을 지원하지 않는다. 그래서 수동으로 준비해야 한다. 마이그레이션을 실행하기 전에 롤백 스크립트를 먼저 작성하는 습관을 들였다.
운영 DB에서 절대 하지 말아야 할 것들:
ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... (락 발생)CREATE INDEX without CONCURRENTLY (쓰기 블로킹)ALTER TABLE ... RENAME COLUMN (코드와 동기화 필요)DROP COLUMN immediately (롤백 불가)안전한 작업:
ADD COLUMN nullable (메타데이터만 변경)CREATE INDEX CONCURRENTLY (동시 접근 허용)무중단 마이그레이션을 배우면서 가장 크게 바뀐 건 시간 개념이었다. 예전엔 "지금 당장 바꾸면 되지" 했는데, 이제는 "며칠에 걸쳐 바꾸자"로 생각이 바뀌었다.
핵심 원칙 3가지:DB 마이그레이션은 달리기가 아니라 등산이다. 빨리 가려다 미끄러지면 전체가 무너진다. 천천히, 확실하게, 한 발씩 내딛는 것. 그게 결국 가장 빠른 길이었다.
지금은 마이그레이션 전에 체크리스트를 확인한다:
마지막 항목이 농담 같지만, 진짜다. 금요일 오후에는 DB 마이그레이션 하지 않는다. 주말을 망치고 싶지 않다면.