무중단 스키마 변경: 컬럼 이름 하나 바꾸는 데 배포가 세 번 필요한 이유
코드와 스키마가 동시에 바뀌지 않는다는 전제에서 확장-수축 방식으로 컬럼을 바꾸는 단계, 잠금이 오래 걸리는 변경, 도구가 놓치는 변경을 정리했습니다.
컬럼 이름 하나 바꾸는 데 왜 배포가 세 번인가
user_name을 username으로 바꾸기로 했습니다. 마이그레이션 한 줄이면 됩니다.
ALTER TABLE users RENAME COLUMN user_name TO username;
이걸 배포에 포함시키면 잠깐 오류가 납니다. 마이그레이션은 배포 과정에서 먼저 적용되는데, 그 순간 아직 돌고 있는 기존 인스턴스는 user_name을 찾습니다. 새 인스턴스로 전부 교체되기까지의 몇십 초 동안 요청이 실패합니다.
롤백하면 더 나쁩니다. 새 코드를 되돌려도 데이터베이스는 이미 바뀐 상태라, 되돌린 코드도 동작하지 않습니다.
원인은 하나입니다. 코드와 스키마는 동시에 바뀌지 않습니다. 배포 중에는 항상 옛 코드와 새 스키마가 겹치는 구간이 있습니다.
원칙: 한 번에 한쪽만 바꾼다
무중단으로 바꾸려면 어느 시점에도 현재 돌고 있는 모든 코드가 현재 스키마에서 동작해야 합니다. 이를 위해 변경을 단계로 쪼갭니다. 흔히 확장-수축(expand-contract)이라 부르는 방식입니다.
이름 변경은 세 단계가 됩니다.
1단계 — 확장: 새 컬럼을 추가합니다. 이 배포에서 코드는 양쪽에 쓰고, 읽기는 아직 옛 컬럼에서 합니다.
ALTER TABLE users ADD COLUMN username text;
await db.update(users).set({ userName: value, username: value }); // 둘 다 쓴다
const name = row.userName; // 읽기는 아직 옛 컬럼
이 시점에 기존 데이터를 새 컬럼으로 복사합니다. 행이 많다면 한 번에 하지 말고 나눠서 처리해야 합니다. 큰 테이블을 통째로 업데이트하면 잠금이 오래 걸려 서비스가 멈춥니다.
2단계 — 전환: 읽기를 새 컬럼으로 옮깁니다. 쓰기는 여전히 양쪽에 합니다.
const name = row.username; // 읽기 전환
이 배포에서 문제가 생기면 코드만 되돌리면 됩니다. 데이터베이스는 건드리지 않았으므로 롤백이 안전합니다. 이 단계를 하루 이틀 두고 지켜보는 것이 핵심입니다. 바로 다음 단계로 넘어가면 검증할 시간이 없습니다.
3단계 — 수축: 옛 컬럼에 쓰는 코드를 제거하고, 배포가 안정된 뒤 컬럼을 삭제합니다.
ALTER TABLE users DROP COLUMN user_name;
세 번 배포하는 이유가 여기 있습니다. 각 단계 사이에 "되돌릴 수 있는 상태"가 존재해야 하기 때문입니다.
잠금이 오래 걸리는 변경들
무중단의 또 다른 축은 마이그레이션 자체가 테이블을 오래 잠그지 않는 것입니다. 데이터베이스와 버전에 따라 동작이 다르므로 쓰기 전에 해당 버전의 문서를 확인하는 습관이 필요합니다. 같은 명령이 버전에 따라 즉시 끝나기도 하고 테이블 전체를 다시 쓰기도 합니다.
특히 주의할 것들이 있습니다.
인덱스 생성은 기본적으로 테이블을 잠급니다. 운영 중이라면 동시 생성 옵션을 쓰세요. 시간은 더 걸리지만 쓰기를 막지 않습니다.
CREATE INDEX CONCURRENTLY idx_users_username ON users(username);
NOT NULL 컬럼 추가는 기존 행을 전부 채워야 해서 무거울 수 있습니다. 나눠서 하는 편이 안전합니다. 널 허용으로 추가 → 기존 데이터 배치로 채우기 → 제약 추가 순서입니다.
제약 조건 추가는 기존 데이터 전체를 검사합니다. 검증을 미루는 옵션이 있는 데이터베이스라면 먼저 제약만 걸고 나중에 검증하는 방식으로 잠금 시간을 줄일 수 있습니다.
그리고 마이그레이션에는 반드시 잠금 타임아웃을 걸어두세요. 예상보다 오래 잠기는 상황에서 무한정 기다리며 뒤에 요청이 쌓이는 것보다, 실패하고 다시 시도하는 편이 낫습니다.
타입 변경과 테이블 분리도 같은 방식
컬럼 타입을 바꾸는 것도 이름 변경과 구조가 같습니다. 새 타입의 컬럼을 추가하고, 양쪽에 쓰고, 읽기를 옮기고, 옛 컬럼을 지웁니다.
테이블을 둘로 쪼개는 것도 마찬가지입니다. 새 테이블을 만들고, 쓰기를 양쪽에 하고, 데이터를 옮기고, 읽기를 전환하고, 옛 테이블을 지웁니다. 단계가 늘어날 뿐 원칙은 같습니다.
도구가 못 잡는 변경이 있다
마이그레이션 도구가 스키마 차이를 보고 SQL을 만들어주는 경우, 도구가 인식하지 못하는 변경이 있습니다. 체크 제약 조건의 내용 변경이 대표적입니다. 스키마 파일에서는 바뀌었는데 실제 데이터베이스에는 적용되지 않아, 배포 후 새 값이 제약에 걸려 거부되는 상황이 생깁니다.
이런 변경은 SQL을 직접 작성해 적용해야 합니다. 그리고 배포 후 실제 데이터베이스에서 제약이 바뀌었는지 확인하는 절차를 넣어두는 편이 안전합니다. 스키마 파일이 아니라 실제 상태를 봐야 합니다.
인스턴스가 하나뿐이면 그냥 바꿔도 되지 않나요?
작은 프로젝트라면 잠깐의 다운타임을 감수하고 단순하게 가는 선택도 합리적입니다. 다만 두 가지는 인스턴스 수와 무관합니다. 하나는 롤백 가능성입니다. 스키마를 먼저 바꿔버리면 코드만 되돌려서는 복구되지 않습니다. 다른 하나는 잠금입니다. 인스턴스가 하나여도 큰 테이블에 무거운 변경을 걸면 그동안 서비스가 멈춥니다.
마이그레이션을 되돌리는 스크립트를 꼭 써야 하나요?
이론적으로는 좋지만 실무에서는 잘 쓰이지 않습니다. 컬럼을 지운 뒤 되돌리면 구조는 복구돼도 데이터는 돌아오지 않기 때문입니다. 되돌리기 스크립트에 의존하는 대신, 위험한 변경을 뒤로 미루는 순서 설계가 실질적인 안전장치입니다. 컬럼 삭제 같은 파괴적 변경은 새 코드가 충분히 안정된 뒤에 별도 배포로 처리하세요.
배포 순서는 어떻게 정하나요?
기본은 "스키마 먼저, 코드 나중"입니다. 새 코드가 요구하는 컬럼이 없으면 바로 실패하기 때문입니다. 이 순서가 성립하려면 스키마 변경이 옛 코드와도 호환돼야 하는데, 확장-수축 방식이 바로 그 조건을 만들어줍니다. 컬럼 추가는 옛 코드에 영향이 없고, 컬럼 삭제는 그 컬럼을 쓰는 코드가 모두 사라진 뒤에 하니까요.
큰 테이블에서 한 번 겪어보기 전에
마이그레이션이 얼마나 걸릴지는 데이터 양에 좌우됩니다. 운영과 비슷한 규모의 데이터를 복사해 미리 돌려보면 실제 소요 시간과 잠금 범위를 확인할 수 있습니다. 운영에서 처음 알게 되는 것보다 훨씬 낫습니다.
데이터베이스를 쓰는 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 사용자가 붙어 데이터가 쌓이기 시작하면 스키마 변경의 난이도가 달라지는데, 그 경험이 설계 감각을 가장 빠르게 키웁니다.
