안전한 데이터베이스 스키마 변경: expand·migrate·contract 운영법
운영 중인 웹 서비스의 스키마를 중단 없이 바꾸기 위해 호환 창, 배치 backfill, 체크포인트, 관측 지표, 롤백 조건을 단계별 배포 단위로 설계하는 방법을 설명합니다.
웹 서비스의 데이터베이스 스키마 변경은 한 번의 ALTER TABLE보다 배포 순서가 어렵습니다. 안전한 기본은 expand로 호환 구조를 먼저 추가하고, 애플리케이션을 migrate해 새 구조를 채운 다음, 사용량과 복구 기록을 확인한 뒤 contract로 옛 구조를 제거하는 것입니다.
이 방식은 특정 제품의 마법 공식이 아니라, PostgreSQL이 제공하는ALTER TABLE 동작과 락·트랜잭션 특성을 확인하면서 배포 위험을 단계로 쪼개는 운영 패턴입니다.

제약부터 적고 호환 창을 만든다
변경 전에 읽기·쓰기 경로, 트래픽이 높은 테이블, 롤백 가능 범위, 데이터 보존 기간을 기록합니다. 새 컬럼을 추가하는 expand는 구버전 애플리케이션도 동작해야 하며, 기본값·NOT NULL·인덱스 생성이 잠금이나 테이블 스캔을 일으키는지 별도로 확인합니다.
호환 창은 두 버전의 애플리케이션이 동시에 실행되는 시간입니다. 따라서 새 코드는 먼저 기존 컬럼을 계속 쓰면서 새 컬럼도 선택적으로 기록하고, 읽기는 새 값이 없을 때 옛 값을 사용하는 방식처럼 양방향 안전 규칙을 가져야 합니다.
단계를 배포 단위로 분리하기
스키마 변경을 하나의 릴리스에 몰아넣지 말고 마이그레이션, 코드 전환, 검증, 제거를 서로 다른 배포 단위로 나눕니다. 각 단계가 끝날 때 다음 단계로 넘어갈 관측값과 중단 기준을 문서에 남겨야 합니다.
Expand: 새 컬럼·테이블을 nullable 또는 호환 기본값으로 추가하고 쓰기 경로를 준비한다.
Migrate: 기존 행을 배치로 채우고 진행률·실패 키·재시작 지점을 저장한다.
Read switch: 새 컬럼을 우선 읽되 누락 시 옛 컬럼으로 대체하고 불일치를 측정한다.
Contract 준비: 구버전 인스턴스가 사라졌는지, 백업과 복구 리허설이 최신인지 확인한다.
Contract: 의존 코드와 옛 컬럼을 제거하고 변경 후 쿼리 계획·오류율을 다시 확인한다.
배치 backfill은 한 번에 전체 테이블을 갱신하지 않는 편이 안전합니다. 작은 범위와 짧은 트랜잭션으로 실행하고, 재시작 가능한 커서나 마지막 키를 기록합니다. 다만 테이블 크기와 복제 지연이 작고 점검 창이 확보된 경우에는 단순한 단일 작업이 더 적합할 수 있습니다.
샘플: users.display_name을 profile_name으로 옮기기
샘플 입력은 `users.display_name`에 2천만 행이 있고 새 코드가 `profile_name`을 요구하는 상황입니다. 결정은 nullable 컬럼을 먼저 추가한 뒤 이중 쓰기를 켜고, 중간 산출물로 backfill 체크포인트와 불일치 수를 남기는 것입니다. 기대 결과는 두 컬럼이 같은 값인 행의 비율이 100%에 도달한 뒤 읽기 전환을 하는 것입니다.
ALTER TABLE users ADD COLUMN profile_name text;
-- app v1: display_name 쓰기 유지
-- app v2: display_name + profile_name 이중 쓰기
-- worker: id > checkpoint ORDER BY id LIMIT 1000
backfill 작업은 각 배치마다 마지막 id, 처리 건수, 실패 키, 시작·종료 시각을 기록합니다. 검증 쿼리에서 NULL과 서로 다른 값을 분리해 대시보드로 내보내고, 불일치가 임계치를 넘으면 읽기 전환을 멈춥니다. 전환 뒤에도 일정 기간 옛 컬럼 fallback 사용량을 관찰합니다.
제거가 위험해지는 실패 경로
실패 예시는 contract 마이그레이션을 먼저 실행해 display_name을 삭제하는 경우입니다. 아직 구버전 인스턴스나 롤백 코드가 옛 컬럼을 읽으면 즉시 500 오류가 납니다. 복구는 삭제를 되돌리는 SQL만이 아니라, 호환 가능한 애플리케이션 버전을 다시 배포하고 백업에서 값 복원을 검증하는 절차여야 합니다.
락 대기와 복제 지연도 별도 실패 신호입니다. 마이그레이션이 예상보다 오래 걸리면 작업을 강제 종료하기보다 새 트랜잭션 시작을 중단하고, 현재 락 보유자·복제 지연·애플리케이션 오류율을 확인한 뒤 재개 창을 다시 잡습니다. 데이터 손실 가능성이 있으면 contract를 취소하고 보존된 옛 구조를 유지합니다.
재평가 기준과 완료 증거
contract를 실행할 조건은 구버전 배포가 0개이고, fallback 읽기가 관찰 창 동안 0건이며, backfill 불일치가 0건이고, 복구 지점과 백업 복원이 검증된 상태입니다. 이 네 가지 중 하나라도 확인할 수 없으면 제거 대신 expand 상태를 유지합니다.
완료 기록에는 적용한 migration 버전, 시작·종료 시각, 처리 행 수, 불일치 수, 락·복제 지연 최대값, 롤백 또는 복구 리허설 결과를 남깁니다. 다음 스키마 변경자는 이 기록으로 같은 호환 창을 재현하고 contract 시점을 판단할 수 있습니다.
두 애플리케이션 버전이 동시에 읽고 써도 오류가 없는가
backfill이 재시작 가능하고 체크포인트가 보존되는가
불일치·fallback·복제 지연을 관찰할 수 있는가
구조 제거 전 백업 복원과 롤백 배포를 검증했는가
제거 후 쿼리 계획과 오류율이 기준선 안에 있는가
위 증거가 모두 저장되면 스키마 변경을 완료로 표시합니다. 단순히 migration 명령이 성공했다는 로그만으로는 데이터 정합성과 이전 버전 호환성을 증명할 수 없으므로 contract 단계의 완료 조건으로 인정하지 않습니다. 변경 문서에는 담당자와 관찰 종료 시각도 남겨 이후 장애가 발생했을 때 어느 단계의 가정이 깨졌는지 추적할 수 있게 합니다. 같은 테이블에 다음 변경을 예약할 때는 이전 contract의 잔여 fallback부터 다시 확인합니다.