데이터베이스 점진적 변경을 통한 무중단 스키마 변경 달성
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
빠르게 변화하는 소프트웨어 개발 세계에서 CI/CD(지속적 통합/지속적 배포) 파이프라인은 표준이 되었습니다. 애플리케이션은 끊임없이 발전하며, 그에 따라 기반이 되는 데이터 모델도 함께 진화합니다. 새로운 열 추가, 데이터 타입 수정, 테이블 이름 변경과 같은 데이터베이스 스키마 변경은 전통적으로 애플리케이션을 오프라인으로 전환해야 하므로 dreaded downtime (예상치 못한 장애 시간)을 초래합니다. 24/7 운영되는 비즈니스에게 이러한 중단은 상당한 재정적 손실과 브랜드 이미지 손상을 초래할 수 있습니다. '무중단' 데이터베이스 스키마 변경 추구는 더 이상 사치가 아니라 고가용성 유지 및 원활한 사용자 경험 보장을 위한 근본적인 요구 사항입니다. 이 글은 이 중요한 목표를 달성하기 위한 핵심 개념과 실질적인 전략을 탐구하여, 애플리케이션이 단 한 순간도 놓치지 않고 발전할 수 있도록 지원합니다.
중단 없는 스키마 진화를 위한 핵심 개념
메커니즘에 대해 자세히 알아보기 전에, 무중단 스키마 변경의 기반이 되는 몇 가지 핵심 개념을 이해하는 것이 중요합니다.
하위 호환성(Backward Compatibility): 이것이 핵심입니다. 데이터베이스 스키마에 이루어진 모든 변경은 여전히 이전 스키마 버전에서 실행 중인 기존 애플리케이션을 중단시켜서는 안 된다는 의미입니다. 이는 일반적으로 이전 스키마를 예상하는 애플리케이션이 스키마가 부분적으로 진화하더라도 데이터를 올바르게 읽고 쓸 수 있어야 함을 의미합니다.
상위 호환성(Forward Compatibility): 이 개념은 새로운 스키마 버전을 실행하는 애플리케이션이 이전 스키마 버전을 따르는 데이터와 여전히 상호 작용(읽기/쓰기)할 수 있도록 보장합니다. 이는 이전 버전과 새 버전의 애플리케이션이 모두 활성화될 수 있는 전환 기간 동안 중요합니다.
원자적 연산(Atomic Operations): 복잡한 스키마 변경에는 항상 달성 가능하지 않을 수 있지만, 원칙은 대규모 변경을 작고 독립적이며 되돌릴 수 있는 원자적 연산으로 분해하도록 권장합니다. 이는 각 단계의 위험 프로파일을 최소화합니다.
마이그레이션 도구: Flyway, Liquibase 또는 사용자 지정 스크립트와 같은 전문 도구는 제어되고 버전 추적되는 방식으로 스키마 변경을 관리하고 적용하는 데 필수적입니다. 이 도구들은 변경 사항이 모든 환경에서 일관되게 적용되도록 보장합니다.
이중 쓰기(Dual-Write) 및 읽기 복제(Read-Replication): 복잡한 데이터 마이그레이션 또는 구조적 변경 중에 이러한 패턴은 매우 중요합니다. 이중 쓰기는 이전 스키마와 새 스키마 위치 모두에 동시에 데이터를 쓰는 것을 포함합니다. 읽기 복제는 데이터 존재 여부 또는 애플리케이션 버전에 따라 이전 및 새 위치 모두에서 읽는 것을 포함할 수 있습니다.
무중단 스키마 변경을 위한 전략 및 단계
무중단 스키마 변경을 달성하는 것은 일반적으로 전환 과정 내내 하위 및 상위 호환성을 모두 유지하도록 세심하게 조정된 다단계 프로세스를 포함합니다. 실질적인 예와 함께 일반적인 전략을 살펴보겠습니다.
1. 새 열 추가 (기본값으로 NOT NULL)
이것은 비교적 간단한 변경이지만 원칙을 강조합니다.
전략:
- 새로운 열을 먼저 NULL 허용으로 추가합니다.
- 새로운 열에 쓰는 애플리케이션을 배포합니다.
- 필요한 경우 기존 데이터를 마이그레이션합니다.
- 열을 NOT NULL로 업데이트합니다.
예시 시나리오: 기존 orders 테이블에 shipping_address 열을 추가합니다.
-- 1단계: 새로운 열을 NULL 허용으로 추가 ALTER TABLE orders ADD COLUMN shipping_address VARCHAR(255) NULL;
- 설명: 이 시점에서 기존 애플리케이션은 정상적으로 작동합니다. 애플리케이션의 새 인스턴스(배포 시)는
shipping_address에 쓰기 시작할 수 있습니다. 중요한 것은shipping_address를 모르는 이전 애플리케이션은 단순히 이를 무시하여 하위 호환성을 유지한다는 것입니다.
// 예시 애플리케이션 코드 (새 버전) public void createOrder(Order order) { // ... 다른 필드 preparedStatement.setString(4, order.getShippingAddress()); // 새 열에 쓰기 // ... } // 예시 애플리케이션 코드 (구 버전) public void createOrder(Order order) { // ... 다른 필드 // 변경 없음, 이전 앱은 shipping_address와 상호 작용하지 않음 // ... }
-- 2단계: `shipping_address`에 쓸 수 있는 새 애플리케이션 버전을 배포합니다. -- (이는 SQL이 아닌 CI/CD 파이프라인에서 처리됩니다)
-- 3단계 (선택 사항이지만 일반적): 기존 데이터 채우기. -- 이는 기존 주문에 대한 배치 작업 또는 일회성 스크립트를 포함할 수 있습니다. UPDATE orders SET shipping_address = (SELECT address FROM users WHERE users.id = orders.user_id) WHERE shipping_address IS NULL;
- 설명: 이 단계는 기존 주문에도
shipping_address가 있도록 보장합니다. 이는 데이터베이스에 과부하를 주지 않도록 주의 깊게, 잠재적으로 배치 단위로 수행해야 합니다.
-- 4단계: 열을 NOT NULL로 만듭니다. ALTER TABLE orders ALTER COLUMN shipping_address VARCHAR(255) NOT NULL;
- 설명: 이제 모든 이전 애플리케이션이 업그레이드되었고 기존 데이터가 채워졌다면(또는 새 데이터가 항상 작성된다면), NOT NULL 제약 조건을 적용할 수 있습니다. 이제 이 열에 NULL 값을 삽입하려는 시도는 실패하여 향후 쓰기에 대한 데이터 무결성을 보장합니다.
2. 열 또는 테이블 이름 변경
열 또는 테이블 이름 변경은 애플리케이션이 데이터에 참조하는 방식에 직접적인 영향을 미치므로 더 복잡합니다.
전략 (블루/그린 배포 접근 방식):
- 원하는 이름으로 새 열/테이블을 생성합니다.
- 애플리케이션에 이중 쓰기를 구현하여 이전 및 새 위치 모두에 씁니다.
- 이전 위치에서 새 위치로 데이터를 채웁니다.
- 애플리케이션이 새 위치에서 읽도록 업데이트합니다.
- 이중 쓰기를 중지합니다.
- 이전 열/테이블을 삭제합니다.
예시 시나리오: products 테이블에서 product_code를 sku로 이름 변경합니다.
-- 1단계: 새 열 'sku'를 NULL 허용으로 추가합니다. ALTER TABLE products ADD COLUMN sku VARCHAR(50) NULL;
// 2단계: 이중 쓰기 로직이 포함된 애플리케이션 배포 (새 버전) // 이전 앱은 계속 'product_code'에 씁니다. public void updateProduct(Product product) { // ... 다른 필드 // 이전 열('product_code')에 쓰기 preparedStatement.setString(2, product.getProductCode()); // 새 열('sku')에 쓰기 preparedStatement.setString(3, product.getSku()); // getSku()가 초기에는 product_code를 반환한다고 가정 // ... }
- 설명: 애플리케이션은 이제 두 열에 동일한 데이터를 씁니다. 이는 이전 및 새 스키마 버전 모두에 최신 데이터가 있음을 보장합니다.
-- 3단계: 'product_code'에서 'sku'로 데이터를 채웁니다. UPDATE products SET sku = product_code WHERE sku IS NULL;
- 설명: 이는 모든 기존 데이터를 마이그레이션합니다. 상당한 작업이 될 수 있으므로 배치 처리를 고려하십시오.
// 4단계: 새 버전 애플리케이션을 'sku'에서 읽도록 배포합니다. // 읽기 우선 순위: 'sku'를 우선 시도하고, 전환 중 안전을 위해 'product_code'를 대체로 사용합니다. public Product getProductById(long id) { // ... 쿼리 String sku = resultSet.getString("sku"); if (sku == null) { sku = resultSet.getString("product_code"); // 전환 중 이전 데이터를 위한 대체 } // ... product.setSku(sku); // 애플리케이션은 이제 'sku'를 예상합니다. // ... return product; }
- 설명: 애플리케이션은 이제
sku에서 읽는 것을 선호합니다. 대체 옵션은 전환 중에 이전 애플리케이션이 직접product_code에 쓴 경우(아직 업데이트되지 않은 배치 작업 등)에도 새 애플리케이션이 이를 읽을 수 있도록 보장합니다.
-- 5단계: 이중 쓰기 중지 (sku에만 쓰는 애플리케이션 배포). -- (애플리케이션 코드('product_code') 쓰기 제거)
-- 6단계: 이전 열 'product_code' 삭제합니다. ALTER TABLE products DROP COLUMN product_code;
- 설명: 모든 애플리케이션이
sku를 사용하고 데이터가 일관적이라는 확신이 들면 이전 열을 제거할 수 있습니다.
3. 테이블 분할 또는 비정규화
테이블 분할 또는 비정규화와 같은 복잡한 작업은 더 복잡한 데이터 마이그레이션과 함께 유사한 원칙을 따릅니다.
전략:
- 새 테이블을 생성합니다.
- 애플리케이션에 이중 쓰기를 구현하여 새 테이블에 데이터를 채웁니다.
- 기존 데이터를 새 테이블에 채웁니다.
- 애플리케이션 읽기를 새 테이블을 사용하도록 업데이트합니다.
- 이중 쓰기를 중지하고 이전 테이블에서의 읽기를 제거합니다.
- 이전 테이블을 삭제하거나 비정규화하는 경우 이전 열을 제거합니다.
이 전략은 데이터베이스 스키마 변경에 애플리케이션 계층이 전혀 오프라인 상태가 되지 않고도 점진적으로 적응할 수 있도록 하는 점진적인 전환을 강조합니다. 각 단계는 하위 또는 상위 호환성을 보장하여 위험을 최소화합니다.
결론
무중단 데이터베이스 스키마 변경을 달성하는 것은 현대의 고가용성 시스템에 필수적인 정교하지만 없어서는 안 될 관행입니다. 하위 호환성 및 상위 호환성 원칙을 수용하고, 변경 사항을 원자적 단계로 분해하며, 이중 쓰기 패턴과 신중한 배포 전략을 활용함으로써 조직은 데이터 모델을 완벽하게 발전시킬 수 있습니다. 스키마 진화에 대한 이러한 반복적인 접근 방식은 지속적인 서비스 제공을 보장하며, 잠재적인 다운타임을 눈에 보이지 않지만 매우 중요한 엔지니어링 성공으로 전환시킵니다.

