데이터베이스 변경은 코드 변경보다 되돌리기 어렵다
애플리케이션 코드는 잘못 배포해도 이전 버전으로 되돌리기 쉽다. 물론 쉽다는 말이 항상 안전하다는 뜻은 아니지만, 바이너리나 컨테이너 이미지를 다시 배포하면 대부분의 코드는 원래 상태로 돌아간다.
데이터베이스는 다르다. 한 번 컬럼을 삭제하면 그 컬럼에 있던 값도 사라진다. 타입을 바꾸다 실패하면 일부 행만 변환된 상태가 남을 수 있다. 대량 업데이트가 잘못 실행되면 백업 복구나 보정 쿼리가 필요하다. 그래서 데이터베이스 마이그레이션은 단순한 배포 작업이 아니라 운영 데이터의 상태를 바꾸는 작업으로 다뤄야 한다.
마이그레이션은 크게 두 종류로 나눌 수 있다.
스키마 마이그레이션: 테이블, 컬럼, 인덱스, 제약 조건을 바꾸는 작업
데이터 마이그레이션: 기존 행의 값을 채우거나 변환하는 작업
실무에서는 둘이 함께 움직인다. 예를 들어 users.name을 users.display_name으로 바꾸려면 새 컬럼을 만들고, 기존 데이터를 복사하고, 애플리케이션이 새 컬럼을 읽게 바꾸고, 마지막에 예전 컬럼을 제거해야 한다. 이 과정을 한 번의 SQL로 끝내려 하면 운영 중인 서비스에서는 위험해진다.
AI 생성 이미지
DDL은 생각보다 무거운 작업이다
DDL은 CREATE TABLE, ALTER TABLE, CREATE INDEX처럼 데이터 구조를 바꾸는 명령이다. 개발 환경에서는 즉시 끝나는 것처럼 보이지만, 운영 환경에서는 전혀 다른 비용을 만든다.
첫째, 락이 걸릴 수 있다. 테이블 구조가 바뀌는 동안 다른 트랜잭션이 같은 테이블을 읽거나 쓰지 못하게 막는 경우가 있다. 특히 쓰기 요청이 많은 테이블에서 DDL이 오래 실행되면 API 타임아웃, 배치 지연, 잠금 대기 증가로 이어진다.
둘째, 테이블 재작성이 발생할 수 있다. 컬럼 타입 변경, 기본값을 가진 컬럼 추가, 큰 컬럼의 저장 방식 변경 같은 작업은 DBMS와 버전에 따라 테이블 전체를 새 구조로 다시 써야 할 수 있다. 이때 테이블 크기가 수백 GB라면 작업 시간은 분 단위가 아니라 시간 단위가 된다.
셋째, 인덱스 생성도 전체 데이터를 읽는다. 인덱스는 빈 구조만 만드는 작업이 아니다. 기존 행을 스캔해 인덱스 항목을 채워야 한다. PostgreSQL의 일반 CREATE INDEX는 인덱스 생성 중 대상 테이블의 쓰기를 막을 수 있고, CREATE INDEX CONCURRENTLY는 쓰기를 막지 않는 대신 더 많은 작업과 시간이 필요하며 트랜잭션 블록 안에서 실행할 수 없는 제약이 있다1.
MySQL 계열도 버전과 스토리지 엔진에 따라 온라인 DDL 가능 여부가 달라진다. 어떤 변경은 메타데이터만 바꾸면 되지만, 어떤 변경은 내부적으로 테이블 복사를 수행한다. 큰 테이블에서는 gh-ost, pt-online-schema-change처럼 그림자 테이블을 만들고 데이터를 점진적으로 복사한 뒤 교체하는 도구를 쓰기도 한다2. 중요한 것은 특정 명령이 온라인으로 보인다고 해서 항상 무중단이라는 뜻은 아니라는 점이다. 실제 데이터 크기, 트래픽, 락 대기, 복제 지연을 함께 봐야 한다.
무중단에 가까운 변경은 단계로 나눈다
안전한 마이그레이션의 기본 패턴은 확장 후 축소다. 먼저 기존 코드와 새 코드가 모두 동작할 수 있도록 스키마를 확장한다. 그다음 데이터를 옮기고, 코드를 전환하고, 충분히 확인한 뒤 예전 구조를 제거한다.
컬럼 추가
가장 안전한 컬럼 추가는 처음에는 느슨하게 시작하는 것이다.
ALTER TABLE users ADD COLUMN display_name varchar(100);처음부터 NOT NULL과 복잡한 기본값을 함께 넣지 않는다. 큰 테이블에서는 기존 모든 행을 검사하거나 값을 채우는 작업이 오래 걸릴 수 있기 때문이다. 절차는 보통 다음과 같다.
nullable 컬럼을 추가한다.
새 코드가 새 컬럼에도 값을 쓰게 한다.
기존 데이터는 별도 백필 작업으로 조금씩 채운다.
모든 행에 값이 있음을 검증한다.
마지막에
NOT NULL,CHECK,UNIQUE같은 제약 조건을 추가한다.
제약 조건은 데이터의 마지막 방어선이다. 하지만 운영 테이블에 제약을 추가할 때는 기존 데이터 검증 비용이 발생한다. 따라서 제약 추가도 트래픽이 낮은 시간, 충분한 타임아웃 설정, 사전 검증 쿼리와 함께 진행해야 한다.
컬럼명 변경
컬럼명 변경은 보기보다 위험하다. ALTER TABLE ... RENAME COLUMN 자체는 빠를 수 있지만, 이전 애플리케이션 버전이 아직 실행 중이면 즉시 실패한다. 롤링 배포 환경에서는 새 코드와 옛 코드가 몇 분에서 몇 시간 동안 함께 떠 있을 수 있다.
안전한 절차는 다음과 같다.
새 이름의 컬럼을 추가한다.
쓰기 코드가 예전 컬럼과 새 컬럼에 동시에 쓰게 한다.
백필로 기존 값을 새 컬럼에 복사한다.
읽기 코드를 새 컬럼 우선으로 바꾼다.
예전 코드가 모두 사라진 것을 확인한다.
예전 컬럼을 삭제한다.
즉, 이름 변경을 실제 rename으로 처리하지 않고 새 컬럼으로 이전하는 방식이다. 번거롭지만 배포 호환성이 좋다.
타입 변경
타입 변경은 더 조심해야 한다. integer를 bigint로 바꾸거나 문자열 길이를 늘리는 작업은 비교적 단순할 수 있지만, 문자열을 날짜로 바꾸거나 금액을 정수에서 소수로 바꾸는 작업은 값 변환 실패가 생길 수 있다.
안전한 절차는 새 타입 컬럼을 추가한 뒤 변환 가능한 데이터만 점진적으로 복사하는 것이다.
ALTER TABLE payments ADD COLUMN paid_amount_decimal decimal(12,2);그다음 배치에서 기존 값을 변환한다. 변환 실패 행은 별도 테이블이나 로그에 남긴다. 모든 값이 검증되기 전까지는 예전 컬럼을 삭제하지 않는다. 타입 변경은 단순한 저장 형식 변경이 아니라 업무 의미 변경일 수 있다. 그래서 코드, API 응답, 관리자 화면, 정산 배치, 분석 쿼리까지 함께 확인해야 한다.
인덱스 추가
인덱스는 조회 성능을 높이지만 생성 과정에서 부하를 만든다. 운영 중에는 가능한 온라인 방식으로 만든다. PostgreSQL에서는 쓰기를 막지 않기 위해 CREATE INDEX CONCURRENTLY를 검토할 수 있다.
CREATE INDEX CONCURRENTLY idx_orders_user_created_at
ON orders (user_id, created_at);다만 이 방식도 공짜는 아니다. 테이블을 여러 번 읽고, CPU와 I/O를 사용하며, 실패하면 유효하지 않은 인덱스가 남을 수 있다1. 따라서 인덱스 생성 전에는 다음을 확인한다.
실제로 해당 쿼리가 자주 실행되는가
기존 인덱스로 대체할 수 없는가
인덱스 크기가 어느 정도로 예상되는가
생성 중 복제 지연이나 디스크 사용량이 감당 가능한가
실패 시 제거하고 재시도할 절차가 있는가
마이그레이션 도구는 실행 이력을 관리하기 위한 장치다
마이그레이션 도구는 SQL 파일을 대신 실행해 주는 도구가 아니다. 핵심은 어떤 변경이 어떤 순서로 적용되었는지 기록하는 것이다. Flyway, Liquibase, Rails migration, Django migration, Alembic 같은 도구는 보통 버전 번호나 타임스탬프가 붙은 파일을 만들고, 데이터베이스 안의 메타데이터 테이블에 적용 여부를 기록한다3.
운영에서 중요한 규칙은 단순하다.
첫째, 마이그레이션 파일은 Git으로 관리한다. 운영 DB에 직접 실행한 SQL이 저장소에 없으면 다음 환경을 재현할 수 없다.
둘째, 실행 순서는 결정적이어야 한다. 여러 브랜치에서 동시에 마이그레이션을 만들면 번호 충돌이나 순서 꼬임이 생길 수 있다. 충돌을 해결하지 않은 채 배포하면 개발, 스테이징, 운영의 스키마가 서로 달라진다.
셋째, 롤백 가능성을 미리 판단한다. 컬럼 추가는 되돌리기 쉽다. 컬럼 삭제와 데이터 변환은 되돌리기 어렵다. 모든 마이그레이션에 기계적인 down 스크립트를 붙이는 것보다, 실제로 복구 가능한지 판단하는 편이 중요하다. 위험한 변경은 롤백보다 롤포워드, 즉 새 보정 마이그레이션으로 앞으로 고치는 전략이 더 안전할 때도 많다.
넷째, DDL과 대량 데이터 변경을 한 파일에 섞지 않는다. 스키마 변경은 짧게 끝내고, 대량 백필은 별도 작업으로 분리하는 편이 관찰과 중단이 쉽다.
대량 백필은 배치 작업으로 다룬다
백필은 새 컬럼이나 새 테이블에 기존 데이터를 채우는 작업이다. 작은 테이블은 한 번의 UPDATE로 끝낼 수 있다. 하지만 운영 테이블에서 수백만 행을 한 번에 갱신하면 긴 트랜잭션, 락 대기, WAL 또는 binlog 증가, 복제 지연, 캐시 무효화가 한꺼번에 발생한다.
백필은 다음 원칙으로 설계한다.
한 번에 처리할 행 수를 제한한다.
기본 키나 생성일 기준으로 범위를 나눈다.
각 배치마다 커밋한다.
진행 상태를 저장한다.
실패해도 같은 배치를 다시 실행할 수 있게 만든다.
서비스 부하가 높으면 속도를 낮추거나 중단한다.
예를 들어 users 테이블의 display_name을 채운다면 다음처럼 범위를 나눌 수 있다.
UPDATE users
SET display_name = name
WHERE id > :last_id
AND id <= :next_id
AND display_name IS NULL;여기서 display_name IS NULL 조건은 재시도를 쉽게 만든다. 같은 범위를 다시 실행해도 이미 처리한 행을 덮어쓰지 않는다. 배치 작업자는 migration_jobs 같은 테이블에 마지막 처리 ID, 처리 건수, 실패 사유, 시작 시각과 종료 시각을 기록한다. 운영자는 이 정보를 보고 속도를 조절하거나 중단 여부를 판단한다.
백필 중에는 데이터베이스 지표를 봐야 한다. CPU, 디스크 I/O, 락 대기, 느린 쿼리, 복제 지연, 커넥션 사용량이 평소보다 얼마나 늘었는지 확인한다. 배치 크기는 고정값이 아니라 운영 상황에 맞춰 조절하는 값이다. 낮에는 500건씩, 새벽에는 5000건씩 처리하는 식으로 바꿀 수 있다.
코드와 스키마는 잠시 공존해야 한다
무중단 배포에서 가장 자주 놓치는 문제는 호환성이다. 배포 중에는 여러 버전의 애플리케이션이 동시에 요청을 처리한다. 새 코드는 새 컬럼을 기대하지만, 아직 스키마가 반영되지 않았을 수 있다. 반대로 스키마는 바뀌었지만 예전 코드가 여전히 예전 컬럼만 읽을 수도 있다.
따라서 마이그레이션은 다음 순서를 지키는 것이 안전하다.
예전 코드가 깨지지 않는 스키마 변경을 먼저 배포한다.
새 코드가 예전 스키마와 새 스키마를 모두 견딜 수 있게 한다.
쓰기 경로를 이중화하거나 새 구조로 전환한다.
읽기 경로를 새 구조로 전환한다.
지표와 데이터 검증을 통해 안정성을 확인한다.
예전 컬럼, 예전 인덱스, 예전 코드 경로를 제거한다.
이 기간에는 중복 저장이 생긴다. 중복은 나쁜 것이 아니라 전환 비용이다. 다만 원본이 무엇인지, 불일치가 생기면 어느 쪽을 기준으로 복구할지 정해야 한다. 이 기준이 없으면 마이그레이션 중 장애가 났을 때 어떤 데이터를 믿어야 하는지 알 수 없다.
운영 체크리스트
운영 DB 마이그레이션 전에는 최소한 다음 질문에 답할 수 있어야 한다.
이 변경은 기존 애플리케이션 버전과 호환되는가?
DDL이 테이블을 잠그거나 재작성할 가능성이 있는가?
예상 실행 시간과 영향을 스테이징의 실제 크기에 가까운 데이터로 확인했는가?
실패하면 중단, 재시도, 롤포워드 중 무엇을 할 것인가?
백업 또는 복구 지점은 준비되어 있는가?
대량 백필의 진행 상태와 실패 행을 추적할 수 있는가?
배포 후 검증할 쿼리와 모니터링 지표가 정해져 있는가?
데이터베이스 변경의 목표는 빠르게 끝내는 것이 아니다. 안전하게 끝내고, 문제가 생겼을 때 어디까지 진행됐는지 알 수 있게 만드는 것이다. 좋은 마이그레이션은 한 번에 멋지게 바꾸는 작업이 아니라, 여러 작은 단계로 위험을 줄이는 운영 절차다.
참고
2Database Schema Migrations & Seeders Best Practices
3Database Migrations: Complete Guide to Schema Management and Best Practices
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.