달리는 기차의 엔진을 교체하라 — 네이버 스마트스토어 Oracle→MySQL 이중 쓰기 전환기
10년 넘게 Oracle로 돌아가던 시스템의 DBMS를 바꾼다는 건, 비행 중인 비행기의 엔진을 교체하는 것과 다를 바 없다. 승객(트래픽)은 계속 타고 있고, 고도(가용성)를 유지해야 하며, 엔진 교체 중 단 한 순간이라도 추력이 끊기면 추락한다. 네이버 스마트스토어 회원 파트가 정확히 이 일을 해냈다. 핵심 무기는 **이중 쓰기(Dual Write)**다.
Oracle이 짊어진 무게
네이버 스마트스토어 플랫폼 내 여러 파트가 공동으로 사용하던 Oracle DBMS는 비즈니스 성장과 함께 리소스 경합이 심화되면서 서비스 성능 불안정을 초래하고 있었다. Oracle 인프라를 확장하면 라이선스 비용이 기하급수적으로 늘어난다. 회원 파트는 이 운영 비효율성과 비용 압박을 해소하고자 오픈소스인 MySQL로의 전환을 결정했다.
문제는 회원 파트의 모듈이 타 부서 시스템과 광범위하게 연계되어 있어 서비스 중단이 불가능했다는 점이다. 전환 후 치명적 성능 저하나 장애가 발생하면 재배포만으로 해결할 수 없으므로, 신속한 롤백 능력도 반드시 확보해야 했다.
[💡 잠깐! 이 용어는?] 이중 쓰기(Dual Write): 모든 쓰기 트랜잭션을 기존 DB와 새 DB에 동시에 반영하는 기법. 신규 시스템의 안정성을 검증하는 기간 동안 두 DB가 동기화된 상태를 유지해 안전한 전환과 즉시 롤백을 가능하게 한다.
3단계 전환 전략 — 안전망 위의 공중 곡예
전환 과정은 세 단계로 진행된다.
- 전환 전: 구버전 앱이 모든 Read/Write를 Oracle에서 처리하되, CUD 작업 시 백그라운드에서 MySQL에도 이중 쓰기를 수행한다.
- 데이터 마이그레이션: 신버전 배포 전에 Oracle 전체 데이터를 MySQL로 마이그레이션해 정합성을 맞춘다.
- 전환 후: 신버전 앱이 모든 Read/Write를 MySQL에서 처리하며, CUD 작업 시 Oracle에도 이중 쓰기를 수행한다.
3단계에서 Oracle 방향 이중 쓰기가 계속 유지된다는 점이 핵심이다. 롤백이 필요한 경우 별도의 데이터 복구 없이 즉시 롤백할 수 있다. 비유하면, 줄타기를 하면서 아래에 안전망을 깔아두는 것이다. 안전망이 있기 때문에 오히려 과감하게 전진할 수 있다.
JPA 이중 쓰기 — 쿼리를 가로채는 Proxy
JPA로 수행하는 CUD 작업은 대부분 단순한 SQL 조합이라 Oracle과 MySQL 간 쿼리 차이가 거의 없었다. datasource-proxy 라이브러리를 활용해 Oracle에서 수행되는 쿼리를 가로챈 뒤, 별도 MySQL DataSource로 동일 쿼리를 실행하도록 Proxy DataSource를 구성했다.
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
LocalContainerEntityManagerFactoryBean factory = getLocalContainerEntityManagerFactoryBean();
factory.setDataSource(dualWriteProxyDatasource);
return factory;
}왜 분산 트랜잭션을 쓰지 않았나
트랜잭션 매니저로 JpaTransactionManager를 사용하는데, 실제 트랜잭션에 참여하는 건 메인 DB인 Oracle DataSource뿐이다. MySQL DataSource는 트랜잭션으로 관리되지 않는다. ChainedTransactionManager나 분산 트랜잭션을 사용하지 않은 이유가 있다.
- 이중 쓰기 구현 시점에는 Oracle과 MySQL 간 데이터 정합성이 대부분 맞지 않는다.
- 서비스 내 JPA의 모든 Oracle CUD 쿼리가 MySQL에서 오류 없이 동작하는지 아직 검증되지 않았다.
- 테이블 인덱스 구성이나 커넥션 풀 등 환경 설정이 아직 최적화되지 않았다.
해결책은 트랜잭션 도중 수행되는 쿼리를 모아두었다가, Oracle 커밋 이후 한꺼번에 MySQL에서 수행하는 것이다.
[💡 잠깐! 이 용어는?]
TransactionSynchronizationManager: Spring의 트랜잭션 동기화 관리자. 트랜잭션 생명주기의 특정 시점(커밋 전, 커밋 후, 완료 후 등)에 콜백을 등록할 수 있다. 이 사례에서는 afterCommit 시점에 MySQL 이중 쓰기를 수행한다.
public void execute(ExecutionInfo execInfo, List<QueryInfo> queryInfoList) throws SQLException {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
List<Pair<ExecutionInfo, List<QueryInfo>>> queryExecInfos =
(List<Pair<ExecutionInfo, List<QueryInfo>>>)
TransactionSynchronizationManager.getResource(OBJECT);
if (Objects.isNull(queryExecInfos)) {
queryExecInfos = Lists.newArrayList();
initDualWriteQueryAccumulationInTransaction(queryExecInfos);
}
queryExecInfos.add(Pair.of(execInfo, queryInfoList));
} else {
// 트랜잭션이 없으면 바로 이중 쓰기 수행
}
}
private void initDualWriteQueryAccumulationInTransaction(
List<Pair<ExecutionInfo, List<QueryInfo>>> queryExecInfos) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
try {
// MySQL 커넥션을 열어서 queryExecInfos에 쌓인 쿼리를 한 번에 실행
} catch (SQLException e) {
// 쿼리 로깅해 실패 원인 분석
}
}
@Override
public void afterCompletion(int status) {
TransactionSynchronizationManager.unbindResourceIfPossible(OBJECT);
queryExecInfos.clear();
}
});
TransactionSynchronizationManager.bindResource(property, queryExecInfos);
}Oracle 트랜잭션이 롤백되면 MySQL에서는 수행된 쿼리가 없으므로 별도 처리가 불필요하다. Oracle 커넥션 점유 시간도 늘어나지 않고, 예외 추적도 용이하다.
MyBatis 이중 쓰기 — 벽지는 건드리지 않고 전선만 교체하기
MyBatis 이중 쓰기에서 가장 큰 도전은 비즈니스 로직 코드를 수정하지 않는 것이었다. 10년 넘은 서비스의 수백, 수천 곳을 수정하는 건 휴먼 에러와 개발 기간 폭증을 의미한다. 집 전체의 전선을 교체하면서 벽지를 하나도 건드리지 않겠다는 목표다.
[💡 잠깐! 이 용어는?]
MapperProxy: MyBatis에서 @Mapper 인터페이스의 프록시 구현체. 메서드 호출이 발생하면 invoke를 통해 SqlSession에 위임하여 실제 SQL을 실행한다.
핵심 아이디어는 SqlSessionFactory를 추상화하는 것이다. Oracle과 MySQL 두 개의 SqlSession이 모두 수행되도록 CombinedSqlSessionFactory를 구현했다.
public class CombinedSqlSessionFactory implements SqlSessionFactory {
private SqlSession openSessionFromDataSource(
ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
SqlSession primarySession = openPrimarySessionFromDataSource(execType, level, autoCommit);
SqlSession secondarySession = openSecondarySessionFromDataSource(execType, level, autoCommit);
return (SqlSession) Proxy.newProxyInstance(
SqlSession.class.getClassLoader(),
new Class[]{SqlSession.class},
new CombinedSqlSessionHandler(primarySession, secondarySession,
applicationEventPublisher)
);
}
}public class CombinedSqlSessionHandler implements InvocationHandler {
private SqlSession oracleSqlSession;
private SqlSession mysqlSqlSession;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
final String methodName = method.getName();
if (CUD액션인_경우(methodName, maybeStatement)) {
Object oracleResult = method.invoke(oracleSqlSession, args);
Object mysqlResult = method.invoke(mysqlSqlSession, args);
return oracleResult;
}
Object oracleResult = method.invoke(oracleSqlSession, args);
return oracleResult;
}
}Oracle과 MySQL 사이에는 근본적인 SQL 문법 차이가 존재한다. 모든 MyBatis XML 쿼리를 MySQL 문법에 맞게 재작성한 뒤 이 구조로 이중 쓰기를 수행했다.
| 기능 | Oracle | MySQL |
|---|---|---|
| PK 생성 | SEQUENCE (INSERT 전) | AUTO INCREMENT (INSERT 후) |
| NULL 처리 | NVL | IFNULL |
| 날짜 함수 | SYSDATE, TO_CHAR | NOW(), DATE_FORMAT |
| 페이징 | ROWNUM | LIMIT |
| 라이선스 비용 | 고비용 | 오픈소스 (무료) |
| 확장성 | 수직 확장 (비용 급증) | 수평 확장 가능 |
6개월의 정합성 검증
데이터 정합성 — Airflow + Hive로 교차 검증
Airflow 파이프라인으로 주기적으로 Oracle DB와 MySQL DB의 주요 테이블 데이터를 추출해 Hive 데이터 웨어하우스로 통합한 뒤, 분산 쿼리로 세 가지를 검출했다.
- 레코드 수 비교
- 핵심 칼럼 값의 해시 비교
- 주요 비즈니스 통계 값 차이
약 6개월간 테이블별 불일치 데이터를 분석하고 로직을 수정한 끝에 정합성을 확보했다.
성능 검증 — 운영 트래픽을 그대로 복제
Kafka 메시지 큐를 활용해 Oracle Read 메서드의 이름, 매개변수, 실행 시간을 JSON으로 직렬화한 뒤, 별도 Consumer 모듈에서 Java Reflection API로 MySQL Repository의 동일 메서드를 호출했다. 운영 트래픽과 동일한 부하를 MySQL에 줄 수 있었고, 성능이 좋지 않은 쿼리를 식별해 인덱스 추가 등의 최적화를 수행했다.
[💡 잠깐! 이 용어는?] Index Merge Optimization: MySQL에서 WHERE 절에 여러 인덱스 칼럼이 OR 조건으로 연결될 때, 각 인덱스를 독립 검색 후 결과를 합치는 전략. 다만 nesting OR 등 복잡한 조건에서는 최적화를 포기하고 풀스캔으로 전환될 수 있다.
마무리
이관 결과 Oracle의 세션 수가 감소하면서 PGA 메모리 사용량이 줄어들었고, 타 부서에 자원적 안정성을 제공하는 동시에 별도 장비 환경에서 파드 수를 늘려 안정적 서비스 확장의 토대를 마련했다. 무중단 DB 전환의 핵심은 결국 이중 쓰기로 안전망을 확보하고, 정합성 검증으로 확신을 쌓아가는 것이다. 6개월간의 정합성 검증과 3개월간의 QA 기간이 길어 보일 수 있지만, 10년 레거시를 무사히 넘기기 위한 투자로는 충분히 합리적이다.
관심 있을 만한 포스트
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험
TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.
VS Code 팀의 AI 에이전트 병렬화 — 월간 릴리스를 주간으로 만든 워크플로우
VS Code 팀이 월간 릴리스에서 주간 릴리스로 전환한 비결. 에이전트 세션 병렬화, 자동화 파이프라인, 품질 게이트 설계 전반을 공개했다.
React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가
React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.
Native JSON Modules — 번들러 없이 JSON을 import하는 시대
Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.