왜 마이그레이션이 배포의 약한 고리인가
애플리케이션 코드는 롤백이 쉽다. 이전 이미지로 되돌리면 끝이다. 하지만 DB 스키마는 상태를 가진다. ALTER TABLE로 컬럼을 드롭한 뒤 문제를 발견하면, 코드만 되돌려도 데이터는 이미 사라져 있다. 실무에서 장애의 상당수는 애플리케이션 버그가 아니라 "리뷰는 통과했지만 프로덕션에서 처음 실행된 마이그레이션"에서 나온다. 로컬과 CI에서 한 번도 실제로 적용해보지 않은 SQL이 수백만 행 테이블에 곧바로 걸리는 것이다.
핵심은 두 가지를 CI에서 강제하는 것이다. 첫째, 마이그레이션이 실제 DB에 적용되는지. 둘째, 적용한 것을 되돌릴 수 있는지. 이 두 검증이 없으면 롤백 스크립트는 "작성만 되고 실행된 적 없는 코드"로 남는다.
up만 검증하는 함정과 up→down→up 사이클
대부분의 CI는 migrate up만 돌리고 끝낸다. 그러면 down 마이그레이션의 문법 오류나 논리 오류를 잡을 수 없다. 신뢰할 수 있는 최소 검증은 up → down → up 사이클이다. 최신 마이그레이션을 적용하고, 한 단계 되돌리고, 다시 적용한다. 이 과정이 깨지지 않으면 롤백 경로가 최소한 실행 가능함을 보장한다.
#!/usr/bin/env bash
set -euo pipefail
# 깨끗한 DB에 전체 up 적용
migrate -database "$DATABASE_URL" -path ./migrations up
# 가장 최근 1개를 롤백
migrate -database "$DATABASE_URL" -path ./migrations down 1
# 다시 up — down이 남긴 잔재로 실패하면 여기서 잡힌다
migrate -database "$DATABASE_URL" -path ./migrations up
echo "up/down/up cycle passed"
CI 워크플로에 실제 DB 붙이기
SQLite in-memory로 대충 검증하면 안 된다. 프로덕션이 PostgreSQL이면 CI도 같은 엔진, 가능하면 같은 메이저 버전을 서비스 컨테이너로 띄운다. 방언 차이(예: SERIAL vs AUTO_INCREMENT, JSONB, 부분 인덱스)는 엔진이 달라지는 순간 검증 의미가 사라진다.
jobs:
migration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: app_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:ci@localhost:5432/app_test?sslmode=disable
steps:
- uses: actions/checkout@v4
- name: up/down/up cycle
run: ./scripts/migration_cycle.sh
파괴적 변경을 정적으로 걸러내기
사이클 테스트를 통과해도 위험한 마이그레이션이 있다. 컬럼/테이블 드롭, NOT NULL 추가, 타입 변경은 락을 잡거나 데이터를 잃는다. 이런 패턴은 리뷰 단계에서 자동으로 라벨링해 사람이 의식적으로 승인하게 만든다.
import sys, re, pathlib
DANGER = {
r"\bDROP\s+(TABLE|COLUMN)\b": "데이터 손실 가능",
r"\bSET\s+NOT\s+NULL\b": "기존 NULL 행에서 실패/락",
r"\bALTER\s+COLUMN\b.*\bTYPE\b": "전체 테이블 재작성 락",
}
hits = []
for f in pathlib.Path("migrations").glob("*up.sql"):
sql = f.read_text().upper()
for pat, why in DANGER.items():
if re.search(pat, sql):
hits.append(f"{f.name}: {why}")
if hits:
print("⚠ 파괴적 변경 감지 (수동 승인 필요):")
print("\n".join(hits))
sys.exit(1) # 승인 라벨 없으면 실패시켜 강제 리뷰
롤백이 불가능한 변경을 다루는 법
모든 변경이 되돌릴 수 있는 것은 아니다. 컬럼을 드롭하면 down에서 컬럼을 다시 만들어도 데이터는 못 살린다. 이때는 롤백 대신 확장-수축(expand-contract) 패턴을 쓴다. 파괴적 작업을 여러 배포로 쪼개, 각 단계가 이전 코드와 호환되게 한다.
| 단계 | DB 작업 | 롤백 방식 |
|---|---|---|
| Expand | 새 컬럼 추가(nullable) | 코드만 되돌림 — 컬럼 방치 |
| Migrate | 양쪽 컬럼에 동시 기록 | 신규 컬럼 무시 |
| Contract | 구 컬럼 드롭 | 이전 배포 안정화 후에만 |
이 방식에서는 "즉시 롤백"이 아니라 "다음 단계로 진행" 또는 "직전 배포 유지"가 되돌리기 전략이 된다. CI는 각 단계의 마이그레이션이 이전 애플리케이션 버전과 함께 떠도 깨지지 않는지를 검증해야 한다.
데이터가 있는 상태에서의 롤백 검증
빈 테이블에서의 up/down/up은 문법만 본다. 실제 위험은 데이터가 있을 때 드러난다. 시드 데이터를 넣은 뒤 마이그레이션을 돌리면, 유니크 제약 위반이나 NOT NULL 충돌 같은 런타임 실패를 프로덕션 전에 잡을 수 있다.
# up 이전 스키마 기준으로 대표 데이터 주입
psql "$DATABASE_URL" -f ./fixtures/seed.sql
# 데이터가 있는 상태에서 마이그레이션 + 롤백
migrate -database "$DATABASE_URL" -path ./migrations up
migrate -database "$DATABASE_URL" -path ./migrations down 1
# 행 수 불변 등 핵심 불변식 검증
psql "$DATABASE_URL" -tAc "SELECT count(*) FROM orders" | grep -qx "1000"
주의할 점
첫째, CI의 up/down/up 통과가 "안전"을 뜻하지는 않는다. 작은 테스트 테이블에서는 즉시 끝나는 ALTER가 프로덕션의 대형 테이블에서는 수 분간 락을 잡는다. 데이터 양에 따른 락 시간은 별도로 추정해야 한다. 둘째, down 스크립트를 항상 작성하되 프로덕션에서의 자동 롤백은 신중히 하라 — 데이터 손실형 down은 오히려 위험하다. 셋째, 마이그레이션은 트랜잭션 안에서 돌리되, PostgreSQL의 CREATE INDEX CONCURRENTLY처럼 트랜잭션 밖에서만 되는 명령은 예외 처리를 명시하라. 검증의 목표는 "완벽한 롤백"이 아니라 "되돌릴 수 없는 변경을 배포 전에 인지하는 것"이다.