운영 중인 웹서비스에서 데이터베이스 구조를 변경하는 작업은 생각보다 위험하다.
개발 환경에서는 컬럼 이름을 변경하고 애플리케이션 코드를 수정한 다음 다시 실행하면 끝나는 경우가 많다.
하지만 프로덕션 환경은 다르다.
기존 버전의 애플리케이션이 계속 요청을 처리하고 있을 수 있고, 여러 서버가 서로 다른 시점에 배포될 수도 있다. 백그라운드 작업자나 예약 작업이 여전히 이전 데이터 구조를 참조하고 있을 가능성도 있다.
이 상황에서 기존 컬럼을 바로 삭제하면 어떤 일이 발생할까?
새로운 애플리케이션은 정상적으로 작동하더라도 이전 버전의 서버에서는 데이터베이스 오류가 발생할 수 있다.
이번 글에서는 운영 중인 웹서비스를 중단하지 않고 데이터베이스 구조를 변경하기 위해 사용할 수 있는 Expand–Migrate–Contract 패턴을 살펴본다.
특히 PostgreSQL 기반 서비스를 예시로 실제 배포 과정과 실패 시나리오를 정리한다.
1. 데이터베이스 변경이 서비스 장애로 이어지는 이유
먼저 웹사이트 정보를 저장하는 간단한 데이터베이스 테이블을 생각해 보자.
CREATE TABLE sites ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); 애플리케이션은 url 컬럼에 저장된 주소를 조회해 사용자에게 제공한다.
SELECT id, name, url FROM sites WHERE id = 100; 그런데 서비스 구조를 개선하면서 url 컬럼 이름을 destination_url로 변경하기로 결정했다.
개발자가 다음 SQL을 실행했다고 가정해 보자.
ALTER TABLE sites RENAME COLUMN url TO destination_url; 데이터베이스의 컬럼 이름은 변경된다.
문제는 기존 애플리케이션이다.
기존 서버가 여전히 다음 쿼리를 실행하면 오류가 발생한다.
SELECT url FROM sites WHERE id = 100; PostgreSQL에서는 존재하지 않는 컬럼을 참조했기 때문에 오류를 반환한다.
즉, 데이터베이스 변경 자체는 성공했지만 서비스는 정상적으로 작동하지 않을 수 있다.
배포 시점에 발생하는 버전 충돌
[Application V1] | | SELECT url v [Database V2] | v [컬럼 없음 오류] 이 문제는 배포 순서와 관련이 있다.
- 데이터베이스를 먼저 변경하면 기존 애플리케이션이 실패할 수 있다.
- 애플리케이션을 먼저 변경하면 새로운 코드가 아직 없는 컬럼을 참조할 수 있다.
- 여러 서버를 순차적으로 배포하면 이전 코드와 새로운 코드가 동시에 실행될 수 있다.
따라서 데이터베이스 구조 변경은 애플리케이션 배포와 분리해 설계해야 한다.
2. Expand–Migrate–Contract 패턴이란?
Expand–Migrate–Contract는 기존 구조를 즉시 제거하지 않고 새로운 구조를 단계적으로 도입하는 방법이다.
핵심은 일정 기간 기존 구조와 새로운 구조를 동시에 지원하는 것이다.
세 단계는 다음과 같다.
| 단계 | 작업 | 목적 |
|---|---|---|
| Expand | 새로운 컬럼이나 테이블 추가 | 기존 코드와 신규 코드의 호환성 확보 |
| Migrate | 데이터 이전 및 애플리케이션 전환 | 새로운 구조로 점진적 이동 |
| Contract | 사용하지 않는 기존 구조 제거 | 최종 구조 정리 |
전체적인 흐름은 다음과 같다.
[기존 데이터베이스] | v EXPAND | v [기존 컬럼 + 신규 컬럼] | v MIGRATE | v [데이터 이전 + 코드 전환] | v CONTRACT | v [신규 컬럼만 유지] 여기서 중요한 것은 세 단계 사이에 충분한 검증 시간을 두는 것이다.
모든 작업을 한 번의 배포에서 끝낼 필요는 없다.
오히려 각각을 독립적으로 배포하고 검증하는 편이 장애 위험을 줄이는 데 도움이 된다.
단, 이 패턴을 사용한다고 모든 데이터베이스 변경이 자동으로 무중단 처리되는 것은 아니다.
DDL 작업의 잠금, 데이터 규모, 애플리케이션 동작, 배포 환경 등을 함께 검토해야 한다.
3. Expand: 기존 컬럼을 유지하면서 새 컬럼 추가하기
첫 번째 단계에서는 기존 url 컬럼을 삭제하지 않는다.
대신 새로운 destination_url 컬럼을 추가한다.
ALTER TABLE sites ADD COLUMN destination_url TEXT; 변경 후 테이블은 다음 구조를 갖는다.
sites ├── id ├── name ├── url ├── destination_url └── created_at 기존 애플리케이션은 여전히 url을 사용할 수 있다.
새로운 애플리케이션은 destination_url을 사용할 준비를 할 수 있다.
왜 처음부터 NOT NULL을 설정하지 않을까?
기존 데이터에는 새로운 컬럼 값이 존재하지 않기 때문이다.
기존 행이 있는 테이블에 값을 준비하지 않은 상태로 NOT NULL 제약을 추가하면 문제가 발생할 수 있다.
우선 nullable 컬럼으로 추가하고, 데이터를 이전한 뒤 제약을 강화하는 방식이 일반적으로 안전하다.
하지만 새로운 컬럼을 추가하는 SQL도 테이블 잠금과 관련이 있다.
PostgreSQL의 ALTER TABLE은 작업 유형에 따라 서로 다른 잠금 수준을 요구한다.
따라서 단순한 컬럼 추가라도 운영 환경에서 잠금 대기 시간이 발생할 수 있다.
운영 시 주의사항
- 트래픽이 높은 시간대인지 확인한다.
- 오래 실행되는 트랜잭션이 있는지 점검한다.
- 변경 쿼리의 잠금 특성을 확인한다.
- 실제 데이터 규모에 가까운 환경에서 미리 테스트한다.
- 백업과 복구 절차를 준비한다.
4. Migrate 1단계: 신규 데이터부터 이중 쓰기 적용하기
새로운 컬럼이 생성됐다고 해서 모든 데이터가 자동으로 이전되는 것은 아니다.
먼저 새롭게 발생하는 쓰기 작업이 두 컬럼을 동시에 갱신하도록 변경한다.
이를 Dual Write라고 한다.
[Application V2] | v [DB Transaction] / \ v v [url] [destination_url] \ / v v [Commit] 예를 들어 다음과 같은 SQL을 사용할 수 있다.
BEGIN; UPDATE sites SET url = 'https://example.com', destination_url = 'https://example.com' WHERE id = 100; COMMIT; 두 컬럼을 하나의 데이터베이스 트랜잭션에서 갱신하면 둘 중 하나만 저장되는 문제를 줄일 수 있다.
다만 모든 쓰기 경로가 이 규칙을 따라야 한다.
예를 들어 관리자 페이지는 두 컬럼을 갱신하지만 백그라운드 작업자는 기존 url만 갱신한다면 데이터가 다시 불일치할 수 있다.
또한 순차 배포 중에는 이전 버전 서버가 잠시 남아 있을 수 있다.
따라서 이중 쓰기 코드 배포 직후부터 두 컬럼이 항상 일치한다고 가정해서는 안 된다.
반드시 확인해야 할 쓰기 경로
- 웹사이트 등록 API
- 웹사이트 수정 API
- 관리자 페이지
- 예약 작업
- 데이터 가져오기 작업
- 외부 시스템 연동
- 백그라운드 Worker
모든 쓰기 경로가 전환되었는지 확인한 뒤 기존 데이터 이전을 진행하는 것이 안전하다.
5. Migrate 2단계: 기존 데이터를 점진적으로 이전하기
이제 기존 url 데이터를 새로운 destination_url 컬럼으로 복사한다.
데이터가 적다면 다음 SQL로도 가능하다.
UPDATE sites SET destination_url = url WHERE destination_url IS NULL; 하지만 운영 중인 테이블에 수백만 건의 데이터가 있다면 한 번의 대규모 UPDATE는 부담이 될 수 있다.
많은 행을 동시에 수정하면 트랜잭션이 길어지고, WAL 생성량과 디스크 사용량이 증가하며, 다른 작업과의 잠금 경합도 발생할 수 있다.
따라서 대규모 데이터에서는 배치 처리(Backfill)를 고려한다.
PostgreSQL 배치 업데이트 예시
WITH batch AS ( SELECT id FROM sites WHERE destination_url IS NULL ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED ) UPDATE sites AS s SET destination_url = s.url FROM batch WHERE s.id = batch.id; 이 SQL은 한 번에 최대 500개 행을 선택해 처리한다.
SKIP LOCKED를 사용하면 다른 트랜잭션이 잠근 행을 기다리지 않고 건너뛸 수 있다.
다만 이 예시는 모든 쓰기 경로의 이중 쓰기 전환이 완료됐다는 전제를 가진다.
그리고 SKIP LOCKED로 건너뛴 행은 이후 반복 실행에서 다시 처리해야 한다.
실제 운영에서는 다음 사항을 고려한다.
- 배치 크기를 설정한다.
- 배치마다 짧게 트랜잭션을 완료한다.
- 데이터베이스 부하를 관찰한다.
- 미처리 행이 남아 있는지 반복 확인한다.
- 마지막에 누락 데이터와 불일치를 검증한다.
이전 완료 여부 확인
SELECT COUNT(*) FROM sites WHERE destination_url IS NULL; 이 값이 0이면 NULL 상태로 남은 데이터가 없다는 뜻이다.
그러나 두 컬럼의 값이 일치한다는 뜻은 아니다.
정확한 비교를 위해 다음 쿼리를 사용할 수 있다.
SELECT COUNT(*) FROM sites WHERE url IS DISTINCT FROM destination_url; IS DISTINCT FROM은 NULL까지 고려해 두 값을 비교한다.
최종 목표는 두 컬럼의 불일치 건수가 0이 되는 것이다.
이중 쓰기와 데이터 이전 과정에서 불일치가 발견되면 해당 행을 재검증하고 복구해야 한다.
6. Migrate 3단계: 읽기 경로를 새로운 컬럼으로 전환하기
데이터 이전이 끝났다면 애플리케이션이 새로운 컬럼을 읽도록 변경할 수 있다.
하지만 모든 서버를 한 번에 전환할 필요는 없다.
Feature Flag를 이용하면 새로운 읽기 경로를 단계적으로 적용할 수 있다.
읽기 경로 예시
def get_site_url(site, use_new_column): if use_new_column: return site["destination_url"] return site["url"] 이 예시에서는 use_new_column의 값에 따라 서로 다른 컬럼을 사용한다.
실제 운영에서는 애플리케이션 설정이나 Feature Flag 시스템과 연결할 수 있다.
단계별 전환 전략
[기존 읽기 100%] | v [신규 읽기 일부 적용] | v [오류율 및 데이터 일치 확인] | v [신규 읽기 확대] | v [신규 읽기 100%] 이 과정에서는 다음 지표를 확인해야 한다.
- 데이터 조회 오류율
- 응답 지연 시간
- NULL 데이터 발생 여부
- 구버전과 신버전의 결과 불일치
- 데이터베이스 쿼리 성능
특히 신규 컬럼 읽기를 확대하는 동안에도 기존 컬럼에 대한 이중 쓰기를 유지해야 한다.
그래야 애플리케이션을 이전 버전으로 되돌려야 하는 상황에 대응하기 쉽다.
7. Contract: 기존 컬럼을 제거하기 전 확인할 사항
신규 컬럼으로 읽기와 쓰기 전환이 완료되었다고 해서 기존 컬럼을 즉시 삭제하면 안 된다.
운영 중인 시스템에는 이전 코드를 사용하는 프로세스가 남아 있을 수 있기 때문이다.
대표적으로 다음 요소를 확인해야 한다.
- 이전 버전의 API 서버
- 배포가 늦어진 백그라운드 Worker
- 오래 실행되는 예약 작업
- 외부 데이터 처리 프로그램
- 데이터 분석용 SQL
- 이전 버전으로 되돌리는 롤백 절차
삭제 전 점검 순서
[신규 컬럼 전환 완료] | v [구버전 프로세스 존재 여부 확인] | v [기존 컬럼 참조 코드 검색] | v [데이터 일관성 검증] | v [백업 및 복구 가능성 확인] | v [기존 컬럼 제거] 이전 버전과의 호환성이 더 이상 필요하지 않다는 것을 검증한 후 기존 컬럼을 제거한다.
ALTER TABLE sites DROP COLUMN url; 이 작업 이후에는 이전 컬럼을 사용하는 애플리케이션으로 단순 롤백할 수 없게 된다.
따라서 Contract 단계는 Expand나 Migrate 단계보다 더 신중하게 진행해야 한다.
실제 운영에서는 기존 컬럼을 제거하는 작업을 별도 배포로 분리하는 것이 적절하다.
8. 실패 시나리오: 배포 중간에 문제가 생기면?
서비스 중단 없는 마이그레이션을 설계할 때 중요한 것은 정상 경로만 고려하지 않는 것이다.
각 단계에서 실패했을 때 어디까지 되돌릴 수 있는지 정의해야 한다.
시나리오 A: 신규 컬럼 추가가 실패한 경우
Expand 단계에서 DDL 작업이 실패했다면 신규 코드를 배포하지 않는다.
데이터베이스 상태를 확인하고 잠금이나 제약 조건 문제를 해결한 뒤 다시 시도한다.
시나리오 B: 데이터 이전 중 Worker가 종료된 경우
배치 처리 도중 Worker가 종료되었다면 마지막으로 커밋된 상태부터 미처리 데이터를 확인한다.
일반적으로 배치마다 독립적으로 커밋하고, 다시 실행해도 안전한 방식으로 설계하는 것이 좋다.
이 예시에서는 destination_url IS NULL 조건을 사용하므로 이미 값이 채워진 행을 반복적으로 갱신하지 않는다.
다만 데이터 불일치까지 자동으로 해결하는 것은 아니므로 별도의 검증 작업이 필요하다.
시나리오 C: 신규 읽기에서 오류가 증가한 경우
새로운 컬럼을 읽는 과정에서 오류가 발생하면 Feature Flag를 통해 기존 읽기 경로로 되돌릴 수 있다.
이때 기존 컬럼에 대한 쓰기가 계속 유지되어야 한다.
[신규 읽기 활성화] | v [오류율 증가 감지] | v [기존 읽기로 전환] | v [문제 분석 및 수정] 시나리오 D: 기존 컬럼을 삭제한 뒤 롤백이 필요한 경우
Contract 단계에서 기존 컬럼을 이미 삭제했다면 상황이 복잡해진다.
이전 애플리케이션은 삭제된 컬럼을 참조할 수 있다.
이 경우 단순한 코드 롤백만으로 문제를 해결할 수 없다.
필요하면 새로운 컬럼을 이용해 이전 호환 구조를 복원하는 별도의 마이그레이션이 필요하다.
따라서 이전 버전의 애플리케이션이 다시 실행될 가능성이 있는 동안에는 기존 컬럼을 제거하지 않는 것이 중요하다.
9. PostgreSQL 잠금과 DDL 작업에서 주의할 점
Expand–Migrate–Contract 패턴이 애플리케이션 호환성 문제를 줄여주더라도 데이터베이스 수준의 잠금 문제가 사라지는 것은 아니다.
PostgreSQL에서는 ALTER TABLE의 세부 작업에 따라 서로 다른 잠금 수준이 적용된다.
예를 들어 일부 컬럼 추가 작업은 테이블 전체를 다시 작성하지 않고 처리할 수 있지만, 작업에 필요한 잠금은 여전히 고려해야 한다.
또한 컬럼의 데이터 타입을 변경하는 작업은 테이블 재작성이나 인덱스 재구축이 필요할 수 있다.
이 때문에 마이그레이션 실행 전에 실제 SQL이 어떤 작업을 수행하는지 확인해야 한다.
배포 전 검토 항목
| 검토 항목 | 확인 내용 |
|---|---|
| 테이블 크기 | 전체 행 수와 저장 용량 |
| 잠금 수준 | 읽기·쓰기 요청에 미치는 영향 |
| 데이터 변경량 | UPDATE로 발생할 수 있는 부하 |
| 실행 시간 | 운영 환경에서 예상되는 처리 시간 |
| 롤백 전략 | 실패 시 이전 상태로 복구 가능한지 |
| 의존성 | View, Trigger, 외래키, 외부 프로그램 영향 |
마이그레이션 도구가 SQL을 자동으로 생성하더라도 실제 실행되는 SQL을 검토하는 과정은 필요하다.
자동화된 도구가 프로덕션 환경의 모든 위험을 알아서 해결해 주는 것은 아니다.
10. 실무에서 사용할 수 있는 배포 순서
지금까지 설명한 내용을 실제 배포 순서로 정리하면 다음과 같다.
배포 1: Expand
- 새 컬럼 추가
- 기존 컬럼 유지
- 이전 애플리케이션의 정상 동작 확인
배포 2: Dual Write
- 새로운 코드에서 두 컬럼을 함께 기록
- 모든 쓰기 경로가 전환됐는지 확인
- 구버전 서버 및 Worker 종료 여부 확인
배포 3: Backfill
- 기존 데이터 배치 이전
- 데이터 불일치 검사
- 미처리 행 재시도
- 완료 후 최종 검증
배포 4: Read Switch
- 새로운 컬럼 읽기 적용
- 오류율 및 성능 점검
- 문제가 발생하면 기존 읽기로 복구
배포 5: 신규 구조 안정화
- 읽기와 쓰기를 새로운 컬럼 중심으로 통일
- 이전 컬럼에 대한 쓰기 제거
- 더 이상 구버전이 실행되지 않는지 확인
배포 6: Contract
- 이전 컬럼 참조 제거 확인
- 백업과 복구 절차 확인
- 기존 컬럼 삭제
- 최종 모니터링
위 순서는 예시이며 서비스 구조에 따라 여러 단계를 분리하거나 통합할 수 있다.
중요한 것은 각 단계가 검증 가능하고, 전환 도중에도 호환성이 유지되도록 설계하는 것이다.
11. 운영 환경 적용 전 체크리스트
스키마 변경
- [ ] 변경 대상 테이블과 컬럼의 사용처를 확인했는가?
- [ ] 기존 코드가 새 스키마에서도 정상 작동하는가?
- [ ] 신규 컬럼의 NULL 허용 여부를 검토했는가?
- [ ] DDL의 잠금 수준을 확인했는가?
- [ ] 실제 규모의 데이터로 테스트했는가?
데이터 이전
- [ ] 신규 쓰기 경로가 모두 전환되었는가?
- [ ] Backfill 작업을 재실행해도 안전한가?
- [ ] 대규모 트랜잭션을 피하도록 설계했는가?
- [ ] 미처리 데이터와 불일치를 측정하는가?
- [ ] 데이터 이전 완료 조건이 명확한가?
배포와 롤백
- [ ] 구버전과 신버전이 일정 기간 공존할 수 있는가?
- [ ] 신규 읽기를 단계적으로 적용할 수 있는가?
- [ ] 장애 발생 시 이전 읽기 경로로 되돌릴 수 있는가?
- [ ] 오래 실행되는 작업자의 호환성을 확인했는가?
- [ ] Contract 단계 이전에 롤백 가능성을 검증했는가?
운영 모니터링
- [ ] 데이터베이스 오류율을 확인하는가?
- [ ] 마이그레이션 진행 상태를 기록하는가?
- [ ] 데이터베이스 잠금과 처리 지연을 관찰하는가?
- [ ] 배포 후 일정 기간 오류율을 점검하는가?
- [ ] 최종적으로 사용하지 않는 컬럼과 코드를 정리하는가?
12. 모든 스키마 변경에 이 패턴이 필요할까?
모든 데이터베이스 변경을 복잡한 단계로 나눌 필요는 없다.
개발 중인 프로젝트이거나 서비스 중단이 허용되는 환경에서는 직접 변경하는 방식이 더 간단할 수 있다.
반면 다음 조건에 해당한다면 Expand–Migrate–Contract 패턴을 고려할 가치가 있다.
- 운영 중 서비스 중단을 최소화해야 한다.
- 애플리케이션 서버가 여러 대다.
- 배포 과정에서 구버전과 신버전이 공존한다.
- 데이터베이스에 많은 데이터가 저장되어 있다.
- 기존 데이터 구조를 변경해야 한다.
- 배포 실패 시 안전한 복구가 필요하다.
중요한 것은 복잡한 아키텍처 자체가 아니다.
데이터베이스 구조와 애플리케이션 코드의 호환성을 유지하면서 새로운 구조로 이동할 수 있도록 만드는 것이다.
결론
운영 중인 웹서비스에서 데이터베이스 구조를 변경할 때 가장 중요한 것은 새로운 SQL을 실행하는 속도가 아니다.
기존 애플리케이션과 새로운 애플리케이션이 서로 다른 구조를 기대하는 상황에서도 서비스가 정상적으로 동작하도록 설계하는 것이다.
Expand–Migrate–Contract 패턴은 이러한 문제를 해결하기 위해 변경 작업을 여러 단계로 나누는 접근법이다.
새로운 구조를 추가하고, 데이터를 이전하고, 애플리케이션을 전환한 뒤, 마지막에 기존 구조를 제거한다.
이 과정에서 버전 호환성, 데이터 일관성, 잠금, 모니터링, 롤백 전략을 함께 고려해야 한다.
무중단 데이터베이스 마이그레이션의 핵심은 변경을 한 번에 끝내는 것이 아니라, 변경 과정 전체가 안전하도록 설계하는 것이다.
웹사이트 디렉터리 서비스에 적용해 생각해 보기
레이더 링크모음과 같이 여러 웹사이트의 주소와 카테고리 정보를 안내하는 서비스도 데이터 구조를 설계할 때 참고할 수 있는 사례다.
이러한 유형의 서비스에서 웹사이트 주소를 저장하는 필드나 카테고리 관리 구조를 변경한다면 기존 데이터를 유지하면서 새 구조로 전환하는 방법을 고려할 수 있다.
다만 실제 데이터베이스 구성과 배포 방식은 서비스마다 다르므로 필요한 마이그레이션 전략도 달라진다.
