개요

프로덕션 데이터베이스를 다운타임 없이 마이그레이션하는 것은 까다로운 작업입니다. 스키마 변경, 데이터 이전, 버전 업그레이드, 심지어 다른 DBMS로의 전환까지 비즈니스 중단 없이 수행해야 합니다.

이 글에서는 무중단 마이그레이션의 핵심 원칙과 검증된 패턴, 그리고 단계별 실행 전략을 살펴보겠습니다.

핵심 개념

1. Expand-Contract 패턴
스키마 변경을 Expand(확장) → Migrate(마이그레이션) → Contract(축소) 세 단계로 나눕니다. 각 단계는 독립적으로 배포 가능하며, 롤백이 안전합니다.

2. Dual Write 전략
새 스키마와 구 스키마에 동시에 쓰기를 수행하여 데이터 일관성을 유지하면서 점진적으로 전환합니다.

3. Shadow Traffic
실제 트래픽을 새로운 시스템으로도 복제하여 프로덕션과 동일한 조건에서 테스트합니다. 결과는 기록만 하고 실제 응답은 기존 시스템을 사용합니다.

4. Feature Flag
읽기/쓰기 경로를 런타임에 전환할 수 있도록 하여, 문제 발생 시 즉시 롤백할 수 있습니다.

실전 예제

1단계: Expand (스키마 확장)

-- 기존 테이블: users (name VARCHAR(100))
-- 목표: name을 first_name, last_name으로 분리

-- Step 1: 새 컬럼 추가 (nullable)
ALTER TABLE users
ADD COLUMN first_name VARCHAR(50),
ADD COLUMN last_name VARCHAR(50);

-- 인덱스는 아직 추가하지 않음 (성능 영향 최소화)

-- Step 2: 트리거 생성 (기존 name 업데이트 시 자동 동기화)
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.name IS NOT NULL THEN
    NEW.first_name := split_part(NEW.name, ' ', 1);
    NEW.last_name := split_part(NEW.name, ' ', 2);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_name_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION sync_user_name();

-- 이 시점에서 배포 1: 애플리케이션 코드는 변경 없음

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

-- 배치로 기존 데이터 마이그레이션 (부하 분산)
DO $$
DECLARE
  batch_size INT := 10000;
  offset_val INT := 0;
  affected_rows INT;
BEGIN
  LOOP
    UPDATE users
    SET
      first_name = split_part(name, ' ', 1),
      last_name = split_part(name, ' ', 2)
    WHERE id IN (
      SELECT id FROM users
      WHERE first_name IS NULL
      ORDER BY id
      LIMIT batch_size
    );

    GET DIAGNOSTICS affected_rows = ROW_COUNT;
    EXIT WHEN affected_rows = 0;

    -- 부하 분산을 위한 대기
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

-- 마이그레이션 완료 확인
SELECT COUNT(*) FROM users WHERE first_name IS NULL;
-- 반환: 0 이면 완료
// 배포 2: Dual Write (애플리케이션 코드 변경)
interface User {
  id: number;
  name: string;  // 기존 필드 (읽기 전용)
  firstName: string;  // 새 필드
  lastName: string;
}

async function createUser(firstName: string, lastName: string) {
  const name = `${firstName} ${lastName}`;

  // 두 형식 모두에 쓰기
  const { data } = await supabase
    .from('users')
    .insert({
      name,           // 구 스키마
      first_name: firstName,  // 신 스키마
      last_name: lastName
    })
    .select()
    .single();

  return data;
}

// Feature Flag로 읽기 소스 전환
async function getUser(id: number) {
  const useNewSchema = await featureFlags.isEnabled('use_new_user_schema');

  const { data } = await supabase
    .from('users')
    .select('*')
    .eq('id', id)
    .single();

  if (useNewSchema) {
    return {
      id: data.id,
      firstName: data.first_name,
      lastName: data.last_name
    };
  } else {
    return {
      id: data.id,
      name: data.name
    };
  }
}

3단계: Validate (검증)

-- 데이터 일관성 검증 쿼리
SELECT
  COUNT(*) as inconsistent_rows
FROM users
WHERE
  name != CONCAT(first_name, ' ', last_name)
  OR first_name IS NULL
  OR last_name IS NULL;

-- Shadow Traffic 로그 분석 (애플리케이션에서 수집)
SELECT
  endpoint,
  COUNT(*) as requests,
  AVG(new_schema_latency - old_schema_latency) as latency_diff,
  SUM(CASE WHEN new_schema_error THEN 1 ELSE 0 END) as error_count
FROM shadow_traffic_log
WHERE created_at > NOW() - INTERVAL '1 hour'
GROUP BY endpoint;

4단계: Contract (정리)

-- 충분한 검증 후 구 스키마 제거

-- Step 1: 트리거 삭제
DROP TRIGGER IF EXISTS sync_name_trigger ON users;
DROP FUNCTION IF EXISTS sync_user_name();

-- Step 2: 인덱스 추가 (이제 안전)
CREATE INDEX idx_users_first_name ON users(first_name);
CREATE INDEX idx_users_last_name ON users(last_name);

-- Step 3: NOT NULL 제약 추가
ALTER TABLE users
ALTER COLUMN first_name SET NOT NULL,
ALTER COLUMN last_name SET NOT NULL;

-- Step 4: 구 컬럼 삭제 (가장 마지막)
ALTER TABLE users DROP COLUMN name;

-- 배포 3: 애플리케이션에서 구 스키마 코드 제거

크로스 DBMS 마이그레이션 (MySQL → PostgreSQL)

# 1. Schema 변환 (pgloader)
sudo apt-get install pgloader

# MySQL 스키마를 PostgreSQL로 변환
pgloader mysql://user:pass@olddb.example.com/mydb \
          postgresql://user:pass@newdb.example.com/mydb

# 2. 실시간 복제 (Debezium + Kafka)
# Debezium MySQL Source Connector
{
  "name": "mysql-source",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "olddb.example.com",
    "database.port": "3306",
    "database.user": "debezium",
    "database.password": "password",
    "database.server.id": "184054",
    "database.server.name": "mydb",
    "table.include.list": "mydb.users,mydb.posts",
    "database.history.kafka.bootstrap.servers": "kafka:9092",
    "database.history.kafka.topic": "schema-changes"
  }
}

# PostgreSQL Sink Connector
{
  "name": "postgres-sink",
  "config": {
    "connector.class": "io.confluent.connect.jdbc.JdbcSinkConnector",
    "connection.url": "jdbc:postgresql://newdb.example.com:5432/mydb",
    "connection.user": "postgres",
    "connection.password": "password",
    "topics": "mydb.users,mydb.posts",
    "auto.create": "false",
    "insert.mode": "upsert",
    "pk.mode": "record_key"
  }
}

# 3. 데이터 검증
# 양쪽 DB에서 레코드 수 비교
SELECT 'MySQL' as source, COUNT(*) FROM users
UNION ALL
SELECT 'PostgreSQL', COUNT(*) FROM users;

# 4. 트래픽 전환 (Nginx)
upstream database {
  server olddb.example.com:3306 weight=100;  # 초기 100%
  server newdb.example.com:5432 weight=0;    # 점진적으로 증가
}

# Feature Flag로 점진적 전환 (10% → 50% → 100%)

PostgreSQL 메이저 버전 업그레이드 (15 → 17)

# 1. Standby 서버에서 먼저 업그레이드
# Streaming Replication 구성 확인
SELECT * FROM pg_stat_replication;

# 2. Standby 중지 및 업그레이드
systemctl stop postgresql-15
/usr/lib/postgresql/17/bin/pg_upgrade \
  --old-datadir=/var/lib/postgresql/15/main \
  --new-datadir=/var/lib/postgresql/17/main \
  --old-bindir=/usr/lib/postgresql/15/bin \
  --new-bindir=/usr/lib/postgresql/17/bin \
  --check  # 먼저 검증만

# 문제 없으면 실제 업그레이드
/usr/lib/postgresql/17/bin/pg_upgrade ... (--check 제거)

# 3. Standby 시작 및 복제 재개
systemctl start postgresql-17

# 4. Standby 검증 후 Failover
# Primary 중지, Standby 승격
SELECT pg_promote();

# 5. 구 Primary를 17로 업그레이드 후 Standby로 전환

# 6. 완료 후 정리
./delete_old_cluster.sh

활용 팁

  • 배포 단계 분리: Expand, Migrate, Contract를 각각 별도 배포로 진행하고, 각 단계 사이에 충분한 검증 시간을 두세요.
  • Feature Flag 필수: LaunchDarkly, Unleash 등을 사용하여 런타임에 읽기/쓰기 경로를 전환할 수 있게 하세요.
  • 모니터링: 마이그레이션 중 지연시간, 에러율, 데이터 일관성을 실시간으로 모니터링하세요. 이상 징후 발견 시 즉시 롤백하세요.
  • 배치 처리: 대용량 데이터 마이그레이션은 반드시 배치로 나누고, 각 배치 사이에 대기 시간을 두어 DB 부하를 분산하세요.
  • Idempotency: 마이그레이션 스크립트는 여러 번 실행해도 안전하도록 작성하세요 (WHERE first_name IS NULL 같은 조건 사용).
  • 백업: 마이그레이션 전 반드시 전체 백업을 수행하고, Point-in-Time Recovery가 가능하도록 WAL 아카이빙을 활성화하세요.
  • Dry Run: 프로덕션 복제본(staging)에서 먼저 전체 과정을 연습하고 소요 시간을 측정하세요.
  • Communication: 마이그레이션 일정, 예상 영향, 롤백 계획을 팀 전체와 공유하세요.

마무리

무중단 데이터베이스 마이그레이션은 신중한 계획과 단계적 실행, 그리고 철저한 검증이 핵심입니다. Expand-Contract 패턴을 따르고, Feature Flag로 점진적으로 전환하며, 모든 단계에서 롤백 가능성을 유지하세요.

급하게 진행하면 데이터 손실이나 서비스 장애로 이어질 수 있습니다. 충분한 시간을 가지고, 각 단계를 검증하며, 팀 전체가 준비된 상태에서 진행하시기 바랍니다. 성공적인 마이그레이션은 완벽한 준비에서 나옵니다.