Skip to content
Published on

마이그레이션 롤백과 검증 — 안전망을 설계하기

Authors

들어가며

배포를 되돌리는 일은 보통 간단합니다. 이전 버전의 컨테이너 이미지를 다시 띄우거나, 트래픽을 이전 디플로이먼트로 전환하면 됩니다. 코드는 멱등하고, 상태가 없으며, 언제든 깨끗하게 교체할 수 있습니다. 그런데 데이터베이스 마이그레이션은 이 직관을 정면으로 배신합니다.

마이그레이션은 상태를 변경합니다. 컬럼을 떨어뜨리면 그 안에 있던 데이터는 사라집니다. 타입을 바꾸면 변환 과정에서 정밀도가 손실될 수 있습니다. 수백만 행을 백필(backfill)하는 도중에 실패하면, 일부는 새 값이고 일부는 옛 값인 어중간한 상태가 남습니다. 코드 롤백은 "이전 상태로 돌아가기"지만, 데이터 롤백은 종종 "이미 일어난 일을 없던 일로 만들기"입니다. 그리고 데이터의 세계에서 "없던 일로 만들기"는 거의 항상 불가능합니다.

이 글은 마이그레이션을 안전하게 다루기 위한 안전망(safety net)을 설계하는 방법을 다룹니다. 핵심 질문은 하나입니다. "이 변경이 잘못되었을 때, 우리는 어떻게 살아남을 것인가?" 롤백이 가능하도록 마이그레이션을 작성하는 법, 백업과 복구 지점을 잡는 법, 스키마와 데이터와 성능과 애플리케이션을 단계적으로 검증하는 법, 카나리와 피처 플래그로 위험을 분산하는 법, 그리고 무언가 잘못되었을 때 따라갈 런북과 포스트모템까지 차례로 살펴보겠습니다.

이 글에서 다루는 내용은 다음과 같습니다.

  • 롤백이 본질적으로 어려운 이유와 데이터 손실의 비가역성
  • 되돌릴 수 있는 변경과 되돌릴 수 없는 변경의 구분
  • 롤백 가능한 마이그레이션을 작성하는 패턴
  • 백업과 복구 지점(recovery point) 전략
  • 스키마, 데이터, 성능, 애플리케이션 스모크의 다단계 검증
  • 카나리 배포와 점진적 롤아웃
  • 피처 플래그를 통한 코드와 스키마의 분리
  • 체크섬과 재집계를 이용한 자동화된 데이터 검증
  • 인시던트 대응 런북과 포스트모템, 그리고 최종 체크리스트

왜 롤백은 어려운가

데이터 손실은 비가역적이다

소프트웨어 엔지니어링의 많은 작업은 되돌릴 수 있습니다. 잘못 작성한 코드는 git revert로 되돌리고, 잘못 띄운 서버는 다시 내리면 됩니다. 하지만 데이터는 다릅니다. 한 번 삭제된 행, 한 번 덮어쓴 값, 한 번 잘려나간 문자열은 원본 없이는 복원할 수 없습니다.

이것을 가장 분명하게 보여주는 예가 컬럼 삭제입니다.

-- 이 마이그레이션은 "롤백"할 수 없습니다.
ALTER TABLE users DROP COLUMN legacy_phone;

문법적으로는 다음과 같은 "다운" 마이그레이션을 쓸 수 있습니다.

-- 이것은 컬럼 "구조"만 복원할 뿐, 데이터는 복원하지 못합니다.
ALTER TABLE users ADD COLUMN legacy_phone VARCHAR(20);

하지만 이 다운 마이그레이션은 거짓말입니다. 컬럼은 돌아오지만 그 안에 있던 모든 전화번호는 영원히 사라졌습니다. 마이그레이션 도구는 "down 스크립트가 존재한다"는 사실만 알 뿐, 그 스크립트가 의미 있는 복원을 하는지는 알지 못합니다. 이것이 롤백을 위험하게 만드는 첫 번째 함정입니다. 도구가 보장하는 "되돌리기"와 실제로 데이터가 보존되는 "되돌리기"는 전혀 다른 개념입니다.

시간이 흐르면 새 데이터가 쌓인다

롤백을 어렵게 만드는 두 번째 요인은 시간입니다. 마이그레이션이 배포된 직후 5분 안에 문제를 발견하면, 그 사이에 들어온 데이터는 많지 않습니다. 하지만 문제를 6시간 뒤에 발견하면, 그동안 사용자들은 새 스키마 위에서 수만 건의 트랜잭션을 만들어 냈습니다.

이제 이전 스키마로 돌아가려면, 그 6시간 동안 쌓인 새 데이터를 어떻게 처리할지 결정해야 합니다. 버릴 것인가? 변환할 것인가? 새 스키마에만 존재하는 컬럼에 들어간 값들은 이전 스키마에 들어갈 자리가 없습니다. 단순한 "되돌리기"가 아니라, 두 스키마 사이의 데이터 정합성 문제가 되어 버립니다.

T0   마이그레이션 적용 (컬럼 추가, NOT NULL)
T0+5m  배포 완료, 트래픽 정상화
T0+10m 사용자들이 새 컬럼에 데이터 입력 시작
...
T0+6h  성능 문제 발견 → 롤백 결정
       이제 6시간 동안 쌓인 새 데이터를 어떻게?
       단순 down 마이그레이션은 이 데이터를 무시하거나 파괴함

잠금과 대용량 테이블

세 번째 요인은 운영 환경의 물리적 제약입니다. 수억 행짜리 테이블에 ALTER를 거는 순간, 데이터베이스 엔진에 따라 테이블 전체에 잠금이 걸리고, 그 사이 모든 읽기와 쓰기가 멈출 수 있습니다. 롤백 마이그레이션 자체가 또 다른 장시간 잠금을 유발해, 롤백이 원래 인시던트보다 더 큰 인시던트를 만들어 내기도 합니다.

그래서 좋은 마이그레이션 전략은 "롤백을 잘 하는 법"이 아니라, 애초에 롤백이 필요 없도록, 혹은 롤백이 안전하도록 변경을 설계하는 데서 출발합니다.

되돌릴 수 있는 변경 vs 되돌릴 수 없는 변경

모든 스키마 변경을 한 축 위에 놓고 보면, 한쪽 끝에는 완벽히 가역적인 변경이, 반대쪽 끝에는 완전히 비가역적인 변경이 있습니다.

변경 유형가역성위험도비고
컬럼 추가 (nullable)높음낮음그냥 드롭하면 됨, 기존 데이터 영향 없음
인덱스 추가높음낮음드롭하면 원상복구, 성능만 영향
테이블 추가높음낮음드롭하면 됨
컬럼 이름 변경중간중간애플리케이션 코드와 동기화 필요
컬럼 추가 (NOT NULL + 기본값)중간중간백필 필요, 대용량이면 잠금 위험
타입 변경 (확장)중간중간int를 bigint로 등, 보통 안전
타입 변경 (축소)낮음높음정밀도 손실 가능, 데이터 검증 필수
컬럼 삭제매우 낮음높음데이터 영구 손실
테이블 삭제매우 낮음매우 높음데이터 영구 손실
데이터 변환 백필낮음높음원본 보존 안 하면 비가역

이 표가 주는 가장 중요한 교훈은, 비가역적 변경을 가역적 변경의 연속으로 분해하라는 것입니다. 컬럼을 삭제하는 대신, 먼저 코드에서 그 컬럼을 더 이상 읽지 않게 만들고, 한동안 관찰한 뒤, 충분히 안전하다고 판단되면 그때 삭제합니다. 이 사이의 모든 단계는 가역적입니다.

확장-수축 패턴 (Expand-Contract)

이 분해를 정형화한 것이 확장-수축 패턴(expand and contract), 혹은 병렬 변경(parallel change)이라 불리는 기법입니다. Martin Fowler가 진화적 데이터베이스 설계에서 정리한 이 패턴은 다음 세 단계로 구성됩니다.

1. Expand (확장)
   - 새 구조를 추가하되, 기존 구조는 그대로 둔다
   - 예: 새 컬럼 추가, 기존 컬럼 유지
   - 코드는 둘 다 쓰거나, 새 것을 우선 읽고 옛 것을 폴백으로 사용

2. Migrate (이행)
   - 기존 데이터를 새 구조로 백필
   - 새로 들어오는 데이터는 양쪽 모두에 기록 (dual write)
   - 검증: 두 구조의 데이터가 일치하는지 확인

3. Contract (수축)
   - 코드가 더 이상 옛 구조를 참조하지 않음을 확인
   - 충분한 관찰 기간 후, 옛 구조를 제거

이 패턴의 핵심은, 각 단계가 독립적으로 배포되고 각 단계가 가역적이라는 점입니다. 확장 단계에서 문제가 생기면 새 컬럼을 드롭하면 됩니다. 이행 단계에서 문제가 생기면 dual write를 끄고 옛 컬럼만 쓰면 됩니다. 가장 위험한 수축 단계는, 그 앞의 모든 단계가 안정적임을 확인한 후에만 실행합니다.

롤백 가능한 마이그레이션 작성하기

up과 down을 항상 짝으로

대부분의 마이그레이션 도구는 up(적용)과 down(되돌리기) 스크립트를 짝으로 작성하게 합니다. golang-migrate는 파일 이름 규칙으로 이를 표현합니다.

000012_add_user_status.up.sql
000012_add_user_status.down.sql

up 스크립트는 다음과 같습니다.

-- 000012_add_user_status.up.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20);

down 스크립트는 다음과 같습니다.

-- 000012_add_user_status.down.sql
ALTER TABLE users DROP COLUMN status;

이 경우 status 컬럼은 새로 추가된 것이므로, 드롭해도 잃을 데이터가 없습니다. 이것이 진짜 가역적인 마이그레이션입니다.

Flyway의 Undo 마이그레이션

Flyway는 U 접두사로 undo 마이그레이션을 표현합니다. 버전이 매겨진 마이그레이션 V12에 대응하는 되돌리기 스크립트는 U12입니다.

-- V12__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20);
-- U12__add_user_status.sql
ALTER TABLE users DROP COLUMN status;

다만 Flyway 공식 문서도 강조하듯, undo는 만능이 아닙니다. DROP된 데이터를 되살리는 undo는 존재할 수 없으며, undo 스크립트는 신중하게 검토되어야 합니다.

Liquibase의 자동/수동 롤백

Liquibase는 변경 세트(changeset) 단위로 롤백을 다룹니다. 일부 변경은 자동으로 롤백 방법을 추론할 수 있고, 그렇지 않은 경우 개발자가 명시적으로 롤백 절을 작성합니다.

databaseChangeLog:
  - changeSet:
      id: add-user-status
      author: youngjukim
      changes:
        - addColumn:
            tableName: users
            columns:
              - column:
                  name: status
                  type: varchar(20)
      rollback:
        - dropColumn:
            tableName: users
            columnName: status

비가역적 변경을 가역적으로 바꾸기

진짜 위험한 것은 데이터를 파괴하는 변경입니다. 컬럼을 삭제해야 한다면, 곧바로 DROP하지 말고 다음과 같이 분해합니다.

-- 1단계: 즉시 삭제하지 않고, 이름을 바꿔 "사용 중지" 표시
ALTER TABLE users RENAME COLUMN legacy_phone TO legacy_phone_deprecated;
-- 2단계 (며칠 뒤, 아무도 참조하지 않음을 확인 후): 백업 테이블로 보존
CREATE TABLE archive_users_legacy_phone AS
SELECT id, legacy_phone_deprecated
FROM users
WHERE legacy_phone_deprecated IS NOT NULL;
-- 3단계 (충분한 관찰 후): 실제 삭제
ALTER TABLE users DROP COLUMN legacy_phone_deprecated;

이렇게 하면 1단계와 2단계는 모두 가역적이며, 3단계를 실행하기 전까지 데이터는 archive 테이블에 안전하게 보존됩니다. 만약 3단계 이후 문제가 발견되어도, archive 테이블에서 데이터를 복원할 수 있습니다.

PostgreSQL에서 잠금을 피하는 패턴

대용량 테이블에서 NOT NULL 컬럼을 추가하는 것은 위험합니다. PostgreSQL에서는 다음 순서로 잠금을 최소화합니다.

-- 1. nullable로 추가 (메타데이터만 변경, 빠름)
ALTER TABLE orders ADD COLUMN region VARCHAR(10);
-- 2. 배치 단위로 백필 (한 번에 전체를 잠그지 않음)
UPDATE orders SET region = 'UNKNOWN'
WHERE region IS NULL AND id BETWEEN 1 AND 100000;
-- 범위를 옮겨가며 반복
-- 3. NOT NULL 제약을 NOT VALID로 먼저 추가
ALTER TABLE orders ADD CONSTRAINT orders_region_not_null
CHECK (region IS NOT NULL) NOT VALID;
-- 4. 별도로 검증 (이 단계는 ACCESS EXCLUSIVE 잠금을 피함)
ALTER TABLE orders VALIDATE CONSTRAINT orders_region_not_null;

인덱스도 마찬가지로 CONCURRENTLY 옵션을 써서 테이블 잠금을 피합니다.

CREATE INDEX CONCURRENTLY idx_orders_region ON orders (region);

백업과 복구 지점

롤백 스크립트가 거짓말일 수 있다면, 진짜 안전망은 무엇일까요? 바로 백업입니다. 어떤 마이그레이션도 잘못될 수 있다는 전제 위에서, 우리는 변경 직전의 상태로 돌아갈 수 있는 복구 지점을 반드시 확보해야 합니다.

복구 지점 목표와 복구 시간 목표

두 가지 핵심 지표를 이해해야 합니다.

지표의미마이그레이션 맥락
RPO (복구 지점 목표)얼마나 많은 데이터를 잃어도 되는가마이그레이션 직전 스냅샷이 있다면 RPO는 그 시점
RTO (복구 시간 목표)복구에 얼마나 걸려도 되는가백업에서 복원하는 데 걸리는 시간

마이그레이션을 실행하기 전에 스냅샷을 찍어두면, 최악의 경우 그 스냅샷으로 복원하여 RPO를 마이그레이션 시작 시점으로 보장할 수 있습니다. 다만 그 이후 들어온 데이터는 잃게 되므로, 이것은 정말 최후의 수단입니다.

마이그레이션 전 스냅샷

운영 환경에서 큰 마이그레이션을 실행하기 직전에는 항상 스냅샷을 찍습니다.

# 논리 백업 (작은 테이블, 특정 스키마)
pg_dump --format=custom --table=users --table=orders \
  --file=/backups/pre_migration_$(date +%Y%m%d_%H%M%S).dump \
  mydb
# 특정 테이블만 빠르게 보존하고 싶을 때 (CTAS)
psql mydb -c "CREATE TABLE users_backup_20260616 AS TABLE users;"

클라우드 관리형 데이터베이스라면 스냅샷 기능을 사용하는 편이 훨씬 빠릅니다. 핵심은, 마이그레이션 런북의 첫 단계가 항상 "복구 지점 확보 확인"이어야 한다는 점입니다.

시점 복구 (PITR)

PostgreSQL의 시점 복구(point-in-time recovery)는 WAL(write-ahead log)을 보존하여, 특정 시각으로 정확히 복원할 수 있게 해 줍니다. 마이그레이션이 T0+30분에 데이터를 망가뜨렸다면, T0 직전 시점으로 복원할 수 있습니다. PITR을 사용하려면 기본 백업과 연속적인 WAL 아카이빙이 미리 설정되어 있어야 합니다.

복구 흐름:
  기본 백업 (T-1d) + WAL 아카이브 (T-1d ~ 현재)
  → recovery_target_time = 'T0 직전'
  → 그 시각까지의 모든 트랜잭션을 재생하여 복원

다단계 검증

마이그레이션이 적용된 후, "성공"이라고 선언하기 전에 무엇을 확인해야 할까요? 검증은 한 번의 통과/실패가 아니라 여러 층위로 이루어져야 합니다.

┌─────────────────────────────────────────┐
│  4. 애플리케이션 스모크 테스트            │  핵심 사용자 흐름이 동작하는가
├─────────────────────────────────────────┤
│  3. 성능 검증                            │  쿼리 계획, 지연시간, 잠금
├─────────────────────────────────────────┤
│  2. 데이터 검증                          │  행 수, 체크섬, 무결성
├─────────────────────────────────────────┤
│  1. 스키마 검증                          │  구조가 의도대로 바뀌었는가
└─────────────────────────────────────────┘
        아래에서 위로 올라가며 검증

1단계: 스키마 검증

가장 먼저, 스키마가 의도한 대로 바뀌었는지 확인합니다. 컬럼이 추가되었는지, 타입이 맞는지, 제약 조건과 인덱스가 존재하는지를 시스템 카탈로그에서 조회합니다.

-- 컬럼이 기대한 타입으로 존재하는지
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'region';
-- 인덱스가 생성되었는지
SELECT indexname FROM pg_indexes
WHERE tablename = 'orders' AND indexname = 'idx_orders_region';

2단계: 데이터 검증

스키마가 맞다고 데이터까지 맞는 것은 아닙니다. 백필이 모든 행을 처리했는지, NULL이 남지 않았는지, 변환된 값이 올바른지 확인합니다.

-- 백필이 끝났는지: NULL이 0이어야 함
SELECT count(*) AS remaining_nulls
FROM orders WHERE region IS NULL;
-- 마이그레이션 전후 행 수가 보존되었는지
SELECT count(*) AS total_rows FROM orders;
-- 분포를 보아 비정상적인 값이 없는지
SELECT region, count(*) FROM orders GROUP BY region ORDER BY 2 DESC;

3단계: 성능 검증

스키마와 데이터가 맞아도, 새 인덱스가 빠져 쿼리가 풀 스캔으로 돌면 운영에서 장애가 납니다. 핵심 쿼리의 실행 계획을 확인합니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE region = 'KR' ORDER BY created_at DESC LIMIT 50;

새 인덱스를 타는지, 예상 비용이 합리적인지, 실제 실행 시간이 기준선 안에 들어오는지 확인합니다. 잠금 대기가 쌓이고 있지 않은지도 함께 봅니다.

-- 현재 잠금 대기 상황 확인
SELECT pid, state, wait_event_type, query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL;

4단계: 애플리케이션 스모크 테스트

마지막으로, 실제 애플리케이션 관점에서 핵심 사용자 흐름이 동작하는지 확인합니다. 데이터베이스만 보면 멀쩡한데 애플리케이션 코드가 새 스키마를 이해하지 못하는 경우가 흔합니다.

# 핵심 엔드포인트 스모크 체크
curl -fsS https://api.example.com/health
curl -fsS https://api.example.com/orders/recent | jq '.items | length'

이 네 단계를 자동화하여 마이그레이션 파이프라인의 일부로 만들면, "성공"의 정의가 명확해집니다. 네 단계를 모두 통과해야 비로소 성공입니다.

카나리와 점진적 롤아웃

코드 배포에서 카나리는 익숙합니다. 새 버전을 일부 인스턴스에만 먼저 배포하고, 지표를 관찰한 뒤 점진적으로 확대합니다. 데이터베이스 마이그레이션에서도 비슷한 사고를 적용할 수 있습니다.

스키마 변경 자체는 보통 전부-아니면-전무지만, 그 스키마를 사용하는 코드 경로는 점진적으로 켤 수 있습니다. 확장-수축 패턴과 결합하면, 새 컬럼을 추가하는 것까지는 모두에게 적용하되, 그 새 컬럼을 실제로 읽고 쓰는 로직은 소수의 트래픽에만 먼저 켜는 것입니다.

단계 1: 스키마 확장 (전체 적용, 위험 낮음)
        새 컬럼 추가 — 아무도 아직 사용하지 않음

단계 2: 백필 + dual write (전체 적용, 관찰)
        새 데이터를 양쪽에 기록, 기존 데이터 백필

단계 3: 새 경로를 1% 트래픽에 활성화 (카나리)
        지표 관찰 — 에러율, 지연시간, 데이터 정합성

단계 4: 5% → 25% → 50% → 100% 점진 확대
        각 단계에서 멈추고 검증

단계 5: 옛 경로 비활성화, 수축 (옛 컬럼 제거)

각 카나리 단계에서 무엇을 관찰해야 할까요? 애플리케이션 에러율과 지연시간은 기본이고, 새 경로에서 만들어진 데이터가 옛 경로의 데이터와 정합성을 유지하는지를 함께 봐야 합니다. 이 정합성 확인이 바로 다음 절의 자동화된 데이터 검증과 맞물립니다.

피처 플래그로 분리하기

마이그레이션에서 가장 까다로운 부분은, 스키마 변경과 코드 변경의 배포 시점이 어긋난다는 점입니다. 스키마를 먼저 바꾸면 옛 코드가 깨질 수 있고, 코드를 먼저 바꾸면 새 코드가 아직 없는 스키마를 참조할 수 있습니다.

피처 플래그(feature flag, 혹은 feature toggle)는 이 둘을 분리하는 강력한 도구입니다. 스키마는 미리 확장해 두고, 코드는 모두 배포하되, 새 동작은 플래그로 꺼 둡니다. 그리고 모든 준비가 끝났을 때 플래그를 켭니다. 플래그를 켜는 것과 끄는 것은 즉각적이고 가역적이므로, 사실상 "데이터를 건드리지 않는 롤백"을 얻게 됩니다.

# 피처 플래그로 새 스키마 경로를 분리
def get_order_region(order, flags):
    if flags.is_enabled("use_new_region_column", order.user_id):
        # 새 경로: 새 컬럼을 직접 읽음
        return order.region
    else:
        # 옛 경로: 기존 로직으로 계산
        return derive_region_from_address(order.shipping_address)
# 쓰기 경로에서는 안전하게 dual write
def save_order_region(order, region, flags):
    order.region = region  # 항상 새 컬럼에 기록 (확장 단계)
    if flags.is_enabled("backfill_legacy_region"):
        order.legacy_region_code = to_legacy_code(region)  # 옛 컬럼도 유지

이 구조의 이점은, 새 경로에서 문제가 발견되면 데이터베이스를 건드리지 않고 플래그만 끄면 즉시 옛 경로로 돌아간다는 점입니다. 마이그레이션 롤백이라는 위험한 작업을, 플래그 토글이라는 안전한 작업으로 치환하는 것입니다.

다만 주의할 점이 있습니다. 플래그를 영원히 남겨 두면 코드가 분기로 가득 차 복잡해집니다. 플래그는 임시 장치입니다. 새 경로가 안정화되면, 플래그와 옛 경로를 함께 정리하는 후속 작업을 반드시 백로그에 남겨야 합니다.

자동화된 데이터 검증

대용량 데이터 마이그레이션에서, 사람이 눈으로 모든 행을 확인하는 것은 불가능합니다. 자동화된 데이터 검증이 필요합니다. 핵심 기법은 두 가지입니다. 체크섬(checksum)과 재집계(re-aggregation)입니다.

체크섬으로 데이터 일치 확인

확장-수축 패턴에서 옛 구조와 새 구조가 같은 데이터를 담고 있어야 한다면, 두 구조의 체크섬을 비교하여 일치 여부를 빠르게 확인할 수 있습니다.

-- 옛 컬럼과 새 컬럼이 의미상 일치하는지 체크섬으로 비교
SELECT
  md5(string_agg(legacy_region_code, ',' ORDER BY id)) AS old_checksum,
  md5(string_agg(to_legacy_code(region), ',' ORDER BY id)) AS new_checksum
FROM orders;

두 체크섬이 같으면 모든 행에서 변환이 일관되게 적용되었다는 강력한 증거입니다. 다르면, 어느 행에서 어긋났는지 좁혀 들어갈 수 있습니다.

-- 불일치하는 행만 찾기
SELECT id, region, legacy_region_code, to_legacy_code(region) AS expected
FROM orders
WHERE legacy_region_code IS DISTINCT FROM to_legacy_code(region);

재집계로 보존 확인

데이터를 변환하는 마이그레이션에서, 합계나 개수 같은 집계값은 보존되어야 하는 경우가 많습니다. 마이그레이션 전후의 집계값을 비교하면 데이터가 유실되거나 중복되지 않았음을 확인할 수 있습니다.

-- 마이그레이션 전에 기준선 집계를 저장
CREATE TABLE migration_baseline AS
SELECT
  count(*) AS row_count,
  sum(amount) AS total_amount,
  count(distinct user_id) AS distinct_users
FROM orders;
-- 마이그레이션 후 동일 집계를 다시 계산하여 비교
SELECT
  b.row_count = a.row_count AS row_count_ok,
  b.total_amount = a.total_amount AS amount_ok,
  b.distinct_users = a.distinct_users AS users_ok
FROM migration_baseline b
CROSS JOIN (
  SELECT count(*) AS row_count,
         sum(amount) AS total_amount,
         count(distinct user_id) AS distinct_users
  FROM orders
) a;

세 컬럼이 모두 true이면, 행 수와 금액 합계와 고유 사용자 수가 모두 보존된 것입니다. 이런 집계 검증은 가볍고 빠르며, 대용량 테이블에서도 전수 비교보다 훨씬 실용적입니다.

표본 검증과 전수 검증

검증 비용과 신뢰도 사이에는 균형이 있습니다.

방법비용신뢰도적합한 경우
표본 검증낮음중간빠른 1차 확인, 대용량
집계 검증낮음중상합계/개수 보존 확인
체크섬 검증중간높음컬럼 단위 정합성
전수 비교높음매우 높음금융 등 무결성 필수 영역

실무에서는 보통 집계 검증과 체크섬 검증을 기본으로 돌리고, 의심이 가는 영역에 한해 전수 비교를 추가합니다.

인시던트 대응 런북

아무리 잘 준비해도 마이그레이션은 잘못될 수 있습니다. 잘못되었을 때 즉흥적으로 대응하면 상황이 악화됩니다. 미리 작성된 런북(runbook)이 있으면, 압박 속에서도 정해진 절차를 따를 수 있습니다.

=== 마이그레이션 인시던트 런북 ===

[0] 감지
    - 알람: 에러율 상승, 지연시간 증가, 잠금 대기 폭증
    - 누가 인시던트 지휘자(IC)인지 즉시 지정

[1] 평가 (5분 이내)
    - 변경이 가역적인가? (위의 가역성 표 참조)
    - 데이터가 손상되고 있는가, 아니면 멈춰 있는가?
    - 영향 범위: 전체 사용자인가, 일부인가?

[2] 출혈 멈추기
    - 피처 플래그가 있다면 즉시 OFF (가장 빠르고 안전)
    - 백필 작업이 돌고 있다면 일시 정지
    - 필요시 트래픽을 옛 경로로 전환

[3] 복구 결정
    - 플래그 OFF로 충분 → 데이터 건드리지 않음 (선호)
    - 스키마만 되돌리면 됨 → down 마이그레이션 실행
    - 데이터가 손상됨 → 백업/PITR로 복원 검토

[4] 복구 실행
    - 복구 지점 확인 (마이그레이션 전 스냅샷 존재?)
    - 실행 전 IC 승인
    - 복구 후 다단계 검증 다시 수행

[5] 안정화 확인
    - 4단계 검증 (스키마/데이터/성능/스모크) 통과
    - 지표가 기준선으로 복귀했는지 관찰

[6] 커뮤니케이션
    - 상태 페이지 갱신, 이해관계자에게 공유
    - 타임라인 기록 (포스트모템 자료)

런북에서 가장 중요한 원칙은, 데이터를 건드리는 복구는 최후의 수단이라는 점입니다. 가능하면 플래그 OFF나 트래픽 전환처럼 데이터를 건드리지 않는 방법으로 먼저 출혈을 멈춥니다. 백업 복원은 그 자체로 또 다른 데이터 손실(복원 시점 이후 데이터)을 의미하므로, 가장 신중하게 결정해야 합니다.

포스트모템

인시던트가 수습되면 끝이 아닙니다. 같은 일이 반복되지 않도록 포스트모템(postmortem)을 작성합니다. 핵심은 비난하지 않는(blameless) 태도입니다. "누가 실수했는가"가 아니라 "어떤 시스템적 조건이 이 실수를 가능하게 했는가"를 묻습니다.

좋은 포스트모템은 다음을 담습니다.

  • 타임라인: 무슨 일이 언제 일어났는가. 감지, 평가, 복구의 각 시각.
  • 영향: 얼마나 많은 사용자가, 얼마나 오래, 어떤 식으로 영향을 받았는가.
  • 근본 원인: 표면적 증상이 아니라 그 아래의 구조적 원인. "왜"를 다섯 번 묻기.
  • 무엇이 잘 되었는가: 안전망이 작동한 부분도 기록한다. 피처 플래그 덕에 빠르게 멈췄다면 그것도 학습이다.
  • 개선 항목: 구체적이고, 담당자가 있고, 기한이 있는 액션 아이템.

예를 들어 "백필이 너무 느렸다"는 증상이라면, 근본 원인은 "배치 크기 설정이 없어 단일 트랜잭션으로 전체를 잠갔다"일 수 있고, 개선 항목은 "모든 백필 마이그레이션에 배치 처리 템플릿을 적용하고, 대용량 테이블 마이그레이션은 사전 리허설을 의무화한다"가 됩니다.

함정들

지금까지의 내용을 실무에서 자주 마주치는 함정들로 다시 정리합니다.

함정 1: down 스크립트가 있으니 안전하다고 믿는 것. 앞서 보았듯, down 스크립트의 존재가 데이터 복원을 보장하지 않습니다. DROP을 되돌리는 down은 컬럼 구조만 되살릴 뿐 데이터는 되살리지 못합니다. down 스크립트가 진짜 가역적인지 항상 의심하십시오.

함정 2: 스키마와 코드를 한 번에 바꾸는 것. 스키마와 코드를 같은 배포에서 동시에 바꾸면, 둘 사이의 짧은 불일치 구간에 장애가 발생합니다. 확장-수축으로 분리하고, 피처 플래그로 시점을 통제하십시오.

함정 3: 운영 환경에서 처음 실행하는 것. 운영과 동일한 규모의 데이터에서 리허설하지 않은 마이그레이션은, 운영에서 처음 잠금 시간이나 백필 시간을 마주합니다. 운영 복제본에서 사전 리허설을 하면 예상 소요 시간과 잠금 위험을 미리 알 수 있습니다.

함정 4: 백필을 단일 트랜잭션으로. 수백만 행을 하나의 UPDATE로 처리하면 거대한 잠금과 긴 트랜잭션, 그리고 WAL 폭증이 발생합니다. 항상 배치로 나누어 처리하십시오.

함정 5: 검증 없이 성공 선언. 마이그레이션이 에러 없이 끝났다고 성공이 아닙니다. 다단계 검증을 통과해야 성공입니다. 특히 데이터 검증과 애플리케이션 스모크를 생략하지 마십시오.

함정 6: 복구 지점 없이 진행. 마이그레이션 직전 스냅샷이나 PITR 설정이 없다면, 최악의 경우 돌아갈 곳이 없습니다. 런북의 0단계는 항상 복구 지점 확보 확인입니다.

함정 7: 피처 플래그를 영원히 방치. 플래그는 임시 장치입니다. 안정화 후에도 플래그와 옛 경로를 정리하지 않으면, 코드 복잡도와 또 다른 종류의 위험이 누적됩니다.

최종 체크리스트

마이그레이션을 운영에 적용하기 전에 다음을 점검하십시오.

[ 준비 ]
[ ] 변경의 가역성 분류 (가역/부분가역/비가역)
[ ] 비가역 변경은 확장-수축으로 분해했는가
[ ] up/down (또는 undo) 스크립트가 짝으로 존재하는가
[ ] down 스크립트가 진짜로 데이터를 보존하는가 (또는 보존 못 함을 명시)
[ ] 운영 복제본에서 리허설을 마쳤는가 (소요 시간/잠금 측정)

[ 안전망 ]
[ ] 마이그레이션 전 스냅샷 또는 PITR 복구 지점 확보
[ ] 대용량 백필은 배치 처리로 작성했는가
[ ] 인덱스 생성에 CONCURRENTLY를 사용했는가
[ ] 새 동작을 피처 플래그로 분리했는가

[ 검증 ]
[ ] 스키마 검증 쿼리 준비 (컬럼/타입/인덱스/제약)
[ ] 데이터 검증 쿼리 준비 (NULL/행 수/분포)
[ ] 집계 기준선 저장 및 사후 비교 자동화
[ ] 체크섬 비교 (옛/새 구조 정합성)
[ ] 성능 검증 (EXPLAIN, 잠금 대기 확인)
[ ] 애플리케이션 스모크 테스트 준비

[ 롤아웃 ]
[ ] 카나리/점진 롤아웃 계획 (1% → 100%)
[ ] 각 단계의 관찰 지표 정의 (에러율/지연/정합성)

[ 대응 ]
[ ] 인시던트 런북 작성 및 공유
[ ] IC(인시던트 지휘자) 지정 방식 합의
[ ] 출혈 멈추기 1순위 = 플래그 OFF 임을 명시
[ ] 포스트모템 템플릿 준비

[ 정리 ]
[ ] 안정화 후 플래그/옛 경로 정리 작업을 백로그에 등록

마치며

데이터베이스 마이그레이션에서 진짜 기술은 "롤백을 잘 하는 것"이 아니라, 애초에 위험한 롤백이 필요 없도록 변경을 설계하는 것입니다. 비가역적 변경을 가역적 단계의 연속으로 분해하고, 스키마 변경과 코드 변경을 피처 플래그로 분리하며, 변경 직전에 복구 지점을 확보하고, 적용 후에는 여러 층위로 검증합니다.

이 모든 장치가 만들어 내는 것은 하나의 안전망입니다. 안전망의 목적은 절대 떨어지지 않는 것이 아니라, 떨어졌을 때 살아남는 것입니다. 마이그레이션은 언젠가 잘못될 것입니다. 그때 우리가 의지할 것은 영웅적인 즉흥 대응이 아니라, 미리 설계해 둔 가역성과 백업과 검증과 런북입니다. 안전망을 먼저 짓고, 그 위에서 변경하십시오.

참고 자료