Expand-Migrate-Contract 패턴: 무중단 데이터베이스 마이그레이션

2025년 12월 10일

database

# Database# Migration# Zero-Downtime# Schema Change

들어가며

24시간 운영되는 서비스를 개발하다보면 데이터베이스 스키마를 변경해야 하는 상황이 자주 발생합니다. 새로운 기능 추가, 성능 개선, 데이터 구조 개선 등 다양한 이유로 스키마 변경이 필요하죠.


하지만 일반적인 방식으로 스키마를 변경하면 다음과 같은 문제가 발생합니다:

-- 예시: 컬럼명 변경
ALTER TABLE users CHANGE COLUMN phone phone_number VARCHAR(20);

이 SQL을 실행하는 순간, phone 컬럼을 사용하던 애플리케이션은 에러를 발생시킵니다. 따라서 전통적인 방식은 다음과 같습니다:

  1. 서비스 중단 (점검 시간 공지)
  2. 스키마 변경 실행
  3. 애플리케이션 배포
  4. 서비스 재개

하지만 24시간 운영되는 서비스에서는 서비스 중단을 허용할 수 없습니다. 점검 시간조차 비즈니스 손실로 이어지고, 글로벌 서비스라면 어느 시간대에 점검을 진행하든 일부 사용자에게 영향을 줄 수밖에 없습니다.

왜 Expand-Migrate-Contract 패턴이 필요한가?

24시간 무중단 서비스 환경에서는 다음과 같은 요구사항이 있습니다:

  1. 서비스 중단 없이 스키마를 변경해야 함
  2. 배포 중에도 구버전과 신버전 애플리케이션이 공존 가능해야 함 (Rolling Deployment)
  3. 문제 발생 시 안전하게 롤백할 수 있어야 함
  4. 대용량 데이터도 점진적으로 마이그레이션 가능해야 함

일반적인 스키마 변경으로는 이런 요구사항을 만족시킬 수 없습니다. 예를 들어:

// 배포 중 상황
// - 서버 A: 구버전 (phone 컬럼 사용)
// - 서버 B: 신버전 (phone_number 컬럼 사용)
// 이미 스키마는 phone -> phone_number로 변경됨

// 서버 A는 에러 발생!
val phone = user.phone // Column 'phone' not found

Rolling Deployment 중에는 구버전과 신버전이 공존하는데, 스키마 변경이 즉시 반영되면 구버전 애플리케이션이 동작하지 않게 됩니다.


Expand-Migrate-Contract 패턴은 이러한 문제를 해결하기 위한 3단계 마이그레이션 전략입니다. 스키마와 애플리케이션 코드를 점진적으로 변경하여 무중단 배포를 가능하게 합니다.

Expand-Migrate-Contract 패턴이란?

Expand-Migrate-Contract 패턴(또는 Parallel Change 패턴)은 무중단 마이그레이션을 위한 3단계 전략입니다.

1단계: Expand (확장)

새로운 스키마를 기존 스키마와 함께 추가합니다. 이 시점에는 구 스키마와 신 스키마가 공존합니다.

  • 새로운 컬럼/테이블 추가
  • 애플리케이션이 두 스키마 모두에 데이터를 쓰도록 수정
  • 기존 기능은 여전히 구 스키마 사용 가능

핵심: 기존 코드를 제거하지 않고 새로운 코드를 추가합니다.

2단계: Migrate (마이그레이션)

기존 데이터를 새로운 스키마로 이동합니다.

  • 백그라운드 작업으로 점진적 마이그레이션 수행
  • 서비스 중단 없이 진행
  • 마이그레이션 진행 상황 모니터링

핵심: 애플리케이션은 계속 동작하면서 데이터만 이동합니다.

3단계: Contract (축소)

모든 데이터 마이그레이션이 완료되면 기존 스키마를 제거합니다.

  • 애플리케이션이 신 스키마만 사용하도록 변경
  • 구 스키마(컬럼/테이블) 제거
  • 코드 정리

핵심: 이제 새로운 스키마만 남게 됩니다.

실전 적용: 구독 모델 스키마 변경

실제로 구독 모델을 개선하면서 이 패턴을 적용한 사례를 살펴보겠습니다.

상황

기존에는 구독 정보를 다음과 같은 단일 테이블로 관리했습니다:

CREATE TABLE subscriptions (
  id BIGINT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  plan_type VARCHAR(50),
  status VARCHAR(20),
  start_date DATE,
  end_date DATE,
  created_at TIMESTAMP,
  updated_at TIMESTAMP
);

하지만 다양한 구독 플랜과 옵션을 지원하기 위해 다음과 같이 구조를 개선하기로 했습니다:

-- 구독 기본 정보
CREATE TABLE subscriptions_v2 (
  id BIGINT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  plan_id BIGINT NOT NULL,
  status VARCHAR(20),
  created_at TIMESTAMP,
  updated_at TIMESTAMP
);

-- 구독 플랜 정보
CREATE TABLE subscription_plans (
  id BIGINT PRIMARY KEY,
  plan_name VARCHAR(100),
  plan_type VARCHAR(50),
  -- 기타 플랜 관련 정보
);

-- 구독 기간 정보
CREATE TABLE subscription_periods (
  id BIGINT PRIMARY KEY,
  subscription_id BIGINT NOT NULL,
  start_date DATE,
  end_date DATE,
  -- 기타 기간 관련 정보
);

이제 Expand-Migrate-Contract 패턴으로 안전하게 마이그레이션해보겠습니다.

1단계: Expand

1.1 새로운 테이블 생성

-- 새로운 테이블들 생성
CREATE TABLE subscriptions_v2 ( ... );
CREATE TABLE subscription_plans ( ... );
CREATE TABLE subscription_periods ( ... );

1.2 애플리케이션 코드 수정 - Dual Write

애플리케이션이 구 스키마와 신 스키마 모두에 데이터를 쓰도록 수정합니다:

// 구독 생성 시
fun createSubscription(userId: Long, planType: String): Subscription {
    // 1. 기존 테이블에 저장 (하위 호환성)
    val subscription = subscriptionRepository.save(
        Subscription(
            userId = userId,
            planType = planType,
            status = "ACTIVE",
            startDate = LocalDate.now(),
            endDate = LocalDate.now().plusMonths(1)
        )
    )

    // 2. 새로운 테이블에도 저장 (Dual Write)
    val plan = subscriptionPlanRepository.findByPlanType(planType)
    val subscriptionV2 = subscriptionV2Repository.save(
        SubscriptionV2(
            userId = userId,
            planId = plan.id,
            status = "ACTIVE"
        )
    )

    subscriptionPeriodRepository.save(
        SubscriptionPeriod(
            subscriptionId = subscriptionV2.id,
            startDate = LocalDate.now(),
            endDate = LocalDate.now().plusMonths(1)
        )
    )

    return subscription
}

1.3 배포

이 시점에서 배포하면:

  • ✅ 기존 기능은 여전히 구 스키마 사용
  • ✅ 새로운 데이터는 양쪽 모두에 저장됨
  • ✅ 서비스 중단 없음
  • ✅ Rolling Deployment 중에도 구버전/신버전 모두 정상 동작

2단계: Migrate

2.1 기존 데이터 마이그레이션 스크립트

백그라운드에서 실행할 마이그레이션 스크립트를 작성합니다:

@Component
class SubscriptionDataMigration(
    private val subscriptionRepository: SubscriptionRepository,
    private val subscriptionV2Repository: SubscriptionV2Repository,
    private val subscriptionPlanRepository: SubscriptionPlanRepository,
    private val subscriptionPeriodRepository: SubscriptionPeriodRepository
) {

    @Scheduled(fixedDelay = 10000) // 10초마다 실행
    fun migrateOldSubscriptions() {
        val batchSize = 1000

        // 아직 마이그레이션되지 않은 데이터 조회
        val oldSubscriptions = subscriptionRepository
            .findNotMigratedSubscriptions(limit = batchSize)

        oldSubscriptions.forEach { old ->
            try {
                // 플랜 찾기 또는 생성
                val plan = subscriptionPlanRepository
                    .findByPlanType(old.planType)
                    ?: createDefaultPlan(old.planType)

                // 새로운 구독 생성
                val newSubscription = subscriptionV2Repository.save(
                    SubscriptionV2(
                        id = old.id, // 동일한 ID 사용
                        userId = old.userId,
                        planId = plan.id,
                        status = old.status
                    )
                )

                // 기간 정보 생성
                subscriptionPeriodRepository.save(
                    SubscriptionPeriod(
                        subscriptionId = newSubscription.id,
                        startDate = old.startDate,
                        endDate = old.endDate
                    )
                )

                // 마이그레이션 완료 표시
                subscriptionRepository.markAsMigrated(old.id)

            } catch (e: Exception) {
                logger.error("Migration failed for subscription ${old.id}", e)
                // 실패한 건은 다음 배치에서 재시도
            }
        }

        logger.info("Migrated ${oldSubscriptions.size} subscriptions")
    }
}

2.2 진행 상황 모니터링

@RestController
class MigrationStatusController(
    private val subscriptionRepository: SubscriptionRepository
) {

    @GetMapping("/admin/migration/status")
    fun getMigrationStatus(): MigrationStatus {
        val total = subscriptionRepository.count()
        val migrated = subscriptionRepository.countMigrated()
        val remaining = total - migrated
        val progress = (migrated.toDouble() / total * 100).roundToInt()

        return MigrationStatus(
            total = total,
            migrated = migrated,
            remaining = remaining,
            progress = progress
        )
    }
}

2.3 데이터 정합성 검증

마이그레이션 후 데이터가 정확히 이동했는지 검증합니다:

@Component
class MigrationValidator {

    fun validateMigration(subscriptionId: Long): Boolean {
        val old = subscriptionRepository.findById(subscriptionId)
        val new = subscriptionV2Repository.findById(subscriptionId)
        val period = subscriptionPeriodRepository.findBySubscriptionId(subscriptionId)

        return old.userId == new.userId &&
               old.status == new.status &&
               old.startDate == period.startDate &&
               old.endDate == period.endDate
    }
}

3단계: Contract

모든 데이터 마이그레이션이 완료되고 검증이 끝나면 축소 단계를 진행합니다.

3.1 애플리케이션 코드 수정 - 신 스키마만 사용

// Dual Write 제거, 새로운 스키마만 사용
fun createSubscription(userId: Long, planType: String): SubscriptionV2 {
    val plan = subscriptionPlanRepository.findByPlanType(planType)

    val subscription = subscriptionV2Repository.save(
        SubscriptionV2(
            userId = userId,
            planId = plan.id,
            status = "ACTIVE"
        )
    )

    subscriptionPeriodRepository.save(
        SubscriptionPeriod(
            subscriptionId = subscription.id,
            startDate = LocalDate.now(),
            endDate = LocalDate.now().plusMonths(1)
        )
    )

    return subscription
}

3.2 배포 및 모니터링

새로운 코드를 배포하고 충분한 시간 동안 모니터링합니다.

3.3 구 스키마 제거

문제가 없다면 기존 테이블을 제거합니다:

-- 백업 후 제거
DROP TABLE subscriptions;

주의사항 및 베스트 프랙티스

1. Dual Write 시 트랜잭션 관리

두 스키마에 데이터를 쓸 때는 트랜잭션을 신중하게 관리해야 합니다:

@Transactional
fun createSubscription(...) {
    // 두 저장 작업이 모두 성공하거나 모두 실패해야 함
    subscriptionRepository.save(...)
    subscriptionV2Repository.save(...)
}

2. 마이그레이션 순서

여러 테이블을 마이그레이션할 때는 의존성 순서를 고려해야 합니다:

  1. 먼저 독립적인 테이블 (subscription_plans)
  2. 그 다음 의존성이 있는 테이블 (subscriptions_v2, subscription_periods)

3. 롤백 계획

각 단계마다 롤백 계획을 수립해야 합니다:

  • Expand 단계: 새 테이블만 제거하면 됨
  • Migrate 단계: 마이그레이션을 중단하고 마이그레이션된 데이터만 삭제
  • Contract 단계: 애플리케이션 코드를 이전 버전으로 롤백

4. 성능 고려사항

Dual Write는 성능 오버헤드가 있습니다:

  • 쓰기 작업이 2배로 증가
  • 트랜잭션 시간 증가
  • 가능한 빨리 Migrate 단계를 완료하고 Contract로 진행

5. 데이터 정합성 보장

마이그레이션 중에는 두 스키마의 데이터가 일치하는지 지속적으로 검증해야 합니다:

@Scheduled(cron = "0 0 * * * *") // 매 시간마다
fun validateDataConsistency() {
    val inconsistencies = findInconsistencies()
    if (inconsistencies.isNotEmpty()) {
        alerting.send("Data inconsistency detected: $inconsistencies")
    }
}

마치며

Expand-Migrate-Contract 패턴은 24시간 운영되는 서비스에서 무중단 데이터베이스 마이그레이션을 안전하게 수행할 수 있는 강력한 방법입니다. 각 단계를 명확히 분리하고, 충분한 검증과 모니터링을 거치면 대규모 스키마 변경도 안전하게 수행할 수 있습니다.


하지만 이 패턴은 다음과 같은 트레이드오프가 있습니다:

  • 장점: 무중단 배포, 안전한 롤백, 점진적 마이그레이션, Rolling Deployment 지원
  • 단점: 구현 복잡도 증가, Dual Write 오버헤드, 마이그레이션 기간 연장

서비스의 특성과 요구사항을 고려하여 이 패턴을 적용할지 결정하시기 바랍니다. 특히 다운타임을 허용할 수 없는 24시간 운영 서비스라면 이 패턴이 매우 유용할 것입니다.

참고자료

© 2025, 미나리와 함께 만들었음