분산 트랜잭션(Distributed Transaction) 알아보기
Overview
DB가 분리되어있는 분산 시스템 서버를 설계할 때 각 트랜잭션의 정합성을 보장하지 않으면 시스템의 일관성을 깨트리는 원인이 됩니다. 분리되어있는 서버는 언제나 오류가 발생할 수 있는 가능성이 0%보다 높으며 이를 해결하기 위해서는 전체적인 시스템이 내결함성(fault tolerance)를 지녀야합니다.
가령 일반적인 MSA 환경에서 클라이언트의 상품을 구매하는 상품서버, 요청 상품의 재고를 관리하는 재고 서버, 외부 결제 서비스에게 요청을 처리하는 결제 서버가 존재합니다. 상품을 구매하는데 재고는 차감이 되었는데 결제 서비스에 오류가 생기면 재고는 다시 차감이 되지 않은 상태로 원복을 해야합니다.

이러한 시나리오는 단일한 DB에서 단일한 서버 로직으로 처리하면 간단한 문제입니다. RDB는 트랜잭션을 지원하기 때문에 재고를 차감하는 로직과 결제를 처리하는 로직을 하나의 트랜잭션으로 묶어 결제 로직이 실패했을 때 트랜잭션을 롤백하면 재고 차감 트랜잭션이 커밋이 되지 않아 문제가 생기지 않습니다. 그러나 서버별 DB가 분리가 되어있는 상황에서는 각각의 RDB에서 트랜잭션이 생기기 때문에 이 두 트랜잭션의 정합성을 보장하기 위한 방법을 구현해야합니다.
시나리오
이 글에서 가정하는 분산 트랜잭션 시나리오는 다음과 같습니다. 클라이언트가 특정 상품을 주문하면 주문 데이터를 생성합니다. 그리고 해당 상품의 재고를 확인하고 적절하면 하나를 차감합니다. 그리고 해당 상품의 결제를 외부 결제 파트너사에게 요청을 하고 성공 응답을 받으면 결과를 저장합니다. 그리고 주문 상태를 결제 완료로 변경하고 클라이언트에게 성공 응답을 내려줍니다.
단, 이 시나리오에서 클라이언트의 요청/응답과 결제 로직에서 결제 파트너사와의 통신 방신은 동기 방식으로 가정을 하겠습니다. 또한 하나의 트랜잭션을 물고 결제 파트너사와 통신을 주고받는 과정에서의 성능은 고려하지 않습니다.
- 클라이언트의 상품 주문 요청
- 상품 주문 데이터 생성
- 잔고 확인 및 차감
- 결제 데이터 생성 및 결제 파트너사에 결제 요청
- 결제 파트너사에게 성공 응답
- 상품 데이터 성공으로 변경
- 클라이언트에게 성공 응답
Happy Path로 로직이 진행되는 경우 위와 같이 로직이 진행될 예정입니다. 다만 도중에 결제 파트너사와는 외부 통신을 하는 단계가 있습니다. 결제 파트너사는 외부망을 사용하므로 네트워크 장애를 우려해야하며 내부 시스템이 아니기 때문에 어떤 시스템 장애가 발생할지 모릅니다. 장애가 발생되면 차감했던 재고를 원복하고 주문 데이터를 실패처리해야합니다. 그렇지않으면 각 데이터간의 정합성 문제가 발생하기 때문에 DB에 데이터를 저장하기 위해 위 연산을 하나의 원자적 트랜잭션(atomic transaction)으로 처리해야할 필요가 있습니다.
모놀로식 서버와 단일 DB Node
모놀리식 서버에서 단일한 DB Node를 사용하여 트랜잭션을 관리하는 경우는 트랜잭션 문제를 쉽게 해결할 수 있습니다.

주문, 재고, 결제 로직을 모두 단일한 트랜잭션으로 관리를 합니다. 즉 모든 쓰기 연산은 주문 데이터 생성부터 결제가 완료되어 주문 완료 상태로 변경하고 나서야 DB에 커밋이 됩니다.

결제 파트너와의 네트워크 통신 오류가 발생해서 실패처리가 되었을 때 주문, 재고, 결제 데이터를 모두 롤백 시키면 됩니다. 그렇게 되면 주문은 생성되지 않으며 재고도 차감되지도 않았을거고 결제 데이터도 생성되지 않았습니다. (다만, 주문의 경우 이전에 생성하는 로직이 있는 경우 실패 상태로 변경해도 되며 결제 시도는 히스토리를 별도의 트랜잭션으로 남길 수 있습니다)
따라서 전체적인 로직의 트랜잭션 원자성을 보장할 수 있습니다.
분산 트랜잭션(Distributed Transaction)
분산 트랜잭션은 DB가 분리되어있는 상황에서만 나타나지 않습니다. 이 글에서는 3가지의 분산 트랜잭션 상황을 이야기합니다.
- 데이터베이스 샤드(Shard)
- 분산 모놀리스(Distributed Monlith)
- 마이크로서비스 아키텍처(Microservices Architecture, MSA)
데이터베이스 샤드(Shard)
단일한 DB를 사용하는 모놀리식 아키텍처에서는 위와 같이 트랜잭션 정합성 문제를 걱정할 필요가 없습니다. 하지만 DB에 주문 정보가 1억개가 넘어가는 경우 발생하는 거래의 연산를 하나의 DB에서 감당하기 어려울 수 있습니다. 따라서 DB 쓰기연산을 분산하기 위해서 Master DB 노드를 분리하는 샤딩(Sharding)을 진행하는 경우가 있습니다.

본 글에서는 다루지 않지만 은행에서 계좌이체를 해야하는 경우 인출계좌는 DB1에서 조회하고 입금계좌는 DB2에서 조회를 해서 각각 데이터를 변경해야할 수 있습니다. 이 경우 DB 노드가 분리되어있기 때문에 각기 다른 트랜잭션을 사용해야 합니다.
분산 모놀리스(Distributed Monolith)
분산 모놀리스(Distributed Monolith)는 모놀리스를 MSA로 전환할 때, 완전하지 못한 단계에 있는 환경입니다. 소스코드를 분리하고 별도의 서버로 배포를 분리했지만 각 서버가 공통된 리소스(DB 등)를 공유하고 있는 모습입니다.
마이크로서비스 패턴 책의 저자이자 microservices.io의 운영자인 크리스 리처드슨은 분산 모놀리스의 형식이 모놀리스의 단점에 마이크로서비스 단점을 추가하는 문제가 발생한다고 이야기 합니다. 물론 분산 모놀리스 환경이 장점이 없다고 할 수는 없지만 많은 문제를 포함하고 있습니다.
- 하나의 서비스가 변경되면 다른 서비스가 다시 배포됩니다.
- 마이크로 서비스는 지연 시간이 짧은 통신에 의존합니다.
- 응용 프로그램의 많은 서비스는 리소스(예: 데이터베이스)를 공유하므로 긴밀하게 연결됩니다.
- 마이크로 서비스 간에 공유된 코드베이스 또는 테스트 환경이 있습니다.

분산 모놀리스 환경에서는 각 서버가 분리 되어있고 상품 주문 로직을 실행하기 위해 각 서버에게 요청을 합니다. 각각의 서버는 동일한 RDB를 공유하고 있지만 데이터 확인 및 변경 작업을 위해 각기 다른 Connection을 획득하고 트랜잭션을 통해 커밋합니다.
MSA
마이크로서비스 아키텍처(Microservices Architecture, MSA) 환경에서는 DB가 별도로 분리되어있는 폴리그랏(Poloyglot) 형태로 서버가 분리되어있습니다. 실제 환경에서는 API Gateway 등의 여러 미들웨어 및 패턴이 조합된 모습이지만 예제에서는 간단하게 표현하겠습니다.

이 경우에는 로직 흐름은 위 분산 모놀리스와 유사하지만 각기 다른 데이터베이스를 사용하며 커밋합니다. 이렇게 분산된 환경에서는 트랜잭션이 분리가 되어있으므로 문제가 발생하면 모든 트랜잭션의 정합성이 깨지게 됩니다.

결제 통신에서 네트워크 오류가 발생했을 때 주문 로직은 오류가 발생해서 주문 및 결제 로직의 트랜잭션은 롤백이 되었지만 이미 재고 차감 트랜잭션은 커밋되었습니다. 이렇게 되면 결제가 진행되지 않았음에도 불구하고 잔고가 차감되어 버립니다.
하나의 연산을 각기 다른 트랜잭션을 사용해서 해결해야할 때 원자성을 보장하지 않으면 데이터 정합성이 맞지 않는 오류가 발생할 수 있습니다. DB 샤딩하는 경우와 분산 모놀리스 환경도 트랜잭션이 분리되어있기 때문에 동일한 문제가 발생할 수 있습니다. 이렇게 단일 시스템의 각 로컬 트랜잭션 연속적으로 호출되어 여러 서비스로 분산되는 것을 분산 트랜잭션(Distributed Transaction)이라고 부릅니다. 분산 트랙잭션도 우리가 알고 있는 트랜잭션 처럼 ACID 속성을 갖춰야 하며 특히 원자적으로 동작해야 합니다.
이 글에서는 분산 트랜잭션의 원자성을 보장하기 위한 방법으로 다음 3가지를 소개합니다.
- 2PC
- TC/C Pattern
- Saga Pattern
2PC(Two Phase Commit)
각 DB의 트랜잭션을 로컬(Local) 트랜잭션을 하나의 글로벌(Global) 트랜잭션으로 묶는 방법입니다.

2PC 트랜잭션은 애플리케이션이 여러 데이터베이스 노드에서 데이터를 읽고쓰는 것으로부터 시작됩니다. 커밋할 준비가 되면 각 노드에 준비 요청을 보내 커밋 가능여부를 묻는 1단계를 진행합니다. 1단계의 응답에 따라서 커밋을 할지 롤백을 할지 최종 결정을 합니다. 모든 커밋이 가능하다는 응답이 오면 트랜잭션 매니저는 2단계에서 커밋 요청을 수행합니다. 만약 불가능한 응답이 오면 모든 노드에 중단 요청을 보냅니다.
각 자원은 2PC를 지원해야하고 코디네이터(혹은 글로벌 트랜잭션 관리자)가 필요합니다. 애플리케이션에서 로컬 트랜잭션을 관리하듯이 코드를 통해 통제가 가능합니다. 다만 지금은 거의 사용하지 않습니다. 이유는 성능 때문입니다.
2PC은 단일 db에 비해서 (Mysql)로 비교하면 10배가량의 performance 차이가 난다고 합니다. 해당 commit의 가능 여부를 network io를 이용하여 판단하기에 io로 인한 퍼포먼스 저하라고 생각하였으나 crash recovery를 위해서 추가적인 fsync()
도 퍼포먼스 저하중 하나의 원인입니다.
2PC는 매우 강력한 일관성 프로토콜을 제공하는 반면 트랜잭션을 요청 받은 서비스로부터 모두 완료 회신을 받기 전까지는 전체 서비스에 잠금(Lock)이 걸립니다. 이로 인해 코디네이터 노드 혹은 대상 트랜잭션 노드가 다운될 경우 전체 시스템에 장애를 유발할 수 있어 MSA 구조에서는 그다지 추천되지 않습니다.
https://post.naver.com/viewer/postView.nhn?volumeNo=29486445&memberNo=36733075
TC/C(Try-Confirm/Cancel) 패턴
2PC의 대안으로 나온 방법 중 하나로는 TC/C 패턴이 있습니다. TC/C 패턴은 분산 트랜잭션을 보장하기 위해 두 단계로 구성된 보상 기반 트랜잭션(distributed transaction by compensation) 방식입니다.

- 먼저 모든 트랜잭션에 필요한 자원예약을 요청합니다. (try)
- 이후에 모든 트랜잭션 자원예약 요청에 대한 응답을 회신합니다.
a. 이전 트랜잭션이 성공하면 다시 작업 확인 요청을 보냅니다. (confirm)
b. 이전 트랜잭션이 실패하거나 예정된 시간이 초과되 작업 취소 요청을 보냅니다. (cancel)
2PC의 트랜잭션은 하나의 글로벌 트랜잭션이지만 TC/C 패턴은 별도의 트랜잭션이 진행됩니다. 이 방법은 REST 기반의 보상 트랜잭션을 비교적 간단하게 구현하기 적합한 방법입니다.
TRY
TRY 단계에서는 서비스가 보류 상태에 놓입니다. 전체 트랜잭션에서 예비 작업이라고 할 수 있습니다.
CONFIRM
CONFIRM 단계에서는 서비스를 확인 상태로 변경합니다. CONFIRM 명령은 트랜잭션이 성공했는지 확인하고 성공한 경우에만 커밋을 합니다. 확인에 실패하면 분산 트랜잭션은 롤백됩니다.
CANCEL
CANCEL 명령은 트랜잭션을 무효화하거나 되돌리는 데 사용됩니다. 분산 트랜잭션은 언제든지 취소할 수 있어야 합니다. CANCEL은 TRY(예비 작업)의 역방향으로 이해할 수 있습니다. 예비 작업에서 지정한 취소 작업을 하나씩 수행하고 완료된 내용은 모두 폐기합니다.

TC/C 패턴의 구현을 위해서는 TRY-CONFIRM-CANCEL 상태를 관리하는 테이블과 이의 로직을 수행하는 조정자(Coordinator) 역할이 필요합니다. 이 예제에서는 Order Server가 조정자 역할을 같이 수행하고 있습니다. 처음 주문 요청이 들어오면 Stock, Payment 서버에 try 요청을 보내 각 작업을 예약하며 각 서버에서 해당 예약 작업을 DB에서 관리합니다.
예약이 성공하면 각 서버에 확인 요청을 보냅니다. 이 때, 잔고 차감 및 실제 결제 요청 작업이 진행됩니다. 각 서버에서 성공적으로 작업이 완료되면 확인이 되었다는 완료 응답을 보냅니다. 이를 통해 모든 작업이 완료되면 주문 데이터를 마무리하고 클라이언트에게 성공 응답을 보냅니다.

하지만 만약 이전과 같이 결제 파트너와의 작업 중 네트워크 에러가 발생을 하면 작업이 실패했으므로 취소 상태로 변경을 하고 취소 응답을 보냅니다. 주문 서버에서는 취소 응답을 받고 다시 재고 서버에게 취소 요청을 보내고 재고 차감을 로직을 원복합니다. 모든 작업이 완료되면 실패 처리를 하고 클라이언트에게 실패 응답을 보내게 됩니다.
이외에 도중에 주문 서버가 다운이 될 때 도중에 중단된 TC/C 데이터를 피봇(재시도 및 타임아웃 처리)할 수 있는 조정자 로직을 구현해야합니다. TC/C 패턴의 구체적인 설계 이론 및 구현 사례는 링크를 통해 확인할 수 있습니다.
보상 트랜잭션
TC/C 패턴을 소개하면서 나온 보상 트랜잭션(Compensating Transaction) 개념은 분산 트랜잭션의 원자성을 보장하기 위한 방법입니다. 즉, 모든 로컬 트랜잭션으로 이뤄져있는 일련의 작업이 모두 커밋이되거나 모두 롤백이 되기 위한 방법이죠.
TC/C 패턴을 잘 들여다보면 분산 트랜잭션의 원자성을 보장은 하고 있지만 일관성에 대한 고려는 아직 해결되지 않았습니다. 가령 동시에 같은 상품에 대한 수많은 주문 요청이 들어왔을 때, 재고 차감이 진행(confirm)되고 결제 요청을 하는 과정에서 실패 처리가 되면 실제로 결제가 완료되지 않았음에도 재고가 없다고 나오는 시점이 생기게 됩니다. 이를 해결하기 위해서는 비관적 락 혹은 분산 락 등으로 제어를 해야하는데 적합한 방식이 아닐 수 있습니다.
이 경우 동시성을 보장하면서 일관성을 유지하는 방법은 사실상 구현하기 어렵습니다. 따라서 당장은 실시간 동기화가 되지 않지만 결국 언젠가는 데이터가 동기화가 되어 일관성을 유지한다는 의미의 최종적 일관성(Eventual Consistency)의 개념을 이해하고 이에 대한 대응을 할 수 있습니다.
최종적 일관성(Eventual Consistency)
최종적 일관성은 결국 모든 액세스가 마지막으로 업데이트된 값을 반환하는 것을 보장한다는 내용의 일관성 모델입니다. 분산 환경에서는 각각의 DB에서 트랜잭션을 사용하기 때문에 강한 데이터 일관성을 유지할 수 없고 각 업데이트 로직이 최종적으로는 동작하게 되어 일관성을 유지하게 된다는 내용입니다.
보상 트랜잭션의 개념도 결국에는 각각의 로컬 트랜잭션이 분리되어있기 때문에 일관성을 해결할 수 없기 때문에 최종적 일관성을 보장하기 위한 방법으로 구현이 됩니다.
사가 패턴(Saga Pattern)
마찬가지로 2PC의 대안으로 나온 사가(SAGA) 패턴이 있습니다. 앞서 설명한 TC/C 패턴도 대안으로 나온 방법이긴 하지만 실질적으로 구현이 쉽지 않고 MSA의 사실상 표준인 사가 패턴만큼 인지도가 있지는 않습니다.

사가 패턴은 마이크로서비스 패턴의 저자인 크리스 리처드슨이 소개한 패턴입니다. 시스템이 비즈니스 규칙을 위반하여 로컬 트랜잭션이 실패할 때 이전 로컬 트랜잭션에서 변경한 내용을 취소하는 일련의 보상 트랜잭션을 실행하는 구조입니다.
- 모든 연산은 순서대로 정렬된다. 각 연산은 자기 데이터베이스에 로컬 트랜잭션으로 실행된다.
- 연산은 첫 번째부터 마지막까지 순서대로 실행된다. 한 연산이 완료되면 다음 연산이 실행된다.
- 연산이 실패하면 전체 프로세스는 실패한 연산부터 맨 처음 연산까지 역순으로 보상 트랜잭션을 통해 롤백된다. 따라서 n개의 연산을 실행하는 분산 트랜잭션은 보상 트랜잭션을 포함하여 2n개의 연산을 준비해야한다.
microservices.io 는 사가 패턴을 구현하는 방법으로 2가지를 제안합니다.
- 코레오그래피(Choreography-based saga)
- 오케스트레이션(Orchestration-based saga)
이 방식에서 서비스간의 통신은 주로 이벤트 기반으로 설계됩니다. 그렇기 때문에 이벤트 결과를 통해 어떤 작업을 수행할지 결정하기 위한 상태 기계(state machine)을 가지고 있어야 합니다.
코레오그래피(Choreography)
코레오그래피 방식은 처리 중 장애가 발생했을 때, 각 서비스가 변경했던 내용을 스스로 롤백할 수 있는 구조로 되어있는 탈 중앙화 방식입니다. 이 방식은 단순한 분산 트랜잭션 혹은 변경될 일이 거의 없는 작업을 구현할 때 적합합니다. 하지만 서비스간 의존 관계에 강한 결합도가 생길 수 있습니다.

코레오그래피 방식은 각각의 서비스가 순차적으로 다음 서비스에게 요청을 합니다. 각 서버는 해당 요청에 대한 상태를 관리하는 테이블을 별도로 가지고 있습니다.

하지만 실패가 생기면 역순으로 다시 롤백 요청을 보내게 됩니다. 각 서버는 처리하는 로직을 그대로 롤백하는 로직을 가지고 있습니다.
오케스트레이션(Orchestration)
오케스트레이션 방식은 참여자가 할 일을 직접 알려주는 오케스트레이터가 존재합니다. 또한 각 서비스가 스스로 변경된 내용을 롤백할 수 있는 로직을 가지고 있어야 합니다.

오케스트레이션 방식은 별도의 오케스트레이터(Orchestrator)가 존재합니다. 예시에서는 주문 서버가 오케스트레이터를 포함하고 있지만 별도의 서버로 독립되도 됩니다. 오케스트레이터는 각 서버에게 보낼 요청 이벤트 테이블 가지고 관장합니다. 오케스트레이션 방식도 순차적으로 서버에게 요청을 합니다.

결제 서버에서 오류가 발생했을 때는 역순으로 롤백요청을 하게 됩니다. 만약 롤백이 실패했을 경우 재시도를 하거나 알림을 발송하거나 하는 등의 로직은 오케스트레이터가 관리를 합니다.
사가 패턴 특징
예시와 같이 사가패턴은 보통 이벤트 방식으로 구성이 됩니다. 또한 비즈니스 로직에 해당하는 데이터 변경과 이벤트 발행 트랜잭션을 하나로 묶고 그 이후에 발행하는 트랜잭셔널 메시징(Transactional Messaging) 방식을 사용하며 아웃박스 패턴을 권장합니다.
단순한 사가를 구현할 때는 코레오그래피 방식이 유용할 수 있지만 대부분의 상황에서는 복잡한 비즈니스를 관장할 수 있는 오케스트레이션 방식을 권장합니다. 하지만 너무 많은 로직이 중앙화되면 문제가 발생할 수 있어서 오케스트레이터는 순서화만 담당하고 비즈니스 로직을 가지지 않 는 것을 권장합니다.
보상 워크플로우(Compensating Workflow)
서비스를 이용하는 사용자 입장에서는 재고가 있는 주문을 요청을 했는데도 이러한 보상 트랜잭션의 개념으로 잔고가 없어 실패하거나 결제가 실패해서 환불이 되는 경우가 있습니다. 기술적으로는 안정적으로 재고 처리를 하고 환불 처리를 했으므로 문제가 없지만 사용자 입장에서는 불쾌한 경험이 됩니다.
이벤트 기반 마이크로서비스 구축(Building Event-Driven Microservices) 책에서는 이에 대해 고객 만족 정책으로 개선을 권장합니다. 가령 보상 형태로 새로운 재고를 발주하고 사용자에게 재고 알림을 보내주도록 등록한다거나 할인 쿠폰을 제공하는 방법입니다.
Conclusion
이렇게 복잡한 분산 트랜잭션을 처리하기 가장 좋은 방법으로 알려진 기술은 DDD에서 나온 이벤트 소싱(Event Sourcing)입니다. 이벤트 소싱은 모든 이벤트를 기록하기 때문에 트랜잭션 문제를 역추적할 수 있는 감사(audit)로서의 역할을 할 수 있으며 내부적으로 TC/C, Saga 패턴과 접목해서 사용할 수도 있습니다. 다만 이벤트 소싱은 개념만으로도 복잡도가 높아 이 글에서는 설명을 생략했습니다.
또한 앞서 소개한 최종적 일관성의 특징은 트랜잭션의 격리(Isolation) 문제가 발생하여 갱신 유실, 더티 읽기 등의 문제가 발생할 수 있습니다. 이에 대한 문제를 대응할 수 있도록 다양한 비격리 대책 전략을 준비해야합니다. ‘마이크로서비스 패턴’ 책에 상세하게 설명되어있습니다.