왜 마이그레이션이 배포의 약한 고리인가

애플리케이션 코드는 롤백이 쉽다. 이전 이미지로 되돌리면 끝이다. 하지만 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처럼 트랜잭션 밖에서만 되는 명령은 예외 처리를 명시하라. 검증의 목표는 "완벽한 롤백"이 아니라 "되돌릴 수 없는 변경을 배포 전에 인지하는 것"이다.