
ACID Transaction
원자성(Atomicity), 일관성(Consitency), 격리성(Isolation), 지속성(Durability)을 보장하는 트랜잭션
분산 트랜잭션(Distributed transaction)은 복수의 시스템 간의 트랜잭션 처리를 의미하며, 주로 서로다른 DB에 분산된 데이터에 대한 트랜잭션 관리를 의미한다. 분산 트랜잭션은 단일 트랜잭션과 마찬가지로 ACID(원자성, 일관성, 고립성, 지속성)을 지원해야 한다. 단일 데이터베이스에서는 단일 트랜잭션 범위 내에서 이 작업이 모두 수행 가능하도록 구현되어 있으나, 서로 다른 데이터베이스, 혹은 트랜잭션 단위로 처리되지 않는 No-SQL 등을 사용하면 ACID를 보장하는 것은 새로운 문제가 된다.
기존에는 이러한 분산 트랜잭션 문제를 해소하기 위하여 2PC(Two-Phase Commit) 패턴을 사용하였다. 2PC는 코디네이터(coordinator)와 여러 데이터베이스간의 합의를 통해 트랜잭션 커밋/롤백이 결정하는 방법으로, 글로벌 트랜잭션에 참여하는 모든 데이터베이스가 커밋이 가능한 상태 혹은 불가능한 상태임을 코디네이터에게 알리고(phase 1), 코디네이터가 커밋 또는 롤백을 수행(phase 2)하는 방식으로 동작한다.
그러나 2PC는 아래 사유들로 인해 MSA구조에서는 적합하지 않다.
CAP 정리 by Eric Brewer
시스템은 일관성(consitency), 가용성(availability), 분할 허용성(partition tolerance) 중 두가지 속성 만 가질 수 있다.
Saga 패턴은 MSA와 같은 분산 아키텍처에서 데이터 일관성을 보장하기 위해 등장한 설계 패턴으로, 비동기 메시징을 이용하여 편성된 일련의 로컬 트랜잭션이다. 서비스 간 데이터 일관성은 연속된 개별 서비스의 로컬 트랜잭션이 이어져, 전체 비즈니스 트랜잭션을 구성하는 패턴이다. 첫번째 트랜잭션이 완료되면 두번째 트랜잭션이 트리거 되고, 두번째 트랜잭션이 완료되면 세번째 트랜잭션이 트리거되는 형태이다.
트랜잭션이 실패하면 2PC에서는 rollback을 수행했으나 이미 local DB에 commit 된 MSA에서는 rollback 처리가 불가능하다. 따라서 saga 패턴에서는 개별 서비스가 실패했을 때 보상 트랜잭션(compensating transaction)을 발생시켜 원래의 상태로 돌려주어야 한다. 즉, Saga 패턴에서는 데이터 일관성을 관리하는 주체가 DBMS가 아닌 애플리케이션이어야 한는 것이 Saga 패턴의 핵심이다. 보상 트랜잭션이 적용되기 전까지 일시적으로 데이터 정합이 깨져있을 수 있으나, 보상 트랜잭션이 완료되면 ‘결과적 정합성(eventually consistent)’을 보장할 수 있다.
Saga패턴
사가는 비동기 메시징을 이용하여 편성한 일련의 로컬 트랜잭션이다. 서비스 간 데이터 일관성은 사가로 유지한다.
Saga와 ACID의 중요 차이점

createOrder() operation is implemented by a saga that consists of local transactions in several services. FTGO의 애플리케이션 중 createOrder()를 처리하는 예제를 살펴보자. 주문 생성 요청에 대하여 애플리케이션은 총 6개의 로컬 트랜잭션을 쳐리하는 구조로 동작한다.
각 서비스는 로컬 트랜잭션이 정상적으로 완료되면 메시지를 발행하여 saga의 다음 단계를 실행시키는 형태로 메시지 기반으로 통신하여 서비스간 느슨하게(loosely) 결합시키고 트랜잭션이 완료될 수 있도록 보장한다. 메시지 브로커 등을 활용하여 일부 서비스의 장애시 메시지를 큐잉 하는 역할도 수행하게 된다.
Saga transaction은 다음의 3종의 트랜잭션으로 구성된다.
| 단계 | 서비스 | 트랜잭션 | 보상 트랜잭션 | 트랜잭션 유형 |
|---|---|---|---|---|
| 1 | Order Service | createOrder() | rejectOrder() | 보상 가능 트랜잭션, 반드시 롤백 지원 필요 |
| 2 | Consumer Service | verifyConsumerDetails() | - | 보상 불필요 트랜잭션 |
| 3 | Kitchen Service | createTicket() | rejectTicket() | 보상 가능 트랜잭션, 반드시 롤백 지원 필요 |
| 4 | Accounting Service | authorizeCreditCard() | - | 피봇 트랜잭션, Saga 진행/중단 결정됨 |
| 5 | Restaurant Order Service | approveRestaurantOrder() | - | 재시도 가능 트랜잭션, 반드시 성공(완료) 보장 |
| 6 | Order Service | approveOrder() | - | 재시도 가능 트랜잭션, 반드시 성공(완료) 보장 |
위 트랜잭션에서 verifyConsumerDetails() 같은 읽기 전용 트랜잭션은 전체 트랜잭션에 실패가 발생하더라도 변경 사항이 없으므로 보상 트랜잭션이 불필요하다. 반면에 approveTicket() 트랜잭션은 항상 성공하는 트랜잭션이므로 보상 트랜잭션이 불필요하며, 재시도 가능 트랜잭션(retriable transaction)으로 서비스의 장애 등으로 단기간 처리가 불가능하다 하더라도 사후에 재시도 등을 통해 처리가 가능한 트랜잭션 유형이다. 피벗 트랜잭션은 최종적으로 saga 트랜잭션의 성공, 실패를 결정하는 트랜잭션으로 피벗 트랜잭션이 성공하면 이후 모든 트랜잭션은 성공 또는 재시도를 통해 성공시켜야 하며, 피벗 트랜잭션이 실패하면 이전의 모든 보상가능 트랜잭션은 보상 처리를 통해 원래 상태로 변경시켜야 한다. 예를 들어 피봇 트랜잭션인 신용카드 승인(4단계)에서 실패하면 다음 순서로 보상 트랜잭션이 동작한다.
Saga는 로직에 의해 처리 단계를 구성해야 하고, 각 구성을 순차적으로 처리해야 한다. 즉, 트랜잭션의 첫 시작이 진행되면 이후 이벤트를 발생시겨 다음 트랜잭션을 지속적으로 처리해야 하며, 처리에 실패가 발생하는 경우 롤백을 처리하는 절차도 순차에 맞게 진행되어야 하며, 이는 이벤트를 기반으로 처리하게 된다. 따라서 트랜잭선의 이벤트 발생 관리 및 순서 관리를 위해 두가지 종류의 편성을 진행하게 된다.
이 방식은 참여자에게 어떤 일을 처리해야 한다는 것을 알려주는 중앙 편성자가 없는 형태이다. 따라서 각 서비스는 이벤트를 구독하고, 수신된 이벤트 중 자신이 처리해야 할 내용이 있으면 처리하고, 처리 결과를 다시 이벤트로 게시 하는 형태로 진행된다.

Saga 참여자가 자신의 DB를 업데이트 하고, DB 트랜잭션의 일부로 이벤트를 발행해야 한다. 트랜잭션은 처리 되었으나 이벤트 발행이 누락되면 트랜잭션의 이후 작업 진행이 누락되고, DB 트랜잭션이 처리 되지 않았는데 이벤트가 발행되어도 정상 처리가 불가능하므로 원자적으로 처리를 보장해야 하는 것이다. 따라서 Saga 참여자간 통신에 transactional messaging 을 사용 필요하다.
Saga 참여자는 자신이 수신한 이벤트와 기존에 처리해서 가지고 있는 데이터와 연관을 찾아 처리가 가능해야 한다. 신용카드 승인 이벤트를 받았을때 어떤 주문에의해 발생한 카드 승인 이벤트인지를 찾을 수 있어야 티켓 상태 변경, 주문 상태 변경 등의 트랜잭션이 가능하게 된다. 따라서 이벤트에는 관련 정보를 확인하기 위한 상관관계ID(correlation ID)를 포함시켜 이벤트를 발행해야 한다.
오케스트레이션 사가(Orchestration saga)는 중앙의 saga Orchestrator가 참여자들에게 어떤 로컬 트랜잭션을 실행해야하는지 알려주는 방식으로, 오케스트레이터는 커맨드/비동기 응답에 상호 작용을 관리하며 모든 트랜잭션에 대한 처리 흐름과, 필요시 보상 트랜잭션을 발생시켜 롤백을 시도를 수행한다.
Saga orchestrator는 state machine으로 모델링에 적합하며, local transaction이 완료되는 시점에 상태 전이가 trigger되어, local transaction의 결과에 따라 상태 전의 및 action을 수행할 수 있어 모델링을 쉽게 할 수 있다.
Create Order Saga using orchestration. Order Service implements a saga orchestrator, which invokes the saga participants using asynchronous request/response. createOrder()를 호출받아 Order(주문) 와 Orchestrator를 생성Orchestrator: Verify consumer 명령을 소비자 서비스에 전송Orchestrator: Create Ticket 명령을 주방 서비스에 전송Orchestrator: Authorize Card 명령을 회계 서비스에 전송Orchestrator: 주방 서비스에 Approve Ticket 명령을 주방 서비스에 전송Orchestrator: Approve Order 명령을 주문 서비스에 전송State machine은 상태와 전이로 구성되며, 전이가 발생될 때마다 액션이 일어나게 된다. Saga에서의 action은 다른 참여자를 호출하는 것으로, 상태간 전이는 지역 트랜잭션을 완료하는 시점에 trigger되며, 트랜잭션의 결과에 따라 다음 상태와 어떤 액션을 취할지를 결정하게 된다. 주문 생성 saga의 FSM 모델은 다음과 같다.
Create Order Saga Saga 참여자는 choreography saga와 마찬가지로 자신의 DB를 업데이트 하고, DB 트랜잭션의 일부로 응답 메시지를 발행해야 한다. 트랜잭션은 처리 되었으나 응답 발행이 누락되면 트랜잭션의 이후 작업 진행이 누락되고, DB 트랜잭션이 처리 되지 않았는데 응답이 발행되어도 정상 처리가 불가능하므로 원자적으로 처리를 보장하는 것이 필요하다. 따라서 Saga 참여자간 통신에 transactional messaging 을 사용 필요하다.
Monolothic 구조에서는 단일 DB를 사용하므로, 어플리케이션에서는 DB에서 제공하는 ACID(Atomicity, Consitency, Isolation, Durability)를 이용하여 트랜잭션을 개발자가 익숙한 형태로 개발할 수 있다. 그러나 분산된 Saga transaction 간에는 각 마이크로서비스가 고유의 DB를 갖고 있으므로 격리성(isolation)을 제공할 수 없다.
Saga 패턴에서 격리성을 보장하지 않아 발생할 수 있는 문제로, 특정 Saga가 실행되는 동안 참여자에 의해 수정된 데이터가 다른 참여자에 의해 변경거나 다른 참여자가 읽어 가는 등의 경우에 발생될 수 있으며, 다음 유형의 문제가 발생할 수 있다.
오더 관련하여 각 비정상 상황의 예(https://happycloud-lee.tistory.com/154)를 살펴보면 다음과 같다.
Lost updates
- 새 피자주문Saga가 시작되고, 바로 주문취소 Saga가 시작되었다.
- 피자주문 -> 주문취소 순서로 진행되야 하는데 어떠한 이유로 주문취소가 먼저 수행되었다.
- 피자주문Saga에서 다시 주문데이터가 생성되어 고객은 취소한 피자를 받게 된다.
Dirty Read
- 피자주문Saga1은 3개 주문처리를 시작했고, 바로 이어 피자주문Saga2가 1개로 정정주문을 시작했다.
- 피자주문Saga1의 포인트결재 트랜잭션이 고객포인트를 차감시켰다. 고객포인트가 0이 되었다.
- 피자주문Saga1이 완료가 안된 상태에서 피자주문Saga2의 고객검증 트랜잭션이 고객포인트가 부족하다고 주문을 거절할 수 있다.
Fuzzy/Unrepeatable Read
- 고객 포인트는 현재 3으로 피자3개 주문이 가능하다.
- 피자주문Saga1은 1개 주문처리를 시작했고, 바로 이어 피자주문 Saga2가 3개로 정정주문을 시작했다.
- 피자주문Saga2의 고객검증 트랜잭션이 먼저 처리되었다. 3점이므로 포인트 검증은 성공한다. 그리고 바로 피자주문Saga1이 포인트결재 트랜잭션이 실행되어 포인트를 2로 만들어 버렸다.
- 피자주문Saga2는 고객 포인트 검증이 성공 했으므로 포인트 결제를 시도한다. 이때 고객 포인트를 읽어 보니 검증시에는 3점이었는데 2점으로 리턴된다.
- 피자주문Saga2가 처음에 포인트 값을 읽었을땐 3점으로 유효하지만, 피자주문Saga1이 포인트 차감 후 다시 읽어보면 포인트값이 달라지기 때문에 Unrepeatable read라고 할 수 있다.
따라서 격리성(isolation)의 제공 불가로 인한 비정상 상태가 되는 것을 방지하기 위해 어플리캐이션을 작성해야 한다. 어플리에캐이션 작성의 방법으로 분산 트랜잭션을 사용하지 않는 다중 DB 구조에서 처리하는 방법을 준용하게 된다. 따라서 semantic lock, cummutative update, pessimistic view, reread value, version file, by value 등의 방법을 도입할 수 있다.
보상 가능 트랜잭션이 생성/수정하는 레코드에 flag를 세팅하여 레코드가 커밋 전이며, 변경 가능함을 표기하여 일종의 lock(접근 차단 혹은 경고)을 표기하고, 트랜잭션이 완료되거나 혹은 보상 트랜잭션이 실행될때 해제되는 형태로 동작한다. 주로 데이터 레코드에 상태 정보 항목에 *_PENDING 상태를 표기하여 트랜잭션이 진행 중임을 표기하고, 처리가 완료되면 *_APPROVED 혹은 롤백 처리 되는 경우 *_REJECTED 로 변경한다.
Semantic lock이 걸려 있는 경우 개별 케이스에 대한 대응 로직을 설계해야 한다. 예를 들어 APPROVAL_PENDING 상태의 주문에 대하여 고객이 취소 요청을 하는 경우, semantic lock이 해제될때까지 client가 대기하거나, 혹은 client에 에러(예외)를 응답하고, client에서 재시도 로직 등을 개발하는 등의 lock 제어 기능을 어플리캐이션에서 개발해야 한다. 또한 deadlock이 발생될 수 있으므로 saga를 rollback 시켜서 deadlock 해소하고 재실행 할 수 있도록 조치해야 한다.
업데이트 동작을 교환적으로 구성하는 것으로, Updates: A->B로 실행되던 B->A로 실행되던 결과가 동일한 transaction이 있다면 Saga transaction 순서 배치할 때 A와 B는 붙여서 배치하여 lost update 현상을 해소하는 방법이다. 예를 들어 debit()과 credit()은 두 트랜잭션이 실행되기만 하면 결과적으로 동일한 값을 얻을 수 있다. 물론 이 경우 overdraft에 대한 대응은 추가적으로 처리가 필요하다.
중요한 트랜잭션은 위험 최소화를 위해 처리순서를 맨 마지막으로 바꾸는 방법으로 중요 트랜잭션 이후에는 repeatable transaction만 수행하여 항상 데이터가 갱신되는 것을 보장하여 trasaction이 완료된 결과만 보이게 한다. Dirty read를 최소화하기 위해 주문 생성 및 주문 취소가 겹쳐서(interleaved) 실행되는 중에 발생될 수 있는 문제 야기할 수 있는 잔액 처리 마이크로서비스를 가장 마지막 혹은 뒤에는 repeatable 트랜잭션만 유지함으로 써 해결할 수 있다. 예를 들어, 고객의 주문 취소 요청이 아직 주문 생성 saga가 완료 전에 들어왔으나, 이미 주문 취소가 불가능한 상황(배달원 할당 완료 등)이 되어 주문 취소 saga와 주문 생성 saga가 동시에 실행되는 경우에 발생될 수 있다.
위 케이스를 해소하기 위해 주문변경Saga의 순서도 고객 잔액을 처리하는 기능을 가장 마지막으로 전환한다면 2번의 케이스가 발생되지 않으므로 잔액을 초과하여 주문을 수용하는 경우가 발생하지 않게될 수 있다.
이 방법은 lost updates를 해소하기 위한 방법으로, saga 처리 중 값을 갱신하기 전에 기존의 값을 다시 읽어 변경이 발생되었는지를 확인하는 방법이다. 만약 값을 다시 읽었는데 기존의 값과 다른 것을 확인했다면 트랜잭션의 동시성 제어가 필요한 상황이 되므로 현재 진행중인 saga를 rollback 후 나중에 재 시작하도록 조치가 가능하다. 이 방법은 일종의 optimistic offline lock 패턴으로 볼 수 있다.
이 방법은 비교환적(noncommutative)인 작업을 교환적(commutative)인 작업으로 변환하는 방법이다. 순서가 맞지 않는 작업 요청을 받았다면, 앞선 작업이 도착할때까지 처리를 대기하였다가, 앞전 순번을 처리한 이후 수행하여 트랜잭션의 순서를 보장하는 형태이다.
이 방법은 위험성이 낮은 요청은 saga 패턴을 적용하되, 은행 트랜잭션 중 특정 금액 이상의 대형 트랜잭션은 2PC등 분산 트랜잭션을 이용하는 방법이다.