티스토리 뷰
분산 트랜잭션에서 이벤트 기반 아키텍처로 전환하기 1편 : MSA에서 분산 트랜잭션이 깨지는 이유 - ChainedTransactionManager와 2PC의 한계
ebson 2026. 3. 11. 21:55모놀리식 애플리케이션에서는 하나의 데이터베이스에 대해 @Transactional 하나면 ACID가 보장됩니다. 비즈니스 로직이 아무리 복잡해도 커밋과 롤백의 경계가 명확하고, 개발자가 트랜잭션 정합성을 의식하지 않아도 프레임워크가 이를 처리해 줍니다. 그런데 마이크로서비스 아키텍처(MSA)로 전환하면 이 전제가 무너집니다. 서비스마다 독립된 데이터베이스를 갖게 되면서, 하나의 비즈니스 작업이 여러 서비스에 걸칠 때 기존의 로컬 ACID 트랜잭션으로는 데이터 정합성을 보장할 수 없게 됩니다. 이 글에서는 이 문제가 왜 발생하는지, 그리고 이를 해결하려는 동기식 접근들이 왜 근본적인 한계를 갖는지 정리합니다.
트랜잭션 경계가 분리되는 순간
모놀리스에서 MSA로 전환할 때 가장 먼저 마주치는 구조적 변화 중 하나는 데이터베이스의 분리입니다. 서비스별로 전용 데이터베이스를 할당하는 것은 MSA의 핵심 원칙이지만, 이 원칙을 따르는 순간 기존의 ACID 트랜잭션 보장은 직접 적용할 수 없게 됩니다.
Microservices.io의 Saga 패턴 문서에서는 이 문제를 다음과 같이 설명합니다.
"Since Orders and Customers are in different databases owned by different services the application cannot simply use a local ACID transaction."
주문 서비스와 고객 서비스가 각각 다른 데이터베이스를 소유하고 있으므로, 로컬 ACID 트랜잭션을 단순히 적용할 수 없다는 것입니다. 모놀리스에서는 하나의 트랜잭션 안에서 주문 테이블과 고객 테이블을 동시에 업데이트할 수 있었지만, MSA에서는 이 두 테이블이 물리적으로 다른 데이터베이스에 존재합니다. 트랜잭션의 경계가 서비스 경계를 따라 분리된 것입니다.
실무에서 마주치는 다중 DataSource 트랜잭션 문제
이 문제는 MSA 간 통신에서만 발생하는 것이 아닙니다. 단일 애플리케이션 내에서도 여러 DataSource를 사용하면 동일한 문제가 나타납니다. Spring Batch 환경에서의 사례를 살펴보겠습니다.
Spring Batch는 Job의 메타데이터를 관리하는 DataSource와 실제 비즈니스 데이터를 처리하는 DataSource가 분리되는 경우가 많습니다. 여기에 비즈니스 요구에 따라 추가 DataSource가 더해지면, 하나의 Step 안에서 N개의 DataSource에 대해 쓰기 작업을 수행해야 하는 상황이 발생합니다.
문제는 Spring의 @Transactional이 기본적으로 하나의 PlatformTransactionManager에만 바인딩된다는 점입니다. 여러 DataSource가 존재하더라도 프레임워크가 자동으로 선택하는 트랜잭션 매니저는 하나뿐입니다. 이 경우 해당 트랜잭션 매니저가 관리하는 DataSource에 대해서만 트랜잭션이 적용되고, 나머지 DataSource에 대한 쓰기 작업은 트랜잭션 보호 밖에 놓이게 됩니다.
결과적으로 한 DataSource에는 데이터가 정상적으로 반영되었는데 다른 DataSource에는 반영되지 않거나, 오류 발생 시 일부 DataSource만 롤백되는 상황이 발생합니다. 이것이 바로 분산 트랜잭션 문제의 본질입니다. 하나의 비즈니스 작업이 여러 트랜잭션 리소스에 걸쳐 있을 때, 전체를 하나의 원자적 단위로 묶을 수 없다는 것입니다.
@Transactional만으로는 부족한 이유
@Transactional은 단일 트랜잭션 리소스에 대해서는 완벽하게 동작합니다. Spring의 선언적 트랜잭션 관리는 AOP 프록시를 통해 메서드 실행 전후로 트랜잭션을 시작하고 커밋 또는 롤백하는 과정을 자동화합니다. 하지만 이 메커니즘은 근본적으로 하나의 PlatformTransactionManager가 하나의 트랜잭션 리소스를 관리하는 구조입니다.
여러 DataSource를 사용하는 환경에서 @Transactional을 지정하면, value 속성으로 특정 트랜잭션 매니저를 지정할 수 있습니다. 하지만 이는 해당 메서드가 지정된 하나의 트랜잭션 매니저로만 트랜잭션을 관리한다는 의미이지, 여러 트랜잭션 매니저를 동시에 조율한다는 의미가 아닙니다. 두 개의 DataSource에 걸친 쓰기 작업을 하나의 원자적 단위로 묶으려면, @Transactional의 범위를 넘어서는 별도의 분산 트랜잭션 관리 메커니즘이 필요합니다.
ChainedTransactionManager - 최선의 시도
이 문제에 대한 한 가지 접근법으로 Spring Data에서 제공했던 ChainedTransactionManager가 있습니다. 이름에서 알 수 있듯이 여러 PlatformTransactionManager를 체인으로 연결하여 순차적으로 트랜잭션을 관리하는 방식입니다.
Spring Data Commons 공식 문서에 따르면, ChainedTransactionManager는 다음과 같이 동작합니다.
"The configured instances will start transactions in the order given and commit/rollback in reverse order."
즉, 설정된 트랜잭션 매니저들이 순서대로 트랜잭션을 시작하고, 커밋과 롤백은 역순으로 수행합니다. 가장 실패 가능성이 높은 트랜잭션 매니저를 목록의 마지막에 배치하여 역순 커밋 시 먼저 처리되도록 하라는 것이 공식 문서의 권장 사항입니다. 이 구조는 얼핏 분산 트랜잭션 문제를 해결하는 것처럼 보입니다. 여러 DataSource에 대한 트랜잭션을 하나의 매니저가 조율하니까요. 하지만 공식 문서는 이 접근의 근본적인 한계를 명확히 밝힙니다.
"A transaction can get into a state, where the first PlatformTransactionManager has committed its transaction and a subsequent PlatformTransactionManager failed to commit its transaction (e.g. caused by an I/O error or the transactional resource failed to commit for other reasons). In that case, commit(TransactionStatus) throws a HeuristicCompletionException to indicate a partially committed transaction."
첫 번째 트랜잭션 매니저가 커밋을 완료한 후에 두 번째 트랜잭션 매니저의 커밋이 실패하면, 첫 번째 커밋은 이미 되돌릴 수 없습니다. HeuristicCompletionException이 발생하면서 부분적으로만 커밋된 상태, 즉 데이터 불일치 상태에 빠지게 됩니다.
이것이 ChainedTransactionManager가 진정한 분산 트랜잭션 조정(coordination)을 제공하지 못하는 이유입니다. 이 클래스는 여러 트랜잭션을 순차적으로 처리할 뿐, 모든 참여자가 커밋 가능한 상태인지 먼저 확인하는 과정이 없습니다. 커밋 단계에서 실패가 발생하면 이미 커밋된 트랜잭션을 롤백할 방법이 없는 것입니다.
Spring Data는 이러한 한계를 인지하고 2.5 버전부터 ChainedTransactionManager를 deprecated 처리했습니다. 공식 문서의 경고는 다음과 같습니다.
"ChainedTransactionManager should be only used if the application can tolerate or recover from inconsistent state caused by partially committed transactions. In any other case, the use of ChainedTransactionManager is not recommended."
부분적으로 커밋된 트랜잭션으로 인한 불일치 상태를 감내하거나 복구할 수 있는 경우에만 사용하라는 것입니다. 달리 말하면, 데이터 정합성이 중요한 시스템에서는 사용하지 말라는 의미입니다. ChainedTransactionManager는 분산 트랜잭션에 대한 정합성 보장이 아니라 최선의 시도(best-effort) 수준의 접근이었던 셈입니다.
2 Phase Commit과 JTA - 강한 일관성의 대가
ChainedTransactionManager의 한계가 커밋 순서에 대한 조율 부재에서 온다면, 이를 정면으로 해결하려는 프로토콜이 2 Phase Commit(2PC)입니다. 2PC는 분산 트랜잭션에 참여하는 모든 리소스가 커밋 가능한지 먼저 확인(Prepare Phase)한 후, 전원이 준비 완료 상태일 때만 실제 커밋(Commit Phase)을 수행합니다. 이를 통해 ACID 수준의 강한 일관성을 보장할 수 있습니다. Java 환경에서는 JTA(Java Transaction API)가 2PC의 표준 구현을 제공합니다. Atomikos나 Narayana 같은 JTA 구현체를 사용하면 여러 DataSource에 걸친 트랜잭션을 하나의 글로벌 트랜잭션으로 관리할 수 있습니다.
그러나 2PC는 MSA 환경에서 심각한 제약을 안고 있습니다. Chris Richardson의 Microservices Patterns에서는 이를 직접적으로 언급하며, Microservices.io의 Saga 패턴 문서에서도 "2PC is not an option"이라고 명시합니다. 그 이유는 크게 두 가지입니다.
첫째, 코디네이터의 단일 장애점(SPOF) 문제입니다. 2PC에서는 트랜잭션 코디네이터가 Prepare와 Commit 단계를 조율합니다. 코디네이터에 장애가 발생하면 참여자들은 Prepare 상태에서 락을 잡은 채 코디네이터의 복구를 기다려야 합니다. 이 기간 동안 해당 리소스에 대한 다른 트랜잭션은 블로킹됩니다. 서비스가 독립적으로 배포되고 확장되어야 하는 MSA 환경에서, 하나의 코디네이터 장애가 여러 서비스를 동시에 멈추게 하는 것은 치명적입니다.
둘째, 성능과 확장성의 제약입니다. 2PC는 모든 참여자가 Prepare에 응답할 때까지 대기해야 하므로, 참여 서비스가 늘어날수록 응답 시간이 증가합니다. 또한 Prepare 단계에서 잡은 락은 Commit이 완료될 때까지 해제되지 않으므로, 트랜잭션이 길어질수록 리소스 경합이 심해집니다. 마이크로서비스의 핵심 이점인 서비스별 독립적인 확장과 배포가 2PC의 동기적 조율 과정과 근본적으로 충돌하는 것입니다.
결국 2PC는 강한 일관성을 보장하는 대신 가용성과 확장성을 희생합니다. 분산 시스템에서는 CAP 정리에 의해 일관성(Consistency)과 가용성(Availability)을 동시에 완벽하게 보장할 수 없다는 이론적 제약도 이 맥락과 맞닿아 있습니다. MSA가 추구하는 높은 가용성과 독립적 확장성을 유지하면서 데이터 정합성도 보장하려면, 2PC와는 다른 접근이 필요합니다.
왜 근본적으로 다른 접근이 필요한가
지금까지 살펴본 동기식 해법들의 한계를 정리하면 다음과 같습니다.
@Transactional은 단일 트랜잭션 리소스에 대해서만 동작하므로 다중 DataSource 환경에서는 적용 범위를 벗어납니다. ChainedTransactionManager는 여러 트랜잭션 매니저를 순차적으로 처리하지만, 커밋 단계의 부분 실패에 대한 보호 장치가 없어 데이터 불일치가 발생할 수 있습니다. 2PC/JTA는 강한 일관성을 보장하지만 코디네이터 장애 시 전체 블로킹과 참여자 증가에 따른 성능 저하라는 대가를 치러야 합니다.
이 접근들의 공통점은 모든 참여자를 동기적으로 조율하여 하나의 원자적 트랜잭션으로 묶으려 한다는 것입니다. 모놀리스에서는 이 전략이 자연스럽게 동작했지만, 서비스와 데이터베이스가 분리된 MSA 환경에서는 이러한 동기적 조율 자체가 시스템의 가용성과 확장성을 제약하는 병목이 됩니다.
그렇다면 발상을 전환해서, 하나의 글로벌 트랜잭션으로 묶는 대신 각 서비스의 로컬 트랜잭션을 독립적으로 실행하되 전체적인 데이터 정합성을 보장하는 방법은 없을까요? 강한 일관성(Strong Consistency)을 포기하는 대신, 최종적으로는 일관된 상태에 도달하는 최종 일관성(Eventual Consistency)을 수용한다면 어떨까요?
이 질문에 대한 답이 바로 Saga 패턴입니다. Microservices.io에서는 Saga를 다음과 같이 정의합니다.
"A saga is a sequence of local transactions. Each local transaction updates the database and publishes a message or event to trigger the next local transaction in the saga."
각 서비스가 자신의 로컬 트랜잭션을 수행하고, 그 결과를 메시지나 이벤트로 발행하여 다음 서비스의 로컬 트랜잭션을 트리거하는 방식입니다. 실패 시에는 이전 단계를 되돌리는 보상 트랜잭션(Compensating Transaction)을 실행하여 정합성을 복구합니다. 동기적 조율 대신 비동기적 이벤트 흐름으로 분산 데이터 정합성을 보장하는 접근입니다.
다음 편에서는 이 Saga 패턴의 구조를 깊이 들여다보고, 왜 비동기 메시징이 Saga의 표준적인 통신 방식이 되는지 살펴보겠습니다.
References
- Chris Richardson, Microservices Patterns (Manning, 2018) — Chapter 4: Managing transactions with sagas
- Microservices.io — Saga pattern: https://microservices.io/patterns/data/saga.html
- Spring Data Commons — ChainedTransactionManager (Deprecated since 2.5): https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/transaction/ChainedTransactionManager.html
'STUDY' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Cache Avalanche
- 캐시 성능 비교
- Spring Batch
- DB 인덱스 성능
- 백엔드 성능 설계
- Eager Initialization
- 트랜잭션 관리
- Redis 성능 개선
- TTL 설계
- Java Performance
- DB 트랜잭션
- Initialization-on-Demand Holder Idiom
- 트래픽 처리
- Hot Key 문제
- Redis vs DB
- Cache Aside
- Redis 캐시 전략
- 백엔드 성능 튜닝
- 캐시와 인덱스
- mybatis
- Enum 기반 싱글톤
- 백엔드 아키텍처
- 스레드 생명주기
- 캐시 장애
- 동시성처리
- spring batch 5
- 백엔드 성능
- Cache Penetration
- InterruptedException
- Double-Checked Locking
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

