게임 백엔드 DB 스키마 변경을 위한 Dual Read·Dual Write 무중단 마이그레이션 전략

멀티플레이 게임 서버에서 DB 스키마를 중단 없이 변경하는 방법을 설명합니다. Dual Read·Dual Write 단계, 데이터 정합성 검증, 롤백과 트래픽 전환까지 실전 절차를 정리합니다.

✓ 복사 완료!
챕터게임 백엔드 & 데이터베이스
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
게임 백엔드 DB 스키마 변경을 위한 Dual Read·Dual Write 무중단 마이그레이션 전략

핵심 요약

게임 백엔드의 DB 스키마를 무중단으로 변경하려면 기존 컬럼과 신규 컬럼을 일정 기간 함께 읽고 쓰는 Dual Read·Dual Write 전략을 사용합니다. 먼저 하위 호환 가능한 스키마를 추가한 뒤 신규 데이터를 양쪽에 기록하고 정합성을 검증합니다. 이후 신규 컬럼 읽기로 전환하고 충분한 관찰 기간을 거친 다음 기존 컬럼을 제거합니다.

핵심은 스키마 변경과 애플리케이션 배포를 한 번에 묶지 않는 것입니다. 멀티플레이 게임에서는 여러 버전의 게임 서버가 동시에 실행될 수 있으므로 구버전 서버도 신규 스키마를 만나 오류가 나지 않도록 단계별 호환성을 설계해야 합니다.

게임 백엔드에서 DB 스키마 변경이 위험한 이유

일반적인 웹 서비스보다 게임 백엔드의 스키마 변경은 더 많은 실행 주체와 상태를 고려해야 합니다. 하나의 계정이 로그인 서버, 로비 서버, 매치메이킹 서버, 게임 세션 서버, 결제 서버에서 동시에 사용될 수 있기 때문입니다.

또한 다음과 같은 상황이 자주 발생합니다.

  • 롤링 배포 중 구버전과 신버전 서버가 동시에 요청을 처리한다.
  • 모바일 클라이언트가 최신 버전으로 즉시 업데이트되지 않는다.
  • 매치 진행 중에는 플레이어 인벤토리와 재화가 짧은 시간에 여러 번 변경된다.
  • 장애 발생 시 일부 서버만 롤백될 수 있다.
  • 배치 작업, 이벤트 소비자, 운영 도구가 애플리케이션과 별도로 DB를 사용한다.

예를 들어 기존 플레이어 테이블이 다음과 같다고 가정하겠습니다.

CREATE TABLE player_profile (
    player_id BIGINT PRIMARY KEY,
    nickname VARCHAR(32) NOT NULL,
    gold INT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP NOT NULL
);

이제 금액의 최대값과 감사 추적을 강화하기 위해 gold를 BIGINT 기반의 wallet_balance로 변경하려고 합니다. 단순히 기존 컬럼의 타입을 즉시 변경하면 다음 문제가 생길 수 있습니다.

  • 대용량 테이블 변경으로 긴 잠금이 발생한다.
  • 구버전 서버가 신규 컬럼을 모르거나 반대로 신버전 서버가 아직 채워지지 않은 컬럼을 읽는다.
  • Dual Write 중 한쪽 쓰기만 성공해 잔액 정합성이 깨진다.
  • 롤백 후에도 신규 컬럼에 기록된 값이 남아 후속 배포를 오염시킨다.

Dual Read·Dual Write란 무엇인가?

Dual Read는 기존 데이터와 신규 데이터를 함께 읽어 결과를 비교하거나 우선순위에 따라 선택하는 방식입니다. Dual Write는 하나의 도메인 변경을 기존 저장 위치와 신규 저장 위치에 동시에 기록하는 방식입니다.

구분의미주요 목적
Dual Read구버전과 신버전 컬럼 또는 테이블을 함께 읽음값 비교, 점진적 전환, 누락 감지
Dual Write기존 위치와 신규 위치에 함께 기록함신규 저장소를 최신 상태로 동기화
Shadow Read사용자 응답에는 사용하지 않고 신규 저장소를 읽음성능과 결과 비교
Backfill기존 데이터를 신규 스키마로 일괄 복사함과거 데이터 채우기
Cutover읽기 기준을 신규 스키마로 변경함트래픽 전환

이 전략의 중요한 원칙은 모든 단계에서 이전 버전과 다음 버전이 서로 이해할 수 있어야 한다는 점입니다. 이를 흔히 Expand, Migrate, Contract 단계로 나눕니다.

DB 스키마 변경은 어떤 순서로 진행해야 하는가?

1단계: Expand — 하위 호환 스키마 추가

첫 배포에서는 기존 컬럼을 삭제하거나 의미를 바꾸지 않습니다. 신규 컬럼, 인덱스, 테이블을 추가하고 기존 서버가 이를 무시해도 정상 동작하도록 설계합니다.

ALTER TABLE player_profile
    ADD COLUMN wallet_balance BIGINT NULL,
    ADD COLUMN wallet_version BIGINT NULL;

초기에는 신규 컬럼을 NULL로 허용하는 편이 안전합니다. 구버전 서버는 이 컬럼을 쓰지 않기 때문에 NOT NULL 제약 조건을 즉시 추가하면 구버전 쓰기가 실패할 수 있습니다.

다만 신규 컬럼에 기본값을 추가하는 방식은 사용하는 DB 엔진과 버전에 따라 테이블 잠금이나 전체 재작성 비용이 달라질 수 있습니다. PostgreSQL, MySQL, MariaDB 등 실제 운영 환경의 DDL 동작을 확인한 뒤 온라인 변경 도구나 별도 마이그레이션 작업을 선택해야 합니다.

신규 컬럼을 추가한 뒤에는 다음을 확인합니다.

  • 구버전 서버가 기존 컬럼만 사용해도 정상 동작하는가?
  • 신규 컬럼을 조회하는 코드가 NULL을 처리하는가?
  • 인덱스 생성이 쓰기 지연을 유발하지 않는가?
  • CDC, 리플리카, 백업 시스템이 신규 구조를 처리하는가?
  • 운영 도구와 배치 코드가 컬럼 순서에 의존하지 않는가?

2단계: Dual Write — 기존 값과 신규 값 동시 기록

신버전 서버는 도메인 변경을 두 저장 위치에 기록합니다. 예를 들어 골드 잔액 변경을 다음처럼 처리할 수 있습니다.

const LEGACY_MAX_GOLD = 2147483647n;

async function updateGold(playerId: bigint, delta: bigint) {
  return db.transaction(async tx => {
    const player = await tx.playerProfile.findForUpdate(playerId);
    const nextGold = BigInt(player.gold) + delta;

    if (nextGold < 0n) {
      throw new Error('insufficient_gold');
    }

    if (nextGold > LEGACY_MAX_GOLD) {
      throw new Error('legacy_gold_range_exceeded');
    }

    await tx.playerProfile.update(playerId, {
      gold: Number(nextGold),
      walletBalance: nextGold,
      walletVersion: (player.walletVersion ?? 0n) + 1n,
      updatedAt: new Date()
    });
  });
}

기존 컬럼 gold가 아직 INT라면 BIGINT 값을 그대로 넣을 수 없습니다. 이 경우 기존 컬럼에 저장할 수 있는 범위를 명시적으로 검사해야 합니다.

이 검사를 생략하면 신규 저장소는 정상적으로 증가했지만 기존 컬럼이 오버플로되거나 잘못된 값으로 저장될 수 있습니다. 반대로 기존 컬럼을 계속 기준으로 삼아야 하는 기간에는 범위를 넘는 계정을 별도 큐로 보내거나 게임 규칙상 상한을 두어야 합니다.

애플리케이션 레벨에서 두 번의 독립적인 DB 트랜잭션을 실행하는 방식은 피해야 합니다.

await writeLegacy();
await writeNew();

첫 번째 쓰기만 성공하고 두 번째 쓰기가 실패하면 두 값이 달라집니다. 같은 DB 트랜잭션으로 묶을 수 있다면 하나의 트랜잭션으로 처리합니다. 저장소가 분리되어 있어 원자적 트랜잭션이 불가능하다면 Outbox 패턴, 재시도 가능한 이벤트, 멱등 키, 정합성 보정 작업을 함께 설계해야 합니다.

3단계: Backfill — 기존 데이터 채우기

Dual Write는 배포 이후 발생하는 변경만 복제합니다. 배포 이전의 기존 행은 별도의 Backfill 작업으로 신규 컬럼에 채워야 합니다.

대량 게임 데이터를 한 번에 갱신하면 DB 부하와 복제 지연이 커질 수 있으므로 작은 배치와 커서 기반 순회를 사용합니다.

UPDATE player_profile
SET wallet_balance = CAST(gold AS BIGINT),
    wallet_version = 0
WHERE player_id > :last_player_id
  AND player_id <= :batch_end
  AND wallet_balance IS NULL;

Backfill 작업의 일반적인 운영 기준은 다음과 같습니다.

항목권장 방식
순회 기준OFFSET보다 단조 증가하는 기본 키 커서 사용
배치 크기DB 부하와 복제 지연을 보며 동적으로 조정
실행 속도일정한 대기 시간을 두거나 레이트 리미터 적용
재실행이미 처리된 행을 건너뛰는 멱등 조건 사용
중단 기준lock wait, CPU, replica lag 임계치 기반 자동 중단
검증행 수, 합계, 샘플 비교, 변경 시각 비교

게임 재화나 인벤토리처럼 현재 값만 복사하면 위험한 데이터는 Backfill 시점의 스냅샷만 믿지 않아야 합니다. Backfill 중에도 실시간 쓰기가 발생하므로 Dual Write가 먼저 활성화되어 있어야 하며 필요하면 행 잠금이나 버전 조건으로 최신 쓰기를 보호해야 합니다.

두 컬럼의 값이 다르면 어떻게 처리하는가?

정합성 검증은 단순한 행 수 비교보다 도메인 규칙에 맞춰야 합니다. 잔액 데이터라면 값과 버전을 함께 비교하고 아이템 목록이라면 수량과 중복 여부를 비교해야 합니다.

SELECT COUNT(*) AS mismatch_count
FROM player_profile
WHERE wallet_balance IS NOT NULL
  AND wallet_balance <> CAST(gold AS BIGINT);

버전 기반 낙관적 잠금을 사용하면 오래된 쓰기가 최신 데이터를 덮어쓰는 문제를 줄일 수 있습니다.

UPDATE player_profile
SET wallet_balance = :next_balance,
    wallet_version = wallet_version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE player_id = :player_id
  AND wallet_version = :expected_version;

영향받는 행이 0개라면 재시도 전에 현재 버전을 다시 읽어야 합니다. 단순 재시도만 반복하면 충돌을 숨기거나 동일 보상 이벤트를 중복 적용할 수 있습니다.

정합성 지표는 최소한 다음을 포함하는 것이 좋습니다.

  • Dual Write 성공률
  • 기존 컬럼과 신규 컬럼의 불일치 행 수
  • 신규 컬럼의 NULL 비율
  • Backfill 처리량과 남은 행 수
  • DB lock wait 시간
  • 읽기 전환 후 오류율과 p95 지연 시간
  • 보상, 인벤토리, 결제 관련 재시도 및 중복 처리 수

Dual Read는 어떻게 구현하는가?

Dual Read는 처음부터 신규 값을 사용자 응답에 사용하지 않습니다. 먼저 Shadow Read로 신규 값을 읽고 기존 값과 비교해 관찰성을 확보합니다.

async function getGold(playerId: bigint) {
  const player = await db.playerProfile.find(playerId);
  const legacyValue = BigInt(player.gold);
  const newValue = player.walletBalance;

  if (newValue !== null && newValue !== legacyValue) {
    metrics.increment('wallet.read_mismatch');
    logger.warn({ playerId, legacyValue, newValue }, 'wallet value mismatch');
  }

  return legacyValue;
}

불일치가 발견되면 어느 값을 무조건 덮어쓸지 결정하지 말고 원인을 분류해야 합니다.

불일치 원인대응
Backfill이 아직 처리하지 못함해당 행을 Backfill 대상으로 유지
Dual Write 일부 실패Outbox 재처리 또는 보정 작업 실행
구버전 서버가 기존 컬럼만 갱신함구버전 서버 종료 전까지 신규 쓰기 경로 보완
중복 이벤트 처리멱등 키와 이벤트 순서 확인
수동 운영 도구 변경도구를 동일한 도메인 서비스 경로로 통합
타입 변환 오류범위 검사와 변환 규칙 수정

검증 기간에 불일치가 허용 한도를 넘지 않고 신규 값의 지연 시간도 문제가 없다는 사실을 확인한 뒤 읽기 기준을 전환합니다.

읽기 전환과 롤백은 어떻게 설계하는가?

읽기 기준은 코드 재배포만으로 바꾸기보다 Feature Flag 또는 설정값으로 제어하는 편이 안전합니다. 예를 들면 다음과 같은 상태를 둘 수 있습니다.

상태읽기쓰기용도
legacy기존 컬럼기존 컬럼변경 전 안정 상태
dual-write기존 컬럼기존·신규 컬럼신규 저장소 동기화
shadow-read기존 컬럼과 신규 컬럼 비교기존·신규 컬럼정합성 관찰
new-read신규 컬럼기존·신규 컬럼읽기 전환 후 검증
new-primary신규 컬럼신규 컬럼 중심, 기존 컬럼 보조계약 종료 직전
contract신규 컬럼신규 컬럼기존 구조 제거 준비
flowchart LR
    A[기존 스키마] --> B[신규 컬럼 추가]
    B --> C[Dual Write 활성화]
    C --> D[Backfill 실행]
    D --> E[Shadow Read 검증]
    E --> F[New Read 전환]
    F --> G[관찰 기간]
    G --> H[기존 컬럼 제거]
    E -. 불일치 또는 장애 .-> C
    F -. 오류 증가 .-> C

읽기 전환 중에는 다음 두 종류의 롤백을 구분해야 합니다.

애플리케이션 롤백

신규 읽기에서 기존 읽기로 Feature Flag를 되돌립니다. Dual Write가 계속 활성화되어 있다면 기존 값과 신규 값을 모두 최신 상태로 유지할 수 있습니다.

스키마 롤백

이미 신규 컬럼을 추가한 뒤에는 컬럼 삭제를 롤백으로 사용하지 않는 편이 안전합니다. 삭제는 데이터 복구를 어렵게 만들기 때문입니다. 장애 시에는 신규 구조를 남겨 둔 채 애플리케이션 동작만 이전 경로로 되돌리고 원인을 해결한 뒤 재전환합니다.

특히 다음 상황에서는 Dual Write를 바로 끄지 않아야 합니다.

  • 아직 구버전 서버가 트래픽을 처리한다.
  • 신규 컬럼의 불일치 행이 남아 있다.
  • Backfill이 완료되지 않았다.
  • 배치, 소비자, 운영 도구가 어느 컬럼을 쓰는지 확인되지 않았다.

게임 백엔드 DB 마이그레이션의 Dual Read·Dual Write 전환 단계를 보여주는 운영 대시보드 예시

멀티플레이 게임의 동시성 문제는 어떻게 다루는가?

게임 서버에서는 한 플레이어의 재화나 인벤토리를 여러 요청이 동시에 변경할 수 있습니다. 따라서 스키마 변환과 동시성 제어를 별개의 문제로 보면 안 됩니다.

재화 변경은 델타와 멱등 키를 함께 기록한다

보상 지급 요청에는 request_id 또는 event_id를 부여하고 이미 처리한 이벤트인지 확인해야 합니다.

CREATE TABLE wallet_operation (
    operation_id VARCHAR(64) PRIMARY KEY,
    player_id BIGINT NOT NULL,
    delta BIGINT NOT NULL,
    created_at TIMESTAMP NOT NULL
);

처리 흐름은 다음과 같습니다.

  1. operation_id가 이미 존재하는지 확인한다.
  2. 존재하면 같은 결과를 반환하고 잔액을 다시 변경하지 않는다.
  3. 존재하지 않으면 잔액 변경과 작업 기록을 같은 트랜잭션으로 저장한다.
  4. 기존 컬럼과 신규 컬럼이 모두 성공했는지 검증한다.

이 구조는 Dual Write 실패 재시도와 네트워크 재전송에 모두 대응할 수 있습니다.

이벤트 순서를 보장한다

Kafka나 다른 메시지 브로커를 사용한다면 플레이어 ID를 파티션 키로 사용해 같은 플레이어의 이벤트 순서를 유지하는 방식을 고려할 수 있습니다. 다만 파티션 키만으로 전체 시스템의 순서가 보장되는 것은 아니므로 DB의 버전 조건과 멱등 처리를 함께 사용해야 합니다.

매치 서버와 영속화 서버의 책임을 나눈다

실시간 매치 서버가 DB 스키마 세부 사항을 직접 알게 하면 마이그레이션 범위가 급격히 커집니다. 매치 서버는 도메인 이벤트를 생성하고 영속화 서비스가 기존·신규 저장 구조로 변환하는 방식이 변경 격리에 유리합니다.

보안과 운영 도구에서 놓치기 쉬운 부분

DB 스키마가 바뀌면 백엔드 코드뿐 아니라 데이터 접근 권한과 운영 절차도 확인해야 합니다.

  • 신규 테이블 또는 컬럼에 서비스 계정 권한이 빠지지 않았는가?
  • Backfill 계정이 운영 데이터 전체를 읽을 필요가 있는가?
  • 로그에 플레이어의 전체 인벤토리나 결제 정보가 노출되지 않는가?
  • 관리자 API가 기존 컬럼과 신규 컬럼을 직접 수정하지 않는가?
  • 감사 로그에 마이그레이션으로 인한 보정 변경을 구분해 기록하는가?
  • 개인정보와 결제 데이터의 보존·삭제 정책이 신규 구조에도 적용되는가?
  • 스테이징과 운영의 스키마 버전 차이를 배포 전에 확인하는가?

특히 운영자가 SQL로 직접 값을 수정할 수 있는 환경이라면 Dual Write 경로를 우회하는 변경이 발생합니다. 가능한 한 관리자 기능도 도메인 서비스와 동일한 명령 API를 사용하게 하고 긴급 SQL 수정이 필요할 때는 보정 작업과 검증 쿼리를 함께 실행해야 합니다.

실전 체크리스트

배포 전

  • 신규 컬럼과 테이블이 하위 호환 방식으로 추가되었다.
  • 구버전 서버가 신규 구조를 만나도 실패하지 않는다.
  • Dual Write를 같은 트랜잭션 또는 재처리 가능한 이벤트로 설계했다.
  • 값의 범위, 정밀도, 시간대, 인코딩 변환 규칙을 정의했다.
  • Feature Flag와 즉시 롤백 경로가 준비되었다.
  • 불일치와 Backfill 진행률을 모니터링할 수 있다.
  • 배치, 이벤트 소비자, 운영 도구의 쓰기 경로를 확인했다.

배포 중

  • 스키마 추가 후 애플리케이션 배포를 진행한다.
  • Dual Write 성공률과 오류 로그를 확인한다.
  • Backfill 속도를 DB와 리플리카 상태에 맞춰 조절한다.
  • 일부 서버 또는 일부 계정에 Shadow Read를 적용한다.
  • 불일치 원인을 분류하고 자동 보정 전에 안전성을 검토한다.

전환 후

  • 신규 읽기의 오류율과 지연 시간이 기존과 비교해 안정적이다.
  • 신규 컬럼의 NULL 비율이 예상과 일치한다.
  • 기존·신규 값의 불일치가 허용 기준 이하이다.
  • 구버전 서버와 오래된 클라이언트의 최소 지원 버전을 확인했다.
  • 충분한 관찰 기간이 지나기 전까지 기존 컬럼을 삭제하지 않는다.
  • 삭제 전 백업 복구 절차와 감사 기록을 검증한다.

자주 묻는 질문 FAQ

Dual Write 중 한쪽 DB 쓰기가 실패하면 어떻게 하나요?

같은 DB 안의 컬럼이라면 하나의 트랜잭션으로 묶어 원자성을 확보합니다. DB가 분리되어 있다면 Outbox 패턴, 멱등 이벤트, 재시도 큐, 정합성 보정 작업을 사용해야 합니다. 실패를 로그만 남기고 무시하면 안 됩니다.

Backfill을 먼저 실행한 뒤 Dual Write를 켜도 되나요?

권장하지 않습니다. Backfill 중 실시간 변경이 발생하면 복사한 값이 최신 상태가 아닐 수 있습니다. 일반적으로 신규 구조 추가, Dual Write 활성화, Backfill, 검증, 읽기 전환 순서가 안전합니다.

기존 컬럼은 언제 삭제해야 하나요?

신규 읽기가 안정화되고 구버전 서버·배치·운영 도구가 모두 제거 또는 전환된 뒤에 삭제합니다. 관찰 기간 동안 불일치가 다시 발생하지 않는지 확인하고 복구 가능한 백업을 남긴 뒤 Contract 단계로 진행해야 합니다.

정리

게임 백엔드의 무중단 DB 마이그레이션은 DDL 한 줄의 문제가 아니라 애플리케이션 버전, 실시간 동시성, 이벤트 재처리, 운영 도구, 롤백 정책을 함께 관리하는 과정입니다.

가장 안전한 기본 순서는 다음과 같습니다.

  1. 하위 호환 신규 구조를 추가한다.
  2. 기존·신규 저장 위치에 함께 기록한다.
  3. 기존 데이터를 배치로 Backfill한다.
  4. Dual Read 또는 Shadow Read로 정합성을 검증한다.
  5. Feature Flag로 신규 읽기로 전환한다.
  6. 관찰 기간 이후 기존 구조를 제거한다.

이 순서를 지키면 패치 중에도 플레이어 세션과 멀티플레이 트래픽을 유지하면서 스키마를 점진적으로 발전시킬 수 있습니다.