무중단 배포에서 가장 위험한 변경은 의외로 코드가 아니라 데이터베이스 스키마 변경인 경우가 많습니다. 애플리케이션은 롤링 배포로 몇 분 안에 교체할 수 있지만, 데이터베이스는 구버전 앱과 신버전 앱이 동시에 읽고 쓰는 공유 상태입니다. 이 상태에서 컬럼을 바로 삭제하거나 타입을 바꾸면, 아직 살아 있는 구버전 인스턴스가 런타임에서 실패합니다. 배포는 성공했는데 일부 트래픽만 500을 뿜는 식의 애매한 장애가 여기서 자주 나옵니다.
온라인 스키마 변경의 핵심은 “한 번에 바꾸기"가 아니라 확장한 뒤 전환하고, 충분히 관측한 뒤 축소하는 것입니다. 흔히 expand-contract 패턴이라고 부릅니다. 새 컬럼을 먼저 추가하고, 양쪽 쓰기를 넣고, 읽기 경로를 옮기고, 백필을 검증하고, 마지막에 낡은 컬럼을 제거합니다. 이 글은 그 흐름을 백엔드 커리큘럼 관점에서 실무 기준으로 정리합니다. 같이 보면 좋은 배경 글은 배포 런북, Consumer-Driven Contract Testing, 관측성 베이스라인입니다.
이 글에서 얻는 것
- 무중단 배포에서 안전한 스키마 변경과 위험한 스키마 변경을 구분할 수 있습니다.
- expand-contract 마이그레이션을
추가 -> 이중 쓰기 -> 읽기 전환 -> 검증 -> 제거단계로 운영하는 기준을 잡을 수 있습니다. - 컬럼 삭제, 타입 변경, NOT NULL 추가, 인덱스 생성, 대량 백필을 언제 분리해야 하는지 숫자와 조건으로 판단할 수 있습니다.
- 배포 체크리스트에 넣을 migration lint, query log 확인, rollback 기준을 설계할 수 있습니다.
핵심 개념/이슈
1) DB 스키마는 내부 구현이 아니라 서비스 계약이다
백엔드 개발자는 종종 데이터베이스를 “우리 서비스 내부 구현"으로 생각합니다. 단일 앱과 단일 DB라면 어느 정도 맞는 말입니다. 하지만 실제 운영에서는 같은 DB를 여러 버전의 앱, 배치, 어드민, 리포트, 데이터 파이프라인이 동시에 봅니다. 이 순간 스키마는 내부 구현이 아니라 여러 소비자가 공유하는 계약이 됩니다.
예를 들어 users.name을 users.display_name으로 바꾸는 작업을 생각해 봅시다. 신버전 앱은 display_name을 읽지만, 구버전 앱은 여전히 name을 읽습니다. 롤링 배포 중 name을 제거하면 구버전 앱은 깨집니다. 반대로 display_name만 추가하고 아무도 쓰지 않으면 장애는 없지만 전환도 일어나지 않습니다. 그래서 스키마 변경은 코드 변경보다 더 보수적인 시간 축이 필요합니다.
실무에서 특히 위험한 변경은 아래입니다.
- 컬럼 삭제, 컬럼명 변경, 테이블명 변경
- 타입 변경, enum 값 제거, 길이 제한 축소
- 기본값 없는
NOT NULL컬럼 추가 - 대형 테이블에 잠금이 긴 DDL 실행
- 인덱스 생성과 대량 백필을 같은 배포에 묶기
이 변경들은 “개발 환경에서 잘 됐다"가 의미 없습니다. 중요한 것은 프로덕션에서 구버전 앱, 신버전 앱, 백필 작업, 읽기 트래픽이 겹치는 동안에도 안전한가입니다.
2) Expand-Contract는 속도를 늦추는 절차가 아니라 실패 반경을 줄이는 구조다
Expand-contract는 보통 아래 순서로 움직입니다.
- Expand: 새 컬럼, 새 테이블, 새 인덱스처럼 신버전이 사용할 구조를 먼저 추가합니다.
- Dual Write: 일정 기간 구필드와 신필드에 동시에 씁니다.
- Backfill: 기존 데이터를 신구조로 채웁니다.
- Read Switch: 읽기 경로를 신필드로 옮기되, 필요하면 fallback을 둡니다.
- Observe: 신구 값 불일치, null 비율, 오류율을 관측합니다.
- Contract: 구필드, 구인덱스, 구코드를 제거합니다.
이 흐름의 장점은 각 단계가 작고 되돌리기 쉽다는 것입니다. 예를 들어 읽기 전환 뒤 문제가 생기면 feature flag로 읽기만 되돌릴 수 있습니다. 반면 컬럼 rename을 한 번에 하면 rollback도 DDL이 되어 위험도가 커집니다. 운영에서는 “가장 빠른 변경"보다 “중간에서 멈춰도 서비스가 정상인 변경"이 더 가치 있습니다.
3) DDL, 코드 배포, 백필은 서로 다른 실패 모드를 가진다
스키마 변경 사고는 하나의 배포 작업처럼 보이지만 실제로는 세 종류의 작업이 섞여 있습니다.
- DDL: 잠금, replication lag, metadata lock, statement timeout이 위험합니다.
- 코드 배포: 버전 호환성, rollback 가능성, feature flag 기본값이 중요합니다.
- 백필: 긴 실행 시간, 배치 크기, 재시도, 중복 실행, I/O 압력이 문제입니다.
세 작업을 한 PR 또는 한 배포 창에 모두 묶으면 실패했을 때 원인 분리가 어려워집니다. 특히 1억 row 테이블에 새 컬럼을 추가하고, 바로 백필하고, 애플리케이션 읽기까지 전환하는 방식은 피해야 합니다. 팀 규모가 작아도 단계는 나눠야 합니다. 단지 의사결정과 자동화가 가벼울 뿐입니다.
판단 기준은 아래처럼 잡을 수 있습니다.
- 테이블이 1,000만 row 이상이면 DDL과 백필을 분리합니다.
- 백필 예상 시간이 10분을 넘으면 온라인 트래픽과 같은 트랜잭션 경로에 두지 않습니다.
- 변경 컬럼을 읽는 서비스가 2개 이상이면 contract test나 query log 확인을 먼저 둡니다.
- rollback에 DDL이 필요하면 고위험 변경으로 분류하고 별도 승인 단계를 둡니다.
4) “삭제"는 기능 완료가 아니라 관측 완료 뒤에 한다
스키마 변경에서 가장 자주 생기는 실수는 새 구조로 전환한 직후 구 구조를 바로 지우는 것입니다. 문제는 전환 완료처럼 보여도 아래 소비자가 남아 있을 수 있다는 점입니다.
- 야간 배치나 월간 리포트처럼 실행 주기가 긴 소비자
- 어드민 화면의 오래된 필터
- 데이터 파이프라인의 SELECT 목록
- 수동 운영 스크립트
- 롤백된 구버전 앱
그래서 contract 단계는 “신버전 배포 완료"가 아니라 구필드 사용이 관측상 0에 수렴한 뒤 진행해야 합니다. 최소 기준은 7일, 결제/정산/권한 같은 핵심 도메인은 14일 이상을 권장합니다. 월간 배치가 있다면 한 주 관측으로는 부족합니다.
실무 적용
1) 컬럼 rename을 안전하게 하는 순서
name을 display_name으로 바꾸는 예시로 실무 절차를 잡아보겠습니다.
1단계, 새 컬럼 추가display_name을 nullable로 추가합니다. 이때 바로 NOT NULL을 걸지 않습니다. 기본값이 있더라도 DB가 테이블 전체를 다시 쓰는지 확인해야 합니다. PostgreSQL, MySQL, Aurora, Cloud SQL 등 엔진과 버전에 따라 안전한 DDL 범위가 다릅니다. 관련 배경은 PostgreSQL WAL, Checkpoint, Replication Lag와 함께 보면 좋습니다.
2단계, 이중 쓰기
애플리케이션 쓰기 경로에서 name과 display_name을 동시에 씁니다. 이때 두 값이 달라질 수 있는 변환 규칙이 있다면 함수로 분리하고 테스트합니다. 예를 들어 공백 trim, 길이 제한, locale 처리 같은 규칙입니다.
3단계, 백필
기존 row의 display_name을 채웁니다. 배치 크기는 처음부터 크게 잡지 말고 5002,000 row 단위로 시작합니다. 각 배치 사이에 100500ms 휴식을 두고, replication lag가 30초를 넘으면 자동으로 멈추게 합니다. 대형 테이블이면 id range 기반으로 나누고, 실패해도 다시 돌릴 수 있게 멱등하게 만듭니다.
4단계, 읽기 전환
읽기 경로를 display_name으로 바꾸되, 일정 기간 display_name is null이면 name을 fallback으로 읽습니다. fallback hit rate가 0.1% 이하로 3일 이상 유지되면 신필드 전환이 안정됐다고 볼 수 있습니다.
5단계, 구필드 제거
query log, slow query log, 데이터 파이프라인, 배치 로그에서 name 참조가 없는지 확인합니다. 그 뒤 구코드를 먼저 제거하고, 마지막에 컬럼을 삭제합니다. 삭제 전에는 반드시 rollback 전략을 다시 확인합니다. 삭제 후에는 일반 rollback으로 구버전 앱이 살아날 수 없기 때문입니다.
2) 의사결정 기준: 무엇을 한 번에 묶고 무엇을 나눌까
아래 기준을 팀 표준으로 두면 스키마 변경 리뷰가 훨씬 빨라집니다.
낮은 위험, 한 배포에 가능
- nullable 컬럼 추가
- 작은 테이블에 인덱스 추가, 예상 실행 1분 이하
- 기존 코드가 읽지 않는 보조 테이블 추가
- enum 값 추가, 구버전이 모르는 값을 읽지 않는 조건
중간 위험, 배포 2회 이상 권장
- 새 컬럼 추가 후 읽기 경로 전환
- 대량 백필이 필요한 변경
- 애플리케이션 2개 이상이 같은 컬럼을 읽는 변경
- 외부 API 응답, 이벤트 스키마, DB 컬럼이 동시에 바뀌는 변경
높은 위험, 별도 런북 필요
- 컬럼 삭제, rename, 타입 축소
- 기본값 없는
NOT NULL추가 - 1,000만 row 이상 테이블 DDL
- rollback에 DDL이 필요한 변경
- 결제, 정산, 권한, 주문 상태처럼 잘못 쓰면 복구 비용이 큰 도메인
숫자는 절대값이 아니라 출발점입니다. 트래픽이 낮고 테이블이 작아도 도메인 영향이 크면 위험도를 올려야 합니다. 반대로 읽기 전용 분석 테이블이면 row 수가 커도 서비스 영향은 낮을 수 있습니다.
3) 백필 운영 기준
백필은 단순 SQL 한 번으로 끝내고 싶지만, 운영에서는 작은 배치 작업으로 보는 편이 안전합니다.
권장 기준:
- 배치 크기: 500~5,000 row에서 시작하고 DB 부하를 보며 조정
- 트랜잭션 시간: 배치당 1초 이하를 목표로 설정
- replication lag: 30초 초과 시 pause, 60초 초과 시 stop
- 오류율: 배치 실패율 1% 이상이면 자동 중단 후 원인 확인
- 재시작성: 같은 범위를 다시 실행해도 결과가 깨지지 않게 설계
- 관측 지표: 처리 row 수, 초당 처리량, 실패 row 수, lag, DB CPU, lock wait
백필이 서비스 성능을 흔들면 DB 복제와 읽기/쓰기 분리나 부하 제어와 Bulkhead 관점으로 다시 봐야 합니다. “빨리 끝내기"보다 “온라인 트래픽을 건드리지 않기"가 우선입니다.
4) CI와 릴리스 체크에 넣을 자동화
문서만으로는 반복 사고를 막기 어렵습니다. 최소한 아래 항목은 자동화나 체크리스트에 넣는 편이 좋습니다.
- migration linter:
DROP COLUMN,RENAME COLUMN, 타입 축소, 위험한NOT NULL을 기본 차단 - backward compatibility test: 구버전 앱이 신스키마에서 뜨는지 확인
- forward compatibility test: 신버전 앱이 구스키마 또는 확장 전 단계에서 어떻게 실패하는지 확인
- query log check: 최근 7~14일 구컬럼 참조가 남아 있는지 확인
- rollback note: 이 변경의 rollback이 코드 revert인지 DDL인지 명시
- feature flag: 읽기 전환과 쓰기 전환을 분리해 제어
여기서 중요한 것은 “스키마 변경이 있으니 조심"이라는 추상 문장이 아니라, CI가 위험한 패턴을 빨리 보이게 만드는 것입니다. Tool Contract Test와 Schema Canary에서 다룬 사고방식도 그대로 적용됩니다. 데이터베이스 스키마 역시 호출 가능한 계약이고, 계약 변경은 테스트 가능해야 합니다.
트레이드오프/주의점
첫째, expand-contract는 배포 횟수를 늘립니다. 작은 팀에서는 번거롭게 느껴질 수 있습니다. 하지만 배포 횟수가 늘어도 각 배포의 위험이 작아지고 rollback이 쉬워집니다. 장애 대응 비용을 포함하면 대개 이쪽이 더 싸게 먹힙니다.
둘째, 이중 쓰기는 코드 복잡도를 늘립니다. 구필드와 신필드 변환 규칙이 어긋나면 데이터 불일치가 생깁니다. 그래서 이중 쓰기 기간은 무한정 끌면 안 됩니다. 일반 서비스는 714일, 핵심 도메인은 1430일 범위에서 관측 기준을 정하고 contract 일정을 잡는 편이 좋습니다.
셋째, fallback은 안전장치이지만 버그를 숨길 수 있습니다. display_name이 비어도 name으로 응답하면 사용자 영향은 줄지만, 전환 실패가 늦게 보일 수 있습니다. fallback hit rate를 반드시 지표로 남겨야 합니다.
넷째, DDL 안전성은 DB 엔진과 버전에 강하게 의존합니다. “PostgreSQL에서는 괜찮다”, “MySQL에서는 온라인 DDL이다” 같은 일반화는 위험합니다. 실제 운영 버전, 테이블 크기, 인덱스 구조, replication 구성, lock timeout 설정을 확인해야 합니다.
다섯째, 삭제는 되돌리기 어렵습니다. 백업이 있어도 특정 컬럼만 빠르게 복원하는 일은 생각보다 복잡합니다. 삭제 전에는 데이터 보존과 삭제 아키텍처 관점에서 법적 보존, 감사 로그, 복구 요구까지 확인해야 합니다.
체크리스트 또는 연습
체크리스트
- 이번 변경이 additive인지 breaking인지 분류했다.
- 구버전 앱과 신버전 앱이 동시에 떠도 동작하는지 확인했다.
- DDL, 코드 배포, 백필, 삭제를 같은 단계에 묶지 않았다.
- 백필 배치 크기, pause 조건, 중단 조건을 숫자로 정했다.
- 구컬럼 사용 여부를 query log와 코드 검색 양쪽에서 확인했다.
- 읽기 전환은 feature flag 또는 설정으로 되돌릴 수 있다.
- contract 단계 전에 최소 7일 이상의 관측 기간을 두었다.
- rollback이 코드 revert인지 DDL인지 릴리스 노트에 적었다.
연습
orders.status를 enum에서 별도order_status_history테이블 기반으로 옮기는 마이그레이션 계획을 expand-contract 단계로 나눠보세요.- 5,000만 row 테이블에 nullable 컬럼을 추가하고 백필해야 한다고 가정하고, 배치 크기와 중단 조건을 적어보세요.
- 최근 30일 PR 중 DB migration이 포함된 PR 5개를 골라, 삭제/rename/타입 변경이 있었는지 확인해 보세요.
- 현재 팀의 릴리스 체크리스트에
DROP COLUMN차단, query log 확인, rollback 방식 명시가 있는지 점검해 보세요.
온라인 스키마 변경의 목표는 DBA처럼 모든 DDL을 외우는 것이 아닙니다. 목표는 공유 상태를 바꿀 때 이전 버전과 다음 버전이 겹치는 시간을 설계하는 것입니다. 이 감각이 생기면 배포는 더 자주 할 수 있고, 데이터베이스 변경은 더 덜 무서워집니다.
💬 댓글