확장/축소 패턴으로 무중단 스키마 변경하기
확장/축소 패턴, 안전한 백필, 호환 릴리스, 검증, 롤백으로 무중단 스키마 변경을 계획하고 배포하세요.

스키마 변경이 장애를 일으키는 이유
스키마 변경은 애플리케이션 버전, 백그라운드 워커, 데이터베이스가 어떤 구조와 값이 유효한지에 대해 더 이상 같은 기준을 공유하지 않을 때 장애를 일으킵니다. 모든 요청이 오류를 반환하는 것처럼 즉시 드러날 수도 있고, 쿼리 지연 증가, 쓰기 실패, 복제본 지연, 재실행해야 하는 작업 큐처럼 서서히 나타날 수도 있습니다.
프로덕션 배포에서 모든 프로세스가 한 번에 바뀌는 일은 드뭅니다. 롤링 배포에서는 이전 애플리케이션 인스턴스와 새 인스턴스가 함께 실행됩니다. 오래 실행되는 워커는 몇 시간 동안 이전 빌드를 유지할 수 있고, 모바일 클라이언트는 몇 달 동안 활성 상태일 수 있으며, 보고서나 통합 작업은 메인 애플리케이션을 거치지 않고 테이블을 사용할 수 있습니다. 이들은 모두 하나의 데이터베이스를 공유합니다.
대표적인 실패 방식은 다음과 같습니다.
- 새 코드가 열 생성 마이그레이션이 끝나기 전에 해당 열에 값을 씁니다.
- 이전 코드가 이후 릴리스에서 이름이 바뀌거나 삭제된 테이블 또는 열을 읽습니다.
- 테이블 재작성, 백필, 인덱스 빌드가 충분한 I/O와 CPU를 사용해 일반 트래픽을 늦춥니다.
- 스키마 명령이 잠금을 기다리는 동안 요청이 그 뒤에 쌓입니다.
- 새 제약 조건이 아직 업그레이드되지 않은 프로세스의 쓰기를 거부합니다.
위험한 부분은 명목상의 실행 시간보다 잠금을 획득하는 과정인 경우가 많습니다. 빠른 ALTER TABLE도 오래 실행 중인 트랜잭션 뒤에서 대기할 수 있습니다. 대기하는 동안 이후 쿼리가 대기 중인 스키마 잠금 뒤에 줄을 설 수 있고, 작은 마이그레이션이 애플리케이션 전체의 멈춤으로 이어질 수 있습니다.
무중단을 달성하려면 아직 실행될 수 있는 모든 애플리케이션 버전이 중간 단계의 데이터베이스 상태를 계속 사용할 수 있어야 합니다. 먼저 호환되는 구조를 추가하고, 트래픽과 데이터를 통제된 단계로 옮긴 뒤, 마지막 소비자가 사라진 후에만 이전 경로를 제거하세요.
이 방식은 실시간 트래픽, 롤링 배포, 엄격한 가용성 목표, 높은 복구 비용이 있는 시스템에 적합합니다. 트래픽이 적은 데이터베이스를 쓰는 작은 내부 도구라면 검증된 점검 시간이 더 나을 수 있습니다. 장애 비용과 마이그레이션 운영 복잡도를 함께 고려해 결정해야 합니다.
쉽게 이해하는 확장/축소
확장/축소 패턴은 호환되지 않는 변경 하나를 호환 가능한 릴리스의 연속으로 바꿉니다. 코드와 데이터가 이전 표현에서 새 표현으로 이동하는 동안 데이터베이스는 일시적으로 두 표현을 지원합니다.
순서는 세 부분으로 나뉩니다.
- 확장 단계에서 현재 코드가 필요한 것을 제거하지 않고 열, 테이블, 인덱스, 제약 조건을 추가합니다.
- 전환 단계에서 호환되는 코드를 배포하고, 과거 데이터를 옮기며, 읽기와 쓰기를 새 표현으로 보냅니다.
- 축소 단계에서 검증을 통해 사용되지 않음이 확인된 이전 코드와 데이터베이스 객체를 삭제합니다.
PostgreSQL 테이블이 사람 이름을 full_name에 저장하고 있고, 애플리케이션에 first_name과 last_name 필드가 필요하다고 해보겠습니다. 확장 단계에서는 full_name을 유지한 채 nullable 열을 추가합니다. 호환 릴리스에서는 전환 중 필요한 표현에 값을 씁니다. 백필은 기존 값을 분리하며, 신뢰성 있게 분리할 수 없는 이름에 대한 명시적인 정책을 둡니다. 새 필드가 충분히 채워진 뒤에만 읽기를 옮깁니다. 축소 단계에서 full_name을 제거합니다.
이 순서는 이전 빌드가 계속 full_name을 찾고 새 빌드는 세 열 모두를 찾을 수 있어 롤링 배포에 잘 맞습니다. 애플리케이션 롤백 경로도 유지합니다. 새 릴리스가 오작동하면 스키마 의존성이 제거되지 않았으므로 이전 빌드를 실행할 수 있습니다.
데이터베이스 롤백은 애플리케이션 롤백과 다릅니다. 데이터를 변환한 뒤 마이그레이션을 되돌리면 정보를 버리거나 오래된 값을 복원할 수 있습니다. 전환 중에는 추가한 데이터베이스 객체는 그대로 두고 애플리케이션 트래픽을 검증된 표현으로 되돌리는 편이 좋습니다. 장애가 안정된 뒤 전방향 마이그레이션을 수정하세요.
이 패턴이 모든 변경에 이중 쓰기 코드가 필요하다는 뜻은 아닙니다. 새 코드만 사용하는 선택 열 하나를 추가할 때는 추가형 마이그레이션과 배포 한 번이면 충분할 수 있습니다. 이름 변경, 표현 변경, 테이블 분할, 필수 필드 변경은 두 애플리케이션 버전이 그렇지 않으면 스키마를 안전하게 공유할 수 없으므로 대체로 단계가 더 필요합니다.
단계를 정하기 전에 변경을 분류하세요
마이그레이션 계획은 작업의 실제 잠금, 재작성, 호환성, 데이터 변환 위험에 맞아야 합니다. 모든 ALTER TABLE을 동일하게 다루면 불필요하게 복잡해지거나 위험한 릴리스가 됩니다.
추가형 변경은 대체로 가장 쉽습니다. nullable 열, 별도 테이블, 온라인 방식으로 만든 인덱스는 애플리케이션 코드가 사용하기 전에 도입할 수 있는 경우가 많습니다. 그래도 명령에는 잠금이 필요하므로 프로덕션과 비슷한 테이블과 트랜잭션 부하에서 동작을 테스트하세요.
삭제형 변경에는 열 삭제 또는 이름 변경, 타입 축소, 테이블 교체, 더 엄격한 제약 조건 추가가 포함됩니다. 이런 변경은 기존 코드가 전제한 사항을 무효화합니다. 코드 참조와 외부 소비자를 제거한 뒤 축소 단계에 배치하세요.
데이터를 바꾸는 작업도 별도로 평가해야 합니다. 타임스탬프 변환, 전화번호 정규화, 레코드 병합, 자유 형식 텍스트 분할은 정보를 잃을 수 있습니다. 백필을 시작하기 전에 잘못된 값과 모호한 값을 어떻게 처리할지 정하세요. 변환을 되돌릴 수 없다면 결과가 비즈니스 수준의 검사를 통과할 때까지 원본을 보존하세요.
사전 검토에서는 다음 다섯 가지를 확인하면 좋습니다.
- 각 문장은 어떤 잠금을 요청하며, 그 잠금을 얼마나 오래 기다리거나 보유할 수 있나요?
- 작업이 테이블을 재작성하거나 많은 WAL을 생성하거나 복제본 지연을 키우나요?
- 영향을 받는 객체를 사용하는 애플리케이션, 작업, 보고서, 변경 데이터 캡처 소비자는 무엇인가요?
- 현재 릴리스와 제안한 릴리스가 모든 전환 상태에서 함께 실행될 수 있나요?
- 작업을 중단하게 하는 신호는 무엇이며, 중단 후에는 정확히 어떤 상태가 남나요?
실제에 가까운 데이터 양과 분포로 정확한 마이그레이션을 실행하세요. 정돈된 천 행짜리 테스트 테이블은 수억 행, 넓은 튜플, 죽은 행, 치우친 값, 장기 실행 트랜잭션이 있는 프로덕션 테이블을 거의 말해주지 못합니다.
PostgreSQL에서 안전하게 확장하기
안전한 PostgreSQL 확장은 짧은 메타데이터 변경, 제한된 잠금 대기, 데이터베이스가 요구하는 별도 온라인 작업을 사용합니다. 새 객체에 의존하는 코드를 배포하기 전에 새 구조를 추가하세요.
기본값 없는 nullable 열 추가는 대체로 짧은 메타데이터 작업입니다.
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
ALTER TABLE customers
ADD COLUMN phone_e164 text;
COMMIT;
제한 시간은 열린 트랜잭션 뒤에서 릴리스가 무기한 대기하지 않도록 합니다. 잠금을 빠르게 획득할 수 없다면 마이그레이션을 실패하게 하고, 방해 요인을 확인한 뒤 더 안전한 시점에 다시 시도하세요. 반복된 잠금 요청이 프로덕션 트래픽을 계속 방해할 수 있으므로 촘촘한 반복 루프로 자동 재시도하지 마세요.
최신 PostgreSQL 릴리스에서는 상수 기본값이 있는 열을 추가할 때 기존 모든 행에 그 값을 즉시 쓰지 않아도 됩니다. 그렇다고 모든 기본값이 안전한 것은 아닙니다. 휘발성 표현식은 재작성이 필요할 수 있고, ALTER TABLE에는 여전히 짧은 ACCESS EXCLUSIVE 잠금이 필요합니다. 일반 규칙에 의존하지 말고 배포된 PostgreSQL 버전과 정확한 표현식의 동작을 확인하세요.
일반 CREATE INDEX는 쓰기를 막을 수 있습니다. 테이블을 계속 쓸 수 있어야 한다면 동시 생성을 사용하세요.
CREATE INDEX CONCURRENTLY idx_customers_phone_e164
ON customers (phone_e164);
CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없습니다. 시간이 더 걸리고 추가 작업을 하며 오래된 트랜잭션을 기다릴 수도 있지만, 일반적인 삽입, 업데이트, 삭제는 계속할 수 있습니다. 그래도 CPU, I/O, WAL을 사용하므로 실행 중에는 데이터베이스 지연 시간과 복제본을 모니터링하세요.
동시 빌드에 실패하면 유효하지 않은 인덱스가 남을 수 있습니다. 재시도 전에 인덱스 상태를 확인하고, 유효하지 않은 객체를 의도적으로 제거하거나 다시 빌드하세요. 모든 파일을 트랜잭션으로 감싸는 마이그레이션 도구는 동시 인덱스 작업을 위한 비트랜잭션 모드를 지원해야 합니다.
새 테이블은 제자리 변환보다 도입하기 쉬운 경우가 많습니다. 일대다 또는 다대다 관계라면 소스 열을 유지한 채 대상 테이블과 인덱스를 추가하세요. 새 쓰기, 과거 데이터, 읽기, 다운스트림 소비자가 모두 이동할 때까지 소스를 삭제하지 마세요.
타입 변경에는 특별한 주의가 필요합니다. 메타데이터만 바뀌는 경우도 있고, 모든 행을 재작성하거나 너무 오래 제한적인 잠금을 잡는 경우도 있습니다. 위험한 변환이라면 대상 타입의 열을 추가하고, 배치로 채우고, 애플리케이션 접근을 전환한 다음 나중에 원본을 삭제하세요. 이렇게 하면 큰 ALTER COLUMN TYPE 하나가 통째로 성공하거나 실패하는 대신, 변환 실패를 기록할 자리도 생깁니다.
호환성을 유지하는 코드 배포하기
호환되는 애플리케이션 코드는 전환 중인 값이 없어도 견디며, 같은 롤아웃에서 삭제형 마이그레이션을 요구하지 않습니다. 첫 애플리케이션 인스턴스가 새 객체를 사용하기 전에 데이터베이스 확장이 끝나야 합니다.
두 표현을 모두 최신으로 유지해야 할 때 이중 쓰기가 유용합니다. 가능하면 두 쓰기를 같은 데이터베이스 트랜잭션에서 수행하세요. 비동기 두 번째 쓰기는 첫 번째가 성공한 뒤 실패할 수 있으며, 이후 읽기에서 드러나는 불일치를 만들 수 있습니다.
이중 쓰기 로직에는 기준이 하나여야 합니다. phone_e164가 phone에서 파생된다면 둘 다 제공됐을 때 어떤 입력을 우선할지 정하고 API 핸들러, 워커, 가져오기, 관리 도구에 같은 정규화를 적용하세요. 그렇지 않으면 모두 그럴듯해 보이는 두 코드 경로가 다른 결과를 저장할 수 있습니다.
읽기는 쓰기보다 나중에 옮기세요. 새 쓰기가 두 형식을 모두 채우고 백필이 과거 행을 처리하는 동안에는 검증된 필드에서 읽기를 유지하세요. 검증 후 새 필드를 우선하고 정의된 폴백 규칙에서만 이전 값을 쓰는 읽기 경로를 배포하세요. 폴백 사용량을 측정해야 합니다. 조용히 남아 있는 폴백은 불완전한 데이터를 영원히 숨길 수 있습니다.
일반적인 릴리스 순서는 다음과 같습니다.
- 릴리스 1에서 애플리케이션 동작을 바꾸지 않고 새 데이터베이스 객체를 추가합니다.
- 릴리스 2에서 기존 읽기를 유지하면서 전환 표현에 값을 씁니다.
- 릴리스 3에서 백필과 일관성 검사가 통과한 뒤 읽기를 전환합니다.
- 릴리스 4에서 롤백 기준 기간이 끝난 후 이전 표현의 유지 관리를 중단합니다.
- 릴리스 5에서 이전 코드 참조를 제거하고, 이후 데이터베이스를 정리합니다.
공개 API 계약과 물리적 스키마 변경은 분리하세요. 데이터베이스 열 이름을 바꿨다고 웹, 모바일, 통합 응답의 필드 이름을 즉시 바꿀 필요는 없습니다. 특히 클라이언트를 서버와 함께 업그레이드할 수 없다면, 해당 계약은 별도의 호환성 정책으로 변경하세요.
모든 작성자를 목록화하세요. HTTP 핸들러는 변경의 한 원천일 뿐입니다. 큐 소비자, 예약 작업, 가져오기 스크립트, 데이터 복구 도구, 데이터베이스 트리거, 직접 관리 작업도 이전 형태의 행을 계속 만들 수 있습니다. 가능하다면 데이터베이스 연결에 애플리케이션 이름을 붙이고 전환 경로 사용을 기록해 놓친 프로세스가 드러나게 하세요.
오래 실행되는 프로세스는 준비된 문, 캐시된 메타데이터, 객체 관계 매핑 계층을 통해 오래된 가정을 유지할 수 있습니다. 축소 전에 롤링 재시작과 연결 풀 동작을 테스트하세요. 최근 트래픽을 처리하지 않은 프로세스도 드문 작업이 처음 실행될 때 실패할 수 있습니다.
데이터베이스를 압도하지 않고 데이터 백필하기
안전한 백필은 작고 다시 시작할 수 있는 배치를 업데이트하고, 프로덕션 상태가 나빠지면 속도를 낮춥니다. 실시간 작성자가 새 표현을 유지할 수 있게 된 뒤에만 시작해야 합니다.
보편적인 행 수가 아니라 경과 시간과 데이터베이스 영향으로 배치 크기를 정하세요. 좁은 행 천 개는 밀리초 안에 끝날 수 있지만, 큰 값이나 비용이 큰 변환이 있는 천 행은 상당한 I/O를 만들 수 있습니다. 보수적으로 시작하고 몇 초 안에 끝나는 트랜잭션을 목표로 하세요. 배치 사이에 커밋해 잠금과 오래된 행 버전이 하나의 트랜잭션에 쌓이지 않게 하세요.
PostgreSQL에서는 일반 UPDATE에 ORDER BY와 LIMIT를 직접 지원하지 않습니다. 공통 테이블 표현식에서 배치를 선택한 뒤 해당 행을 업데이트하세요.
WITH batch AS (
SELECT id
FROM my_table
WHERE id > $1
AND new_col IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE my_table AS target
SET new_col = transform_expression(target.old_col)
FROM batch
WHERE target.id = batch.id
AND target.new_col IS NULL
RETURNING target.id;
애플리케이션은 완료한 가장 큰 id를 커서로 기록합니다. 조건부 업데이트는 재실행을 멱등적으로 만들므로, 커밋 후 충돌이 나도 이미 처리한 행이 손상되지 않습니다. 커서가 커밋되지 않은 배치를 지나서 진행되지 않도록 충분히 신중하게 진행 상황을 저장하세요.
증가하는 id 커서는 테이블의 시작 부분을 반복해서 스캔하지 않게 하지만, 커서 아래에서 늦게 수정된 행이나 새로 삽입된 행은 잡지 못합니다. 남은 NULL 값 전체를 대상으로 추적 패스를 마무리하세요. 식별자가 정렬되지 않았거나 행이 적격 상태 사이를 이동할 수 있다면, 한 번의 전방 스캔이 완료된다고 가정하지 말고 작업 테이블이나 다른 명시적 체크포인트를 사용하세요.
여러 워커는 FOR UPDATE SKIP LOCKED로 행을 할당받을 수 있지만, 병렬 처리는 쓰기 압력을 키우고 진행 상황 추적을 복잡하게 합니다. 건너뛴 행과 그 행을 영구히 지나치는 커서를 함께 사용하지 마세요. 병렬 워커에는 할당된 식별자 큐나 반복 적격성 스캔이 더 안전합니다.
쿼리 지연 시간, 활성 연결, 잠금 대기, WAL 생성량, 복제본 재생 지연, 죽은 행 증가 같은 프로덕션 측정값으로 속도를 조절하세요. 임계값을 넘으면 일시 중지하고 체크포인트에서 다시 시작하세요. 고정 대기는 간단하지만, 데이터베이스 피드백은 트래픽 변화에 더 잘 대응합니다.
일부 행만 처리하면 된다면 모든 행을 변경하지 마세요. 새 필드, 소스 상태, 마이그레이션 표식으로 필터링하세요. 변환 비용이 크다면 일관성이 허용하는 범위에서 업데이트 트랜잭션 밖에서 계산한 뒤 짧은 조건부 쓰기를 하세요. 데이터를 조용히 지어내지 말고 거부된 값의 수와 표본을 남기세요.
각 업데이트 이후에는 자동 진공과 복제본이 작업을 감당해야 합니다. 백필이 기본 서버에서는 성공적으로 끝나도 복제본이 크게 뒤처지거나 테이블 팽창이 이후 쿼리를 느리게 할 수 있습니다. 속도 제한은 배치의 즉각적인 실행 시간뿐 아니라 지연되어 나타나는 비용도 고려해야 합니다.
데이터와 프로덕션 트래픽 검증하기
새 경로가 기준이라고 말하려면 데이터 검사, 애플리케이션 텔레메트리, 의존성 증거가 모두 일치해야 합니다. 완료된 작업 카운터만으로는 정확성을 증명할 수 없습니다.
먼저 완전성과 일관성을 확인하세요. PostgreSQL의 IS DISTINCT FROM은 어느 한쪽이 NULL일 때 알 수 없는 결과를 내는 <>와 달리 NULL을 명시적으로 처리하며 값을 비교합니다.
SELECT count(*)
FROM customers
WHERE normalize_phone(phone) IS DISTINCT FROM phone_e164;
매우 크고 바쁜 테이블에서 인덱스 없는 전체 테이블 카운트를 반복 실행하지 마세요. 한 번의 통제된 검증, 제한된 식별자 범위, 표본, 테이블을 순차적으로 진행하는 임시 검증 프로세스를 사용하세요. 올바르지 않을 때의 비용과 데이터베이스 여유 용량에 따라 적절한 방법이 달라집니다.
검증에는 다음이 포함되어야 합니다.
- 새 필드가 필요한 행에 예상치 못한 누락 값이 남아 있지 않습니다.
- 잘못된 입력과 빈 입력을 포함해 새 값이 합의한 변환과 일치합니다.
- 과거 데이터 처리가 끝난 뒤에도 새 행과 업데이트가 일관성을 유지합니다.
- 읽기 폴백 사용량이 계획한 임계값에 도달했으며, 서버가 제어하는 트래픽에서는 대개 0입니다.
- 오류율, 쿼리 지연 시간, 잠금, 복제본 지연이 릴리스 한도 안에 있습니다.
열뿐 아니라 비즈니스 결과도 비교하세요. 마이그레이션이 가격, 권한, 계정 상태, 식별자를 바꾼다면 사용자가 의존하는 합계와 불변 조건을 검증하세요. 두 열이 기계적으로 일치해도 둘 다 잘못된 비즈니스 규칙을 담고 있을 수 있습니다.
정리 전에는 전체 운영 주기를 관찰하세요. 올바른 기간은 고정된 일주일 규칙이 아니라 실제 시스템 동작에 따라 정합니다. 월말 처리, 드물게 실행되는 청구 작업, 지연된 큐 재시도, 이전 모바일 클라이언트의 최대 수명을 포함해야 할 수 있습니다. 각 소비자가 이전했다는 증거를 기록하세요.
애플리케이션 아키텍처가 허용한다면 읽기 전환을 카나리 방식으로 진행하세요. 새 읽기 경로로 트래픽 일부를 보내 결과를 비교한 뒤 점진적으로 늘립니다. 롤백 동작은 간단하게 유지하세요. 백필을 되돌리지 말고 읽기를 검증된 표현으로 돌리면 됩니다.
데이터가 준비된 뒤 제약 조건 추가하기
모든 작성자가 규칙을 따르고 기존 데이터가 검증된 뒤에만 제약 조건을 엄격하게 적용하세요. 확장 중 NOT NULL, 검사, 외래 키를 강제하면 트래픽을 막거나 이전 프로세스의 쓰기를 거부할 수 있습니다.
PostgreSQL에서는 검사 제약 조건을 NOT VALID로 추가할 수 있습니다. 그러면 과거 행 전체를 즉시 스캔하지 않고도 새 행 또는 변경된 행에는 규칙을 적용합니다. 백필 후 별도로 검증하세요.
ALTER TABLE customers
ADD CONSTRAINT customers_phone_e164_present
CHECK (phone_e164 IS NOT NULL) NOT VALID;
ALTER TABLE customers
VALIDATE CONSTRAINT customers_phone_e164_present;
검증이 성공하면 지원되는 PostgreSQL 릴리스는 열을 NOT NULL로 설정할 때 그 증명을 사용해 전체 테이블 스캔을 한 번 더 피할 수 있습니다. 최종 변경에는 여전히 강한 테이블 잠금이 필요하므로, 제한된 잠금 제한 시간과 재시도 계획을 사용하세요.
ALTER TABLE customers
ALTER COLUMN phone_e164 SET NOT NULL;
ALTER TABLE customers
DROP CONSTRAINT customers_phone_e164_present;
임시 검사가 가치가 있다면 남겨둘 수 있지만, 같은 규칙을 중복으로 유지하면 규칙은 바뀌지 않은 채 카탈로그만 복잡해집니다.
외래 키도 NOT VALID와 VALIDATE CONSTRAINT를 사용해 비슷한 순서를 따를 수 있습니다. 제약 조건을 만든 뒤에는 새 쓰기가 검사되고, 과거 데이터 검증은 나중에 이뤄집니다. 참조 관계에서 삭제나 업데이트가 비용 큰 스캔을 일으킬 수 있다면 지원 인덱스를 의도적으로 추가하세요.
애플리케이션 검증은 데이터베이스 강제보다 먼저 이뤄져야 하지만 이를 대신하지는 못합니다. 코드는 사용자에게 더 명확한 오류를 보여주고, 데이터베이스는 모든 경로에서 쓰인 데이터를 보호합니다. 롤아웃 중에는 제약 조건 위반을 관찰해 의존성 감사에서 놓친 작성자를 찾으세요.
이전 경로를 안전하게 축소하기
축소 단계에서는 데이터베이스 객체보다 애플리케이션 의존성을 먼저 제거해야 합니다. 텔레메트리와 검증으로 새 경로가 기준임이 확인되면, 별도 릴리스를 통해 정리할 수 있습니다.
먼저 이전 필드 읽기와 폴백 로직을 제거하세요. 이후 이전 필드 쓰기를 중단하고 드문 경로를 잡아낼 만큼 오래 프로덕션을 관찰하세요. 이전 표현을 언급하는 기능 플래그, 트리거, 호환성 뷰, 복구 스크립트, 예약 작업을 제거하세요. 내보낸 소스와 마이그레이션 코드를 검색하되, 메인 저장소 밖의 보고서, 통합 쿼리, 변경 데이터 캡처 설정도 점검하세요.
안전한 정리 순서는 다음과 같습니다.
- 폴백 읽기를 제거하고 텔레메트리에 더 이상 나타나지 않는지 확인합니다.
- 이전 쓰기를 중단하고 동기화 코드를 삭제합니다.
- 배포 가능한 모든 버전에서 애플리케이션 참조를 제거합니다.
- 적절한 온라인 방식으로 더 이상 쓰지 않는 인덱스와 제약 조건을 삭제합니다.
- 이후 데이터베이스 릴리스에서 이전 열이나 테이블을 삭제합니다.
PostgreSQL 열 삭제는 주로 카탈로그 변경이지만, 여전히 ACCESS EXCLUSIVE 잠금이 필요합니다. 따라서 짧은 문도 장기 실행 트랜잭션 뒤에서 기다리고 이후 작업을 막을 수 있습니다. 잠금 제한 시간을 적용하고, 미리 장기 실행 트랜잭션을 확인하며, 위험이 낮은 시간에 시도하세요.
쓰기 차단을 감당할 수 없다면 오래된 인덱스에 DROP INDEX CONCURRENTLY를 사용하세요. 동시 생성과 마찬가지로 트랜잭션 블록 안에서 실행할 수 없고 마이그레이션 도구가 처리해야 하는 제약이 있습니다.
코드 정리와 물리적 삭제를 한 릴리스에 묶지 마세요. 분리하면 정리된 애플리케이션은 아직 사용하지 않는 객체가 남아 있는 데이터베이스에서 실행됩니다. 애플리케이션 문제가 생겨도 스키마를 다시 만들거나 데이터를 복원하지 않고 롤백할 수 있습니다.
테이블을 삭제하기 전에 시퀀스, 뷰, 함수, 권한, 트리거, 복제 게시, 외부 쿼리의 소유 관계를 확인하세요. 의도한 변경에 포함되지 않은 의존성까지 제거할 수 있으므로 프로덕션 마이그레이션에서 편의상 CASCADE를 사용하지 마세요.
롤백과 실패한 단계 처리하기
롤백 계획은 일반적인 다운 마이그레이션 하나에 의존하지 말고 각 단계별 안전한 조치를 정의해야 합니다. 추가형 객체, 데이터 이동, 읽기 전환, 삭제는 복구 특성이 서로 다릅니다.
확장 단계에서 잠금을 획득하지 못했다면 애플리케이션은 바꾸지 말고, 방해 트랜잭션을 해결한 뒤 다시 시도하세요. 동시 인덱스 빌드가 실패했다면 유효하지 않은 인덱스를 남겼는지 확인하고, 다음 시도 전에 해당 객체를 정리하세요.
백필이 부하를 만든다면 일시 중지하세요. 이미 커밋된 멱등 배치는 그대로 두어도 됩니다. 배치 크기나 속도를 낮추고 비용 큰 변환을 해결한 뒤 체크포인트에서 다시 시작하세요. 수백만 건의 올바른 업데이트를 되돌리는 일은 대개 프로덕션 복구에 도움이 되지 않으면서 위험만 더합니다.
새 읽기 경로가 잘못된 결과를 반환한다면 새 데이터를 진단용으로 유지한 채 읽기를 이전 표현으로 되돌리세요. 이중 쓰기가 정확하다고 확인된 경우에만 계속하세요. 작성자 자체에 문제가 있다면 영향을 받은 행을 복구하기 전에 작성자를 비활성화하거나 애플리케이션을 롤백하세요.
축소 후에는 이전 빌드를 배포하는 것만으로는 부족하고 데이터를 복원해야 할 수 있습니다. 되돌릴 수 없는 시점을 명시적으로 정하세요. 시스템 복구 정책이 요구하는 백업이나 스냅샷을 만들고, 릴리스 전에 복원을 테스트하며, 저장 비용이 허용한다면 합의한 보존 기간 동안 이전 객체를 유지하세요.
스키마 명령은 트랜잭션 처리될 수 있지만 외부 효과가 항상 포함되지는 않습니다. 동시 인덱스 작업, 큐 메시지, 캐시 변경, 애플리케이션 배포는 하나의 원자적 트랜잭션을 공유하지 않습니다. 런북에는 각 부분 실패 후 관찰 가능한 상태와 안전하게 이어갈 명령을 적어야 합니다.
흔한 마이그레이션 함정 피하기
실패한 무중단 마이그레이션은 대부분 새 상태를 너무 일찍 강제하거나 이전 상태의 소비자를 놓칩니다. 다음 함정은 승인 전에 명시적으로 검토할 가치가 있습니다.
- 이전 애플리케이션 인스턴스가 아직 필드를 생략할 수 있는데
NOT NULL을 추가합니다. - 대규모 백필을 하나의 트랜잭션에서 실행해 잠금과 행 버전을 너무 오래 유지합니다.
- 이전 코드가 원래 이름을 계속 쓰는데도 열 이름 변경을 추가형 변경처럼 다룹니다.
- 모든 쓰기 경로와 과거 행이 새 표현을 채우기 전에 읽기를 전환합니다.
- 성공적인 배포를 보고서, 워커, 복제본, 통합이 호환된다는 증거로 여깁니다.
또 다른 미묘한 실패는 양방향 동기화에서 나옵니다. 트리거가 old_col을 new_col로 복사하는 한편 애플리케이션 코드는 new_col을 old_col로 다시 복사합니다. 정규화나 트리거 순서 차이로 순환이 생기거나 의도한 값을 덮어쓰고, 소유 관계가 불명확해질 수 있습니다. 한 방향을 선호하고 각 릴리스에서 어떤 표현이 기준인지 문서화하세요.
기본값은 누락된 작성자 업데이트를 숨길 수 있습니다. 새 필수 열이 빈 값이나 일반적인 기본값을 받으면 이전 코드는 호환되는 듯 보이지만 의미상 잘못된 데이터를 저장합니다. 누락 자체가 유용한 진단 정보라면 nullable 전환을 사용하고, 모든 작성자가 의미 있는 값을 제공한 뒤 실제 규칙을 강제하세요.
기능 플래그만으로는 호환되지 않는 스키마 명령이 안전해지지 않습니다. 비활성화된 코드 경로도 이전 프로세스에서 로드되거나 준비되거나 실행될 수 있습니다. 배포 가능하거나 활성인 어떤 버전도 참조하지 않을 때까지 데이터베이스 객체를 유지해야 합니다.
마이그레이션 소유권도 중요합니다. 검증과 제거 일정을 포함해 축소 단계까지 전환을 책임질 한 사람이나 팀을 지정하세요. 그렇지 않으면 임시 열, 플래그, 동기화 작업이 몇 달 동안 남아 이후 모든 변경 비용을 키울 수 있습니다.
중단 없이 전화번호 열 교체하기
customers.phone을 정규화된 customers.phone_e164로 교체하려면 추가형 열, 명확한 변환 정책, 호환되는 코드, 제한된 백필, 읽기 전환, 지연된 정리가 필요합니다. 저장된 모든 값을 자동으로 정규화할 수 있는 것은 아니므로 변환 정책이 SQL보다 먼저 와야 합니다.
먼저 기존 값을 분류하세요. 필요한 국가 정보가 알려져 있으면 유효한 번호를 변환할 수 있습니다. 빈 값은 NULL이 될 수 있습니다. 모호하거나 형식이 잘못된 번호는 추측하지 말고 예외 보고서에 넣어야 합니다. 모든 고객에게 전화번호가 필요한 제품인지 결정하세요. 그에 따라 나중에 NOT NULL이 적절한지가 정해집니다.
짧은 잠금 제한 시간을 두고 열을 추가하세요.
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE customers
ADD COLUMN phone_e164 text;
COMMIT;
새 입력을 정규화하고 phone과 phone_e164를 한 트랜잭션에 쓰는 코드를 배포하세요. 처음에는 phone에서 읽기를 유지합니다. 계정 가져오기, 지원 도구, 워커 작업, 고객 픽스처를 만드는 테스트를 포함해 모든 작성자를 업데이트하세요.
적격 행을 짧은 트랜잭션으로 백필하세요. 마지막으로 처리한 식별자, 변환한 수, 건너뛴 수, 각 실패 범주의 이유를 기록하세요. 프로덕션 지연 시간과 복제본 지연에 맞춰 작업 속도를 제한하세요. 전방향 패스가 끝나면 적격한 NULL 값을 다시 스캔해 동시 삽입이나 재시작 후 놓친 행을 잡으세요.
애플리케이션과 같은 정규화 규칙으로 일관성 검사를 실행하고, 국제 접두사, 내선 번호, 빈 값, 중복 연락처 레코드, 오래된 가져오기 데이터를 수동으로 표본 검사하세요. 행 수는 범위를 증명할 뿐, 올바른 전화번호를 증명하지는 못합니다.
phone_e164가 있으면 이를 반환하고, phone은 기록되는 예외에서만 쓰는 읽기 경로를 배포하세요. 폴백 사용과 정규화 오류를 모니터링하세요. 폴백이 영구 동작이 되도록 두지 말고 남은 예외를 해결하세요.
새 필드가 기준이 되면 폴백을 제거하고 phone 쓰기를 중단하세요. 적절한 운영 주기 동안 드문 작업과 통합 트래픽을 관찰합니다. 제품 규칙이 요구할 때만 검증된 제약 조건을 추가하세요.
마지막으로 phone에 대한 코드 참조를 제거하세요. 인덱스나 제약 조건은 별도로 삭제하고, 이후 마이그레이션에서 제한된 잠금 대기로 열을 삭제하세요. 그 삭제 전 어느 시점에 읽기 전환이 실패하더라도 두 열을 모두 사용할 수 있는 상태에서 애플리케이션 동작을 롤백하면 됩니다.
이 사례는 스키마 작업만으로 해결할 수 없는 도메인 문제도 보여줍니다. 사람이 입력한 데이터를 분할하거나 정규화할 때는 항상 정보가 보존되는 것이 아닙니다. 마이그레이션 계획은 예외를 보존하고 이를 해결할 담당자가 처리할 방법을 제공해야 합니다.
배포 전 릴리스마다 확인하기
릴리스 체크리스트는 호환성을 증명하고 프로덕션 영향을 제한하며 현재 단계의 복구 조치를 명시해야 합니다. 장애 중 운영자가 의도를 다시 추측하지 않도록 변경과 함께 근거를 보관하세요.
배포 전 다음을 확인하세요.
- 애플리케이션 버전이 이 릴리스 전후의 데이터베이스 상태에서 모두 동작합니다.
- 트래픽 뒤에서 대기할 수 있는 스키마 명령에 잠금 제한 시간과 문 제한 시간이 설정되어 있습니다.
- 백필 또는 검증 작업에 진행, 일시 중지, 재개, 속도 제한 제어가 있습니다.
- 대시보드에서 오류, 지연 시간, 잠금, 데이터베이스 부하, WAL, 복제본 지연을 확인할 수 있습니다.
- 이미 제거한 객체에 의존하지 않고 롤백 조치를 테스트했습니다.
명확한 완료 조건을 기록하세요. 예를 들면 전체 작업 주기 동안 새 일관성 실패가 0건, 서버가 제어하는 트래픽의 폴백 읽기가 0건, 알려진 모든 소비자 업그레이드 완료, 통제된 검증 쿼리 성공 등이 있습니다. 백필 중에는 완료 비율이 유용하지만, 100% 처리됐다고 100% 정확한 것은 아닙니다.
코드 검토와 별도로 마이그레이션 순서를 검토하세요. SQL과 애플리케이션 변경이 각각 올바르더라도 배포가 잘못된 순서로 실행되면 실패할 수 있습니다. 다른 단계가 완료된 뒤에만 시작해도 되는 단계를 명시하세요.
중단 조건은 가능하면 수치로 정하세요. 허용 가능한 쿼리 지연 시간, 잠금 대기, 복제본 지연, 오류율, 배치 시간을 정의하세요. 임계값을 넘으면 운영자가 장애 중 새 승인을 구하지 않고 작업을 중단할지, 대기 중인 문을 취소할지, 읽기를 되돌릴지 알아야 합니다.
새 표현이 읽기와 쓰기를 처리하고, 과거 데이터가 검증을 통과했으며, 이전 객체가 제거되고, 임시 운영 장치가 사라진 뒤에야 마이그레이션이 완료됩니다.
프로세스를 반복 가능하게 만들기
재사용 가능한 마이그레이션 런북은 확장/축소를 담당자와 측정 가능한 관문이 있는 일상적인 릴리스 작업으로 바꿉니다. 실시간 배포 중에도 따를 수 있을 만큼 짧고, 부분 실패 상태를 설명할 만큼 구체적이어야 합니다.
런북은 다섯 섹션으로 구성하세요.
- 확장: 정확한 스키마 작업, 예상 잠금, 제한 시간, 트랜잭션 요구 사항입니다.
- 호환성: 영향을 받는 코드, 작성자, 읽기 경로, 플래그, 클라이언트, 배포 순서입니다.
- 백필: 변환 정책, 배치, 체크포인트, 속도 조절, 예외 처리입니다.
- 검증: SQL 검사, 비즈니스 불변 조건, 텔레메트리, 완료 임계값입니다.
- 축소: 의존성 제거, 관찰 기간, 물리적 정리, 복구 한계입니다.
모든 전환 객체에 담당자와 예상 완료일을 지정하세요. 열, 인덱스, 플래그, 트리거, 작업을 같은 곳에서 추적하세요. 정리는 선택적인 유지보수가 아니라 마이그레이션의 일부입니다.
Koder.ai로 개발하는 팀은 프로덕션 변경을 시작하기 전에 Planning Mode를 사용해 이런 단계와 체크포인트를 구체화할 수 있습니다. 소스 코드 내보내기를 사용하면 마이그레이션 SQL과 호환성 로직도 다른 애플리케이션 코드처럼 검토할 수 있습니다. Koder.ai는 배포, 호스팅, 스냅샷, 롤백을 지원하지만 애플리케이션 롤백이 커밋된 데이터 변환까지 되돌린다고 가정해서는 안 됩니다. 데이터베이스 복구 계획이 이전 표현에 더 이상 의존하지 않을 때까지 스키마 호환성을 유지하세요.
가능하면 쓰기가 많은 작업은 트래픽이 적은 시간에 예약하되, 시간대만을 유일한 안전 장치로 삼지는 마세요. 제한된 트랜잭션, 피드백 기반 속도 조절, 관찰 가능한 진행 상황, 테스트한 일시 중지 조치가 트래픽이나 데이터가 예상과 다르게 움직일 때 온라인 마이그레이션을 관리 가능하게 합니다.
자주 묻는 질문
스키마 변경이 왜 장애를 일으킬 수 있나요?
스키마 변경은 이전 버전과 새 버전의 애플리케이션이 서로 다른 데이터베이스 구조를 기대할 때 프로덕션을 멈추게 할 수 있습니다. 롤링 배포 중에는 두 버전이 동시에 실행될 수 있으므로, 열을 너무 일찍 삭제하거나 이름을 바꾸면 읽기나 쓰기가 실패할 수 있습니다.
확장/축소 마이그레이션 패턴이란 무엇인가요?
확장/축소는 호환되지 않는 변경을 안전한 단계로 나누는 방식입니다. 먼저 새 구조를 추가하고, 코드와 데이터를 새 구조로 옮긴 뒤, 모든 소비자가 이전 구조 사용을 멈춘 후에만 이전 구조를 제거합니다.
중단 없이 데이터베이스 열의 이름을 바꾸거나 교체하려면 어떻게 하나요?
먼저 새 열을 추가하고 이전 열은 그대로 둡니다. 두 필드를 모두 처리할 수 있는 코드를 배포하고, 기존 행을 작은 배치로 백필한 뒤, 검증 후 읽기를 전환합니다. 이전 열은 이후 릴리스에서 삭제합니다.
트래픽을 막지 않고 PostgreSQL 열을 추가할 수 있나요?
대체로 가능합니다. 기본값 없는 nullable 열 추가는 PostgreSQL에서 짧은 메타데이터 변경인 경우가 많지만, 여전히 테이블 잠금이 필요합니다. 짧은 잠금 제한 시간을 설정해 긴 트랜잭션 뒤에서 기다리지 않고 마이그레이션이 실패하도록 하세요.
쓰기 작업을 막지 않고 인덱스를 만들려면 어떻게 하나요?
테이블을 계속 쓸 수 있어야 한다면 CREATE INDEX CONCURRENTLY를 사용하세요. 시간이 더 걸리고 데이터베이스 부하가 늘며 트랜잭션 블록 안에서는 실행할 수 없습니다. 실행 중에는 지연 시간, WAL, 복제본 지연을 모니터링하세요.
애플리케이션은 언제 이전 필드와 새 필드에 이중 쓰기를 해야 하나요?
두 표현을 모두 최신 상태로 유지해야 한다면 두 값을 가능한 한 같은 데이터베이스 트랜잭션에서 쓰세요. 값이 다를 때 어느 필드를 우선할지 정하고, API, 워커, 가져오기, 지원 도구에서 같은 정규화 규칙을 사용하세요.
대규모 PostgreSQL 테이블을 안전하게 백필하려면 어떻게 하나요?
짧고 다시 시작할 수 있는 배치로 처리하고 배치마다 커밋하세요. 체크포인트를 저장하고, 아직 처리할 일이 남은 행만 업데이트하며, 쿼리 지연, 잠금 대기, WAL 양, 복제 지연이 커지면 작업 속도를 낮추거나 일시 중지하세요.
백필이 완료되었고 정확하다는 것을 어떻게 알 수 있나요?
백필이 끝났다는 이유만으로 읽기를 전환하지 마세요. 필수 값이 존재하는지 확인하고, 이전 표현과 새 표현을 비교하며, 폴백 읽기를 모니터링하고, 과거 데이터 처리 후에도 새 쓰기가 일관적인지 확인하세요.
NOT NULL, 검사 제약 조건, 외래 키는 언제 추가해야 하나요?
기존 데이터가 검증을 통과하고 실행 중인 모든 작성자가 유효한 값을 제공한 뒤 엄격한 제약 조건을 추가하세요. PostgreSQL에서는 일부 제약 조건을 NOT VALID로 추가해 새 행에는 적용하고 과거 행은 별도로 검증할 수 있습니다.
이전 스키마 경로는 언제 제거해도 안전한가요?
먼저 폴백 읽기를 제거하고, 이후 이전 쓰기를 중단한 다음 전체 운영 주기 동안 시스템을 관찰하세요. 모든 애플리케이션, 작업, 보고서, 통합, 클라이언트가 이전 객체를 더 이상 참조하지 않으면 관련 코드를 제거하고 다음 릴리스에서 데이터베이스 열이나 테이블을 삭제하세요.