본문 바로가기
Framework/Spring Boot

[Spring Boot] 다중 DB 트랜잭션의 딜레마 - JTA(2PC) 글로벌 트랜잭션과 ChainedTransactionManager의 치명적 한계, 그리고 SAGA 패턴으로의 진화

반응형

여러분이 다니는 회사의 비즈니스가 급성장하면서, 10년 동안 온갖 레거시(Legacy) 데이터가 꼬여있는 기존의 오라클(Oracle) DB 서버로는 한계를 느끼기 시작했습니다. 그래서 새로운 마이크로서비스(MSA) 모듈을 런칭할 때는 최신의 트렌디한 MySQL 8.0(또는 PostgreSQL)을 도입하기로 결정했습니다. 축하합니다! 이제 여러분이 담당하는 스프링 부트 애플리케이션은 두 개의 완전히 다른 이기종 데이터베이스(Heterogeneous Database)와 동시에 커넥션을 맺고 통신해야 합니다. 여기까지는 단순히 application.yml에 DataSource를 두 개 적으면 되는 쉬운 일입니다. 그런데 여기서 백엔드 시스템의 데이터 무결성을 파괴하고 개발자들의 등골을 오싹하게 만드는 최악의 딜레마가 발생합니다.

"만약 낡은 오라클 DB에 데이터를 저장(INSERT)하고, 이어서 같은 메서드 안에서 새로운 MySQL DB에도 데이터를 저장(INSERT)하는 하나의 서비스 로직이 있다고 가정해 봅시다. 오라클에 저장을 훌륭하게 마치고 MySQL에 저장하려는 찰나에 에러(Exception)가 터져서 실패하면, 이미 오라클에 물리적으로 커밋(Commit)되어버린 데이터는 도대체 어떻게 롤백(Rollback)시킬 것인가요?"

우리가 흔히 코드에 발라두는 @Transactional 애노테이션은 기본적으로 '단일 데이터베이스'의 커넥션에만 적용되도록 설계되어 있습니다. 두 개의 DB 커넥션을 열어놓고 로직을 태운다면, 스프링 부트는 기본 DataSource(예: 오라클) 하나에만 롤백 명령을 내릴 뿐, 두 번째 DB(MySQL)의 트랜잭션은 완전히 독립적으로 놀게 되어버립니다. 한쪽은 결제가 승인되었는데, 다른 한쪽 DB에는 포인트가 지급되지 않는 식의 데이터 파편화(Inconsistency) 현상이 와장창 터져버리는 것이죠.

결제나 정산 시스템에서 0.1원의 오차라도 절대 용납될 수 없는 이 끔찍한 데이터 파편화를 막기 위해, 두 개의 다른 데이터베이스를 하나의 거대한 트랜잭션 원자성(Atomicity)으로 묶어버리는 JTA(글로벌 트랜잭션) 2PC 프로토콜ChainedTransactionManager의 치열한 꼼수, 그리고 현대 MSA 아키텍처의 구세주인 SAGA 패턴에 대해 상세히 알아보겠습니다.

1. 두 개의 DataSource, 그리고 겉도는 두 개의 TransactionManager

다중 DB를 설정하기 위해서는 application.ymlspring.datasource.oraclespring.datasource.mysql 두 개의 접속 정보를 세팅하고, 스프링의 @Configuration 설정 파일에서 두 개의 DataSource 빈(Bean), EntityManagerFactory, 그리고 각각을 통제할 PlatformTransactionManager를 수동으로 생성해주어야 합니다. 즉, 프로젝트 내부에 오라클용 oracleTransactionManager와 MySQL용 mysqlTransactionManager가 각각 별개의 통제탑으로 존재하게 됩니다.

ChainedTransactionManager 및 JTA 2PC(2-Phase Commit) 글로벌 트랜잭션 동기화 아키텍처 다이어그램

 

이제 비즈니스 서비스 클래스에서 @Transactional(transactionManager = "oracleTransactionManager")와 같이 특정 매니저를 명시해주면, 그 괄호 안의 트랜잭션 블록에서는 오라클 DB만 롤백의 보호를 받습니다. 만약 이 메서드 내부에서 MySQL 리포지토리를 몰래 호출해서 데이터를 쓴다면 어떻게 될까요? 트랜잭션 관리를 전혀 받지 않는 'Auto-Commit(자동 커밋)' 모드로 MySQL DB에 실시간으로 쿼리가 콱 박혀버립니다. 뒤늦게 오라클 저장 로직에서 에러가 터져서 오라클은 롤백된다 하더라도, 이미 MySQL에 박힌 데이터는 취소되지 않고 영원히 남아 쓰레기 데이터(고아 데이터)가 되어버립니다.

2. 꼼수로 엮어낸 사슬: ChainedTransactionManager (스프링 부트 2.x 이하)

이 지옥 같은 데이터 불일치 문제를 임시방편으로 해결하기 위해, 과거 스프링 데이터(Spring Data) 진영에서는 ChainedTransactionManager라는 매우 직관적인(하지만 태생적으로 꼼수 같은) 클래스를 제공했습니다. 이름 그대로 여러 개의 독립된 트랜잭션 매니저를 '사슬(Chain)'처럼 엮어버리는 방법입니다.

// 💡 두 개의 트랜잭션 매니저를 하나의 사슬로 엮어버리는 Chained 방식!
@Bean
public PlatformTransactionManager chainedTransactionManager(
        @Qualifier("oracleTransactionManager") PlatformTransactionManager oracleTx,
        @Qualifier("mysqlTransactionManager") PlatformTransactionManager mysqlTx) {
    
    // 오라클과 MySQL 트랜잭션을 하나의 체인으로 순차적으로 묶어서 반환!
    return new ChainedTransactionManager(oracleTx, mysqlTx);
}

이제 서비스 로직 상단에 @Transactional(transactionManager = "chainedTransactionManager") 하나만 달아주면 마치 마법이 일어난 것처럼 보입니다. 스프링 부트는 메서드가 시작될 때 등록된 순서대로 오라클 트랜잭션을 먼저 열고, 이어서 MySQL 트랜잭션을 엽니다. 로직이 무사히 끝나면 '역순'으로 MySQL을 먼저 물리적으로 커밋(Commit)하고, 마지막에 오라클을 커밋합니다. 만약 중간에 런타임 에러가 터지면 양쪽 모두에게 롤백(Rollback) 명령을 쏴버립니다! 주니어 개발자는 여기서 박수를 치며 "와, 두 DB가 완벽하게 하나로 묶였어!"라고 환호하며 퇴근합니다.

⚠️ ChainedTransactionManager의 치명적이고 끔찍한 한계점

하지만 산전수전 다 겪은 시니어 아키텍트의 눈에는 이 체이닝 방식의 끔찍한 물리적 구멍이 훤히 보입니다. 비즈니스 로직이 다 끝나고 커밋을 칠 때, 먼저 MySQL 커밋은 성공적으로 디스크에 쓰였는데, 직후에 오라클 커밋을 때리려는 0.001초의 찰나에 오라클 DB가 죽거나 네트워크가 단절되어 오라클 커밋이 실패(Exception)한다면?

이미 MySQL은 커밋을 때려서 물리적으로 데이터가 영구 저장되어 버렸습니다! 주워 담을 수가 없습니다. 결국 오라클은 롤백되고 MySQL은 커밋되는 데이터 불일치가 또 터져버립니다. 이 방식도 '완벽한' 데이터 정합성을 물리적으로 100% 보장하지는 못하는 반쪽짜리 꼼수에 불과합니다. (실제로 이 치명적인 설계 결함 때문에, 스프링 프레임워크 5.3 (Spring Boot 2.5) 이상부터는 ChainedTransactionManager 클래스 자체가 아예 Deprecated(비권장 및 삭제 예정) 처리되어 버렸습니다.)

3. 완벽한 글로벌 원자성: JTA와 2PC(Two-Phase Commit)의 무거움

그렇다면 은행의 이체 시스템처럼 절대 0.001초의 틈조차 허용할 수 없는 완벽한 다중 DB 원자성(Atomicity)은 도대체 어떻게 구현해야 할까요? 여기서 자바 엔터프라이즈 에디션(Java EE)의 궁극기인 JTA (Java Transaction API)2PC (2-Phase Commit, 2단계 커밋) 프로토콜이 등장합니다. 이를 쓰려면 스프링 내장 매니저가 아니라, Atomikos(아토미코스)Bitronix 같은 외부 글로벌 트랜잭션 매니저를 도입해야 합니다.

2PC 아키텍처는 이름 그대로, 커밋 과정을 두 단계로 쪼개어 극도로 신중하고 보수적으로 진행합니다.

  • Phase 1 (Prepare, 준비 확인 단계): 글로벌 트랜잭션 코디네이터(Atomikos)가 오라클과 MySQL 양쪽 DB에게 "너희들 데이터 쓸 준비 완벽하게 다 끝났어? 롤백할 일 절대 없어? 디스크에 쓰기 직전 락(Lock)을 건 상태로 대기해!"라고 명령합니다. 만약 양쪽 중 한 곳이라도 "아니오, 저 디스크 용량이 꽉 차서 못 써요(No)"라고 대답하면, 코디네이터는 즉시 양쪽 모두에게 강제 롤백 명령(Abort)을 내려버립니다.
  • Phase 2 (Commit, 확정 단계): 양쪽 DB 모두가 "네, 100% 준비 완료(Yes)!"라고 굳은 약속의 응답을 보내왔을 때, 비로소 코디네이터가 "좋아, 이제 양쪽 동시에 물리적 디스크에 커밋(Commit) 때려!"라고 최종 승인 명령을 내립니다.

이 2PC 프로토콜을 통과하면 데이터는 신의 영역에서 보호받는 것처럼 완벽한 100%의 정합성을 유지합니다. 하지만 세상에 공짜는 없습니다. 양쪽 DB가 서로의 응답 상태를 기다려야 하므로 트랜잭션이 끝날 때까지 DB의 Row Lock(락)이 해제되지 않습니다. 이는 곧 엄청난 성능 저하(오버헤드)와 데이터베이스 데드락(Deadlock)의 폭증으로 이어집니다.

4. 현대 아키텍처의 해답: JTA를 버리고 SAGA 패턴(최종 일관성)으로!

결국 JTA 2PC를 도입하면 시스템의 트래픽 처리량(TPS)이 바닥을 치게 됩니다. 그래서 최근의 마이크로서비스(MSA) 아키텍처 진영에서는 글로벌 트랜잭션(JTA)이라는 무거운 족쇄를 과감히 버렸습니다. 대신 각각의 DB에 로컬 트랜잭션을 날려 일단 커밋을 해버리고, 만약 두 번째 작업(MySQL)에서 에러가 터지면, 메시지 큐(Kafka, RabbitMQ)를 통해 첫 번째 작업(오라클)에게 "야, 뒤에서 에러 났어! 너 아까 저장했던 데이터 다시 취소(보상 쿼리 실행)해!"라고 비동기 이벤트를 쏘는 방식을 사용합니다.

순간적으로는 오라클에 데이터가 들어가 있고 MySQL에는 데이터가 없는 불일치(Inconsistency)가 발생하지만, 카프카를 타고 보상 트랜잭션이 돌아가면 '결과적으로는(Eventually)' 데이터 정합성이 다시 맞아떨어지게 됩니다. 이것이 바로 그 유명한 SAGA 패턴(보상 트랜잭션)이자 최종 일관성(Eventual Consistency) 아키텍처입니다.

정리하며

이번 57단계 포스팅에서는 이기종 다중 데이터베이스를 묶을 때 발생하는 끔찍한 데이터 파편화 현상과, 이를 손쉽게 묶어주려다 퇴출당한 ChainedTransactionManager의 한계점, 완벽한 원자성을 띠지만 서버를 무겁게 만드는 2PC(JTA) 글로벌 트랜잭션의 철학, 그리고 현대 MSA의 해답인 SAGA 패턴까지 뼈 때리게 파헤쳐 보았습니다. 백엔드 아키텍처에서 '절대적인 100% 정합성'과 '시스템 성능(TPS)'은 결코 양립할 수 없는 영원한 트레이드오프(Trade-off)의 관계입니다. 이를 조율하는 것이 시니어의 역할입니다. 자, DB 인프라 세팅과 트랜잭션 설계는 끝났으니 이제 다시 코드 레벨 쿼리단으로 돌아가 볼까요? JPA를 쓰는 모든 백엔드 개발자가 반드시 한 번은 마주하고 피눈물을 흘리게 되는 지옥의 퍼포먼스 버그! 다음 58단계 포스팅에서는 "JPA N+1 문제의 완전한 종결: 무지성 Fetch Join의 OOM 참사, EntityGraph, 그리고 궁극의 무기인 BatchSize를 활용한 실무 인쿼리(In Query) 최적화 튜닝 가이드"에 대해 아주 치밀하게 딥다이브 해보겠습니다!

반응형