Expand-and-Contract 패턴: 무중단 DB 스키마 변경을 3단계 PR로 분할하기
운영 중 DB 스키마를 바꾸면서도 서비스를 멈추지 않는 정형 패턴, Expand-and-Contract 패턴을 3단계 PR 분할의 실전 이력과 함께 정리합니다.
TL;DR
운영 중 DB 스키마를 바꾸면서 서비스를 멈추지 않으려면 변경을 expand / migrate / contract 3단계로 분할해 각 단계가 하위 호환(backward compatibility)을 유지하게 해야 합니다.
정렬용 컬럼을 도메인 의미가 있는 컬럼으로 교체한 실전 이력을 케이스로 정리합니다.
DB 스키마를 바꿀 때, 레거시 컬럼을 쓰는 코드는 그대로 두고 신규 컬럼을 먼저 넣어 양쪽이 공존하는 동안 조금씩 옮긴 뒤, 나중에 레거시 컬럼을 지우는 방식으로 일해 왔습니다. 배포 중 단절이 있으면 안 되니 자연스럽게 그렇게 됐다고 생각했습니다.
이 방식이 리팩토링 패턴으로 이미 정립돼 있다는 사실은 나중에야 확인했습니다. DB 스키마 변경 맥락에서는 Expand-and-Contract, 코드 변경 일반 맥락에서는 Parallel Change로 불리며, 각각 Scott Ambler의 Refactoring Databases (2006)와 Martin Fowler의 bliki 글에 체계가 잡혀 있습니다.
이름을 알고 나니 두 가지가 달라졌다고 봅니다. 후임에게 가이드할 때 “expand 단계만 따로 PR로 분리하자”처럼 한 문장으로 전달할 수 있게 되고, 각 단계에서 무엇을 지켜야 하는지 체크리스트를 세우게 됩니다.
무중단 배포의 문제
배포는 순간이 아닙니다. 순차 배포(rolling deployment) 중에는 직전 버전(N-1)과 현재 배포분(N)이 동시에 떠 있고, 롤백 가능성이 닫히기 전까지는 양쪽 모두가 같은 DB를 바라봅니다. 이렇게 여러 버전이 공존하는 중간 상태를 버전 차이(version skew)라고 부릅니다. 무중단 배포는 버전 차이 구간 내내 하위 호환성(backward compatibility) 이 깨지지 않게 유지하는 일이고, Expand-and-Contract 패턴은 DB 스키마 변경에서 이 호환성을 지키는 방법 중 하나입니다.
그래서 “스키마와 코드를 같이 바꾸는 단일 배포”는 아래 시나리오 모두에서 단절을 만듭니다.
- 배포 중 단절: N-1 인스턴스가 존재하지 않는 신규 컬럼을 SELECT 하거나, 반대로 N 인스턴스가 방금 DROP된 컬럼을 SELECT 하는 순간 500
- 롤백 불가: 신규 코드 배포와 레거시 컬럼 DROP을 한 번에 하면 문제가 발생했을 때 돌아갈 수 없음
- 데이터 오염: 마이그레이션 스크립트가 일부 레코드만 처리한 상태에서 신규 코드가 동작하면 불완전 상태로 운영됨
이 셋을 모두 피하려면 “한 번에 하는 일”을 줄여야 합니다. 이것이 Expand-and-Contract 패턴의 동기라고 봅니다.
무중단의 요구 조건
단계 분할의 원리
각 단계는 그 배포 시점에 N-1과 N이 공존해도 깨지지 않아야 한다는 단일 불변식을 지킵니다. 이 불변식을 만족하는 가장 작은 단위로 분할한 결과가 3단계입니다.
- Expand: 레거시 컬럼을 살려둔 채 신규 컬럼을 nullable로 추가. N-1은 신규 컬럼을 무시하고, N은 둘 다 읽을 수 있습니다.
- Migrate: 여러 하위 작업을 포함하는 전환 구간입니다. ① 기존 레코드에 신규 컬럼 값을 채우고(백필), ② 쓰기는 레거시/신규 양쪽에 하는(write-both) 기간을 거치며, ③ 읽기를 신규 컬럼 기준(read-new)으로 전환하고, ④ 신규 컬럼만으로 충분히 검증되면 레거시 쓰기를 중단합니다. 이 단계가 끝나면 애플리케이션은 레거시 컬럼을 읽지도 쓰지도 않는 상태가 됩니다.
- Contract: 레거시 컬럼이 비활성 상태라는 것이 확인된 뒤 DROP만 수행합니다. 이 시점에는 N-1 인스턴스가 이미 배포 종료된 상태여야 합니다.
DB 스키마의 하위 호환 필요성
Ambler와 Fowler는 주로 “코드의 하위 호환성”을 강조합니다. 하지만 실제로는 DB 스키마도 하위 호환이어야 합니다. N-1 코드가 아직 살아 있는 동안 DB가 먼저 DROP되면 결국 단절이 생깁니다.
따라서 contract 단계는 프로덕션 관찰 기간을 거친 뒤 롤백 윈도우(직전 버전으로 되돌릴 가능성이 아직 열린 구간)가 닫힌 시점에 별도 릴리즈로 편성해야 안전하다고 봅니다.
적용 사례: 3단계 PR 분할
이 패턴을 실제로 적용했을 때, 3개 단계를 2개 릴리즈로 편성했습니다. expand와 migrate 두 단계는 같은 릴리즈에서 두 PR로, contract 단계는 관찰 기간을 둔 후 다음 릴리즈로 분리했습니다.
Expand + Migrate 단계
확장은 스키마 → 데이터 → API 순으로 아래에서 위로 쌓아 올립니다.
- 스키마: 신규 컬럼을 nullable로 추가하고, 자연 키와 신규 컬럼으로 유니크 제약을 건다
- 데이터: 기존 레코드에
신규 컬럼 = f(레거시 컬럼)으로 백필하고, 쓰기는 레거시/신규 양쪽(write-both), 읽기는 신규(read-new)로 전환, 검증 후 레거시 쓰기를 중단한다 - API: 신규 API는 신규 컬럼 기준으로 재구성하고, 기존 API는 어댑터로 응답 키를 레거시 컬럼명 그대로 유지해 호환을 보장한다
이 시점에 두 컬럼이 공존하지만, 외부에서 보면 기존 API는 종전 응답을 그대로 유지하고 신규 API만 새 필드를 노출합니다. 하위 호환을 지킵니다.
Contract 단계
축소는 Expand의 반대 방향으로, 위에서 아래로 거둡니다.
- API: 레거시 API·경로 제거. 외부 호출자가 신규 API로 이전된 뒤 수행한다
- 데이터: Migrate에서 이미 신규 컬럼만 사용 중이므로 별도 작업은 없다
- 스키마: 레거시 컬럼 DROP 및 회귀 검증(회귀 테스트와 응답 스냅샷 비교)
이번 적용에서는 외부 호출자 이전이 끝나지 않아 1번을 미루고, 기존 API 어댑터를 임시로 남긴 채 3번(컬럼 DROP과 검증)만 진행했습니다.
결과와 남은 과제
3단계를 2개 릴리즈로 분할한 결과, 각 배포 시점에 하위 호환이 유지되어 서비스 중단 없이 컬럼 교체를 완료했습니다. 단계마다 롤백 경로도 열려 있었습니다. 릴리즈가 두 번으로 늘어나는 비용은 있지만, 배포 중 단절 위험을 피한다는 점에서 합리적인 트레이드오프라고 봅니다.
Contract 단계로 물리적 컬럼은 사라졌지만, 이걸로 완전히 끝났다고 보지는 않습니다. 남은 과제는 아래와 같습니다.
- 어댑터 정리: 기존 API 어댑터의 “레거시 컬럼명 응답 생성” 코드는 언젠가 제거 대상이 됩니다. 외부 호출자 마이그레이션을 유도한 뒤 별도 메이저 릴리즈에서 치우는 게 자연스럽다고 봅니다.
- 관찰 기록의 자산화: 이번 관찰 기간에 확인한 에러율·쿼리 지표를 문서로 남겨 다음 마이그레이션의 기준값으로 재활용할 필요가 있습니다.
- 패턴 공부의 지속: 이 글은 3단계 뼈대만 정리한 수준이고, Refactoring Databases가 다루는 세부 데이터베이스 리팩토링 패턴과 분산 환경에서의 변형(이중 쓰기 실패 보정, 온라인 스키마 변경 도구 등)은 더 파고들 여지가 있다고 봅니다.
참고
- Scott Ambler, Refactoring Databases: Evolutionary Database Design (2006)
- Martin Fowler, ParallelChange
이 글은 Claude와 함께 작업했습니다.