지금까지 개발을 하면서 단일 데이터베이스만 사용했고, 다중 데이터베이스를 다뤄볼 수 있는 환경도 없었다.
그런데 회사 프로젝트의 소스코드를 보니 3개의 DB가 붙어 있었고,
다중 DB 환경에서는 트랜잭션 처리를 어떻게 하고, 또 어떻게 할 수 있는지 궁금해졌다.
그래서 이번 글에서는 트랜잭션 매니저의 역할과 동작, 그리고 다중 DB 환경에서는 트랜잭션 처리를 어떻게 해야 하는지 기술해보겠다.
트랜잭션 매니저를 알아보기 앞서 트랜잭션이 무엇인지 간단히 짚고 가자.
트랜잭션(Transaction) : 여러 개의 DB 작업이 하나의 실행 단위로 묶여 동작해야 할 때, 그 작업들을 하나로 묶는 단위
대표적인 예시
이러한 문제를 방지하기 위해 여러 작업을 하나의 실행 단위(트랜잭션)로 묶고, 그중 하나라도 실패하면 전체를 롤백하도록 처리한다.
그런데 이 commit/rollback을 애플리케이션 코드에서 커넥션을 직접 열고 닫으며 관리하는 것은 번거롭고 실수하기도 쉽다.
이 역할을 대신 맡아주는 것이 바로 트랜잭션 매니저다.
즉, 트랜잭션의 시작을 알리고, 커넥션 단위로 트랜잭션 상태를 추적하며, commit/rollback 시점을 제어하는 주체가 트랜잭션 매니저다.
Spring에서 @Transactional을 사용하면 내부적으로 PlatformTransactionManager(예: DataSourceTransactionManager)가 이 역할을 대신 해준다.
정확히는 AOP 방식으로 동작한다.
메서드 실행 전후에 프록시가 개입하여 트랜잭션 시작과 commit/rollback을 대신 호출해준다.
여기서 핵심은 트랜잭션 매니저는 하나의 DataSource(= 하나의 DB 커넥션) 단위로 동작한다는 점이다.

앞서 살펴봤듯이 트랜잭션 매니저는 트랜잭션이 생길 때마다 새로 만들어지는 것이 아니라, 지정한 DataSource별로 존재하는 빈(Bean)이다.
따라서 DB가 물리적으로 분리되어 있다면 DB마다 별도의 트랜잭션 매니저가 존재하게 된다.
그래서 여러 DB를 다루는 로직에 @Transactional을 하나만 붙인다고 해서 여러 DB의 작업이 하나의 트랜잭션으로 묶이는 것은 아니다.
각 트랜잭션 매니저는 자신의 DataSource에 대해서만 독립적으로 트랜잭션을 관리한다.
문제는 이때 발생한다.
각 트랜잭션 매니저는 다른 DB의 트랜잭션 상태를 알 수 없기 때문에, A DB는 commit되었지만 B DB는 실패하는 상황이 발생할 수 있다.
결국 여러 DB에 걸친 작업을 하나의 트랜잭션처럼 묶어 관리할 수 있는 방법이 필요하다.
그래서 다중 DB 환경에서 트랜잭션을 처리하는 대표적인 3가지 방법을 알아보자.
글로벌 트랜잭션이란 다수의 작업 요청에 대해 하나의 DB 커넥션에 종속적으로 동작하는 것이 아니라, 트랜잭션 매니저를 통해 다수의 DB에 걸친 트랜잭션을 함께 관리하는 것을 말한다.
2PC(Two-Phase Commit)는 이 글로벌 트랜잭션을 실제로 어떻게 구현할 것인가에 대한 방법이다.
여러 DB에 걸친 작업을 두 단계로 나누어 확정하며, 아래의 흐름대로 흘러간다.
1단계 (Prepare)
트랜잭션 매니저가 참여하는 모든 DB에게 커밋할 준비가 되었는지 먼저 물어본다.
각 DB는 이때 실제로 커밋하지 않고, 현재 커밋이 가능한 상태인지 검증한 뒤 응답만 해준다.
이 동안 관련 리소스는 락을 걸고 대기한다.
2단계 (Commit)
전원이 "Yes"일 경우 트랜잭션 매니저가 모두에게 실제 커밋을 지시한다.
반대로 하나라도 "No"일 경우 전원에게 롤백을 지시한다.
자바에서는 대표적으로 JTA(Java Transaction API)가 이 역할을 제공하며, 실제 구현을 위해서는 Atomikos·Bitronix 같은 트랜잭션 코디네이터와 이 절차를 지원하는 XA 드라이버가 필요하다.
1단계에서 모든 DB의 상태를 미리 확인하기 때문에, 일부만 성공하고 일부만 실패하는 상황이 구조적으로 발생하지 않도록 할 수 있다.
또한 트랜잭션 매니저가 중간에 죽더라도 트랜잭션 로그가 남아 있어서, 재기동 시 미완결 트랜잭션을 마저 처리할 수 있다.
이러한 장점 때문에 2PC는 강한 일관성과 ACID 보장이 필요한 상황에서 사용할 수 있다.
하지만 좋은 장점에는 그만큼의 단점이 따른다.
모든 DB의 동의를 확인하는 동안 관련 리소스에 락을 걸게 되고, 그 리소스는 그동안 다른 작업에서 자유롭게 사용하기 어려운 상태가 된다.
따라서 트랜잭션이 길어질수록 시스템 성능에 영향을 줄 수 있다.
또한 참여하는 DB가 늘어날수록 트랜잭션 매니저와 각 DB 사이의 네트워크 왕복이 늘어나 지연이 커진다.
무엇보다 모든 DB가 XA를 지원해야 한다는 제약이 있다.
따라서 XA를 지원하지 않는 리소스인 NoSQL, 외부 API 등이 섞여 있다면 이 방식 자체를 적용하기 어렵다.
체인 방식 트랜잭션은 여러 개의 트랜잭션 매니저를 Prepare 단계 없이 순서대로 호출하는 방식을 말한다.

이 방식이 위험한 이유는 중간 DB의 커밋이 실패하면 앞서 커밋된 DB는 이미 확정된 상태이기 때문에 되돌릴 수 없다는 데 있다.
실패 시점 이후의 트랜잭션들만 롤백을 시도할 뿐, 이미 커밋된 DB는 롤백 대상이 아니다.
2PC처럼 커밋 전에 "전원이 준비됐는지" 미리 확인하는 절차가 없기 때문에 생기는 문제다.
그리고 이 방식은 공식적으로도 지양되고 있다.
Spring Data Commons의 GitHub 이슈(DATACMNS-1817)에서는 두 번째 트랜잭션 매니저의 커밋이 실패하면 이미 모든 synchronization이 처리된 상태라 복구할 방법이 없다는 점을 근거로, ChainedTransactionManager와 관련 클래스 전체를 deprecated 처리하기로 했다.

단순한 구조가 필요한 경우에는 이러한 방식을 참고하여 필요한 기능만 커스텀으로 구현해 사용하는 경우도 있다.
사가 패턴은 각 DB의 로컬 트랜잭션을 독립적으로 실행하고, 중간에 실패하면 이미 성공한 앞 단계의 작업을 보상 트랜잭션(Compensating Transaction)을 통해 되돌리는 방법이다.
2PC처럼 커밋 자체를 물리적으로 되돌리는 것이 아니라,
출금 취소나 주문 취소처럼 반대되는 새로운 작업을 수행하여 원래 상태로 맞춘다.
예를 들어 출금 → 주문 → 배송 등록 순서로 작업하던 중 배송 등록이 실패했다면,
출금 성공
↓
주문 성공
↓
배송 등록 실패
↓
주문 취소
↓
출금 취소
와 같이 이전 작업에 대한 보상 트랜잭션을 실행한다.
사가를 사용하는 방법에는 Choreography 방식과 Orchestration 방식이 있다.
Choreography 방식은 중앙 관리자가 없어 구조가 비교적 단순하지만, 서비스가 많아질수록 이벤트 흐름이 복잡해져 전체 트랜잭션을 추적하기 어려워질 수 있다.
Orchestration 방식은 Orchestrator가 전체 흐름을 관리하기 때문에 트랜잭션의 흐름을 파악하고 관리하기 쉽다.
하지만 흐름 제어가 Orchestrator에 집중되어 중앙 관리자의 복잡성이 커질 수 있다.
요구사항에 따라 달라지겠지만, 각 방식이 어떤 상황에 적합한지 생각해봤다.