Skip to content
Published on

DB 마이그레이션 전략 개론 — 스키마, 데이터, 그리고 무중단

Authors

들어가며

데이터베이스 마이그레이션은 거의 모든 서비스가 피할 수 없는 작업입니다. 새로운 기능을 추가하려면 테이블에 컬럼을 더하고, 인덱스를 만들고, 때로는 수십억 건의 데이터를 다른 형태로 옮겨야 합니다. 코드는 배포가 잘못되면 이전 버전으로 롤백하면 그만이지만, 데이터베이스는 그렇게 단순하지 않습니다. 한 번 실행된 DDL은 되돌리기 어렵고, 잘못 옮겨진 데이터는 영원히 복구하지 못할 수도 있습니다.

필자가 운영하던 한 서비스에서는 트래픽이 가장 많은 시간대에 큰 테이블에 인덱스를 추가하려다가 테이블 전체에 락이 걸려 30분간 장애가 발생한 적이 있습니다. 또 다른 사례에서는 컬럼 타입을 변경하는 마이그레이션을 검증 없이 프로덕션에 적용했다가 일부 데이터가 잘려 나가는 사고를 겪었습니다. 이런 경험은 모두 마이그레이션을 "그냥 SQL을 실행하는 일"로 가볍게 본 데서 비롯되었습니다.

이 글에서는 데이터베이스 마이그레이션을 하나의 엔지니어링 분야로 바라보고, 마이그레이션의 종류부터 핵심 원칙, 버전 관리형 마이그레이션, 무중단 배포 전략, 트랜잭셔널 DDL, 백업과 드라이런, 환경 승격, 팀 프로세스, 그리고 사고 예방 체크리스트까지 차근차근 정리하겠습니다. 특정 도구에 종속되지 않고 어디서나 적용할 수 있는 원칙 중심으로 설명하겠습니다.

마이그레이션의 종류

마이그레이션이라는 단어는 매우 넓은 범위를 포괄합니다. 무엇을 옮기느냐에 따라 위험의 성격과 대응 방법이 완전히 달라지므로, 먼저 종류를 구분하는 것이 중요합니다.

스키마 마이그레이션

스키마 마이그레이션은 데이터베이스의 구조를 변경하는 작업입니다. 테이블을 생성하거나 삭제하고, 컬럼을 추가하거나 제거하며, 인덱스나 제약 조건을 변경하는 일이 모두 여기에 속합니다. DDL(Data Definition Language) 문으로 표현되며, 대부분의 마이그레이션 도구가 가장 먼저 다루는 영역입니다.

-- 컬럼 추가 (스키마 변경)
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;

-- 인덱스 추가 (스키마 변경)
CREATE INDEX idx_users_email ON users (email);

-- 제약 조건 추가 (스키마 변경)
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0);

데이터 마이그레이션

데이터 마이그레이션은 구조가 아니라 데이터 자체를 옮기거나 변형하는 작업입니다. 새 컬럼에 기본값을 채우거나, 한 테이블의 데이터를 정규화하여 여러 테이블로 분리하거나, 인코딩을 변환하는 일이 여기에 해당합니다. DML(Data Manipulation Language)로 표현되며, 대량의 행을 다룰 때는 성능과 락 관리가 매우 중요합니다.

-- 새 컬럼에 값을 채우는 데이터 마이그레이션
UPDATE users SET display_name = username WHERE display_name IS NULL;

-- 데이터를 다른 테이블로 옮기는 마이그레이션
INSERT INTO user_profiles (user_id, bio)
SELECT id, bio FROM users WHERE bio IS NOT NULL;

엔진 마이그레이션

엔진 마이그레이션은 같은 데이터베이스 제품의 버전을 올리는 작업입니다. PostgreSQL 14에서 16으로, MySQL 5.7에서 8.0으로 올리는 경우가 대표적입니다. SQL 호환성, 기본 설정 변경, 성능 특성 변화 등을 모두 고려해야 합니다.

플랫폼 마이그레이션

플랫폼 마이그레이션은 데이터베이스 제품 자체를 바꾸거나, 온프레미스에서 클라우드로 옮기는 등 가장 큰 규모의 변경입니다. 예를 들어 Oracle에서 PostgreSQL로 이전하거나, 자체 호스팅 MySQL을 Amazon RDS로 옮기는 경우입니다. 대개 AWS DMS 같은 전용 도구와 장기적인 병행 운영 기간을 필요로 합니다.

종류별 비교

종류대상주요 도구위험도되돌리기
스키마테이블, 컬럼, 인덱스Flyway, Liquibase, migrate중간비교적 용이
데이터행, 값, 형식배치 스크립트, ETL높음어려움
엔진DBMS 버전pg_upgrade, mysql_upgrade높음매우 어려움
플랫폼DBMS 제품, 인프라AWS DMS, 전용 마이그레이터매우 높음거의 불가능

핵심 원칙: 되돌릴 수 있고, 작고, 검증된

마이그레이션을 안전하게 다루기 위한 세 가지 핵심 원칙이 있습니다. 이 원칙들은 도구와 무관하게 언제나 유효합니다.

되돌릴 수 있게 (Reversible)

가능하다면 모든 마이그레이션은 되돌릴 수 있도록 설계해야 합니다. 컬럼을 추가했다면 그 컬럼을 제거하는 역방향 스크립트를, 인덱스를 만들었다면 그 인덱스를 삭제하는 스크립트를 함께 준비합니다. 다만 데이터를 삭제하거나 타입을 손실 있게 변환하는 작업은 본질적으로 되돌릴 수 없다는 점을 인지하고, 이런 작업은 더욱 신중하게 다뤄야 합니다.

작게 (Small)

하나의 마이그레이션은 가능한 한 작은 단위로 쪼개야 합니다. 한 번에 열 개의 테이블을 바꾸는 거대한 마이그레이션은 실패 지점을 파악하기 어렵고, 부분적으로 실패했을 때 복구가 까다롭습니다. 작은 마이그레이션은 리뷰하기 쉽고, 문제가 생겨도 영향 범위가 좁습니다.

검증된 (Verified)

프로덕션에 적용하기 전에 반드시 검증을 거쳐야 합니다. 개발 환경과 스테이징 환경에서 실제 데이터에 가까운 데이터로 실행해 보고, 실행 시간과 락 영향을 측정합니다. "로컬에서 잘 돌았으니 괜찮겠지"라는 가정이 가장 위험합니다.

안전한 마이그레이션의 세 기둥

   되돌릴 수 있게        작게            검증된
   (Reversible)       (Small)        (Verified)
        |               |                |
   역방향 준비       단위 분리        스테이징 실행
        |               |                |
        +---------------+----------------+
                        |
                  안전한 배포

버전 관리형 마이그레이션

현대적인 마이그레이션의 핵심은 모든 변경을 버전이 매겨진 파일로 관리하는 것입니다. 데이터베이스 콘솔에 직접 접속해서 손으로 SQL을 실행하는 방식은 추적이 불가능하고 재현이 어렵습니다. 대신 마이그레이션을 코드와 함께 버전 관리 시스템에 보관합니다.

파일 네이밍 규칙

대부분의 도구는 타임스탬프나 순번을 접두사로 사용하는 파일 네이밍 규칙을 따릅니다. 이렇게 하면 마이그레이션의 실행 순서가 명확해집니다.

migrations/
  V20260601120000__create_users_table.sql
  V20260602093000__add_email_index.sql
  V20260603140000__add_last_login_column.sql
  V20260604101500__backfill_display_name.sql

golang-migrate 같은 도구는 up과 down 파일을 쌍으로 관리합니다.

migrations/
  000001_create_users_table.up.sql
  000001_create_users_table.down.sql
  000002_add_email_index.up.sql
  000002_add_email_index.down.sql

마이그레이션 파일 예시

각 마이그레이션 파일은 하나의 논리적 변경만 담습니다. 아래는 사용자 테이블을 생성하는 up 파일과 그것을 되돌리는 down 파일의 예시입니다.

-- 000001_create_users_table.up.sql
CREATE TABLE users (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    username    VARCHAR(100) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_users_username ON users (username);
-- 000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;

마이그레이션 이력 테이블

마이그레이션 도구는 어떤 버전이 적용되었는지 추적하기 위해 전용 이력 테이블을 데이터베이스 안에 유지합니다. Flyway는 flyway_schema_history, Liquibase는 databasechangelog라는 테이블을 사용합니다. 이 테이블 덕분에 도구는 아직 적용되지 않은 마이그레이션만 골라 실행할 수 있습니다.

flyway_schema_history (개념도)

installed_rank | version | description          | success
---------------+---------+----------------------+--------
1              | 1       | create users table   | true
2              | 2       | add email index      | true
3              | 3       | add last login col   | true

포워드 온리 vs 롤백

마이그레이션을 되돌리는 전략에는 크게 두 가지 철학이 있습니다.

롤백 방식

롤백 방식은 각 마이그레이션마다 역방향 스크립트(down)를 준비하고, 문제가 생기면 그것을 실행해 이전 상태로 되돌리는 접근입니다. 직관적이지만 함정이 있습니다. 데이터를 삭제하거나 변형한 마이그레이션은 down 스크립트로 완벽히 복원되지 않는 경우가 많기 때문입니다. 예를 들어 컬럼을 DROP한 뒤 다시 ADD해도 안에 들어 있던 데이터는 돌아오지 않습니다.

포워드 온리 방식

포워드 온리 방식은 절대 뒤로 가지 않고, 문제가 생기면 그것을 바로잡는 새로운 마이그레이션을 앞으로 추가하는 접근입니다. 잘못 만든 컬럼이 있다면 그것을 되돌리는 대신, 그 컬럼을 정리하는 새 마이그레이션을 작성합니다. 대규모 프로덕션 환경에서는 down 스크립트의 안전성을 보장하기 어렵기 때문에 포워드 온리를 선호하는 팀이 많습니다.

항목롤백 방식포워드 온리 방식
되돌리기 방법down 스크립트 실행새 마이그레이션 추가
데이터 손실 위험down이 불완전할 수 있음명시적으로 처리
운영 복잡도낮아 보이지만 함정 있음일관적이고 예측 가능
권장 환경소규모, 초기 단계대규모 프로덕션

트랜잭셔널 DDL

트랜잭셔널 DDL은 DDL 문을 트랜잭션 안에서 실행하여, 마이그레이션 도중 실패하면 모든 변경이 한꺼번에 롤백되도록 보장하는 기능입니다. PostgreSQL은 대부분의 DDL을 트랜잭션 안에서 처리할 수 있어 이 점에서 매우 강력합니다. 반면 MySQL은 많은 DDL 문이 암묵적 커밋을 유발하므로 트랜잭셔널 DDL을 기대하기 어렵습니다.

PostgreSQL에서의 트랜잭셔널 DDL

여러 개의 DDL 문을 하나의 트랜잭션으로 묶으면, 중간에 하나라도 실패했을 때 전체가 깔끔하게 원상복구됩니다.

BEGIN;

ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ;
CREATE INDEX idx_orders_status ON orders (status);

-- 여기서 오류가 나면 위의 세 변경이 모두 롤백됩니다.
COMMIT;

트랜잭션 안에서 피해야 할 작업

주의할 점이 있습니다. PostgreSQL에서 CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없습니다. 이 명령은 테이블을 잠그지 않고 인덱스를 만들기 위한 것인데, 트랜잭션 안에서는 그 락 회피 메커니즘이 동작하지 못하기 때문입니다. 따라서 대형 테이블에 무중단으로 인덱스를 추가할 때는 트랜잭션 밖에서 별도로 실행해야 합니다.

-- 트랜잭션 밖에서 단독으로 실행 (무중단 인덱스 생성)
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

무중단 마이그레이션 전략

서비스를 멈추지 않고 스키마를 바꾸는 것은 마이그레이션에서 가장 어려운 부분입니다. 핵심 아이디어는 위험한 변경을 여러 단계로 나누어, 각 단계에서 기존 코드와 새 코드가 모두 정상 동작하도록 만드는 것입니다.

Expand and Contract 패턴

가장 널리 쓰이는 무중단 패턴은 확장-수축(Expand and Contract) 패턴입니다. 컬럼 이름을 바꾸는 간단해 보이는 작업조차 무중단으로 하려면 여러 단계가 필요합니다.

컬럼 이름 변경 (username -> handle) 무중단 절차

1단계 Expand   : 새 컬럼 handle 추가 (기존 username 유지)
2단계 백필     : username 값을 handle로 복사
3단계 이중 쓰기 : 애플리케이션이 두 컬럼 모두에 기록
4단계 읽기 전환 : 애플리케이션이 handle을 읽도록 배포
5단계 Contract : 안정화 후 username 컬럼 제거

이 패턴의 핵심은 어느 한 단계에서도 구버전 애플리케이션과 신버전 애플리케이션이 동시에 동작할 수 있다는 점입니다. 롤링 배포 중에 두 버전이 공존하더라도 데이터 일관성이 깨지지 않습니다.

위험한 작업과 안전한 대안

위험한 작업문제점안전한 대안
NOT NULL 컬럼 즉시 추가큰 테이블 전체 재작성, 락nullable로 추가 후 백필, 나중에 제약 추가
일반 CREATE INDEX테이블 쓰기 락CREATE INDEX CONCURRENTLY 사용
컬럼 타입 즉시 변경전체 재작성, 긴 락새 컬럼 추가 후 점진적 백필
대량 UPDATE 한 번에긴 트랜잭션, 락 경합배치로 나누어 처리

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

큰 테이블에 NOT NULL 컬럼을 한 번에 추가하면 테이블 전체가 락에 걸릴 수 있습니다. 다음과 같이 단계를 나누면 안전합니다.

-- 1단계: nullable 컬럼으로 추가 (빠르고 락이 짧음)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- 2단계: 기존 행을 배치로 백필
UPDATE users SET phone = '' WHERE phone IS NULL AND id BETWEEN 1 AND 100000;
-- (범위를 바꿔가며 반복 실행)

-- 3단계: 검증 가능한 NOT NULL 제약을 NOT VALID로 먼저 추가
ALTER TABLE users ADD CONSTRAINT users_phone_not_null
    CHECK (phone IS NOT NULL) NOT VALID;

-- 4단계: 별도로 검증 (테이블 전체 락 없이)
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;

대량 UPDATE를 배치로 나누기

수백만 건을 한 번의 UPDATE로 처리하면 긴 트랜잭션이 생겨 락 경합과 복제 지연을 유발합니다. 작은 배치로 나누어 처리하는 것이 안전합니다.

-- 배치 단위로 반복 실행하는 예시
UPDATE orders
SET status = 'archived'
WHERE id IN (
    SELECT id FROM orders
    WHERE status = 'old' AND archived = false
    LIMIT 5000
);

백업과 드라이런

어떤 마이그레이션이든 프로덕션에 적용하기 전에 두 가지를 반드시 갖춰야 합니다. 바로 백업과 드라이런입니다.

백업

위험도가 높은 마이그레이션 직전에는 항상 백업을 확보합니다. 논리 백업과 물리 백업의 특성을 이해하고 상황에 맞게 선택합니다. 무엇보다 중요한 것은 백업이 실제로 복원 가능한지 미리 검증하는 것입니다. 복원해 본 적 없는 백업은 백업이 아닙니다.

# PostgreSQL 논리 백업 (특정 데이터베이스 덤프)
pg_dump --format=custom --file=backup_before_migration.dump mydb

# 복원 검증 (별도의 임시 데이터베이스로 복원해 보기)
pg_restore --dbname=mydb_restore_test backup_before_migration.dump
# MySQL 논리 백업
mysqldump --single-transaction --routines --triggers mydb > backup_before_migration.sql

드라이런

드라이런은 실제로 변경을 적용하지 않고 마이그레이션이 무엇을 할지 미리 확인하는 절차입니다. 많은 도구가 이 기능을 제공합니다. 또한 스테이징 환경에서 프로덕션과 유사한 규모의 데이터로 실제 실행해 보면서 소요 시간과 락 영향을 측정하는 것도 넓은 의미의 드라이런입니다.

# Liquibase: SQL을 실제 실행하지 않고 출력만 (드라이런)
liquibase update-sql

# Flyway: 적용될 마이그레이션 목록 확인
flyway info

# golang-migrate: 현재 버전 확인
migrate -path ./migrations -database "$DATABASE_URL" version

실행 계획 확인

대량 데이터 마이그레이션의 경우 EXPLAIN으로 실행 계획을 미리 확인하여 의도치 않은 전체 스캔이나 비효율을 잡아낼 수 있습니다.

EXPLAIN ANALYZE
UPDATE orders SET status = 'archived'
WHERE created_at < now() - INTERVAL '1 year';

환경 승격: dev에서 stg를 거쳐 prod로

마이그레이션은 절대 프로덕션에 바로 적용하지 않습니다. 개발(dev), 스테이징(stg), 프로덕션(prod) 환경을 차례로 거치며 검증합니다. 같은 마이그레이션 파일이 모든 환경에서 동일하게 적용되어야 환경 간 스키마 드리프트를 막을 수 있습니다.

환경 승격 파이프라인

  [dev] ---- 검증 ----> [stg] ---- 검증 ----> [prod]
    |                     |                     |
  개발자 로컬          프로덕션 유사          실제 서비스
  빠른 반복            데이터로 리허설        신중한 적용
    |                     |                     |
  스키마 작성          실행 시간 측정         백업 후 배포
                       락 영향 확인          모니터링

각 환경의 역할

환경목적데이터핵심 활동
dev마이그레이션 작성과 빠른 반복소량의 시드 데이터스키마 설계, 단위 검증
stg프로덕션 리허설프로덕션 유사 규모실행 시간, 락 영향 측정
prod실제 적용실데이터백업, 배포, 모니터링

스테이징 환경은 가능한 한 프로덕션과 비슷한 데이터 규모를 갖추어야 의미가 있습니다. 데이터가 천 건뿐인 스테이징에서는 잘 돌던 마이그레이션이 수억 건의 프로덕션에서는 몇 시간씩 걸릴 수 있기 때문입니다.

팀 프로세스

마이그레이션은 한 사람의 일이 아니라 팀의 일입니다. 안전한 마이그레이션 문화를 위해서는 명확한 프로세스가 필요합니다.

마이그레이션은 코드 리뷰 대상

모든 마이그레이션 파일은 애플리케이션 코드와 똑같이 풀 리퀘스트와 코드 리뷰를 거칩니다. 리뷰어는 락 영향, 되돌리기 가능성, 백필 전략, 배치 처리 여부 등을 확인합니다.

CI 파이프라인에서의 검증

CI 파이프라인에서 마이그레이션을 임시 데이터베이스에 자동으로 적용해 보고, 적용 후 다시 되돌릴 수 있는지까지 검증하면 좋습니다.

# CI에서 마이그레이션 적용 후 롤백을 검증하는 예시
migrate -path ./migrations -database "$DATABASE_URL" up
migrate -path ./migrations -database "$DATABASE_URL" down 1
migrate -path ./migrations -database "$DATABASE_URL" up

코드 배포와 마이그레이션의 순서

애플리케이션 코드 배포와 마이그레이션의 순서는 신중하게 정해야 합니다. 일반적으로 컬럼을 추가하는 확장 단계는 코드 배포보다 먼저, 컬럼을 제거하는 수축 단계는 코드 배포보다 나중에 실행합니다. 이렇게 하면 배포 도중 구버전과 신버전 코드가 공존하더라도 안전합니다.

안전한 배포 순서 (컬럼 추가의 경우)

  1. 마이그레이션 적용 (새 컬럼 추가) - 구버전 코드는 새 컬럼을 무시
  2. 새 코드 배포 (새 컬럼 사용)
  3. 모니터링 후 안정화

안전한 배포 순서 (컬럼 제거의 경우)

  1. 새 코드 배포 (해당 컬럼 사용 중단)
  2. 모니터링 후 안정화
  3. 마이그레이션 적용 (컬럼 제거)

사고 예방 체크리스트

마지막으로, 프로덕션 마이그레이션 직전에 점검할 체크리스트를 정리합니다. 이 목록을 습관처럼 확인하면 대부분의 사고를 예방할 수 있습니다.

프로덕션 마이그레이션 사전 체크리스트

[ ] 마이그레이션이 버전 관리되고 코드 리뷰를 통과했는가
[ ] 스테이징에서 프로덕션 유사 데이터로 실행해 보았는가
[ ] 실행 시간과 락 영향을 측정했는가
[ ] 되돌리기 전략(down 또는 포워드 픽스)이 준비되어 있는가
[ ] 직전 백업이 확보되고 복원 가능성이 검증되었는가
[ ] 큰 테이블 인덱스는 CONCURRENTLY로 만드는가
[ ] 대량 UPDATE는 배치로 나누었는가
[ ] NOT NULL, 타입 변경 등 락 위험 작업을 단계로 분리했는가
[ ] 코드 배포와 마이그레이션의 순서가 정해졌는가
[ ] 적용 시간대가 트래픽이 낮은 시점인가
[ ] 적용 중 모니터링과 롤백 담당자가 지정되었는가

자주 발생하는 사고 유형

사고 유형원인예방책
락에 의한 장애큰 테이블 즉시 인덱스, 타입 변경CONCURRENTLY, 단계 분리
데이터 손실검증 없는 타입 변환, 삭제백업, 스테이징 검증
복제 지연한 번의 거대한 트랜잭션배치 처리
스키마 드리프트환경별 수동 적용버전 관리, 자동 승격
배포 충돌코드와 마이그레이션 순서 오류Expand and Contract

마치며

데이터베이스 마이그레이션은 단순히 SQL을 실행하는 일이 아니라, 위험을 관리하는 엔지니어링 분야입니다. 핵심은 변경을 되돌릴 수 있게, 작게, 검증된 상태로 다루는 것이며, 모든 변경을 버전 관리형 파일로 추적하고 환경을 차례로 거쳐 승격하는 것입니다.

무중단 마이그레이션은 위험한 변경을 여러 단계로 쪼개어 구버전과 신버전이 공존할 수 있게 만드는 데서 출발합니다. 확장-수축 패턴, 트랜잭셔널 DDL, CONCURRENTLY 인덱스, 배치 처리 같은 기법은 모두 이 목표를 위한 도구입니다. 무엇보다 백업과 드라이런, 그리고 팀의 코드 리뷰 문화가 사고를 예방하는 마지막 안전망이 됩니다.

이 글에서 다룬 원칙과 체크리스트가 여러분의 다음 마이그레이션을 조금 더 안전하게 만드는 데 도움이 되기를 바랍니다. 결국 가장 좋은 마이그레이션은 아무도 그것이 일어났는지 알아차리지 못하는 마이그레이션입니다.

참고 자료