확장/축소(expand/contract) 패턴으로 무중단 스키마 변경을 배우세요: 안전하게 컬럼을 추가하고, 배치로 백필하고, 호환되는 코드를 배포한 뒤 오래된 경로를 제거하는 방법입니다.

데이터베이스 변경으로 발생하는 다운타임은 항상 깔끔하고 명확한 장애로 드러나지 않습니다. 사용자 입장에서는 페이지가 무한히 로딩되거나 결제 실패가 발생하거나 앱이 갑자기 "문제가 발생했습니다"를 보여줄 수 있습니다. 팀 입장에서는 알림, 증가하는 에러율, 그리고 정리해야 할 실패한 쓰기 작업들의 쌓임으로 드러납니다.
스키마 변경이 위험한 이유는 데이터베이스가 앱의 모든 실행 버전과 공유된다는 점입니다. 릴리스 중에는 구버전과 신버전 코드가 동시에 살아 있는 경우가 많습니다(롤링 배포, 여러 인스턴스, 백그라운드 잡). 언뜻 맞아 보이는 마이그레이션도 그 중 하나의 버전을 깨트릴 수 있습니다.
일반적인 실패 사례는 다음과 같습니다:
코드 자체가 괜찮더라도 릴리스가 차단되는 진짜 문제는 타이밍과 버전 간 호환성입니다.
무중단 스키마 변경의 핵심 규칙은 하나입니다: 중간 상태들 각각이 구버전과 신버전 모두에 대해 안전해야 한다는 것. 데이터베이스를 기존 읽기/쓰기를 깨지 않고 바꾸고, 두 형태를 모두 처리할 수 있는 코드를 배포한 뒤 아무도 구 경로를 참조하지 않을 때만 구 경로를 제거하세요.
실제 트래픽이 많거나 엄격한 SLA, 그리고 많은 앱 인스턴스와 워커가 있다면 그만한 노력이 값어치가 있습니다. 반면 조용한 내부 도구라면 계획된 유지보수 창이 더 간단할 수 있습니다.
대부분의 데이터베이스 관련 사고는 앱이 데이터베이스가 즉시 바뀔 거라고 기대하는 동안 실제 데이터베이스 변경은 시간이 걸리기 때문에 발생합니다. 확장/축소 패턴은 위험한 변경 하나를 더 작고 안전한 단계들로 나누어 이 문제를 피합니다.
짧은 기간 동안 시스템은 두 가지 "방식"을 동시에 지원합니다. 먼저 새 구조를 도입하고, 구 구조는 계속 유지하며 데이터를 점진적으로 옮긴 뒤 정리합니다.
패턴은 단순합니다:
이 방식은 롤링 배포와 잘 어울립니다. 예를 들어 서버 10대를 하나씩 업데이트할 때 구버전과 신버전이 잠깐 같이 돌아가게 되는데, 확장/축소는 이 중첩 기간 동안 동일한 데이터베이스에서 둘 다 호환되도록 합니다.
롤백도 덜 두렵습니다. 새 릴리스에 버그가 있으면 데이터베이스를 롤백하지 않고 앱만 롤백할 수 있습니다. 왜냐하면 확장 창 동안 구 구조가 여전히 존재하기 때문입니다.
예시: PostgreSQL의 full_name 컬럼을 first_name과 last_name으로 분리하고 싶다면, 새 컬럼을 추가(확장)하고, 구·신 양쪽 형태를 읽고 쓸 수 있는 코드를 배포한 뒤 오래된 행을 백필하고, 아무도 full_name을 사용하지 않는다고 확신되면 full_name을 삭제(축소)합니다.
확장 단계의 목표는 기존 것을 제거하는 것이 아니라 새 옵션을 추가하는 것입니다.
가장 흔한 첫 번째 움직임은 새 컬럼을 추가하는 것입니다. PostgreSQL에서는 보통 NULL 허용하고 기본값 없이 추가하는 것이 가장 안전합니다. NOT NULL이나 기본값을 바로 추가하면 Postgres 버전과 변경 내용에 따라 테이블 재작성이나 더 무거운 락을 유발할 수 있습니다. 안전한 순서는: NULL 허용으로 추가 → 관대한 코드 배포 → 백필 → 나중에 NOT NULL 적용입니다.
인덱스도 주의가 필요합니다. 일반 인덱스 생성은 예상보다 더 오래 쓰기를 막을 수 있습니다. 가능하면 동시(concurrent) 인덱스 생성을 사용해 읽기/쓰기 흐름을 유지하세요. 시간이 더 걸리지만 릴리스를 멈추는 락을 피할 수 있습니다.
확장은 새 테이블을 추가하는 것을 의미할 수도 있습니다. 단일 컬럼에서 다대다 관계로 옮긴다면 조인 테이블을 추가하면서 기존 컬럼을 유지할 수 있습니다. 구 경로는 작동을 계속하고 새 구조가 데이터를 모으기 시작합니다.
실무에서 확장에는 보통 다음이 포함됩니다:
확장 후에는 구버전과 신버전 앱이 동시에 놀랄 일 없이 돌아가야 합니다.
대부분의 릴리스 문제는 중간에서 발생합니다: 일부 서버는 새 코드, 일부는 구 코드가 실행되는 동안 데이터베이스는 이미 변하고 있는 상황입니다. 목표는 명확합니다: 롤아웃 중 어떤 버전이라도 구 스키마와 확장된 스키마 둘 다에서 동작해야 합니다.
일반적인 접근법은 듀얼-라이트입니다. 새 컬럼을 추가하면 새 앱은 구 컬럼과 새 컬럼 둘 다에 쓰기합니다. 구 버전은 계속 구 컬럼만 쓰고, 이는 컬럼이 여전히 존재하므로 괜찮습니다. 새 컬럼은 처음에 옵션으로 두고 모든 라이터가 업그레이드될 때까지 엄격한 제약은 미룹니다.
읽기는 보통 쓰기보다 더 신중하게 전환합니다. 당분간은 완전히 채워진 구 컬럼을 유지하다가 백필과 검증이 끝나면 새 컬럼을 우선으로 읽되 새값이 없으면 구값으로 폴백하게 하세요.
API 출력 형식도 데이터베이스가 밑에서 바뀌는 동안 안정적으로 유지하세요. 내부 필드를 추가하더라도 모든 소비자(웹, 모바일, 통합)가 준비될 때까지 응답 형태를 바꾸지 마십시오.
롤백 친화적인 롤아웃은 보통 이렇게 보입니다:
핵심은 구 구조를 제거하는 순간이 되돌릴 수 없는 첫 단계가 되도록 끝까지 미루는 것입니다.
많은 "무중단 스키마 변경"이 실패하는 지점은 바로 백필입니다. 기존 행을 새로운 컬럼으로 채우면서 긴 락, 느린 쿼리, 예상치 못한 부하 급증을 피해야 합니다.
배치가 중요합니다. 배치는 빠르게(초 단위, 분이 아닌) 끝나도록 하세요. 각 배치가 작으면 작업을 일시중지, 재개, 조정하기 쉬워 릴리스를 막지 않습니다.
진행 상황을 추적하려면 안정적인 커서가 필요합니다. PostgreSQL에서는 보통 기본 키를 사용합니다. id 순으로 행을 처리하고 마지막으로 완료한 id를 저장하거나 id 범위로 작업하세요. 이렇게 하면 작업 재시작 시 비용이 큰 전체 테이블 스캔을 피할 수 있습니다.
간단한 패턴 예시는 다음과 같습니다:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
업데이트에 조건(WHERE new_col IS NULL)을 두어 작업이 멱등(idempotent)하게 만드세요. 재실행은 여전히 필요한 행만 건드리므로 불필요한 쓰기를 줄입니다.
백필 중에 새 데이터가 들어오는 것을 대비하세요. 보통 순서는 다음과 같습니다:
좋은 백필은 지루해야 합니다: 꾸준하고 측정 가능하며 DB가 뜨거워지면 쉽게 일시정지할 수 있어야 합니다.
가장 위험한 순간은 새 컬럼을 추가하는 순간이 아니라 그 컬럼을 의존할 수 있다고 판단하는 순간입니다.
축소 단계로 넘어가기 전에 두 가지를 증명하세요: 새 데이터가 완전하고, 프로덕션에서 안전하게 읽히고 있다는 것.
빠르고 반복 가능한 완전성 체크로 시작하세요:
듀얼-라이트를 쓰고 있다면 무언의 버그를 잡기 위해 일관성 체크를 추가하세요. 예를 들어 old_value <> new_value인 행을 시간별로 조회해 0이 아니면 경고하도록 하면 어떤 라이터가 여전히 구 컬럼만 업데이트하는지 빠르게 발견할 수 있습니다.
마이그레이션이 실행되는 동안 기본 프로덕션 지표를 모니터링하세요. 쿼리 시간이나 락 대기가 급증하면, 안전하다고 생각한 검증 쿼리조차 부하를 더할 수 있습니다. 특히 배포 직후 새 컬럼을 읽는 코드 경로의 에러율을 주시하세요.
두 경로를 얼마나 오래 유지해야 할까요? 적어도 하나의 완전한 릴리스 사이클과 백필 재실행 하나를 견딜 만큼이면 충분합니다. 많은 팀이 1~2주를 사용하거나 구버전 앱 인스턴스가 완전히 사라졌다고 확신할 때까지 기다립니다.
축소는 팀이 긴장하는 단계인데, 왜냐하면 되돌릴 수 없는 것처럼 느껴지기 때문입니다. 확장을 잘 해뒀다면 축소는 대부분 정리 작업이고 작은, 저위험 단계로 나눠 진행할 수 있습니다.
시점을 신중히 고르세요. 백필이 끝난 직후에 바로 삭제하지 마세요. 적어도 한 번의 릴리스 사이클을 더 지나게 해서 지연된 잡과 엣지 케이스가 드러날 시간을 주세요.
안전한 축소 순서는 보통 다음과 같습니다:
가능하면 축소를 두 개의 릴리스로 나누세요: 첫 번째 릴리스에서 코드 참조를 제거(추가 로깅 포함)하고, 나중에 데이터베이스 객체를 삭제합니다. 이렇게 하면 롤백과 문제 해결이 훨씬 쉬워집니다.
PostgreSQL의 세부 사항도 중요합니다. 컬럼 삭제는 대개 메타데이터 변경이지만 잠시 ACCESS EXCLUSIVE 락이 필요합니다. 조용한 시간을 계획하고 마이그레이션을 빠르게 유지하세요. 추가한 인덱스가 있다면 DROP INDEX CONCURRENTLY로 제거해 쓰기 차단을 피하세요(이 명령은 트랜잭션 블록 내부에서 실행할 수 없으므로 마이그레이션 툴이 이를 지원해야 합니다).
무중단 마이그레이션이 실패하는 이유는 데이터베이스와 앱이 허용되는 상태에 대해 동의하지 못할 때입니다. 패턴은 중간 상태가 구 코드와 새 코드 모두에 안전할 때만 작동합니다.
자주 보이는 오류들은 다음과 같습니다:
현실적인 시나리오: API에서 full_name을 쓰기 시작했지만 사용자를 생성하는 백그라운드 잡은 여전히 first_name과 last_name만 설정합니다. 그 잡이 야간에 실행되어 full_name = NULL인 행을 넣고, 이후 코드가 full_name이 항상 존재한다고 가정하면 문제가 발생합니다.
각 단계를 며칠 동안 실행될 수 있는 릴리스처럼 취급하세요:
반복 가능한 체크리스트는 한 데이터베이스 상태에서만 동작하는 코드를 배포하는 실수를 막아줍니다.
배포 전에 데이터베이스에 확장된 요소들(새 컬럼/테이블, 낮은 락 방식으로 생성된 인덱스)이 이미 존재하는지 확인하세요. 그리고 앱이 관대하게 동작하는지(구 형태, 확장된 형태, 반쯤 백필된 상태 모두 견딜 수 있는지) 확인하세요.
짧은 체크리스트 예시는 다음과 같습니다:
마이그레이션은 읽기가 새 데이터를 사용하고, 쓰기가 더 이상 구 데이터를 유지하지 않으며, 최소한 하나의 간단한 체크(카운트나 샘플링 등)로 백필을 검증했을 때 완료로 간주합니다.
예를 들어 customers 테이블의 phone 컬럼이 제각각의 형식으로 저장되거나 간혹 비어있다고 합시다. 이를 phone_e164로 교체하고 싶은데 릴리스 중 차단을 허용할 수 없습니다.
깨끗한 확장/축소 순서는 다음과 같습니다:
phone_e164를 NULL 허용, 기본값 없음으로 추가phone과 phone_e164 둘 다에 쓰도록 업데이트하되, 사용자에게는 변화가 없게 읽기는 phone을 유지phone_e164를 우선 읽고 NULL이면 phone으로 폴백하는 코드 배포phone_e164를 사용하면 폴백을 제거하고 phone을 삭제한 뒤 필요한 제약을 추가각 단계가 하위 호환성을 지키면 롤백이 단순해집니다. 읽기 전환이 문제를 일으키면 앱을 롤백하면 되고, 백필이 부하를 일으키면 작업을 일시중지하거나 배치 크기를 줄이고 재개하면 됩니다.
팀 정렬을 쉽게 하려면 계획(정확한 SQL, 어느 릴리스에서 읽기를 전환할지, 완료를 측정하는 방법, 각 단계의 책임자)을 한 곳에 문서화하세요.
확장/축소는 루틴처럼 느껴질 때 가장 잘 작동합니다. 팀이 스키마 변경마다 재사용할 수 있는 짧은 런북을 만드세요. 한 페이지 정도로 간결하되 신규 팀원이 따라할 수 있을 정도로 구체적이면 좋습니다.
실용적 템플릿 항목 예시:
사전 소유권을 정하세요. "모두가 누군가가 축소를 할 거라고 생각했다"는 말이 오래된 컬럼과 기능 플래그가 몇 달 동안 남는 원인입니다.
온라인에서 백필을 돌리더라도 트래픽이 적은 시간을 선택하세요. 배치를 작게 유지하고 DB 부하를 보며 빠르게 멈추기 쉽습니다.
Koder.ai(koder.ai)로 빌드하고 배포한다면 Planning Mode는 프로덕션에 손대기 전에 단계와 체크포인트를 정리하는 데 유용합니다. 호환성 규칙은 동일하게 적용되지만, 미리 단계를 적어두면 장애를 막는 "지루한" 단계들을 건너뛰기 어려워집니다.
데이터베이스는 앱의 모든 실행 버전이 공유해서 사용합니다. 롤링 배포나 백그라운드 잡이 있는 동안 구버전과 신버전 코드가 동시에 돌아가므로, 이름을 바꾸거나 컬럼을 제거하거나 제약을 추가하는 마이그레이션이 특정 버전에서는 실패를 일으킬 수 있습니다.
중간 상태의 데이터베이스가 구버전과 신버전 코드 모두에서 동작하도록 마이그레이션을 설계한다는 뜻입니다. 먼저 새로운 구조를 추가하고, 일정 기간 두 가지 경로를 병행 운영한 뒤 아무도 구 구조에 의존하지 않을 때 구 구조를 제거합니다.
확장은 기존 앱이 필요로 하는 것을 제거하지 않고 새 컬럼, 테이블, 인덱스 등을 추가하는 단계입니다. 축소는 새 경로가 안정적으로 작동함을 증명한 뒤 구 컬럼과 구 읽기/쓰기 경로, 동기화 로직 등을 정리하는 단계입니다.
대부분의 경우 기본값 없이 NULL 허용 컬럼을 추가하는 것이 가장 안전합니다. 이렇게 하면 테이블 재작성이나 무거운 락을 피할 수 있습니다. 이후 코드에서 컬럼이 없거나 NULL인 상황을 견딜 수 있게 배포하고, 점진적으로 백필한 뒤 나중에 NOT NULL 같은 제약을 적용하세요.
전환 기간 동안 새 앱이 구 필드와 새 필드 둘 다에 쓰기하는 방식입니다. 구 버전의 인스턴스나 잡이 여전히 구 필드만 쓰더라도 데이터 일관성이 유지됩니다.
짧은 시간 내에 끝나는 작은 배치로 백필하세요. 각 배치는 재실행해도 안전(idempotent)해야 하며, 재시작 시 이미 처리된 행은 다시 건드리지 않도록 조건을 두세요. 쿼리 시간, 락 대기, 복제 지연 등을 모니터링하고 DB 부하가 올라가면 중단하거나 배치 크기를 줄이세요.
새 컬럼에 NULL이 남아있지 않은지 확인하고, 채워야 할 행 수 대비 채워진 수를 비교하세요. 샘플 ID를 골라 구값과 새값을 비교해보고, 빈 문자열이나 0 같은 엣지 케이스도 점검하세요. 듀얼-라이트를 사용 중이면 old_value <> new_value 같은 일관성 체크를 주기적으로 돌려 이상을 알리세요.
NOT NULL을 너무 일찍 추가하거나, 대량의 백필을 한 트랜잭션으로 처리해 락이나 테이블 부푼(bloat)이 생기면 문제가 됩니다. 기본값이 테이블 재작성을 유발하는 경우도 있으니 주의하세요. 또한 읽기를 새 컬럼으로 전환하기 전에 쓰기가 안정적으로 새 컬럼을 채우는지 확인해야 합니다.
구 필드 쓰기를 중단하고 읽기도 새 필드만 보도록 바꾼 뒤, 충분한 릴리스 사이클(예: 1~2주) 동안 모니터링하세요. 롤아웃과 백필을 한 번 더 돌려 지연 잡이나 엣지 케이스를 잡을 시간을 두는 것이 안전합니다. 가능하면 코드에서 참조를 제거하는 릴리스와 데이터베이스 객체를 삭제하는 릴리스를 나누세요.
트래픽이 적고 유지보수 창을 허용한다면 단번에 마이그레이션을 해도 되지만, 실제 사용자와 여러 인스턴스, 백그라운드 작업, SLA가 있다면 확장/축소 패턴을 따르는 것이 더 안전합니다. Koder.ai의 Planning Mode에서 단계와 체크를 미리 적어두면 필수적인 단계를 건너뛰는 실수를 줄일 수 있습니다.