들어가며
24시간 운영되는 서비스를 개발하다보면 데이터베이스 스키마를 변경해야 하는 상황이 자주 발생합니다. 새로운 기능 추가, 성능 개선, 데이터 구조 개선 등 다양한 이유로 스키마 변경이 필요하죠.
하지만 일반적인 방식으로 스키마를 변경하면 다음과 같은 문제가 발생합니다:
-- 예시: 컬럼명 변경
ALTER TABLE users CHANGE COLUMN phone phone_number VARCHAR(20);이 SQL을 실행하는 순간, phone 컬럼을 사용하던 애플리케이션은 에러를 발생시킵니다. 따라서 전통적인 방식은 다음과 같습니다:
- 서비스 중단 (점검 시간 공지)
- 스키마 변경 실행
- 애플리케이션 배포
- 서비스 재개
하지만 24시간 운영되는 서비스에서는 서비스 중단을 허용할 수 없습니다. 점검 시간조차 비즈니스 손실로 이어지고, 글로벌 서비스라면 어느 시간대에 점검을 진행하든 일부 사용자에게 영향을 줄 수밖에 없습니다.
왜 Expand-Migrate-Contract 패턴이 필요한가?
24시간 무중단 서비스 환경에서는 다음과 같은 요구사항이 있습니다:
- 서비스 중단 없이 스키마를 변경해야 함
- 배포 중에도 구버전과 신버전 애플리케이션이 공존 가능해야 함 (Rolling Deployment)
- 문제 발생 시 안전하게 롤백할 수 있어야 함
- 대용량 데이터도 점진적으로 마이그레이션 가능해야 함
일반적인 스키마 변경으로는 이런 요구사항을 만족시킬 수 없습니다. 예를 들어:
// 배포 중 상황
// - 서버 A: 구버전 (phone 컬럼 사용)
// - 서버 B: 신버전 (phone_number 컬럼 사용)
// 이미 스키마는 phone -> phone_number로 변경됨
// 서버 A는 에러 발생!
val phone = user.phone // Column 'phone' not foundRolling 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. 마이그레이션 순서
여러 테이블을 마이그레이션할 때는 의존성 순서를 고려해야 합니다:
- 먼저 독립적인 테이블 (subscription_plans)
- 그 다음 의존성이 있는 테이블 (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시간 운영 서비스라면 이 패턴이 매우 유용할 것입니다.