5 Billion Records, 10 Terabytes, Four Weeks: A Real Data Migration Playbook

Author: Avraham Rosenzweig, Roy Noyman (Wix Engineering) | Source: wix.engineering | Published: 2026-09-02


한 줄 요약

마이그레이션이 2년에서 4주로 줄어든 차이는 툴링이 아니라 — 툴링은 거의 바뀌지 않았다 — 모든 마이그레이션에서 나타나는 문제가 무엇인지 마침내 배우고 그것을 도중이 아니라 시작 전에 다루기 시작한 것이다.

핵심 주장/내용

  • 기본 플레이북 (압축판): V2 API를 설계하고, V1 앞에 API 프록시를 두고, population 기반 토글로 신규 사용자에게 롤아웃하고, CDC로 데이터를 마이그레이션하고, V1이 idle해질 때까지 진행 중 변경을 계속 동기화하고, 모두를 V2로 라우팅하고 옛 시스템을 폐기한다. 플레이북은 강한 기반이지만 현실 세계에서는 일이 지저분해진다. 이 글은 플레이북을 올바르게 따라도 여전히 나타나는 문제들에 대한 것이다
  • 문제 1 — 당신의 데이터베이스에는 비밀이 있다: 마이그레이션 코드를 한 줄 쓰기 전에 소스 데이터베이스와 친숙해져야 한다. 아무리 강조해도 지나치지 않다
    • 레거시 서비스는 수년의 변경을 지니고 있고, 거의 항상 예상보다 더 손상되어 있다. 실제로 발견한 것들 — UUID 스펙을 따르지 않는 UUID, 어떤 검증도 허용하지 말았어야 할 길이의 first name, 문서화된 한도보다 많은 항목을 담은 리스트, 그리고 최소 한 건의 생년월일이 19,653년
    • 이런 레코드가 존재하는 이유: 검증이 시간이 지나며 바뀌었고, 옛 코드에 버그가 있었고, 다른 규칙을 가진 시스템에서 데이터를 임포트했기 때문
    • 왜 중요한가: V2 API는 더 엄격한 검증을 가질 것이다. V1이 조용히 받아들이는 레코드가 변환하려 할 때 마이그레이션 서비스를 깨뜨린다
    • 해법은 단순하고 거의 노력이 들지 않는데 가시성 이득은 엄청나다: 다른 무엇보다 먼저 마이그레이션 서비스의 read 단계를 구현하고, 소스 DB에 바인딩하고, 일관성이 의심되는 모든 필드와 구조에 로깅을 추가하라. 스냅샷 dry run을 돌려 대시보드를 만들어라. 뭔가를 발견하게 된다. 5십억 레코드 10테라바이트를 이미 마이그레이션한 뒤에 그중 일부가 변환될 수 없음을 발견하는 게 아니라 지금 발견하라
  • 문제 2 — parity gap이 당신을 찾아낼 것이다. 먼저 찾아라: parity gap은 V1과 V2가 같은 입력에 다른 결과를 반환하는 것. 누락된 기능, 시스템 간 다른 검증, 또는 V2 구현의 순전한 버그 때문에 생긴다. parity gap이 있으면 사용자에게 롤아웃할 수 없고, 늦게 찾으면 몇 주가 든다
    • 첫째: 테스트에 일찍 투자하라. 마이그레이션은 강한 테스트 스위트가 여러 배로 값을 하는 장소 중 하나다. Wix는 E2E 테스트에 크게 의존하는데 사용자와 서버가 실제로 경험하는 대로 시스템을 검증하기 때문이다. 프로덕션 트래픽을 건드리기 전에 V1 E2E 스위트를 API 프록시를 통해 V2 구현에 대해 돌려라
      • 중요한 점은 기존 테스트 재사용만이 아니라 애초에 의미 있는 E2E 커버리지를 갖는 것이다. 유닛 테스트는 개별 로직을 검증하지만 마이그레이션은 컴포넌트 사이의 경계에서 실패한다 — 데이터·검증·부작용에 대한 가정이 만나는 곳에서
    • 둘째: LLM으로 V1 API의 실제 프로덕션 사용을 분석하라. V1·V2 코드베이스, 테스트 스위트, 실제 프로덕션 트래픽 샘플을 주고 버전 간 다르게 동작할 수 있는 유스케이스를 식별하게 하라. 기존 테스트가 커버하지 않는 격차를 드러내는데, 그 테스트들이 V1 가정에 대해 쓰였기 때문이다
    • API 프록시의 compare mode — V1으로 가는 모든 read 요청이 V2로 비동기 shadow call을 트리거하고, 응답이 비교되어 match/diff 점수 대시보드가 나온다. 전체 마이그레이션을 확정하기 전에 프로덕션 트래픽 일부에 대해 돌려라. 핵심 단어는 before다 — 데이터를 다 마이그레이션한 뒤에 compare mode를 돌리는 것은 버그를 발견하기 전에 이미 마이그레이션 비용을 지불했다는 뜻
  • 문제 3 — 나쁜 레코드 하나가 전부를 멈출 수 있다: CDC는 파티션별 메시지 순서를 보존한다. 그것이 신뢰성의 근거이고, 동시에 취약성의 근거다 — 메시지 하나가 실패하면 그 파티션에서 뒤의 모든 것이 멈춘다
    • 커넥터를 시작하고, 스냅샷을 돌리고, 퇴근하고, 아침에 compare 대시보드를 분석할 거라 기대하며 돌아온다. 그런데 어떤 위치에서 레코드 하나가 실패했고, 마이그레이션 서비스는 자정부터 멈춰 있었다
    • 세 가지 접근이 있고 올바른 것은 데이터를 얼마나 신뢰하느냐에 달렸다:
전략동작선택 기준
Blocking retries실패 시 파이프라인이 멈추고 사람이 핸들러를 고칠 때까지 멈춰 있다속도를 잃고 on-call이 필요하지만 아무것도 조용히 건너뛰이지 않는다. 데이터 일관성이 다른 모든 것보다 우선일 때
Try, catch, skip변환을 감싸고, 조사할 만한 컨텍스트와 함께 실패를 로깅하고, 메트릭을 내보내고, 넘어간다파이프라인이 일정대로 끝나고 나중에 triage — 해당 테넌트 제외, 버그 수정, 영향받은 슬라이스 재마이그레이션. 사람 없이 완료가 필요하고 알려진 낙오자 집합을 감내할 수 있을 때
Dead letter queue실패 메시지가 별도 토픽으로 전환메인 파이프라인이 절대 멈추지 않고 아무것도 잃지 않으며 자기 일정으로 복구·재생. 가장 견고하고 가장 만들기 힘들다 — 예상대로의 트레이드오프
  • 셋 다 써봤다. 요점은 무엇이 최선이냐가 아니라 “레코드가 실패하면 무슨 일이 일어나는가”가 발견이 아니라 설계 문서의 한 줄이어야 한다는 것
  • 문제 4 — 마이그레이션 속도의 산수: 50억 레코드(약 10TB, 서비스가 약 200,000 RPM 처리)에 대해 DBA가 라이브 트래픽 보호를 위해 5,000 RPM을 넘지 말라고 하면 마이그레이션은 694일 걸린다
    • 그런데 그 계산은 불완전하다. eager 마이그레이션이 히스토리컬 레코드를 처리하는 동안 데이터베이스는 평균 약 15,000 RPM — 하루 약 2,200만 변경 — 으로 변경을 축적한다. lazy 마이그레이션이 그 변경도 처리해야 한다. eager가 5,000 RPM이고 변경이 15,000 RPM으로 도착하면 결코 끝나지 않는다
    • 따라서 실용적 운영 속도는 사실상 둘뿐이다 — 들어오는 변경률을 앞지를 만큼 빠르거나, 몇 년을 돌려도 괜찮을 만큼 느리거나
    • 레버 1: 파티션. CDC가 파티션으로 메시지를 만들어 병렬화를 가능하게 한다. 하지만 파티션 수를 시작 전에 정해야 하고, 나중이 아니다. 돌아가는 마이그레이션에 파티션을 추가하려 하면 “too late, 처음부터 다시 해야 한다”는 말을 듣는다. 필요 처리량을 미리 계산하고 파티션당 예상 처리율로 나눠 설정하라. contacts 마이그레이션에는 100,000 RPM 유지에 350개 파티션이 필요했다
    • 레버 2: 데이터베이스 용량. 옛 contacts DB는 16TB 최대치 중 14TB를 써서 공간이 부족했고, write 부하가 15,000~50,000 RPM, 단일 master의 MySQL이었다. DBA·플랫폼 엔지니어링·DB 인프라 팀이 샤딩을 구현해 DB를 8개 작은 DB로 쪼개 write 처리량을 샤드 수만큼 곱했다. 샤딩으로 필요한 처리량에서 돌려 실제로 끝낼 수 있었다. 마이그레이션 타임라인이 DB 용량에 막혀 있다면 이 대화를 일찍 해야 한다
    • 하나 더: 마이그레이션 서비스가 다른 시스템을 건드리고 부작용을 만들면 그 시스템들에도 한계가 있다. 일부는 문서화되어 있고 많은 것은 아니다. 문서화되지 않은 것은 사람들이 와서 “당신이 상류의 뭔가를 다운시키고 있으니 마이그레이션을 멈추라”고 할 때 발견한다
      • 해법은 decouple — 마이그레이션 중에 다른 시스템을 직접 호출하는 대신 BI 이벤트나 Kafka 메시지를 생산하고, 그 호출이 필요한 것에 대해 별도 후속 마이그레이션을 돌려라. blast radius를 줄여라
  • 문제 5 — 정리도 마이그레이션의 일부다: 마이그레이션은 데이터가 V2에 들어갔을 때 끝나지 않는다. V1이 사라졌을 때 끝난다
    • V1 API에 트래픽이 없는지 확인 → 파드 제거하고 서비스 종료 → 코드 삭제 (⚠️ proto 패키지에 특히 주의 — stale proto 참조가 인시던트를 일으킨다) → 마이그레이션된 population 데이터 삭제 (population 스토리지에는 실제 비용이 있다) → 마지막으로 옛 데이터베이스를 drop
    • 그 마지막 단계가 무섭게 느껴진다. 그럴 필요 없다. 데이터는 V2에 있다. V1 데이터베이스를 남겨두는 것은 당신을 더 안전하게 만들지 않는다 — 돈만 들고 어느 것이 source of truth인지에 대한 모호함만 만든다

주요 수치 / 사실

  • contacts 마이그레이션: 50억 레코드 / 10TB / 서비스 ~200,000 RPM. DBA 제한 5,000 RPM으로는 694일
  • 마이그레이션 중 DB가 축적하는 변경 평균 ~15,000 RPM = 하루 ~2,200만 건 → 5,000 RPM으로는 영원히 못 끝낸다
  • 350개 파티션으로 100,000 RPM 유지
  • 옛 DB: 16TB 중 14TB 사용, write 15,000~50,000 RPM, 단일 master MySQL → 8개 샤드로 분할
  • Discount V2가 4주에 완료. Contacts V5도 최근 완료. 커리어 초기의 7개월·2년 마이그레이션과 비교하면 그 차이는 거의 전적으로 이 글의 문제들을 도중에 처리할 놀라움이 아니라 일급 계획 관심사로 다룬 데서 왔다
  • 다음 단계 — supervised, agentic 마이그레이션: 위의 모든 것이 체크리스트다 — dry-run population 정의, 실행, 로그 연구, 변환 버그 수정과 PR 열기, 파티션 사이징, DB 리소스 협상, compare mode 실행, diff 추적, 실제 마이그레이션 시작, 대시보드 감시, 깨진 부분집합 재마이그레이션, 정리. 길고 반복적이고 거의 모든 단계가 이제 정확히 기술할 수 있는 패턴을 따른다 — 충분히 여러 번 해봐서 이 글을 쓸 수 있게 되었기 때문
    • 세 조각: 대시보드와 로그에서 마이그레이션 상태를 지속적으로 인지하는 것, 그 상태에 대해 추론하고 다음 수를 결정하는 것, 그것을 실행하는 것(실패한 변환을 고치는 PR 열기, 리소스 조정, compare 점수가 떨어지면 부분집합 재마이그레이션 트리거)
    • 제목의 무거운 일을 하는 단어는 supervised다. 에이전트는 플레이북을 따를 수 있다 — 그게 플레이북을 갖는 요점이다. 어려운 부분은 플레이북 밖으로 벗어나는 순간, 바로 이 글이 다루는 순간들을 인식하고 멈춰서 사람에게 묻도록 가르치는 것이다. 첫 버전은 자주 묻고 열 번째 버전은 드물게 물을 것이고, 2년 뒤 우리 팀의 마이그레이션은 파이프라인을 운영하는 것보다 에이전트의 pull request를 리뷰하는 것에 가까워 보일 것
  • “plan for the mess, build for safety, clean up after yourself, and celebrate when V1 is finally, truly dead.”

관련 위키


Source: 원문 보기