← 목록으로 돌아가기

무중단 데이터베이스 마이그레이션: Expand and Contract 패턴으로 스키마 변경 안전하게 배포하기

Database

스키마 변경은 코드 배포보다 오래 남는다

애플리케이션 코드는 잘못 배포해도 이전 버전으로 되돌릴 수 있습니다. 하지만 데이터베이스 스키마 변경은 되돌리기 어렵습니다. 컬럼을 삭제했는데 이전 버전 서버가 아직 그 컬럼을 읽고 있다면 즉시 장애가 납니다. 타입을 바꿨는데 오래 떠 있는 워커가 예전 형식으로 쓰기를 계속하면 데이터가 섞입니다. 무중단 배포에서 가장 자주 과소평가되는 부분이 데이터베이스 마이그레이션입니다.

무중단 마이그레이션의 기본 원칙은 이전 코드와 새 코드가 한동안 같은 스키마를 함께 사용할 수 있게 만드는 것입니다. 서버는 한 번에 모두 바뀌지 않습니다. 롤링 배포, 배치 잡, 큐 컨슈머, 관리자 도구가 서로 다른 버전으로 잠시 공존합니다. 이 공존 시간을 견디지 못하는 스키마 변경은 위험합니다.

Expand and Contract 패턴은 이 문제를 단계로 나눕니다. 먼저 새 구조를 추가하고, 코드가 새 구조를 쓰도록 전환하고, 데이터를 채우고, 충분히 안정화된 뒤 오래된 구조를 제거합니다. 한 번의 큰 변경을 여러 번의 작은 호환 변경으로 쪼개는 방식입니다.


데이터베이스 마이그레이션 단계

1. Expand 단계에서는 추가만 한다

첫 단계는 기존 동작을 깨지 않는 추가입니다. 새 컬럼을 nullable로 추가하거나, 새 테이블을 만들거나, 새 인덱스를 concurrently 방식으로 생성합니다. 이때 기존 애플리케이션은 여전히 예전 컬럼과 테이블을 사용합니다. 새 구조가 생겼지만 아직 의존하지 않으므로 롤백도 쉽습니다.

예를 들어 users.name을 first_name과 last_name으로 나누고 싶다고 가정합니다. 바로 name을 삭제하거나 not null 제약을 걸면 위험합니다. 먼저 first_name, last_name 컬럼을 nullable로 추가합니다. 기존 코드는 계속 name을 읽고 쓰므로 장애가 없습니다. 그 다음 새 코드가 name과 새 컬럼을 동시에 쓰도록 배포합니다.

인덱스도 마찬가지입니다. PostgreSQL에서는 큰 테이블에 일반 CREATE INDEX를 실행하면 쓰기 잠금을 오래 잡을 수 있습니다. 운영 테이블에는 CREATE INDEX CONCURRENTLY를 사용해야 합니다. 제약 조건도 즉시 검증하지 않고 NOT VALID로 추가한 뒤 나중에 VALIDATE CONSTRAINT를 실행하는 방식이 더 안전합니다.


2. Dual Write와 Backfill을 분리한다

새 컬럼을 추가한 뒤에는 새 데이터가 양쪽에 기록되게 해야 합니다. 이것이 dual write입니다. 사용자가 이름을 수정하면 기존 name과 새 first_name, last_name을 함께 갱신합니다. 이렇게 해야 전환 기간에 어느 버전의 코드가 읽어도 일관된 값을 볼 수 있습니다.

기존 데이터는 backfill로 채웁니다. backfill은 운영 트래픽과 경쟁하지 않게 작게 나눠 실행해야 합니다. 한 번에 수백만 행을 업데이트하면 replication lag, lock wait, vacuum 부담이 커집니다. 기본 키 범위로 나누고, 배치 크기를 제한하고, 각 배치 사이에 쉬는 시간을 둡니다. 진행률과 실패 건수를 별도 테이블이나 로그로 남기면 중단 후 재시작하기 쉽습니다.

Backfill이 완료됐다고 바로 옛 구조를 삭제하면 안 됩니다. 읽기 경로를 새 컬럼으로 전환하고, 충분한 기간 동안 오류율과 데이터 불일치 지표를 봐야 합니다. 새 컬럼과 기존 컬럼을 비교하는 검증 잡을 돌려 불일치가 없는지 확인합니다. 이 검증이 없으면 전환 뒤에야 누락 데이터를 발견할 수 있습니다.


3. Contract 단계는 가장 늦게 한다

Contract는 오래된 컬럼, 테이블, 코드 경로를 제거하는 단계입니다. 이 단계는 기능 배포가 끝난 직후가 아니라, 모든 서비스가 새 구조를 사용하고 있다는 확신이 생긴 뒤에 진행합니다. 오래된 배치 잡, 수동 운영 스크립트, 어드민 페이지가 남아 있는지 확인해야 합니다.

삭제 전에는 관측성을 붙입니다. 더 이상 쓰이지 않아야 하는 컬럼에 쓰기 요청이 들어오는지, 예전 API 버전이 호출되는지, deprecated 코드 경로가 실행되는지 로그와 메트릭으로 확인합니다. 0이라고 믿는 것과 실제로 0을 관측하는 것은 다릅니다. 운영에서는 후자가 필요합니다.

컬럼 삭제도 작은 단위로 합니다. 먼저 애플리케이션 코드에서 참조를 제거하고 배포합니다. 그 다음 데이터베이스에서 컬럼을 삭제합니다. 대형 테이블의 컬럼 삭제는 DB 종류와 버전에 따라 메타데이터 변경으로 끝날 수도 있고, 테이블 재작성 비용이 발생할 수도 있습니다. 사전에 스테이징에서 동일한 데이터 규모로 확인해야 합니다.


4. 실패를 전제로 한 체크리스트

  • 새 스키마가 이전 코드와 호환되는가
  • 모든 배포 단계를 독립적으로 롤백할 수 있는가
  • backfill은 재시작 가능하고 idempotent한가
  • 큰 테이블 인덱스 생성이 쓰기 잠금을 만들지 않는가
  • 새 읽기 경로 전환 전 데이터 검증 잡을 실행했는가
  • 오래된 코드 경로 사용 여부를 로그로 확인했는가
  • 컬럼 삭제 전 배치, 워커, 어드민, 외부 연동을 모두 점검했는가

4. 인덱스와 제약 조건은 별도 배포 단위로 본다

스키마 변경에서 인덱스와 제약 조건은 코드보다 더 조심스럽게 다뤄야 합니다. 작은 개발 데이터베이스에서는 즉시 끝나는 ALTER TABLE이 운영에서는 수 분 동안 lock을 만들 수 있습니다. 특히 대형 테이블에 not null 제약을 추가하거나, 기본값이 있는 컬럼을 추가하거나, 일반 CREATE INDEX를 실행하는 작업은 DB 버전과 테이블 크기에 따라 위험도가 크게 달라집니다.

운영에서는 인덱스 생성을 애플리케이션 배포와 분리하는 편이 좋습니다. 먼저 인덱스를 concurrently로 만들고, 쿼리 플랜이 새 인덱스를 사용하는지 확인한 뒤, 그 인덱스에 의존하는 코드를 배포합니다. 코드 배포와 인덱스 생성을 한 PR에 묶으면 롤백 타이밍이 꼬입니다. 코드 롤백은 쉬워도 진행 중인 인덱스 생성은 별도 관리가 필요합니다.

제약 조건도 단계화할 수 있습니다. 기존 데이터가 깨끗한지 모르는 상태에서 foreign key나 check constraint를 즉시 검증하면 긴 잠금이 발생할 수 있습니다. PostgreSQL에서는 NOT VALID로 제약을 추가하고, 이후 VALIDATE CONSTRAINT를 별도 작업으로 실행하는 전략을 사용할 수 있습니다. 이렇게 하면 새 데이터에는 제약을 적용하면서 기존 데이터 검증 비용을 분리할 수 있습니다.

마이그레이션 도구에는 timeout과 lock timeout을 설정해야 합니다. lock을 얻지 못하면 기다리는 대신 실패하도록 만들어야 운영 트래픽을 막지 않습니다. 실패한 마이그레이션을 다시 실행할 수 있도록 idempotent하게 작성하는 것도 중요합니다. "이미 컬럼이 있으면 건너뛰기", "이미 인덱스가 있으면 재생성하지 않기" 같은 방어가 있으면 배포 재시도 때 훨씬 안전합니다.


5. 데이터 검증은 전환 전후 두 번 한다

Backfill이 끝났다고 전환이 끝난 것은 아닙니다. 데이터가 채워졌는지 확인하는 것과 애플리케이션이 새 데이터를 정확히 사용하는지는 다른 문제입니다. 전환 전에는 기존 컬럼과 새 컬럼의 값이 논리적으로 같은지 검증해야 합니다. 전환 후에는 새 코드가 실제 트래픽에서 오류 없이 동작하는지 확인해야 합니다.

검증 잡은 샘플링으로 시작할 수 있지만, 중요한 데이터라면 전체 검증이 필요합니다. 예를 들어 결제 상태나 권한 데이터는 일부 누락도 큰 문제가 될 수 있습니다. 검증 결과는 단순 로그보다 테이블에 남기는 편이 좋습니다. 어떤 row가 실패했는지, 실패 이유가 무엇인지, 재처리했는지 추적할 수 있어야 합니다.

전환 기간에는 dual read 전략도 사용할 수 있습니다. 새 컬럼을 읽되, 값이 없거나 이상하면 기존 컬럼으로 fallback하고 경고 로그를 남깁니다. 이 방식은 사용자 장애를 막으면서 누락 데이터를 찾는 데 도움을 줍니다. 다만 fallback은 영구 전략이 아니어야 합니다. 일정 기간 후에는 fallback 로그가 0인지 확인하고 제거해야 합니다.

마지막으로 삭제 전에는 "사용하지 않음"을 관측해야 합니다. 코드 검색만으로는 부족합니다. 오래된 배치 스크립트, ad-hoc SQL, 외부 연동이 남아 있을 수 있습니다. deprecated 컬럼에 trigger나 audit log를 붙여 접근 여부를 확인하는 방법도 있습니다. 실제 사용량이 0임을 확인한 뒤 삭제해야 합니다.


6. 롤백 시나리오를 단계마다 작성한다

무중단 마이그레이션은 성공 경로보다 실패 경로가 중요합니다. 새 컬럼 추가가 실패했을 때, dual write 배포 후 오류율이 증가했을 때, backfill 중 replication lag가 커졌을 때, 새 읽기 경로 전환 후 데이터 불일치가 발견됐을 때 각각 어떻게 멈추고 되돌릴지 정해야 합니다. 단계마다 롤백 전략이 다르면 배포 승인도 더 현실적으로 할 수 있습니다.

Expand 단계의 롤백은 보통 쉽습니다. 새 컬럼이나 새 테이블이 아직 사용되지 않았다면 그대로 남겨두거나 나중에 제거하면 됩니다. Dual write 단계에서는 더 조심해야 합니다. 새 코드가 양쪽에 쓰고 있다면 이전 코드로 롤백해도 기존 컬럼은 계속 정상이어야 합니다. 이 때문에 전환 초반에는 기존 쓰기 경로를 유지하는 것이 안전합니다.

Backfill 실패는 재시작 가능해야 합니다. 실패 지점부터 다시 실행할 수 없으면 운영자는 전체 작업을 다시 돌려야 하고, 그만큼 위험이 커집니다. 배치 처리 상태, 마지막 처리 key, 실패 row, 재시도 횟수를 기록하면 중단과 재개가 쉬워집니다. 작업 속도도 실시간으로 조절할 수 있어야 합니다. 장애 징후가 보이면 batch size를 줄이거나 일시 정지할 수 있어야 합니다.

Contract 단계의 롤백은 가장 어렵습니다. 삭제한 컬럼은 쉽게 돌아오지 않습니다. 그래서 contract는 기능 배포와 분리하고, 백업과 복구 절차를 확인한 뒤 진행합니다. 가능하면 삭제 전 한동안 읽기 전용 archive를 남겨두거나, 삭제 대상 데이터를 별도 테이블로 보존하는 전략을 검토합니다.


7. 배포 순서는 문서보다 자동화가 낫다

마이그레이션 단계가 많아질수록 사람이 순서를 기억하는 방식은 위험합니다. expand, dual write, backfill, read switch, contract를 체크리스트로만 관리하면 바쁜 배포 중 누락이 생길 수 있습니다. 가능한 단계별 스크립트와 검증 명령을 만들어두는 편이 안전합니다.

예를 들어 backfill 완료율이 100%가 아니면 read switch 배포를 막고, deprecated 컬럼 접근 로그가 남아 있으면 contract 작업을 막는 식입니다. 수동 승인도 필요하지만, 승인자는 자동 검증 결과를 보고 판단해야 합니다. 무중단 마이그레이션의 품질은 절차를 얼마나 반복 가능하게 만들었는지에서 갈립니다.


8. 작은 마이그레이션을 자주 하는 팀이 더 안전하다

스키마 변경을 오래 모았다가 한 번에 배포하면 위험이 커집니다. 컬럼 추가, 데이터 보정, 코드 전환, 컬럼 삭제가 한 배포에 섞이면 문제가 생겼을 때 어떤 단계가 원인인지 알기 어렵습니다. 작은 마이그레이션을 자주 실행하는 팀은 각 단계의 영향 범위가 작고, 실패했을 때 멈출 지점이 명확합니다.

이를 위해서는 마이그레이션 리뷰 기준이 필요합니다. 대형 테이블 변경 여부, 잠금 가능성, 롤백 전략, backfill 속도 제한, 검증 쿼리, 모니터링 항목을 PR 템플릿에 포함합니다. 데이터베이스 변경은 코드 diff만 보면 위험을 놓치기 쉽습니다. 실행 계획과 운영 절차가 함께 리뷰되어야 합니다.


결론: 무중단 마이그레이션은 호환성 기간을 설계하는 일이다

스키마 변경을 안전하게 만드는 핵심은 빠른 실행이 아닙니다. 이전 코드와 새 코드가 동시에 살아 있는 시간을 인정하고, 그 시간을 견딜 수 있는 중간 상태를 만드는 것입니다. Expand and Contract는 번거로워 보이지만 장애를 작은 배포 단계로 흡수하게 해줍니다.

데이터베이스는 애플리케이션보다 보수적으로 다뤄야 합니다. 한번 삭제한 데이터와 깨진 스키마 호환성은 쉽게 돌아오지 않습니다. 추가하고, 채우고, 전환하고, 검증하고, 마지막에 제거하는 순서를 지키는 것이 무중단 마이그레이션의 기본입니다.