무중단 스키마 변경 5단계 1부: 추가와 이전
다운타임 없이 컬럼을 교체하기 위한 첫 단계로, 새로운 컬럼을 추가하고 기존 데이터를 백그라운드에서 복사하는 과정을 배웁니다.
지난 시간에 우리는 `RENAME COLUMN` 한 줄의 유혹을 참아내고, 안전한 우회로인 Expand and Contract(팽창과 수축) 패턴을 사용해야 한다고 배웠습니다. 이번 시간에는 이 패턴을 실무에 적용하는 무중단 스키마 변경 5단계 중 전반부에 해당하는 1, 2, 3단계를 직접 다뤄봅니다. 어떻게 해야 기존 서비스에 단 1초의 중단도 발생시키지 않고 새로운 컬럼을 추가하고 데이터를 복사할 수 있는지 그 마법 같은 과정을 상세히 알아봅시다.
전체 5단계 로드맵의 이해
본격적인 작업에 들어가기에 앞서, 우리가 앞으로 거치게 될 전체 여정을 지도처럼 머릿속에 그려보는 것이 좋습니다. 무중단 마이그레이션은 데이터베이스와 애플리케이션 양쪽을 번갈아가며 조작하는 섬세한 과정입니다.
무중단 스키마 변경 5단계 (Expand and Contract)
- 1[DB] 1단계: 새로운 컬럼 추가 (Expand)
- 2[App] 2단계: 양방향 쓰기(Dual Writing) 배포
- 3[DB] 3단계: 기존 데이터 백필(Backfill) 이관
- 4[App] 4단계: 신규 컬럼으로 읽기 전환 배포
- 5[DB] 5단계: 구버전 컬럼 삭제 (Contract)
이번 에피소드에서는 새로운 공간을 만들고(1단계), 들어오는 데이터를 양쪽에 모두 기록하며(2단계), 과거의 데이터를 천천히 새 공간으로 옮겨 담는(3단계) 과정에 집중하겠습니다.
1단계: 새로운 컬럼 추가 (Expand)
첫 번째 작업은 기존 구조를 파괴하지 않고, 새로운 데이터베이스 컬럼을 추가하여 공간을 팽창(Expand) 시키는 것입니다. 예를 들어 사용자의 전화번호를 저장하던 `phone_number` 컬럼의 이름을 `contact_number`로 바꾸고 싶다면, `contact_number`라는 새로운 컬럼을 하나 더 만드는 마이그레이션 파일을 작성합니다.
이때 가장 중요한 철칙이 하나 있습니다. 새로 추가하는 컬럼은 절대로 기존 애플리케이션의 동작을 방해해서는 안 됩니다.
Nullable 또는 Default 값의 중요성
만약 새로 추가하는 `contact_number` 컬럼을 `NOT NULL` 제약조건과 함께 생성하면 어떻게 될까요? 아직 업데이트되지 않은 현재 구버전 애플리케이션은 회원가입을 처리할 때 `phone_number`에만 값을 넣을 뿐, `contact_number`라는 존재 자체를 모릅니다. 따라서 새로운 사용자를 저장하는 순간 `contact_number`에 값이 없다는 이유로 `NOT NULL` 제약조건 에러가 발생하게 됩니다.
이를 방지하기 위해 새로운 컬럼은 반드시 다음 중 하나의 조건을 만족해야 합니다.
- Nullable 허용: 컬럼에 `NULL` 값이 들어가는 것을 허용하여, 앱이 명시적으로 값을 넣지 않아도 저장이 되게 합니다.
- Default 값 설정: 컬럼에 기본값(Default)을 지정하여, 빈 값이 들어오더라도 데이터베이스가 알아서 채워 넣게 합니다.
1단계 핵심 규칙
- 1기존 컬럼은 절대 건드리지 않고 그대로 둔다.
- 2새로 추가하는 컬럼은 반드시 Nullable이거나 Default 값을 가져야 한다.
이 마이그레이션 파일이 운영 DB에 실행되더라도, 애플리케이션 입장에서는 아무런 변화를 느끼지 못하며 시스템은 평온하게 유지됩니다.
2단계: 양방향 쓰기(Dual Writing) 배포
데이터베이스에 새로운 빈 공간(`contact_number`)이 생겼습니다. 이제 애플리케이션 코드를 수정하여, 이 공간에 데이터를 채워 넣을 차례입니다.
이 단계의 핵심은 새로운 데이터가 생성되거나 수정될 때, 예전 컬럼과 새 컬럼 양쪽 모두에 값을 저장(Dual Writing)하도록 만드는 것입니다.
동시 기록의 필요성
애플리케이션 코드를 수정하여 데이터베이스에 저장(Save)하는 로직을 찾습니다. 그리고 값을 넣을 때 다음과 같이 두 컬럼에 모두 동일한 값을 쓰도록 변경합니다.
// [기존 코드]
// db.users.insert({ phone_number: "+821012345678" });
// [2단계 수정 코드 - 양방향 쓰기]
db.users.insert({
phone_number: "+821012345678", // 구 컬럼 (기존 로직 유지)
contact_number: "+821012345678" // 신 컬럼 (동시 저장)
});왜 이렇게 양쪽에 모두 써야 할까요? 아직 데이터를 읽어가는(Read) 로직은 여전히 구버전 컬럼(`phone_number`)을 바라보고 있기 때문입니다.
만약 성급하게 새 컬럼에만 데이터를 쓰기 시작하면, 사용자가 자신의 프로필을 조회할 때 구 컬럼에서 빈 값을 읽어오게 되는 참사가 벌어집니다. 따라서 읽기 로직이 완전히 새 컬럼으로 넘어갈 때까지는 양쪽 컬럼의 데이터 싱크를 맞추기 위해 무조건 두 곳 모두에 데이터를 기록해야 합니다.
이 코드가 포함된 애플리케이션을 운영 환경에 배포합니다. 롤링 배포가 진행되는 동안 구버전 앱은 옛날 컬럼에만 쓰고, 신버전 앱은 양쪽에 씁니다. 구 컬럼에는 항상 데이터가 들어가므로 아무런 에러가 발생하지 않습니다.
3단계: 기존 데이터 이관 (Backfill)
2단계 배포가 완료된 시점부터 새롭게 가입하거나 프로필을 수정한 사용자는 `contact_number` 컬럼에 전화번호가 잘 채워지고 있습니다. 하지만 이전에 이미 가입해 있던 수많은 기존 사용자들의 `contact_number` 컬럼은 여전히 텅 비어 있는(NULL) 상태입니다.
이제 과거의 데이터들을 새 컬럼으로 채워 넣는 작업이 필요하며, 이를 백필(Backfill)이라고 부릅니다.
백그라운드 데이터 복사
데이터를 옮기는 작업은 별도의 백그라운드 스크립트나 마이그레이션 쿼리를 통해 수행됩니다. 핵심은 `UPDATE users SET contact_number = phone_number WHERE contact_number IS NULL;` 과 같은 형태의 쿼리를 실행하여 기존 값을 복사하는 것입니다.
하지만 데이터가 수백만, 수천만 건이라면 이 단순한 쿼리 하나가 테이블 전체에 오랫동안 잠금(Lock)을 걸어 서비스 전체를 먹통으로 만들 수 있습니다.
안전한 데이터 백필 방법
권장
- 데이터를 일정한 크기(Chunk)로 나누어 조금씩 업데이트하세요. (예: 1000건씩)
- 데이터베이스의 CPU와 트래픽 모니터링을 보며 한가한 시간에 스크립트를 실행하세요.
- 이관 스크립트가 중간에 중단되더라도 다시 실행하면 이어서 작업할 수 있도록(Idempotent) 멱등성을 보장하세요.
주의
- 수백만 건의 데이터를 UPDATE 문 한 방으로 처리하지 마세요.
- 트래픽이 피크를 찍는 시간대에 대규모 데이터 이관 작업을 시작하지 마세요.
완전한 데이터 동기화 달성
백그라운드 스크립트가 며칠에 걸쳐 1000건씩 모든 데이터를 옮겼다고 가정해 봅시다. 이제 `users` 테이블을 조회해 보면, 구 컬럼(`phone_number`)과 신규 컬럼(`contact_number`)의 데이터가 100% 동일하게 일치하는 것을 볼 수 있습니다.
2단계에서 설정해 둔 '양방향 쓰기' 덕분에, 백필 작업이 진행되는 도중에 누군가 전화번호를 수정하더라도 새 컬럼에 즉시 값이 반영되므로 데이터가 꼬일 염려가 없습니다.
여기까지 무사히 도달했다면 가장 까다로운 데이터 이관 작업이 끝난 것입니다. 이제 애플리케이션이 새로운 컬럼을 쳐다보도록 방향만 틀어주면 됩니다. 다음 회차에서는 읽기 로직을 전환하고 기존 찌꺼기를 안전하게 청소하는 4, 5단계를 마저 살펴보겠습니다.
예시
-- 예시
개념 퀴즈
퀴즈 답을 맞춰야 학습 완료가 됩니다.1단계에서 새로운 컬럼을 추가할 때 기존 애플리케이션의 에러를 방지하기 위해 반드시 지켜야 하는 제약 조건은 무엇인가요?
3줄 요약
- 11단계: 하위 호환성을 위해 새 컬럼은 Nullable이거나 Default 값을 주어 DB에 추가합니다.
- 22단계: 데이터 일관성을 위해 앱 코드에서 구 컬럼과 신 컬럼 양쪽 모두에 데이터를 저장하도록 배포합니다.
- 33단계: 테이블 락(Lock)을 방지하기 위해 기존 데이터를 작게 쪼개어 백그라운드에서 천천히 복사(Backfill)합니다.