포스트

네이버 D2 「스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기」 리뷰 — 두 DB에 동시에 쓰되 한쪽 실패는 무시하고, 읽기 트래픽을 복제해 성능을 재고, 6개월간 불일치를 0으로 만든 과정

원문: 스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기 — NAVER D2, 조호영·김지한, 2026-01-22

한 줄 요약

10년 넘은 서비스의 데이터베이스를 Oracle에서 MySQL로 바꾸는데, 서비스를 멈출 수 없고 문제가 나면 바로 되돌릴 수 있어야 했다. 답은 이중 쓰기다. 쓰기를 두 DB에 동시에 하되 새 DB 쪽 실패는 무시하고, 실제 읽기 요청을 복제해 새 DB의 성능을 미리 재고, 6개월 동안 두 DB의 데이터를 대조해 불일치를 하나씩 없앤 뒤에야 읽기를 옮겼다. 옮기고 나서도 Oracle 방향으로 이중 쓰기를 계속해 언제든 롤백할 수 있게 뒀다.

왜 옮기는가

스마트스토어의 여러 파트가 Oracle 한 대를 같이 쓰고 있었다. 비즈니스가 커지자 서로 자원을 뺏어 성능이 불안정해졌고, Oracle을 늘리면 라이선스 비용이 크게 뛴다. 그래서 회원 파트가 자기 몫을 MySQL로 떼어 내기로 했다. 조건은 둘이다. 다른 부서 시스템과 넓게 연결돼 있어 중단 불가, 그리고 DB 전환 뒤 치명적 성능 저하가 나면 재배포로는 못 고치므로 빠른 롤백.

핵심 아이디어: 두 곳에 쓰고, 한 곳에서 읽는다

전환은 세 단계다.

  1. 전환 전: 옛 애플리케이션이 읽기·쓰기를 전부 Oracle에서 하되, 쓰기(등록·수정·삭제)가 일어날 때마다 뒤에서 MySQL에도 같이 쓴다.
  2. 데이터 이관: 새 애플리케이션을 배포하기 전에 Oracle 전체 데이터를 MySQL로 옮겨 맞춘다.
  3. 전환 후: 새 애플리케이션이 읽기·쓰기를 MySQL에서 하고, 뒤에서 Oracle에도 같이 쓴다.

3단계에서도 Oracle에 계속 쓰기 때문에, 문제가 생기면 데이터 복구 없이 옛 애플리케이션으로 되돌리면 된다. 이것이 롤백 능력이다.

자세히 보기 1: 새 DB 쪽 실패는 트랜잭션을 깨지 않는다

이중 쓰기에서 가장 중요한 결정이 여기 있다. 한 트랜잭션 안에서 쿼리 여럿을 실행하다 오류가 나면 두 DB의 정합성은 어떻게 되는가.

원문은 MySQL 쿼리를 트랜잭션에 포함시키지 않기로 했다. 두 DB를 한 트랜잭션으로 묶는 도구(ChainedTransactionManager, 분산 트랜잭션)가 있지만 쓰면 안 되는 이유가 분명하다. 이중 쓰기를 시작하는 시점에는 두 DB의 데이터가 아직 안 맞고, 모든 쿼리가 MySQL에서 오류 없이 도는지 검증되지 않았고, 인덱스·커넥션 풀 설정도 안 된 상태다. MySQL 쪽만 실패하는 일이 여러 이유로 생기는데, 그때마다 Oracle 트랜잭션까지 롤백되면 서비스가 망가진다.

그래서 트랜잭션 도중 실행된 쿼리를 모아 두었다가 커밋 후 한꺼번에 MySQL에서 실행한다. 롤백되면 MySQL에는 아무것도 실행되지 않았으니 신경 쓸 것이 없고, Oracle 커넥션을 붙잡는 시간도 늘지 않고, 실패하면 로그만 남긴다. MySQL 쪽 실패는 용인하고, 주기적 검증으로 불일치 원인을 찾아 없애는 방식이다. Spring의 TransactionSynchronizationManager로 커밋 시점에 끼어드는 구현이다.

자세히 보기 2: 비즈니스 코드를 한 줄도 안 고치고 두 곳에 쓰기

서비스는 JPA와 MyBatis를 같이 쓴다. JPA 쪽은 실행되는 SQL을 가로채는 라이브러리(datasource-proxy)로 Oracle에서 실행된 쿼리를 그대로 MySQL에도 흘려보냈다. 단순 CUD라 두 DB의 문법 차이가 거의 없어 가능했다.

MyBatis 쪽이 어려웠다. 쓰기 로직마다 “MySQL 매퍼도 호출”을 넣으려면 10년치 비즈니스 코드 수백, 수천 곳을 고쳐야 한다. 그래서 MyBatis의 내부 구조를 이용했다. @Mapper 인터페이스 호출은 결국 SqlSession의 메서드 호출로 끝나므로, SqlSession을 만들어 주는 팩토리를 감싸서 하나의 세션이 Oracle과 MySQL 두 세션을 들고 있다가 CUD면 양쪽에 실행하게 만들었다(CombinedSqlSessionFactory). 읽기는 Oracle 결과만 쓴다. 이렇게 하면 이중 쓰기 로직이 한 곳에 모이고, 전환이 끝나면 그 한 곳만 지우면 된다. 대신 MyBatis XML은 MySQL 문법(SYSDATENOW(), NVLIFNULL, ROWNUMLIMIT)으로 한 벌을 더 써야 했다.

자세히 보기 3: 성능은 실제 읽기 요청을 복제해서 쟀다

쓰기는 이중 쓰기로 실제 부하가 MySQL에 가지만, 읽기는 안 간다. 그런데 Oracle에서 잘 돌던 복잡한 쿼리가 MySQL에서 똑같이 빠른지는 실제 부하로 확인해야 한다. HTTP 요청 자체를 복제하면 다른 부서 시스템까지 두 번 호출되어 부작용이 크다.

원문의 방법이 깔끔하다. Repository 계층의 읽기 메서드가 호출될 때 메서드 이름과 인자를 JSON으로 캡처해 Kafka로 보내고, 별도 소비자가 그것을 받아 리플렉션으로 MySQL을 바라보는 같은 Repository 메서드를 호출한다. 비즈니스 로직은 거치지 않고 DB 조회만 재현된다. 메시지마다 Oracle에서의 실행 시간도 실려 있어 쿼리별로 MySQL과 비교할 수 있었고, 느린 쿼리를 고치고 인덱스를 추가했다.

자세히 보기 4: 정합성은 6개월 동안 대조했다

이중 쓰기 로직에 문제가 있어 데이터가 다르게 쓰이고 있지 않은지는 정량적으로 확인해야 한다. Airflow로 주기적으로 두 DB의 주요 테이블을 Hive에 모아 레코드 수, 핵심 컬럼의 해시, 비즈니스 통계값을 비교했다. 추출 시각 차이로 생기는 불일치는 손으로 걸렀다. 테이블별 불일치 건수 리포트를 보며 로직을 고치기를 약 6개월 반복해 정합성을 맞췄다. 여기에 QA 3개월. 전환이 끝났는지는 모든 대상 쿼리에 미리 붙여 둔 주석(회원개발쿼리)이 Oracle에 더 이상 들어오지 않는 것으로 확인했다.

트러블슈팅 셋

  • Index Merge: MySQL은 WHERE에 인덱스 컬럼이 OR로 묶이면 각 인덱스를 따로 찾아 합치는데, 조건이 조금만 복잡해지면 포기하고 풀스캔한다. Oracle에서 빠르던 쿼리가 MySQL에서 풀스캔이 됐고, UNION으로 쪼개 해결했다.
  • 이관 배치의 페이징: Spring Batch의 JdbcPagingItemReader가 만드는 페이징 쿼리는 PK가 하나면 옵티마이저가 잘 최적화하지만, PK가 둘이면 “정렬 + OR 조건” 때문에 거의 항상 전체 정렬이 일어난다. 두 번의 Top-N 조회를 UNION ALL로 합치는 쿼리를 만드는 커스텀 QueryProvider로 풀었다.
  • 시퀀스 vs auto increment: Oracle 시퀀스는 INSERT 전에 ID가 나오지만 MySQL auto increment는 INSERT 후에 나온다. JPA에서 persist() 직후 ID를 쓰던 연관관계 저장 로직이 깨져서 flush 시점과 저장 순서를 고쳤고, 못 고치는 곳은 채번 테이블을 따로 뒀다. 원문도 적었듯 UUID v7이나 Snowflake처럼 DB에 의존하지 않는 ID를 썼다면 없었을 문제다.

HikariCP의 MySQL 권장 설정(cachePrepStmts, useServerPrepStmts, rewriteBatchedStatements 등)도 정리돼 있다. rewriteBatchedStatements는 배치 INSERT를 한 문장으로 묶어 보내 대량 삽입에서 수십 배 차이가 난다.

결과

Oracle 세션 수와 메모리 사용량이 줄어 공용 장비를 쓰는 다른 부서에 여유가 생겼고, 회원 파트는 별도 장비에서 파드를 늘릴 수 있게 됐다. 성능 모니터링 중 튜닝한 쿼리 덕에 API 지연도 개선됐다.

읽고 남는 질문

  • 커밋 후 MySQL에 쓰는 방식은 “Oracle 커밋은 됐는데 MySQL 쓰기 전에 프로세스가 죽는” 창이 있다. 그 창에서 생긴 불일치가 6개월 리포트에 얼마나 잡혔는지, 그것을 어떻게 메웠는지가 궁금하다.
  • 읽기 복제는 Repository 메서드 단위라, 한 트랜잭션 안에서 여러 조회가 순서대로 일어나는 패턴(앞 조회 결과로 뒤 조회)은 재현되지 않는다. 성능 검증에서 빠진 부분이 있었는지.
  • 이중 쓰기 코드를 제거한 시점과 그 뒤 Oracle을 완전히 끊은 시점은 언제였는지. 롤백 창을 얼마나 오래 열어 두는 것이 적당한지에 대한 판단이 있으면 좋겠다.

한 줄로 가져가기

무중단 DB 전환의 뼈대는 “두 곳에 쓰고 한 곳에서 읽는다”이지만, 실제로 일을 되게 한 것은 새 DB 실패를 트랜잭션에서 떼어낸 결정, 비즈니스 코드를 안 건드린 가로채기, 그리고 6개월간의 대조였다.

시리즈

빅테크 기술 블로그 리뷰

31편 중 16편

  1. 1 카카오 「실시간 메시징 시스템 개발기」(3편) 리뷰 — Redis에 몰린 부하를 서버로 옮기고, 그 서버를 pprof로 세 번 깎은 이야기
  2. 2 카카오 「추가배포 없이 API의 case 통일시키기」 리뷰 — 받는 쪽이 자기 케이스에 맞춰 알아서 읽게 하면 배포 순서가 사라진다
  3. 3 카카오 「MySQL DATETIME, TIMESTAMP 데이터 타입에 대한 분석」 리뷰 — 바이트 단위 저장 구조부터 아직 안 고쳐진 Y2K38까지
  4. 4 네이버 D2 「6개월 만에 연간 수십조를 처리하는 DB CDC 복제 도구 무중단/무장애 교체하기」 리뷰 — 복제·검증·복구를 셋으로 나누고, 옛 도구와 새 도구를 서로 모르게 같이 돌린 전환
  5. 5 카카오 「MySQL ALTER DDL 수행 방식에 대한 이해」 리뷰 — Copy·In-Place·Instant는 '무엇을 복사하느냐'보다 '언제 Exclusive 메타 락을 잡느냐'로 구분된다
  6. 6 카카오 「MySQL Orchestrator 기반의 새로운 HA 표준 개발기」 리뷰 — 10년 멈춘 Perl 도구를 떠나 Raft 클러스터로, 그리고 slave_net_timeout 한 줄
  7. 7 카카오 「MySQL Ver. 8.0 New Feature: Instant DDL Algorithm에 대한 이해」 리뷰 — 컬럼을 0.01초에 추가하는 대가는 '읽을 때마다 버전을 대조하는 것'이다
  8. 8 네이버 D2 「CDC 복제 이후 오라클이 느려졌다? child cursor 폭증이 만든 예상치 못한 문제」 리뷰 — 같은 SQL인데 바인딩 타입이 다르면 Oracle은 다른 쿼리로 본다
  9. 9 카카오 「MySQL InnoDB Log에 대한 이해 - (1)」 리뷰 — 트랜잭션 하나가 어떻게 MTR 여러 개로 쪼개져 Redo Log Buffer에 들어가는가
  10. 10 카카오 「PostgreSQL to ES: Kafka Connect CDC 파이프라인」 1·2편 리뷰 — 변경이 없어서 디스크가 차고, LSN이 사라져서 스냅샷을 다시 짜야 했던 CDC의 실제 운영 비용
  11. 11 LINE 「기획서 없이 내재화하기: 검증 로직으로 동일함을 증명하다」 리뷰 — 블랙박스는 입력과 출력만 정의하면 통계로 같음을 증명할 수 있다
  12. 12 뱅크샐러드 「게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)」 리뷰 — 같은 WHERE version 조건인데, 충돌한 요청을 어떻게 하는가에서 갈리는 두 설계
  13. 13 우아한형제들 「Spring Batch와 Querydsl」 리뷰 — offset을 버린 Reader가 21분을 4분으로 만든 이유, 그리고 그 Reader가 답하지 않는 두 가지
  14. 14 네이버 D2 「테스트는 어떻게 좋은 코드를 만드는가(feat. 험블 객체 패턴)」 리뷰 — 목이 많아지는 것은 테스트의 문제가 아니라 설계의 신호
  15. 15 당근 「QR을 찍으면 무슨 일이 벌어질까? 당근페이 현장 결제의 모든 것」 리뷰 — 카드망을 빌려 7주 만에 낸 결제와, 그 글이 다루지 않은 승인 응답이 사라지는 순간
  16. 16 네이버 D2 「스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기」 리뷰 — 두 DB에 동시에 쓰되 한쪽 실패는 무시하고, 읽기 트래픽을 복제해 성능을 재고, 6개월간 불일치를 0으로 만든 과정
  17. 17 야놀자 「RESTful API validation 자동화 하기」 리뷰 — 인터페이스 하나에서 검증·문서·타입을 뽑는 이유, 그리고 자동화가 없으면 누락이 필연이라는 문장
  18. 18 카카오 「MySQL Json 데이터 타입의 저장 구조와 성능 비교」 리뷰 — 통째로 넣고 통째로 꺼내면 TEXT, 키로 파고들면 JSON
  19. 19 LINE 「도메인에 의존하지 않는 채팅 플랫폼은 어떻게 만들었을까?」 리뷰 — 사용자를 모르는 채팅 플랫폼, 웹으로 만든 클라이언트, SOFT STOP으로 갈아 끼우는 챗봇 시나리오
  20. 20 카카오페이 「MSA 환경에서 네트워크 예외를 잘 다루는 방법」 리뷰 — Unknown을 타입으로 만든 글과, Unknown을 상태로 저장한 프로젝트가 갈리는 지점
  21. 21 카카오 「메시징 서버의 스트레스 테스트 노하우와 AI가 덜어 준 부분」 리뷰 — 지표를 네 층으로 내려가 읽는 법, 그리고 LLM에게 맡긴 것과 맡기지 못한 것
  22. 22 네이버 D2 「일 3,000만 건의 네이버페이 주문 메시지를 처리하는 Kafka 시스템의 무중단 전환 사례」 리뷰 — 두 벌로 발행해 대조한 검증기와, 발행 제어 키를 파티션 키와 같게 둔 이유
  23. 23 카카오 「잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정」 리뷰 — 벤더가 8ms 만에 리포트를 보냈고, 우리는 101ms짜리 트랜잭션 안에 있었다
  24. 24 컬리 「컬리의 입고 시스템이 외부 인입 데이터를 안전하게 동기화하는 방법」 리뷰 — 145회 재시도가 맞는 도메인과 재시도를 금지한 도메인, 그리고 발행부와 수신부가 각자 책임지는 구조
  25. 25 네이버 D2 「@RequestCache: HTTP 요청 범위 캐싱을 위한 커스텀 애너테이션 개발기」 리뷰 — 캐시의 수명을 '요청 하나'로 맞추면 TTL 고민이 사라진다, 그리고 @RequestScope가 안 되는 이유
  26. 26 무신사 「Kafka와 Strimzi를 이용하여 6개의 도메인을 하나의 도메인으로 합쳐보았습니다」 리뷰 — 배치 없이 CDC와 Kafka Streams로 옮긴 결정, 그리고 통합 모델이 원본과 같다는 것을 누가 확인하는가
  27. 27 쿠팡 「대용량 트래픽 처리를 위한 쿠팡의 백엔드 전략」 리뷰 — 캐시 두 겹과 '분 단위 99.99% 동일'이라는 문장, 그리고 그 0.01%를 누가 어떻게 세는가
  28. 28 LINE 「LINE에서 Kafka를 사용하는 방법 - 1편」 리뷰 — 초당 4GB 클러스터가 바이트가 아니라 요청 수를 제한하는 이유, 그리고 2,000틱짜리 파이프라인에서 같은 것을 본 기록
  29. 29 카카오 「MySQL 인증 플러그인 caching_sha2_password에 대한 이해」 리뷰 — 비밀번호 해시가 바뀌는 것보다 '평문이 서버까지 가야 한다'는 점이 전환의 진짜 비용
  30. 30 LINE 「초당 100만 건, LINE 앱에 Apache Kafka 종단 간 암호화 적용기」 리뷰 — 인터셉터와 시리얼라이저만으로 브로커에 평문을 남기지 않는 법
  31. 31 카카오뱅크 「하루 N억 건의 알림 시스템 구축기 (1)」 리뷰 — P99의 80%가 대기였다는 진단, Age 기반 Work-Stealing, 그리고 큐를 나누는 순간 순서를 잃는 문제
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다