왜 스키마 변경이 장애를 부르는가

롤링 배포나 블루-그린 배포 환경에서는 구버전 코드와 신버전 코드가 반드시 일정 시간 동안 공존한다. 이때 스키마를 파괴적으로 바꾸면 문제가 생긴다. 컬럼 이름을 username에서 login_id로 바꾸는 ALTER TABLE ... RENAME을 배포와 동시에 실행하면, 아직 살아 있는 구버전 인스턴스는 존재하지 않는 컬럼을 조회하다 500 에러를 던진다. 반대로 롤백해야 할 때 신버전 스키마가 이미 데이터를 옮겨버렸다면 구버전 코드는 정상 동작하지 못한다.

핵심 원인은 스키마와 코드를 동시에 원자적으로 바꿀 수 없다는 데 있다. 데이터베이스 마이그레이션과 애플리케이션 배포는 서로 다른 시점에 반영되며, 그 사이에 반드시 불일치 구간이 생긴다.

expand-contract 패턴의 개념

expand-contract(팽창-수축, parallel-change라고도 부른다)는 이 불일치 구간을 안전하게 통과하기 위해 변경을 여러 단계로 쪼개는 기법이다. 한 번에 바꾸지 않고, 구버전과 신버전이 모두 만족하는 중간 상태를 거친다.

  • Expand: 기존 구조를 유지한 채 새 구조를 추가한다(하위 호환).
  • Migrate: 코드가 새 구조를 읽고 쓰도록 배포하고, 과거 데이터를 백필한다.
  • Contract: 아무도 참조하지 않게 된 옛 구조를 제거한다.

각 단계 사이에는 배포와 검증이 들어간다. 모든 단계는 개별적으로 롤백 가능하다.

단계별 비교

단계DB 작업구버전 코드신버전 코드
Expand새 컬럼 추가(nullable)정상미배포
Migrate더블 라이트 + 백필정상(옛 컬럼)정상(새 컬럼)
Contract옛 컬럼 제거제거됨정상

실전 예시: 컬럼 이름 변경

username을 login_id로 바꾼다고 하자. 1단계는 새 컬럼을 추가하는 것뿐이다. 기본값이나 NOT NULL 제약을 이 시점에 걸면 대용량 테이블에서 락과 테이블 재작성이 발생하므로 반드시 nullable로 추가한다.

-- Step 1 (Expand): 하위 호환되는 추가만 수행
ALTER TABLE users ADD COLUMN login_id VARCHAR(255);
CREATE INDEX CONCURRENTLY idx_users_login_id ON users (login_id);

-- Step 2 (Migrate): 과거 데이터 백필. 대용량은 배치로 나눠 락 시간 최소화
UPDATE users
SET login_id = username
WHERE login_id IS NULL AND id BETWEEN :start AND :end;

애플리케이션은 이 기간 동안 두 컬럼에 모두 쓰고(더블 라이트), 읽기는 새 컬럼 우선으로 처리한다. 이렇게 하면 배포가 롤링으로 진행되는 도중에도 어떤 인스턴스가 읽든 일관된 값을 얻는다.

class User(Base):
    username = Column(String)   # 옛 컬럼: 아직 제거 금지
    login_id = Column(String)   # 새 컬럼

    def set_login(self, value):
        # 더블 라이트: 구버전 코드가 username을 읽어도 깨지지 않음
        self.username = value
        self.login_id = value

    @property
    def login(self):
        # 읽기는 새 컬럼 우선, 백필 전 레코드는 폴백
        return self.login_id or self.username

Contract 단계와 검증

모든 인스턴스가 신버전으로 교체되고 백필이 100% 완료되었음을 확인한 뒤에야 옛 컬럼을 지운다. "확인"은 감이 아니라 쿼리로 한다. 아래처럼 미백필 레코드가 0인지, 옛 컬럼을 참조하는 트래픽이 없는지 로그와 메트릭으로 검증한 후 진행한다.

-- 백필 완료 검증: 0이어야 Contract 진행
SELECT count(*) FROM users WHERE login_id IS NULL;

-- Step 3 (Contract): 참조가 사라진 뒤에만 실행
ALTER TABLE users DROP COLUMN username;

Contract는 서두르지 않아도 된다. 며칠~몇 주 지연시켜도 시스템은 정상 동작하므로, 롤백 안전 마진을 충분히 두고 마지막에 정리하는 편이 낫다.

CI/CD 파이프라인에 녹이기

마이그레이션은 배포와 별도 잡으로 분리하되, "expand 마이그레이션 → 앱 배포 → contract 마이그레이션"의 순서를 파이프라인이 강제하도록 구성한다. expand는 배포 이전, contract는 배포 이후 잡에 둔다.

jobs:
  expand:
    steps:
      - run: alembic upgrade +1   # 하위 호환 변경만 포함
  deploy:
    needs: expand
    steps:
      - run: ./deploy.sh          # 롤링 배포
  contract:
    needs: deploy
    if: github.ref == 'refs/heads/main'
    steps:
      - run: alembic upgrade head # 옛 구조 제거는 별도 커밋/PR로

주의점

  • 파괴적 변경을 한 마이그레이션에 섞지 마라. expand용 커밋과 contract용 커밋을 분리해야 롤백 단위가 명확해진다.
  • NOT NULL·기본값은 백필 이후에. 추가 시점에 제약을 걸면 락과 풀 테이블 스캔이 발생한다. Postgres라면 인덱스는 CONCURRENTLY로 만든다.
  • 백필은 반드시 배치로. 단일 UPDATE는 긴 트랜잭션과 락 경합을 만든다. 키 범위로 잘라 처리하고 진행률을 기록한다.
  • 이벤트·캐시·읽기 복제본도 스키마다. Kafka 메시지 포맷이나 API 응답 필드에도 동일한 팽창-수축 원칙을 적용해야 소비자가 깨지지 않는다.
  • contract를 잊지 마라. 방치된 중간 상태는 기술 부채가 된다. 티켓으로 남겨 반드시 회수한다.