Skip to content
Published on

무중단 데이터 마이그레이션 — Dual-Write, Backfill, CDC로 옮기기

Authors

들어가며

데이터 마이그레이션은 단순히 "한 테이블에서 다른 테이블로 INSERT SELECT 한 번 돌리는 일"처럼 보이지만, 운영 중인 서비스에서는 전혀 다른 난이도의 작업입니다. 데이터가 멈춰 있다면 그냥 복사하면 됩니다. 그러나 실제 서비스는 마이그레이션이 진행되는 동안에도 계속 쓰기가 들어옵니다. 사용자는 주문을 넣고, 결제가 발생하고, 프로필이 수정됩니다. 우리가 데이터를 옮기는 그 순간에도 원본은 계속 변합니다.

그래서 무중단 마이그레이션의 본질은 "정지된 데이터를 옮기는 것"이 아니라 "흐르는 강물을 다른 강바닥으로 옮기는 것"에 가깝습니다. 강물을 막지 않고도 물길을 바꿔야 합니다. 이 글에서는 다음 세 가지 핵심 도구를 조합해 이 문제를 푸는 방법을 다룹니다.

  • Dual-Write: 애플리케이션이 구/신 저장소에 동시에 쓰기
  • Backfill: 과거 데이터를 배치로 옮기기
  • CDC(Change Data Capture): 변경 로그를 읽어 신 저장소로 실시간 동기화

그리고 이 도구들을 어떻게 단계적으로 조합해서 "읽기 컷오버 → 쓰기 컷오버"로 안전하게 전환하는지, 검증은 어떻게 하는지, 문제가 생기면 어떻게 롤백하는지를 정리합니다.

이 글에서 다루는 마이그레이션의 범위는 넓습니다. PostgreSQL에서 PostgreSQL로의 테이블 분리, MySQL에서 새로운 샤딩 클러스터로의 이전, 모놀리식 DB에서 마이크로서비스 전용 DB로의 분리, 심지어 RDBMS에서 다른 종류의 저장소로의 이전까지 같은 원리가 적용됩니다.

왜 데이터 마이그레이션이 어려운가

일관성(Consistency) 문제

가장 먼저 부딪히는 벽은 일관성입니다. 원본(source)과 대상(target)이 어느 순간에도 동일한 상태를 유지하도록 만드는 것은 생각보다 어렵습니다. Backfill로 과거 데이터를 옮기는 동안에도 원본은 계속 변합니다. 우리가 막 복사한 행이 1초 뒤에 수정되면, 대상에는 과거 버전이 남습니다.

시각 t0: source의 row(id=42) = {amount: 1000}
시각 t1: backfill이 row(id=42)를 target으로 복사 → target = {amount: 1000}
시각 t2: source의 row(id=42)가 {amount: 2000}으로 UPDATE
결과:    source = 2000, target = 1000  (불일치!)

이 불일치를 막으려면 backfill 이후에 발생한 변경을 어떻게든 target에 다시 반영해야 합니다. 바로 이 지점에서 CDC나 Dual-Write가 필요해집니다.

순서(Ordering) 문제

두 번째 벽은 순서입니다. 같은 행에 대한 여러 변경이 발생했을 때, 이 변경들이 target에 적용되는 순서가 source의 순서와 달라지면 최종 상태가 틀어집니다.

source 순서:  UPDATE amount=2000  →  UPDATE amount=3000
target 적용:  UPDATE amount=3000  →  UPDATE amount=2000  (역전!)
결과:         source = 3000, target = 2000

CDC 파이프라인에서 파티션이 여러 개거나, Dual-Write에서 두 저장소에 쓰는 순서가 보장되지 않으면 이런 역전이 생깁니다. 그래서 같은 키에 대한 변경은 같은 파티션/순서로 처리되도록 설계해야 합니다.

그 외의 어려움

문제설명
스키마 차이source와 target의 컬럼 타입, 제약, 인덱스가 다를 수 있음
데이터 양수억 행을 옮기는 동안 source에 부하를 주면 안 됨
트랜잭션 경계여러 행을 한 트랜잭션으로 묶는 의미가 target에서 깨질 수 있음
삭제 처리source에서 DELETE된 행을 target에서도 지워야 함
중복 처리재처리 시 같은 변경이 두 번 적용되면 안 됨(멱등성)

이 모든 문제를 한 번에 푸는 마법은 없습니다. 대신 Dual-Write, Backfill, CDC를 조합해 단계적으로 위험을 줄여 나갑니다.

패턴 1: Dual-Write

개념

Dual-Write는 애플리케이션이 쓰기 요청을 받을 때 source와 target 두 곳에 모두 쓰는 방식입니다. 새 데이터는 자동으로 양쪽에 들어가므로, 마이그레이션 시점 이후의 데이터는 target에도 존재하게 됩니다.

                +-----------------+
   write  --->  |  Application    |
                +--------+--------+
                         |
              +----------+----------+
              |                     |
              v                     v
        +-----------+        +-----------+
        |  source   |        |  target   |
        |  (old DB) |        |  (new DB) |
        +-----------+        +-----------+

간단한 구현

func CreateOrder(ctx context.Context, o Order) error {
    // 1) source에 먼저 쓴다 (진실의 원천)
    if err := sourceDB.Insert(ctx, o); err != nil {
        return fmt.Errorf("source write failed: %w", err)
    }
    // 2) target에도 쓴다
    if err := targetDB.Insert(ctx, o); err != nil {
        // target 실패는 어떻게 처리할 것인가? (아래 논의)
        log.Warn("target write failed", "order_id", o.ID, "err", err)
        metrics.Inc("dual_write.target_failure")
    }
    return nil
}

Dual-Write의 위험

Dual-Write는 직관적이지만 함정이 많습니다. 핵심은 "두 저장소에 쓰는 일이 원자적이지 않다"는 데 있습니다.

케이스 A: source 성공, target 실패 → target에 데이터 누락
케이스 B: source 성공, target 성공, 그러나 그 사이 프로세스 죽음 → 부분 반영
케이스 C: 동시성 — 두 요청이 서로 다른 순서로 두 DB에 도달 → 순서 역전

분산 트랜잭션(2PC)으로 묶으면 원자성은 얻지만 지연과 가용성을 잃습니다. 한쪽 DB가 느려지면 전체 쓰기가 느려지고, 코디네이터가 죽으면 트랜잭션이 멈춥니다. 그래서 실무에서는 보통 2PC를 피하고 다음 중 하나를 택합니다.

전략설명단점
Best-effort dual-writetarget 실패를 로깅만 하고 무시target에 구멍이 생김
Source 우선 + CDC 보정source만 진실, target은 CDC로 채움dual-write 불필요해짐
Outbox 패턴source 트랜잭션에 outbox 행을 같이 기록별도 릴레이 필요

실제로 Dual-Write를 단독으로 쓰면 case A로 인한 누락이 누적됩니다. 그래서 많은 팀이 Dual-Write 대신 또는 함께 CDC를 씁니다. Dual-Write의 가장 안전한 변형은 Outbox 패턴입니다.

Outbox 패턴

Outbox 패턴은 비즈니스 데이터와 "이벤트"를 같은 트랜잭션으로 기록해서 원자성을 확보합니다.

BEGIN;
  INSERT INTO orders (id, amount, status)
  VALUES (42, 2000, 'CREATED');

  INSERT INTO outbox (aggregate_id, event_type, payload)
  VALUES (42, 'OrderCreated', '{"id":42,"amount":2000}');
COMMIT;

같은 트랜잭션이므로 둘 다 커밋되거나 둘 다 롤백됩니다. 그 다음 별도 릴레이(혹은 CDC)가 outbox 테이블을 읽어 target으로 전파합니다. 이렇게 하면 "source에는 썼는데 이벤트는 안 나간" 상황이 사라집니다.

패턴 2: Backfill

개념

Backfill은 마이그레이션 시작 시점에 이미 존재하던 과거 데이터를 배치로 옮기는 작업입니다. Dual-Write나 CDC는 "이제부터의 변경"만 잡으므로, 과거 데이터는 별도로 옮겨야 합니다.

배치 전략

수억 행을 한 번에 SELECT * 하면 source DB가 멈춥니다. 반드시 키 범위로 잘라서 배치로 처리합니다.

func Backfill(ctx context.Context, batchSize int) error {
    var lastID int64 = 0
    for {
        rows, err := sourceDB.Query(ctx, `
            SELECT id, amount, status, updated_at
            FROM orders
            WHERE id > $1
            ORDER BY id
            LIMIT $2`, lastID, batchSize)
        if err != nil {
            return err
        }
        batch := scanRows(rows)
        if len(batch) == 0 {
            break // 끝
        }
        // target에 UPSERT (멱등)
        if err := targetDB.UpsertBatch(ctx, batch); err != nil {
            return err
        }
        lastID = batch[len(batch)-1].ID
        time.Sleep(throttle) // source 부하 조절
    }
    return nil
}

Backfill 설계 원칙

  • 키 범위(keyset) 페이지네이션: OFFSET 대신 id > lastID 방식으로 점점 느려지지 않게
  • UPSERT 사용: 같은 배치를 두 번 돌려도 안전하도록(멱등성)
  • 스로틀링: source CPU, 복제 지연(replication lag)을 보며 속도 조절
  • 재시작 가능: 진행 위치(checkpoint)를 저장해 중단 후 이어서 실행
  • 읽기 전용 복제본 사용: 가능하면 source의 read replica에서 읽어 부하 분산

Backfill과 실시간 변경의 경합

Backfill이 row(id=42)를 t1에 복사했는데, t2에 CDC가 같은 row의 UPDATE를 전달했다고 합시다. 만약 CDC 이벤트가 backfill보다 먼저 처리되면, backfill의 오래된 값이 나중에 덮어쓸 수 있습니다. 이를 막는 일반적 방법은 다음과 같습니다.

1) 버전/타임스탬프 비교: target.updated_at >= incoming.updated_at 이면 무시
2) Backfill은 INSERT ... ON CONFLICT DO NOTHING 사용 (이미 있으면 CDC 값 보존)
3) CDC는 항상 UPSERT (최신 값으로 덮어쓰기)

즉 backfill은 "빈 자리만 채우고", 실시간 변경은 CDC가 책임지도록 역할을 나누면 경합이 단순해집니다.

패턴 3: CDC (Change Data Capture)

개념

CDC는 데이터베이스의 변경 로그(WAL, binlog 등)를 읽어 변경 이벤트 스트림으로 바꾸는 기술입니다. 애플리케이션 코드를 건드리지 않고도 source의 모든 INSERT/UPDATE/DELETE를 잡아낼 수 있습니다. 대표적인 도구가 Debezium입니다.

   +-----------+    WAL/binlog    +-----------+    events    +-----------+
   |  source   | ---------------> | Debezium  | -----------> |  Kafka    |
   |  (DB)     |                  | connector |              |  topic    |
   +-----------+                  +-----------+              +-----+-----+
                                                                   |
                                                                   v
                                                            +-----------+
                                                            | consumer  |
                                                            | -> target |
                                                            +-----------+

PostgreSQL 논리적 복제 기반 CDC

PostgreSQL은 논리적 복제(logical replication)와 복제 슬롯(replication slot)을 제공합니다. Debezium은 이 위에서 동작합니다.

-- 논리적 복제를 위한 설정 (postgresql.conf)
-- wal_level = logical

-- 발행(publication) 생성
CREATE PUBLICATION orders_pub FOR TABLE orders;

-- 복제 슬롯 확인
SELECT slot_name, plugin, active
FROM pg_replication_slots;

복제 슬롯은 강력하지만 위험합니다. 컨슈머가 멈추면 WAL이 정리되지 않고 쌓여 디스크가 가득 찰 수 있습니다. 반드시 슬롯의 lag을 모니터링해야 합니다.

-- 복제 슬롯이 잡아둔 WAL 크기 확인
SELECT slot_name,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS retained_wal
FROM pg_replication_slots;

Debezium 커넥터 설정 예시

{
  "name": "orders-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "source-db",
    "database.port": "5432",
    "database.user": "debezium",
    "database.dbname": "shop",
    "topic.prefix": "cdc",
    "plugin.name": "pgoutput",
    "slot.name": "orders_slot",
    "publication.name": "orders_pub",
    "table.include.list": "public.orders",
    "snapshot.mode": "initial"
  }
}

여기서 snapshot.mode가 중요합니다. initial이면 Debezium이 시작할 때 기존 데이터의 스냅샷을 먼저 찍고 그 다음 변경 스트림으로 넘어갑니다. 이 스냅샷이 사실상 backfill 역할을 대신하기도 합니다. 다만 대용량 테이블에서는 incremental snapshot을 고려해야 합니다.

CDC 이벤트의 모양

Debezium 이벤트는 변경 전후 상태를 모두 담습니다.

{
  "op": "u",
  "before": { "id": 42, "amount": 1000, "status": "CREATED" },
  "after":  { "id": 42, "amount": 2000, "status": "PAID" },
  "source": { "lsn": 123456789, "ts_ms": 1718500000000 },
  "ts_ms": 1718500000123
}
  • op: c(create), u(update), d(delete), r(read=snapshot)
  • before/after: 변경 전후 행
  • source.lsn: 로그 위치, 순서 판단에 사용

컨슈머에서의 멱등 적용

CDC 컨슈머는 같은 이벤트를 두 번 받을 수 있습니다(at-least-once). 그래서 적용은 반드시 멱등이어야 합니다.

func ApplyEvent(ctx context.Context, e CDCEvent) error {
    switch e.Op {
    case "c", "u", "r":
        // LSN 비교로 오래된 이벤트는 무시
        _, err := targetDB.Exec(ctx, `
            INSERT INTO orders (id, amount, status, _lsn)
            VALUES ($1, $2, $3, $4)
            ON CONFLICT (id) DO UPDATE
              SET amount = EXCLUDED.amount,
                  status = EXCLUDED.status,
                  _lsn   = EXCLUDED._lsn
            WHERE orders._lsn < EXCLUDED._lsn`,
            e.After.ID, e.After.Amount, e.After.Status, e.LSN)
        return err
    case "d":
        _, err := targetDB.Exec(ctx,
            `DELETE FROM orders WHERE id = $1 AND _lsn < $2`,
            e.Before.ID, e.LSN)
        return err
    }
    return nil
}

_lsn 컬럼으로 "이미 더 최신 변경이 적용되었으면 무시"하는 가드를 두면 순서 역전과 재처리를 동시에 막을 수 있습니다.

세 도구를 조합한 전체 그림

실무에서는 보통 Backfill + CDC 조합을 씁니다. Dual-Write는 CDC 도입이 어려운 환경에서 보조로 쓰거나 Outbox 형태로 변형해서 씁니다. 전형적인 흐름은 다음과 같습니다.

[1] CDC 슬롯 생성 (이 시점부터의 변경을 잡기 시작)
        |
        v
[2] Backfill 시작 (과거 데이터를 배치로 target에 채움)
        |   동시에 CDC는 신규 변경을 target에 적용
        v
[3] Backfill 완료 + CDC lag ~ 0  (source == target 수렴)
        |
        v
[4] 검증 (row count, checksum, 샘플 비교)
        |
        v
[5] Shadow read (target을 읽어보되 결과는 버림, 비교만)
        |
        v
[6] Read cutover (읽기를 target으로 전환)
        |
        v
[7] Write cutover (쓰기를 target으로 전환, source 중단)
        |
        v
[8] 안정화 후 source/CDC 정리

순서가 중요합니다. CDC 슬롯을 backfill보다 먼저 만들어야 backfill 도중의 변경을 놓치지 않습니다. 그리고 멱등 적용 덕분에 backfill과 CDC가 같은 행을 건드려도 최종 상태가 수렴합니다.

검증(Verification)

컷오버 전에 source와 target이 정말 같은지 확인해야 합니다. "아마 같겠지"로 넘어가면 데이터 유실을 프로덕션에서 발견하게 됩니다.

1단계: 행 수 비교(row count)

가장 싸고 빠른 검사입니다. 다만 행 수가 같아도 내용이 다를 수 있으니 1차 게이트로만 씁니다.

-- source
SELECT count(*) FROM orders;
-- target
SELECT count(*) FROM orders;

2단계: 체크섬(checksum) 비교

행 단위 또는 범위 단위로 체크섬을 계산해 비교합니다. 전체를 한 번에 비교하면 비싸므로 키 범위로 나눠서 합니다.

-- 범위별 체크섬 (PostgreSQL)
SELECT
  (id / 100000) AS bucket,
  count(*) AS cnt,
  md5(string_agg(
    id || ':' || amount || ':' || status,
    ',' ORDER BY id
  )) AS checksum
FROM orders
GROUP BY (id / 100000)
ORDER BY bucket;

source와 target에서 같은 쿼리를 돌려 bucket별 checksum이 다른 구간만 골라 정밀 비교하면 효율적입니다.

3단계: 샘플 비교(sample comparison)

전수 비교가 어려우면 랜덤 샘플을 뽑아 행 전체를 비교합니다. 특히 최근에 변경된 행, 경계값(가장 큰 id, NULL 많은 행)을 우선적으로 봅니다.

검증 우선순위:
1) 가장 최근에 수정된 행 (CDC lag로 인한 누락 탐지)
2) 가장 오래된 행 (backfill 초기 버그 탐지)
3) 경계값 / 특이값 (NULL, 음수, 빈 문자열)
4) 무작위 N건

검증 결과 정리

검사비용잡아내는 것한계
Row count매우 낮음대량 누락/중복내용 차이 못 잡음
Checksum(범위)중간내용 불일치 구간정렬/타입 차이에 민감
Sample 비교낮음구체적 행 차이전수 보장 안 됨
Shadow read중간실제 쿼리 결과 차이운영 부하 증가

Shadow Read (그림자 읽기)

검증을 한 단계 더 끌어올리는 기법이 shadow read입니다. 실제 운영 트래픽으로 target을 읽어보되, 결과는 사용자에게 주지 않고 source 결과와 비교만 합니다.

func GetOrder(ctx context.Context, id int64) (Order, error) {
    // 진실은 여전히 source
    o, err := sourceDB.GetOrder(ctx, id)
    if err != nil {
        return Order{}, err
    }
    // shadow: target도 읽어 비교 (비동기, 실패 무시)
    go func() {
        shadow, serr := targetDB.GetOrder(context.Background(), id)
        if serr != nil {
            metrics.Inc("shadow.read_error")
            return
        }
        if !shadow.Equal(o) {
            metrics.Inc("shadow.mismatch")
            log.Warn("shadow mismatch", "id", id,
                "source", o, "target", shadow)
        }
    }()
    return o, nil
}

Shadow read는 실제 쿼리 패턴(조인, 필터, 정렬)에서의 차이를 잡아냅니다. Row count나 checksum이 통과해도, 특정 쿼리에서 인덱스나 스키마 차이로 다른 결과가 나오는 경우를 여기서 발견할 수 있습니다. 단, 운영 부하가 약 2배가 되므로 샘플링 비율을 조절합니다.

컷오버(Cutover)

Read cutover 먼저

읽기를 먼저 전환하는 이유는 위험이 낮기 때문입니다. 읽기는 데이터를 바꾸지 않으므로, target에서 잘못 읽어도 source 데이터는 멀쩡합니다. 문제가 보이면 즉시 source로 되돌리면 됩니다(읽기 롤백은 무손실).

read_from_target = feature_flag("orders.read_target")  // 0% -> 1% -> 10% -> 50% -> 100%

퍼센트를 점진적으로 올리며 shadow mismatch와 에러율을 봅니다.

Write cutover는 신중하게

쓰기 전환은 되돌리기 어렵습니다. write cutover 이후 target에만 들어간 데이터는 source에 없으므로, 단순 롤백 시 그 데이터를 잃습니다. 그래서 write cutover 전에 보통 다음을 준비합니다.

1) 역방향 CDC 준비: target -> source 동기화를 미리 구성
   (롤백 시 target의 신규 데이터를 source로 되돌리기 위함)
2) 짧은 쓰기 정지 윈도우(선택): 순간적으로 쓰기를 막고
   CDC lag을 0으로 만든 뒤 전환하면 가장 안전
3) 멱등 키 보장: 전환 직후 재시도가 중복을 만들지 않도록

컷오버 시퀀스 예시

T-0   write를 source로 받되 CDC가 target에 반영 중 (lag ~0 유지)
T-1   read를 100% target으로 전환, 24시간 관찰
T-2   역방향 CDC(target->source) 활성화
T-3   (선택) 5초간 쓰기 일시 정지, 양방향 lag 0 확인
T-4   write를 target으로 전환
T-5   source로의 쓰기 차단, target이 단일 진실
T-6   1주 안정화 후 정방향 CDC, 복제 슬롯, source 정리

롤백(Rollback)

마이그레이션 계획에서 가장 중요한 부분은 "되돌리는 길"입니다. 롤백 경로가 없는 컷오버는 도박입니다.

단계롤백 방법데이터 손실
Backfill 중그냥 중단, target 폐기없음
Read cutover플래그를 source로 되돌림없음
Write cutover 직후역방향 CDC로 target 변경을 source에 반영 후 전환역방향 동기화가 있어야 무손실
Write cutover 후 오래 경과역방향 CDC 유지 여부에 달림없으면 손실 위험

핵심 교훈: write cutover 시점에 역방향 CDC를 미리 켜 두면, 전환 직후 문제가 생겨도 target에 쌓인 신규 데이터를 source로 되돌릴 수 있어 무손실 롤백이 가능합니다. 이 역방향 경로를 며칠 유지한 뒤 안전이 확인되면 정리합니다.

멱등성과 재처리(Idempotency & Reprocessing)

CDC와 Backfill 모두 "같은 변경을 여러 번 적용해도 결과가 같아야 한다"는 멱등성을 요구합니다. 컨슈머는 재시작될 수 있고, 이벤트는 중복 전달될 수 있고, backfill은 재실행될 수 있기 때문입니다.

멱등성을 보장하는 방법

1) UPSERT(ON CONFLICT): 존재하면 갱신, 없으면 삽입 → 중복 INSERT 방지
2) 버전 가드: _lsn / updated_at / version 컬럼으로 오래된 변경 무시
3) 자연키 또는 결정적 키: 같은 입력은 항상 같은 PK로 매핑
4) 멱등 토큰: 메시지 ID를 처리 기록 테이블에 저장해 중복 차단

처리 기록(dedupe) 테이블 예시

CREATE TABLE processed_events (
  event_id   TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 적용 트랜잭션 안에서
BEGIN;
  INSERT INTO processed_events (event_id)
  VALUES ('evt-abc-123')
  ON CONFLICT (event_id) DO NOTHING;
  -- 위 INSERT가 0건이면 이미 처리한 이벤트 → 본 작업 건너뜀
  -- ... 실제 데이터 적용 ...
COMMIT;

재처리(reprocessing)는 무서운 일이 아니라 정상적인 운영의 일부입니다. 멱등성이 보장되면 "처음부터 다시 돌려도 같은 결과"이므로, CDC 컨슈머 오프셋을 과거로 되감아 의심 구간을 다시 적용해도 안전합니다.

사례 연구

사례 1: 큰 테이블을 두 개로 분리

orders 테이블이 너무 커져서 ordersorder_items로 분리해야 한다고 합시다.

[기존] orders(id, user_id, item_json, amount, ...)
[목표] orders(id, user_id, amount, ...)
       order_items(id, order_id, sku, qty, price)

절차:

  1. 새 두 테이블 생성, CDC 슬롯을 기존 orders에 연결
  2. Backfill: 기존 orders를 읽어 새 두 테이블로 변환 삽입(item_json을 파싱해 order_items 행 생성)
  3. CDC 컨슈머도 같은 변환 로직 적용(신규/수정 주문을 두 테이블로 분배)
  4. 검증: 주문 건수, 금액 합계, 아이템 개수 합계를 source/target에서 비교
  5. Read cutover → Write cutover

이때 변환 로직(transform)이 backfill과 CDC 양쪽에서 동일해야 합니다. 변환이 한쪽에만 적용되면 데이터가 갈라집니다. 변환 함수를 공통 모듈로 빼서 둘이 같은 코드를 쓰게 만드는 것이 핵심입니다.

사례 2: 모놀리식 DB에서 서비스 분리

결제 기능을 별도 마이크로서비스로 떼어내며 전용 DB로 옮기는 경우입니다.

[기존] monolith_db.payments  (모놀리식이 직접 접근)
[목표] payment_service_db.payments  (결제 서비스만 접근)

이 경우 애플리케이션 경계가 바뀌므로 Dual-Write(또는 Outbox)가 유용합니다.

  1. 결제 서비스가 새 DB에 쓰기 시작, 동시에 모놀리식은 기존 DB를 읽음
  2. Outbox + CDC로 신규 결제를 양쪽 동기화
  3. Backfill로 과거 결제 이전
  4. 검증 후 모놀리식의 결제 읽기를 새 서비스 API 호출로 전환(read cutover)
  5. 모놀리식의 결제 쓰기를 새 서비스로 위임(write cutover)
  6. 모놀리식의 직접 DB 접근 제거

서비스 분리에서는 데이터뿐 아니라 "접근 경로"도 함께 옮겨야 한다는 점이 테이블 분리와 다릅니다.

흔한 함정(Pitfalls)

1. CDC 슬롯을 backfill보다 늦게 만들기

슬롯을 늦게 만들면 그 사이의 변경을 놓칩니다. 항상 슬롯/CDC를 먼저 시작하고 backfill을 나중에 합니다.

2. 복제 슬롯 lag을 방치하기

컨슈머가 멈추면 PostgreSQL의 WAL이 정리되지 않고 쌓여 source 디스크가 가득 찹니다. 최악의 경우 source DB가 다운됩니다. 슬롯 lag 알람은 필수입니다.

3. 순서 보장을 잊기

같은 키의 변경이 다른 순서로 적용되면 최종 상태가 틀어집니다. 같은 키는 같은 파티션으로, 적용 시 LSN/버전 가드를 둡니다.

4. DELETE를 빠뜨리기

INSERT/UPDATE만 신경 쓰다가 DELETE를 안 옮기면 target에 유령 행이 남습니다. CDC의 op=d를 반드시 처리합니다.

5. 스키마 변경과 마이그레이션을 동시에

마이그레이션 도중 source에 컬럼을 추가하면 CDC와 변환 로직이 깨질 수 있습니다. 마이그레이션 윈도우 동안에는 source 스키마를 동결하는 것이 안전합니다.

6. 검증을 컷오버 직전에만

검증은 한 번이 아니라 지속적으로 돌려야 합니다. 컷오버 직전에만 보면, 그 사이 새로 생긴 불일치를 놓칩니다.

7. 롤백 리허설을 안 하기

롤백 절차를 문서로만 두고 실제로 해보지 않으면, 정작 위급할 때 작동하지 않습니다. 스테이징에서 롤백을 실제로 실행해 봐야 합니다.

8. 타임존/타입 미스매치

source와 target의 timestamp 타입, 타임존, 숫자 정밀도가 다르면 checksum이 매번 틀립니다. 비교 전에 타입을 정규화합니다.

마이그레이션 체크리스트

[ 준비 ]
[ ] target 스키마 정의 및 인덱스 설계 완료
[ ] 변환(transform) 로직을 backfill/CDC가 공유하는 공통 모듈로 작성
[ ] CDC 슬롯/커넥터 구성, snapshot.mode 결정
[ ] 멱등 적용 보장(UPSERT + 버전 가드)
[ ] dedupe 테이블 또는 멱등 토큰 준비

[ 실행 ]
[ ] CDC 슬롯을 backfill보다 먼저 생성
[ ] keyset 페이지네이션 + 스로틀링으로 backfill
[ ] backfill checkpoint 저장(재시작 대비)
[ ] CDC lag, 복제 슬롯 lag, source 부하 모니터링

[ 검증 ]
[ ] row count 비교(1차 게이트)
[ ] 범위별 checksum 비교
[ ] 최근/오래된/경계값 샘플 비교
[ ] shadow read로 실제 쿼리 결과 비교

[ 컷오버 ]
[ ] read cutover를 퍼센트로 점진 전환
[ ] 역방향 CDC(target->source) 준비
[ ] (선택) 짧은 쓰기 정지 윈도우로 lag 0 확보
[ ] write cutover 후 source 쓰기 차단

[ 롤백/정리 ]
[ ] 롤백 절차 문서화 + 스테이징 리허설
[ ] 안정화 기간 동안 역방향 CDC 유지
[ ] 안전 확인 후 슬롯/커넥터/source 정리

마치며

무중단 데이터 마이그레이션의 핵심은 화려한 도구가 아니라 단계적 위험 축소입니다. Backfill로 과거를 옮기고, CDC로 현재를 따라잡고, 멱등성으로 충돌을 흡수하고, 검증으로 확신을 얻고, read를 먼저 write를 나중에 전환하며, 항상 되돌아갈 길을 열어 둡니다.

특히 기억할 세 가지는 다음과 같습니다. 첫째, CDC 슬롯을 backfill보다 먼저 만들어 변경을 놓치지 마십시오. 둘째, 모든 적용을 멱등하게 만들어 재처리를 두려워하지 마십시오. 셋째, write cutover 전에 역방향 CDC를 켜서 무손실 롤백 경로를 확보하십시오. 이 세 가지만 지켜도 대부분의 마이그레이션은 사고 없이 끝납니다.

데이터는 흐르는 강물입니다. 강을 막지 말고, 천천히 새 강바닥을 파고, 물길이 안정되면 옛 강바닥을 메우십시오.

참고 자료