개주 훈련일지/🏋️ 전집중 호흡 훈련

네이버 스마트스토어 Oracle → MySQL 무중단 마이그레이션 사례 리뷰

lshfood2 2026. 2. 18. 18:58

내가 읽은 블로그 포스팅

https://d2.naver.com/helloworld/6512234

 

[ 무중단 마이그레이션 사례 리뷰 ]

네이버 스마트스토어 플랫폼의

DB 마이그레이션 사례를 읽었다.


10년 이상 운영된 Oracle 기반 레거시 프로젝트가

서비스 중단 없이 MySQL로 전환하면서,

어떤 전략을 선택했고 어떤 기술적 문제를 해결했는지

전환 과정이 구체적으로 정리돼 있었다.

 

단순히 DB를 바꾸는 것이 아니라,

무중단 운영과 신속한 롤백 가능성을

동시에 만족시키기 위해 안전장치를

여러 겹으로 쌓는 과정이 핵심이었다.

 

이번 포스팅에서는 이 글을 읽고 이해한

전환 전략, 구현 포인트, 검증 방식(성능/정합성),

그리고 트러블슈팅에서 얻은 인사이트를 정리한다.

 

핵심 요약

핵심을 우선적으로 3가지로 요약하였다.

운영 트래픽을 멈추지 않고 DB를 바꾸려면,

  1. 데이터 쓰기 동기화(이중 쓰기)
  2. 성능 검증(그림자 읽기)
  3. 정합성 검증(대량 비교)

이 3개가 함께 굴러가야 한다.


1. 기존 서비스 구조

JPA + MyBatis
   ↓
HikariCP
   ↓
JDBC
   ↓
Oracle DBMS (기존)

 

당면한 이슈

  • 회원 파트가 타 부서 시스템과 광범위하게
    연계되어 있어 서비스 중단이 사실상 불가
  • DB 전환은 무중단 배포가 필수
  • 장애 발생 시 신속한 롤백 능력도 확보해야 함

2. 전환 전략: 이중 쓰기(dual write)

전환 전략의 핵심은 이중 쓰기다.


쓰기(CUD) 작업을 기존 DB와 신규 DB에

동시에 반영해 두 DB를 동기화 상태로 유지하고,

 

전환 중/전환 후 문제 발생 시에도

즉시 롤백할 수 있게 만든다.

 

전환 과정(이중 쓰기 아키텍처)

전(기존)
- Read/Write: Oracle
- CUD: Oracle + MySQL (이중 쓰기)

마이그레이션
- 배포 전 Oracle 전체 데이터를 MySQL로 이관
- 기준 시점 정합성 맞춤

후(전환 이후)
- Read/Write: MySQL
- CUD: MySQL + Oracle (이중 쓰기 유지, 롤백 대비)

이 구조의 핵심은 전환 전/후 모두

‘이중 쓰기’가 유지된다는 점이다.


그래서 문제가 생기면 Read/Write 경로를

다시 Oracle로 돌려 빠르게 원복 할 수 있다.


3-1. 기술적 도전과 해결 과정 : JPA 이중 쓰기

JPA 기반 CUD는 비교적 단순한 SQL 형태로 실행되므로

Oracle에서 실제 수행되는 쿼리를 가로채 MySQL에서도

동일하게 실행하는 방식으로 이중 쓰기를 구현했다.


이를 위해 datasource-proxy 방식으로

Oracle 쿼리를 인터셉트하고,

MySQL DataSource로도

동일 쿼리를 재실행하도록 구성했다.

 

Proxy DataSource 구조

(1) Oracle로 나가는 쿼리 인터셉트
        ↓
(2) Query Listener: 쿼리 파싱 + dual write 대상 여부 판단
        ↓ (대상이면)
(3) MySQL에 동일 쿼리 수행

Oracle DataSource  → Oracle DB
       │
       └→ MySQL DataSource → MySQL DB

 

트랜잭션 처리 문제

문제는 트랜잭션이었다.

  • 트랜잭션 매니저(JpaTransactionManager)는 기본적으로
    메인 DB(Oracle) 중심으로 트랜잭션을 관리한다.
  • 이중 쓰기에서 MySQL은 트랜잭션에 포함되지 않을 수 있다.
  • 결과적으로 Oracle은 롤백되는데 MySQL은
    일부 반영될 수 있어 정합성이 깨질 위험이 생긴다.

그렇다고 MySQL까지 둘 다 성공해야

커밋으로 묶는 방식은 금지된다.

(ChainedTransactionManager/분산 트랜잭션)


전환 초기 MySQL은 아직 불안정할 수 있고,

MySQL만 실패하는 상황이 생길 수 있는데

이 실패가 Oracle 트랜잭션까지 같이 롤백시키면

서비스 장애로 이어지기 때문이다.

 

따라서 필요한 구조는 아래처럼 정리된다.

  • Oracle 트랜잭션은
    정상적으로 커밋/롤백되게 유지
  • MySQL 쓰기는 Oracle 커밋 이후에
    별도 트랜잭션으로 따라 쓰기
  • MySQL 실패는 서비스 트랜잭션을
    실패시키지 않고 기록만 남김

실제 실행 흐름(코딩 구조)

1. 쿼리 실행될 때마다 execute 호출

2. 트랜잭션 안이면
   - MySQL에 즉시 실행하지 않고 쿼리를 리스트에 누적
   - Oracle 커밋 성공(afterCommit) 시점에 누적 쿼리를 MySQL에 일괄 실행
   - 트랜잭션 종료(afterCompletion) 시 누적 리스트 정리

3. 트랜잭션이 없으면
   - 쿼리 발생 즉시 MySQL에도 바로 실행

 

엔터티/PK 전략

MySQL에서는 PK 생성 전략으로

auto increment를 사용하는 경우가 많다.


전환 이후 MySQL → Oracle 방향으로

이중 쓰기를 수행할 때는 MySQL에서 생성된 PK를

먼저 얻어 Oracle INSERT에 PK를 채워 넣어야 한다.

즉, 쿼리 보정이 필요할 수 있다.

 

단 DB 독립 PK(UUID v7, Snowflake ID 등)를 사용하면

이 이슈가 크게 줄어든다.

 

또한 DB 타입 차이로 인해 일부 컬럼은

columnDefinition을 MySQL 기준으로 맞춰야 할 수 있다.


3-2. 기술적 도전과 해결 과정 : MyBatis 이중 쓰기

초기 접근으로는 Oracle용 MyBatis 호출 뒤에

MySQL용 MyBatis 호출을 추가하는 방식이 떠오르지만,

이 방식은 레거시에서 변경 범위가 폭발한다.

 

호출 추가 방식 예시

mybatisRepository.updateData(i);        // Oracle
mybatisForMySQLRepository.updateData(i); // MySQL

이 패턴을 모든 쓰기 로직에 추가하려면

비즈니스 로직 전체에서 수백~수천 곳을 수정해야 하고,

누락/휴먼 에러 위험도 커진다.


그래서 목표는 ‘비즈니스 로직 수정 없이’

DB 실행 관문 한 곳에서 이중 쓰기를

중앙 처리하는 구조를 만드는 것이다.

 

MyBatis 동작 구조(중앙 관문 찾기)

1) @Mapper 인터페이스가 빈으로 등록됨 (실체는 MapperProxy)
2) mybatisRepository.method() 호출 → MapperProxy.invoke 실행
3) MapperProxy가 MapperMethod를 찾음(cachedMapperMethod)
4) MapperMethod.execute가 쿼리 타입에 따라 SqlSession 호출
   - INSERT: SqlSession.insert(queryId, param)
   - UPDATE: SqlSession.update(queryId, param)
   - SELECT: SqlSession.select...
5) SqlSession이 Configuration에서 queryId에 해당하는 SQL(MappedStatement)을 찾음
6) SqlSession이 DataSource로 DB에 실제 SQL 실행

 

체크 포인트

  • 실제 DB 실행의 관문은 SqlSession
  • SqlSession 단계에서만 가로채면 비즈니스 로직
    수정 없이 이중 쓰기를 중앙에서 처리 가능

MySQL용 MyBatis XML을 별도로 만드는 이유

Oracle과 MySQL은 SQL 문법 차이가 있기 때문에

Oracle 쿼리를 그대로 MySQL에서 실행하기 어렵다.

즉, MySQL 문법에 맞춘 XML을 별도로 준비해야 한다.
Oracle용 XML
: namespace가 com.example.UserMapper, id가 updateData

MySQL용 XML
: 동일 기능이지만 MySQL 문법으로 작성된 updateData

이를 위해 XML내의 SQL을 보유하는

SqlSession도 2개가 되어야 한다.

 

SqlSessionFactory 추상화로 MyBatis 이중 쓰기(프록시 세션)

추상화를 통해 Oracle과 MySQL 두 개의

SqlSession을 모두 수행하게 구현

1) SqlSession 생성 시
   - Oracle 세션(primary) + MySQL 세션(secondary)을 동시에 생성

2) 두 세션을 직접 노출하지 않고
   - SqlSession 인터페이스 형태의 프록시를 반환

3) MyBatis는 프록시 SqlSession만 사용
   - insert/update/select 호출은 모두 프록시로 들어감

4) 프록시 내부에서 CUD/READ 분기
   - CUD: Oracle 실행 → MySQL 실행 → Oracle 결과 반환
   - READ: Oracle만 실행 → Oracle 결과 반환

4. 쿼리/DBMS 성능 검증: Read 트래픽 복제 호출

Oracle과 MySQL은 실행 계획/최적화 방식이 달라

특정 고부하 쿼리가 MySQL에서

예상치 못한 과부하를 만들 수 있다.


그래서 이중 쓰기 기간 동안,

신규 MySQL이 실제 운영 Read 트래픽을

견딜 수 있는지 성능 검증이 필요했다.

 

운영 트래픽을 HTTP 단에서 복사하는 방식은

타 시스템 중복 호출 위험이 있어,

Repository 계층의 Read 호출 정보를 메시지로 전달해

MySQL에서 동일 호출을 ‘재현’하는 방식을 사용했다.

 

전략 흐름도

(운영 서비스)
Oracle로 Read 실행 → 사용자 응답은 정상 처리
        │
        ├─ 동시에 Read 호출 정보 캡처(메서드명/파라미터/Oracle 실행시간)
        │
        └→ JSON으로 포장 → 메시지 큐(Kafka)로 전송

(성능 측정 Consumer)
Kafka 메시지 수신
  ↓
JSON을 복원
  ↓
MySQL Repository의 동일 메서드를 Reflection으로 호출
  ↓
실행 시간 기록/분포 확인 → 느린 쿼리 튜닝/인덱스 추가

이로 인해 기록된 지표들을 활용해

성능이 좋지 않은 쿼리를 수정하고,

인덱스 추가 등을 수행해

안정적인 성능을 확보할 수 있었다.


5. 정합성 검증(Airflow + Hive)

이중 쓰기로 실시간 쓰기 동기화는 가능하지만,

로직 버그나 누락으로 두 DB 데이터가

미세하게 달라질 수 있다.


그래서 최종 전환 전에 두 DB의

데이터 일치 여부를 정량적으로 검증해야 한다.

 

검증은 워크플로 관리 도구인 Airflow로 

정합성 검증 프로세스를 자동화하였고,
Hive 데이터 웨어하우스로 통합 및 비교 완료했다.

 

검증 흐름

Airflow가 주기적으로 작업 실행
  ↓
Oracle + MySQL 주요 테이블 데이터 추출
  ↓
Hive(분석 환경)로 적재
  ↓
분산 쿼리로 비교
  - row 수 비교
  - 핵심 컬럼 해시 비교
  - 주요 통계 값 비교
  ↓
불일치 발견 시 원인 분석/수정
  ↓
반복 검증으로 정합성 확보

6. 마이그레이션 후 아키텍처 및 검증(QA)

데이터만 옮기는 것으로 끝나지 않고,

DBMS 변화가 기존 비즈니스 로직에

예기치 못한 영향을 주지 않도록

약 3개월간 QA 기간을 두고 검증했다.

 

쿼리 실행 결과의 미세한 차이부터 트랜잭션 처리 방식까지

철저히 확인해 내부 인프라가 바뀌어도 서비스 기능이

기존과 동일하게 안정적으로 동작함을 확인했다.

 

또한 쿼리가 더 이상 Oracle로 유입되지 않고

MySQL로 유입되는지까지 검증하며 전환 완료 상태를 점검했다.

 

전환 완료 점검 예시

  • 전환 대상 쿼리마다 고유한 주석
    (예: /* 회원개발쿼리 */)을 미리 붙여둔다.
  • 전환 후 Oracle에서 그 주석이 달린 쿼리가
    더 이상 찍히지 않는지 모니터링한다.
  • 찍히면 ‘아직 Oracle로 가는 쿼리가 남아있다’는 뜻이고,
    안 찍히면 ‘전환이 제대로 됐다’는 근거가 된다.
[전환 전 준비]
전환 대상 SQL들에 공통 태그 주석 삽입
예) /* 회원개발쿼리 */
        ↓
[전환 진행]
Read/Write 경로를 MySQL로 전환
        ↓
[검증/모니터링]
Oracle DB 쿼리 로그/모니터링에서
'/* 회원개발쿼리 */' 포함 쿼리가 들어오는지 감시
        ↓
[판단]
- Oracle에 주석 쿼리 유입 O → 전환 누락(경로 일부가 아직 Oracle)
- Oracle에 주석 쿼리 유입 X → 전환 완료(Oracle로 안 감)

7-1. 트러블슈팅 및 기타 고려 사항
: MySQL Index Merge Optimization

문제 상황

OR 조건이 들어간 WHERE에서 Oracle은

인덱스를 잘 타는데 MySQL은 풀스캔으로

(전체 테이블 읽기) 떨어지는 경우가 있다.

 

발생 이유

MySQL은 OR 조건이 단순하면 Index Merge를

(여러 인덱스를 따로 검색해서 합치기) 할 수 있지만,

OR이 조금만 복잡해지거나 AND/IN이 섞이면

최적화가 깨지고 풀스캔으로 떨어질 수 있다.

 

해결 방법

OR을 한 쿼리로 유지하지 않고 OR의 각 덩어리를

별도 쿼리로 쪼개 UNION으로 합친다.

그러면 각 쿼리가 인덱스를 타기 쉬워져 성능이 좋아진다.

 

요약 한 줄

MySQL에서 복잡한 OR은 풀스캔이 날 수 있으니

OR을 UNION으로 쪼개 인덱스를 타게 만든다.


7-2. 트러블슈팅 및 기타 고려 사항
: 마이그레이션 배치 성능 이슈(Spring Batch + 페이징)

문제 상황

큰 테이블을 페이지 단위로 읽어오는

페이징 쿼리가 복합 PK 테이블에서 느려지고,

매 페이지마다 ‘전체 정렬’이 발생할 수 있다.

 

발생 이유

단일 PK는 인덱스 범위 스캔으로

필요한 만큼만 깔끔하게 잘라오기 쉽다.

 

하지만 복합 PK 페이징은 조건이 보통
A > ? OR (A = ? AND B > ?) 형태가 된다.

 

여기에 ORDER BY까지 붙으면

DB가 인덱스 시작 지점을 정확히 잡기 어려워

‘전체 정렬 → 그중 100개만 추출’ 같은

비효율적인 실행 방식으로 떨어질 수 있다.

 

해결 방법

OR 조건을 그대로 두지 않고

케이스를 둘로 분리한 뒤 UNION ALL로 합친다.

  • A = 마지막A AND B > 마지막B
  • A > 마지막A
    이렇게 나누면 각각이 인덱스를 타고
    Top-N(상위 N개) 방식으로 조회되기 쉬워진다.
    이 형태의 쿼리를 자동으로 만들도록
    커스텀 QueryProvider를 구현해 사용한다.

요약 한 줄

복합키 페이징은 OR + 정렬 때문에 전체 정렬이 터질 수 있으니,

조건을 분리해 UNION ALL로 인덱스를 타게 만든다.


7-3. 트러블슈팅 및 기타 고려 사항
: MySQL HikariCP 설정(권고 설정)

문제 상황

같은 코드인데도 Oracle에서는 괜찮던 처리가
MySQL에서만 미묘하게 느리거나

네트워크 왕복이 많아지는 경우가 있다.

 

발생 이유

MySQL JDBC 드라이버(Connector/J)는

성능 최적화를 위한 옵션이 많은데 기본값이면

  • PreparedStatement 재사용이 잘 안 되거나
  • 세션 상태를 매번 서버로 보내거나
  • 배치 INSERT/UPDATE를 비효율적으로 전송하는
    손해가 발생할 수 있다.

해결 방법

드라이버 최적화 옵션을 켜서 ‘재사용/배치/왕복’을 줄인다.

  • cachePrepStmts / useServerPrepStmts
    : PreparedStatement 캐시/재사용
  • rewriteBatchedStatements
    : 배치 쿼리를 한 번에 묶어서 전송
  • useLocalSessionState / elideSetAutoCommits
    : 불필요한 상태/설정 전송 감소
  • cacheResultSetMetadata / cacheServerConfiguration
    : 자주 쓰는 정보 캐시

요약 한 줄

MySQL에서는 드라이버 최적화 옵션을 켜서

PreparedStatement 재사용, 배치 묶기, 네트워크 왕복을 줄인다.


7-4. 트러블슈팅 및 기타 고려 사항
: Oracle 시퀀스 vs MySQL auto increment(JPA에서 자주 터지는 포인트)

문제 상황

Oracle에서는 저장 직후에도 ID를 알고 있어서

잘 돌아가던 연관관계 저장 로직이

MySQL(auto increment)로 바꾸면

저장 시점에 ID가 없어 로직이 꼬일 수 있다.

 

발생 이유

식별자 생성 시점이 다르기 때문이다.

  • Oracle SEQUENCE
    : INSERT 전에 ID를 미리 생성 가능
    → persist 직후에도 ID 활용 가능
  • MySQL IDENTITY(auto increment)
    : INSERT가 나간 뒤에야 ID가 생성됨
    → persist 직후에는 ID가 없을 수 있음

해결 방법

ID가 생기는 시점에 맞춰 저장 흐름을 조정한다.

  • flush 시점 조절
  • 저장 순서 변경
  • 연관관계 주인 설정을 명확히 해서 ID 의존도를 낮추기
    정말 로직 변경이 어렵다면 채번용 MySQL 테이블을
    따로 두어 ID를 먼저 만들도록 우회한다.

요약 한 줄

Oracle은 ID가 먼저 생기고 MySQL은 INSERT 후에 생기므로,

ID에 기대던 저장/연관관계 로직은 flush/순서/설계를 조정해야 한다.


8. 마이그레이션 결과

MySQL로 이관이 성공하면서 Oracle에 붙는 세션이 줄어

Oracle 작업용 메모리(PGA)와 swap 사용량이 감소했고,

그 덕분에 시스템 메모리 여유가 생겨

공용 인프라 환경에서의 자원 경쟁이 완화되었다.

 

또한 별도 장비 환경에서 파드 수를 더 늘려

서비스 확장/운영 안정성을 확보했으며,

모니터링과 쿼리 튜닝을 통해

API 응답 지연 시간(Latency)도 개선되었다.


[ 마무리 ]

개인적으로는 ‘마이그레이션 과정’이 실제로

어떻게 진행되는지 처음 제대로 접해본 사례였다.


그냥 데이터를 옮기고 연결 문자열만 바꾸는 수준이 아니라,

무중단 전환과 롤백을 동시에 만족시키려면

운영 중인 서비스 흐름 자체를 안전장치로

감싸야한다는 점이 인상 깊었다.

 

특히 이중 쓰기(dual write)나

그림자 읽기(리드 트래픽 복제) 같은 방식은

처음엔 떠올리기 어려운 접근이었는데,

전환 기간 동안 신규 DB를 실전처럼 검증하면서도

서비스에는 영향을 최소화한다는 관점에서

‘아 이렇게 하는구나’ 싶어서 신기했다.

 

또 DB 정합성을 지키기 위해 트랜잭션, PK 생성 방식,

쿼리 실행 계획처럼 평소에는 당연하게 동작한다고

생각했던 요소들을 다시 한번 되돌아보게 됐다.


결과적으로 마이그레이션 자체뿐 아니라,

DB와 영속성 계층이 ‘왜 이렇게 설계되어 있는지’

관련 개념까지 함께 학습할 수 있어서 좋았다.

 

어려웠던 용어 정리

  • Kubernetes
    컨테이너로 실행되는 앱을 여러 개로 띄우고,
    배포/확장/복구를 자동화해 운영하는 플랫폼(시스템).
  • Pod(파드)
    쿠버네티스에서 앱이 실제로 실행되는 최소 단위.
    같은 서비스를 여러 파드로 늘리면 트래픽을 나눠 처리할 수 있다.
  • HikariCP
    스프링에서 많이 쓰는 ‘DB 커넥션 풀’.
    DB 연결을 매번 새로 만들지 않고
    미리 만들어 재사용해 성능을 안정화한다.
  • JDBC
    자바에서 DB에 접속해 SQL을 실행하는 표준 API(인터페이스).
  • Kafka(카프카)
    메시지를 ‘토픽’에 쌓아두고, 다른 프로그램이 그 메시지를 읽어
    처리하도록 하는 대용량 메시지 큐/이벤트 스트리밍 시스템.
  • 직렬화 / 역직렬화
    직렬화는 객체를 전송/저장 가능한 형태(예: JSON 문자열)로 바꾸는 것.
    역직렬화는 JSON 문자열을 다시 객체 형태로 복원하는 것.
  • Reflection(리플렉션)
    메서드 이름을 문자열로 받아서 실행하는 기능.
    ‘메서드명을 기록해 두고 나중에 그대로 호출’ 같은 구조에 사용된다.
  • EntityManager flush
    JPA가 쌓아둔 변경 내용을 실제 SQL로 DB에 반영하는 시점.
  • JpaTransactionManager
    스프링에서 JPA 트랜잭션 경계를 관리하는 트랜잭션 매니저.
    보통 하나의 메인 DB 기준으로 커밋/롤백을 통제한다.
  • ChainedTransactionManager / 분산 트랜잭션
    여러 DB를 하나의 트랜잭션처럼 묶어 ‘둘 다 성공해야 커밋’하게 만드는 방식.
    전환 초기 불안정한 신규 DB가 있으면 서비스 장애를 만들 수 있어 주의 대상.
  • TransactionSynchronizationManager / afterCommit / afterCompletion
    스프링 트랜잭션의 ‘커밋 이후 실행’,
    ‘완료 이후 정리’ 같은 후처리를 걸 수 있게 해주는 도구/훅.
  • MyBatis MapperProxy / MapperMethod / SqlSession / MappedStatement
    MapperProxy
    : @Mapper 인터페이스 호출을 가로채는 프록시
    MapperMethod
    : 호출된 메서드가 어떤 SQL 타입인지 판단해 SqlSession 호출
    SqlSession
    : 실제 SQL 실행 관문(insert/update/select 실행 지점)
    MappedStatement
    : queryId에 매핑된 SQL 정보(파싱된 형태)
  • Airflow(에어플로우)
    정기 작업(추출/적재/비교 같은 배치)을 ‘파이프라인’으로 구성해
    스케줄 실행/관리하는 워크플로 도구.
  • Hive 데이터 웨어하우스
    대량 데이터를 모아두고 분산 쿼리로 빠르게 분석/비교하는 저장소/분석 환경.
  • Index Merge / 풀스캔
    Index Merge
    : 여러 인덱스를 따로 검색해 결과를 합치는 최적화
    풀스캔
    : 인덱스를 못 타고 테이블 전체를 읽는 실행 방식(느려질 수 있음)
  • PGA 메모리 / swap
    PGA
    : Oracle 세션이 사용하는 작업 메모리(정렬/해시 같은 작업에 사용)
    swap
    : RAM이 부족할 때 디스크를 임시 메모리처럼 쓰는 영역(느려질 수 있음)