롤링 배포 중에는 옛 버전과 새 버전 애플리케이션이 같은 DB를 동시에 쓴다. 그래서 컬럼 이름 하나를 바꾸는 마이그레이션이 옛 버전 서버를 바로 깨뜨리고, 잘못 잡은 잠금 하나가 테이블 전체를 몇 분씩 멈춘다. 이 글은 스키마 변경을 "넓히기(Expand)"와 "좁히기(Contract)" 사이 다섯 단계로 나눠, 단계마다 무엇을 배포하고 어떤 SQL을 어떤 조건에서 돌리는지, 어디까지 되돌릴 수 있는지를 정리한 것이다.

한눈에 보기
예시는 member.tel 컬럼을 phone_number로 바꾸는 작업이다. tel은 NOT NULL이고, 바꾼 뒤에도 NOT NULL과 인덱스가 있어야 한다.
| 단계 | 앱 배포 | DB 변경 | 되돌리기 |
|---|---|---|---|
| 1. Expand | 없음(v1 그대로) | phone_number 추가, NULL 허용 |
컬럼 삭제 |
| 2. Dual write | v2: 두 컬럼에 쓰고 tel에서 읽음 |
없음 | v1로 롤백 |
| 3. Backfill | v2 + 배치 | 기존 행 채우기 | 배치 중단 |
| 4. Read 전환 | v3: phone_number에서 읽음 |
NOT NULL, 인덱스 | v2로 롤백 |
| 5. Contract | v4: phone_number만 씀 |
tel 삭제 |
불가 |

한 번에 RENAME COLUMN을 하면 안 되는 이유는 두 가지다. 첫째, 롤링 배포나 Blue-Green 전환 동안 옛 버전이 몇 분에서 몇 시간 살아 있다. 그 사이 옛 버전의 SELECT tel이 모두 실패한다. 배포 방식별로 두 버전이 공존하는 시간은 Blue-Green·Canary·Rolling 배포 전략 비교에서 다뤘다. 둘째, ALTER TABLE은 대부분 ACCESS EXCLUSIVE 잠금을 잡는다. 긴 트랜잭션 뒤에서 이 잠금을 기다리는 동안, 뒤에 온 모든 조회까지 줄을 서서 멈춘다.
1단계 Expand: 새 컬럼은 비어 있게 추가한다
할 일
- NULL을 허용하는 컬럼으로 추가한다. PostgreSQL에서 NULL 허용 컬럼 추가는 카탈로그만 바꾸고 바로 끝난다.
- 기본값이 필요하면 상수만 쓴다. PostgreSQL 11부터 상수 기본값은 테이블을 다시 쓰지 않는다.
clock_timestamp()같은 휘발성 함수 기본값은 모든 행을 다시 쓴다. - 잠금 대기 시간을 짧게 건다. 기다리다 실패하는 편이 테이블 전체를 멈추는 것보다 낫다.
SET lock_timeout = '3s'; -- 잠금을 3초 안에 못 얻으면 실패하고 재시도
SET statement_timeout = '15s';
ALTER TABLE member ADD COLUMN phone_number varchar(20);
이 단계에는 앱 배포가 없다. v1은 새 컬럼을 모르고, 그래도 아무 문제가 없어야 한다. SELECT *를 DTO에 그대로 매핑하는 코드가 있으면 여기서 깨지니 먼저 찾아 둔다.
2단계 Dual write: 두 컬럼에 함께 쓴다
할 일
- v2를 배포한다. 쓰기는
tel과phone_number둘 다, 읽기는 여전히tel이다. - 두 쓰기는 같은 트랜잭션 안에 둔다. 하나만 성공한 행이 생기면 4단계 검증에서 차이가 난다.
- 이 테이블에 쓰는 곳을 모두 찾는다. 배치, 관리자 화면, 다른 시스템의 DB 연계까지다. 앱 밖의 쓰기가 많으면 트리거로 동기화하는 편이 빠진 곳이 없다.
CREATE FUNCTION member_sync_phone() RETURNS trigger AS $$
BEGIN
NEW.phone_number := NEW.tel;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER member_sync_phone BEFORE INSERT OR UPDATE OF tel ON member
FOR EACH ROW EXECUTE FUNCTION member_sync_phone();
트리거를 쓰면 4단계에서 읽기를 옮긴 뒤 방향을 바꾸거나 지워야 한다. 그대로 두면 v4가 tel 없이 쓸 때 트리거가 phone_number를 NULL로 덮는다.
3단계 Backfill: 기존 행을 나눠서 채운다
할 일
- 기본 키 범위로 잘라 배치마다 커밋한다. 한 번의
UPDATE로 수천만 행을 바꾸면 잠금이 오래 잡히고 WAL과 복제 지연이 폭증한다. phone_number IS NULL조건을 붙여 다시 돌려도 안전하게 만든다. 이미 dual write로 채워진 행은 건드리지 않는다.- 시작할 때
max(id)를 기록해 두고 거기까지만 돈다. 그 뒤에 생긴 행은 v2가 이미 채운다.
-- :start를 5,000씩 늘리며 반복, 배치마다 커밋하고 잠깐 쉰다
UPDATE member
SET phone_number = tel
WHERE id >= :start AND id < :start + 5000
AND phone_number IS NULL;
배치 크기는 한 번에 1초 안에 끝나는 크기로 정하고, 복제 지연과 응답 시간을 보며 조절한다. 행을 바꾸면 죽은 튜플이 쌓이니 autovacuum이 따라오는지도 본다. 테이블이 아주 크면 PostgreSQL Partitioning과 대용량 테이블 관리처럼 파티션 단위로 나눠 채운다.
4단계 Read 전환: 검증하고 제약을 붙인다
할 일
- 두 컬럼이 같은지 확인한다. 아래 결과가 0이어야 다음으로 간다.
- v3를 배포해 읽기를
phone_number로 옮긴다. 플래그로 읽기 대상을 바꿀 수 있게 해 두면 앱 롤백 없이 되돌릴 수 있다. - NOT NULL은 검증과 잠금을 나눠 붙인다. 바로
SET NOT NULL을 하면 ACCESS EXCLUSIVE 잠금을 잡은 채 전체를 훑는다.
SELECT count(*) FROM member WHERE phone_number IS DISTINCT FROM tel; -- 0이어야 함
ALTER TABLE member ADD CONSTRAINT member_phone_nn
CHECK (phone_number IS NOT NULL) NOT VALID; -- 짧은 잠금, 기존 행은 안 봄
ALTER TABLE member VALIDATE CONSTRAINT member_phone_nn; -- 읽기·쓰기를 막지 않고 검증
ALTER TABLE member ALTER COLUMN phone_number SET NOT NULL; -- PG 12+: 검증된 CHECK가 있으면 스캔 생략
ALTER TABLE member DROP CONSTRAINT member_phone_nn;
CREATE INDEX CONCURRENTLY idx_member_phone_number ON member (phone_number);
NOT VALID도 새로 들어오는 행에는 바로 적용된다. 그래서 제약을 붙이기 전에 phone_number를 쓰지 않는 v1 서버가 하나도 남지 않았는지 먼저 확인한다. CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 돌 수 없고, 실패하면 INVALID 인덱스가 남는다. 남은 인덱스는 DROP INDEX CONCURRENTLY로 지운 뒤 다시 만든다.
5단계 Contract: 옛 컬럼을 지운다
할 일
- v4 배포 전에
ALTER TABLE member ALTER COLUMN tel DROP NOT NULL;로 옛 컬럼의 NOT NULL을 푼다. 그러지 않으면tel을 쓰지 않는 v4의 INSERT가 실패한다. - v4를 배포해
tel쓰기를 멈춘다. 2단계 트리거가 있으면 이때 지운다. - 한 배포 주기 이상 기다린다. 롤백할 일이 없다는 것이 확인되어야 지운다.
tel을 읽는 곳을 마지막으로 찾는다. 뷰, 리포트 쿼리, CDC로 이 컬럼을 받는 하위 시스템, ORM 엔티티 매핑이다. JPA 엔티티에 필드가 남아 있으면 지운 순간 조회가 실패한다.- 백업을 확인하고
ALTER TABLE member DROP COLUMN tel;을 돌린다. PostgreSQL은 컬럼을 숨김 처리만 하고 공간은 테이블을 다시 쓸 때 돌려받는다.
이 단계부터는 앱 롤백으로 되돌릴 수 없다. 앞 단계를 몇 주에 걸쳐 나눠도 되지만, Expand와 Contract를 같은 릴리스에 넣으면 패턴 전체가 무의미해진다.
도구 설정과 단계별 되돌리기
마이그레이션 파일은 단계마다 따로 만들고 배포 파이프라인에서 앱 배포와 순서를 정한다. Expand는 앱보다 먼저, Contract는 앱보다 나중이다. 파이프라인에서 이 순서를 어떻게 고정하는지는 공공 SI 환경의 CI/CD 배포·백업·롤백 자동화와 GitOps와 Argo CD 기반 선언적 배포 아키텍처에서 다뤘다.
- Flyway는 스크립트별 설정 파일(
V7__index.sql.conf의executeInTransaction=false)로, Liquibase는 changeSet의runInTransaction="false"로 트랜잭션을 끈다. 쓰는 버전의 문서로 이름을 확인한다. - MySQL 8.0은 컬럼 추가 같은 일부 변경을
ALGORITHM=INSTANT로 메타데이터만 바꿔 끝낸다. 지원하지 않는 변경은INPLACE나 gh-ost, pt-online-schema-change 같은 복제 테이블 방식 도구를 쓴다. - 같은 원리는 이벤트 메시지와 LLM 응답 스키마에도 그대로 적용된다. Kafka Consumer Group Rebalancing과 Consumer Lag 대응, LLM Structured Output과 응답 스키마 검증 설계를 함께 보면 좋다.
| 단계 | 문제가 생기면 | 되돌리는 방법 | 데이터 손실 |
|---|---|---|---|
| Expand | 잠금 대기, 타임아웃 | 재시도 또는 컬럼 삭제 | 없음 |
| Dual write | 새 컬럼 쓰기 오류 | v1로 롤백 | 없음 |
| Backfill | 복제 지연, 응답 지연 | 배치 중단, 크기 축소 | 없음 |
| Read 전환 | 조회 결과 차이 | 플래그 또는 v2로 롤백 | 없음(두 컬럼 유지) |
| Contract | 옛 컬럼을 읽는 곳 발견 | 백업에서 복원 | 있음 |
자주 하는 실수
Expand와 Contract를 한 번에 배포한다. 두 버전이 공존하는 동안 옛 버전이 지워진 컬럼을 찾다가 실패한다.
lock_timeout 없이 ALTER를 돌린다. 긴 트랜잭션 하나 뒤에서 잠금을 기다리는 동안 그 테이블의 모든 조회가 멈춘다.
백필을 한 문장으로 끝낸다. 수천만 행 UPDATE는 복제 지연과 테이블 비대를 만들고, 중간에 실패하면 처음부터 다시 해야 한다.
검증 없이 읽기를 옮긴다. dual write가 빠진 경로가 하나라도 있으면 일부 행이 비어 보이고, 옛 컬럼이 남아 있을 때만 원인을 찾을 수 있다.
v1이 남은 상태에서 NOT NULL 제약을 붙인다. NOT VALID여도 새 행에는 적용되므로 v1의 INSERT가 모두 실패한다.
점검표
- 변경 하나를 Expand부터 Contract까지 단계별 마이그레이션 파일로 나눴다.
- 모든 DDL 앞에
lock_timeout을 걸고, 실패하면 재시도한다. - 새 컬럼은 NULL 허용 또는 상수 기본값으로 추가했다.
- 테이블에 쓰는 모든 경로(앱, 배치, 관리자, 외부 연계)가 두 컬럼에 쓴다.
- 백필은 기본 키 범위로 나눠 배치마다 커밋하고, 다시 돌려도 안전하다.
- 두 컬럼 불일치 건수가 0인 것을 확인한 뒤 읽기를 옮겼다.
- NOT NULL은
NOT VALIDCHECK →VALIDATE→SET NOT NULL순서로 붙였다. - 인덱스는
CONCURRENTLY로 트랜잭션 밖에서 만들고, INVALID 인덱스가 없는지 확인했다. - 옛 컬럼을 읽는 뷰·리포트·CDC 소비자·ORM 매핑이 없음을 확인한 뒤 지웠다.
- Contract 전에 백업과 복원 절차를 확인했다.
스키마 변경은 넓히고, 양쪽에 쓰고, 채우고, 읽기를 옮긴 뒤에야 좁히는 순서로 나누면 두 버전이 공존하는 동안에도 멈추지 않고, 마지막 삭제 직전까지 언제든 되돌릴 수 있다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.