1. Seata란?
Apache Seata는 마이크로서비스 아키텍처에서 고성능과 사용 편의성을 제공하는 분산 트랜잭션 프레임워크입니다. 알리바바에 의해 시작되었으며, 2023년에 Apache 재단에 기부되었습니다.
Seata의 주요 특징으로는 다양한 트랜잭션 모델을 지원합니다.
AT, TCC, Saga, XA등 다양한 모델을 지원합니다.
Seata는 3 계층 아키텍처(TC, TM, RM)로 이루어져 있어, 분산 트랜잭션 환경에서 데이터 일관성, 서비스 확장성, 관리 편의성을 보다 효과적으로 제공합니다. 또한, 한국에서 많은 백엔드 엔지니어분들이 사용하시는 Spring-Boot와도 호환성이 좋다는 장점이 있습니다.
2. Seata의 내부 구조
Seata는 TC, TM, RM으로 이루어져있습니다.
- TC : 트랜잭션 조정자 (Transaction Coordinator) / 서버에 위치
- TM : 트랜잭션 관리자 (Transaction Manager) / 클라이언트에 위치
- RM : 리소스 관리자 (Resource Manager) / 클라이언트에 위치
TC (트랜잭션 조정자)
TC는 여러 마이크로서비스에 걸친 분산 트랜잭션을 조정하고 관리하는 역할을 합니다.
글로벌 트랜잭션 상태를 유지하고, 브랜치 트랜잭션 등록을 관리하며, 커밋 / 롤백 프로세스를 조율하는 중앙 제어 노드라고 생각하시면 됩니다.
TC는 주로 독립형 서비스로 실행되는 서버 애플리케이션으로 구현됩니다.
이때 Netty 기반의 원격 서버로 구현됩니다.
TM(트랜잭션 관리자)
TM은 글로벌 트랜잭션의 범위를 정의합니다.
TM은 애플리케이션 측에서 작동하며 서버 측 트랜잭션 조정자(TC)와 협력하여 글로벌 트랜잭션의 수명을 관리합니다.
@GlobalTransactional
public void businessMethod() {
// Business logic that may span multiple microservices
serviceA.doSomething();
serviceB.doSomething();
}
위 코드와 같이 메서드에 @GlobalTransactional 어노테이션이 붙으면 TM은 자동으로 메서드가 실행되기 전에 글로벌 트랜잭션을 시작하고, 메서드가 끝난 뒤 자동으로 커밋 / 롤백을 수행합니다.
어노테이션과 같은 선언형 프로그래밍 방식이 아닌 방식으로도 범위를 지정할 수 있습니다.
// Begin a global transaction
GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
try {
tx.begin();
// Business logic
serviceA.doSomething();
serviceB.doSomething();
// Commit the transaction if successful
tx.commit();
} catch (Exception e) {
// Rollback the transaction on any exception
tx.rollback();
throw e;
}
JDBC의 Connection과 같은 명령형 프로그래밍 방식으로도 범위를 지정할 수 있습니다.
일반적으로 @GlobalTransaction은 Controller, DAO보단 Service 계층에 배치하는 것을 추천합니다.
RM(리소스 관리자)
RM은 분산 트랜잭션에 참여하는 리소스(Database)를 관리합니다.
로컬 트랜잭션 브랜치를 유지하고, Undo Log를 생성하며, TC와 협력해 트랜잭션의 일관성을 유지하는 역할을 한다고 생각하시면 됩니다.
RM은 트랜잭션의 타입에 따라 행동이 달라집니다.
- AT Mode : RM은 DB 프록시를 통해 SQL 구문을 분석하여 자동으로 Undo Log를 생성합니다. (이때 생성한 Undo Log는 롤백 단계에서 사용합니다)
- TCC Mode : RM은 트랜잭션 자원을 try / confirm / cancel 단계로 나누어 관리합니다.
- SAGA Mode : RM은 각 단계별 로컬 트랜잭션 및 보상 트랜잭션을 실행하며, 상태 머신과 연동하여 분산 트랜잭션의 각 분기를 실제로 처리하는 역할을 담당합니다.
- XA Mode : XA 표준에 따라 트랜잭션의 준비, 커밋, 롤백을 수행하며, TC와 협력하여 전체 분산 트랜잭션의 원자성과 일관성을 보장합니다.
- TM이 TC에게 글로벌 트랜잭션 시작을 요청하면, TC는 XID(글로벌 트랜잭션 고유 식별자)를 생성합니다.
- 각 서비스의 RM이 로컬 트랜잭션을 실행한 후, 해당 트랜잭션을 TC에 브랜치 트랜잭션으로 등록합니다.
- 모든 브랜치 트랜잭션이 준비되면 TM은 TC에게 글로벌 커밋 / 롤백을 요청합니다.
- TC는 TM에게 온 요청에 따라 모든 브랜치 트랜잭션에 대해 커밋 또는 롤백 명령을 전달합니다.
여기까지 이해를 하셨다면 여러분은 Seata에 공통적으로 적용되는 핵심 아키텍처를 이해하신 겁니다.
3. Seata에서 지원하는 트랜잭션 모드
Seata는 마이크로서비스 아키텍처 전반의 다양한 분산 트랜잭션 시나리오를 처리하기 위해 4가지의 트랜잭션 모드를 제공합니다.
트랜잭션 모드를 이해하시기 전에 반드시 TC, TM, RM에 대해 이해를 하셔야 합니다.
각 트랜잭션 모드의 흐름에 대해 설명을 하고 TC, TM, RM이 해당 트랜잭션 모드에서 어떤 역할을 하는지에 대해 작성하겠습니다.
1. AT
AT 모드는 Seata의 기본 트랜잭션 모드로, 비즈니스 로직을 수정하지 않고 어노테이션만 추가하면 분산 트랜잭션을 손쉽게 적용할 수 있다는 장점이 있습니다. 이는 기존의 2PC(Two-Phase Commit) 개념을 바탕으로 성능을 개선한 방식으로, 비즈니스 코드를 변경하지 않고도 트랜잭션 처리를 가능하게 한다고 이해하시면 됩니다.
순서
- TM이 TC에 XID 발급 요청 -> TC가 글로벌 트랜잭션 시작
- RM이 JDBC 프록시로 SQL 실행 전 / 후 스냅샷 캡처
- RM이 로컬 트랜잭션 내에서 UNDO_LOG INSERT 준비 (캡처한 스냅샷을 JSON으로 직렬화, INSERT 쿼리 준비)
- RM이 TC에 글로벌 락 획득 시도 (최대 30회 재시도)
- 락 획득 성공 시 트랜잭션 커밋, (비즈니스 데이터 업데이트, UNDO_LOG INSERT 수행)
- 커밋 시:
- TC가 모든 RM에 UNDO_LOG 삭제 지시
- 롤백 시
- Dirty Write 검증 수행 후, before_image 기반 복원
- UNDO_LOG 삭제 및 TC에 결과 보고
- UNDO_LOG 미존재시 GlobalFinished 레코드 삽입
AT 모드의 핵심 메커니즘은 바로 UNDO_LOG입니다.
RM은 SQL 실행 전 후로 beforeImage, afterImage를 메모리로 캡처한 후, JSON 형식으로 UNDO_LOG 테이블에 INSERT 준비를 합니다. 이후, 트랜잭션이 커밋될 때 비즈니스 데이터를 업데이트하며, 함께 INSERT 되어 영구적으로 저장합니다.
BEGIN;
-- 1. 비즈니스 데이터 업데이트
UPDATE product SET stock=49 WHERE id=100;
-- 2. UNDO_LOG 기록
INSERT INTO undo_log (xid, branch_id, rollback_info)
VALUES ('xid:xxx', 641789253, '{"beforeImage":{"stock":50}, "afterImage":{"stock":49}}');
COMMIT; -- 두 작업이 동시에 커밋/롤백됨
UNDO_LOG를 기록할 때 undo_log 테이블에 저장하는 beforeImage, afterImage를 활용해서 롤백을 수행합니다.
자세히 설명하자면, 롤백 단계 중, Dirty Write 검증 수행 후, beforeImage 기반 복원이 있는데 여기에서 Dirty Write 검증을 할 때 현재 데이터와 afterImage를 비교해 검증을 합니다. 이때 현재 데이터와 afterImage가 같으면 검증에 문제가 없으나, 데이터가 다르면 다른 트랜잭션이 데이터를 변경을 했다고 판단을 해 Dirty Write를 감지해 사용자에게 수동 개입이 필요하다는 알림을 전송합니다.
그러나 AT 모드에서 @GlobalTransactional 어노테이션을 사용하면 글로벌 락(Global Lock) 메커니즘이 활성화되어 Dirty Write 발생 가능성이 크게 줄어듭니다. 이는 Seata가 관리하는 글로벌 락을 통해, 하나의 트랜잭션이 완료될 때까지 해당 자원에 대한 다른 글로벌 트랜잭션의 접근을 제한하기 때문입니다.
이 글로벌 락은 데이터베이스 락이 아닌, Seata가 별도로 관리하는 락으로, 다른 트랜잭션이 동일 자원에 접근하려 할 경우 이를 제어하여 충돌을 방지합니다.
반면, 일반적인 DB 락은 로컬 트랜잭션 커밋 시 해제되므로, 글로벌 트랜잭션 외부에서 SQL 문이 실행될 경우 Dirty Write가 발생할 수 있습니다. (2PC의 경우 DB락을 글로벌 락의 커밋 / 롤백 시점까지 유지합니다)
TC, TM, RM의 역할
- TC : 트랜잭션의 롤백, 커밋을 결정한 후 RM에 전파, 글로벌 락을 관리해 RM 간 연산 격리성 보장
- TM : 글로벌 트랜잭션 범위 정의, 글로벌 트랜잭션 시작
- RM : 로컬 트랜잭션 관리, SQL문 전, 후로 데이터 스냅샷 캡처 후 UNDO_LOG 기록, TC의 명령에 맞게 커밋, 롤백 수행
AT는 여러 테이블이 단일 데이터베이스에 속할 때 사용해야 합니다.
만약 여러 데이터베이스에 속하는 경우 TCC, SAGA등의 모드를 사용해 구현해야 합니다.
2. TCC
TCC모드는 Try, Commit, Cancel 작업을 개발자가 직접 구현해야 하는 침입형 분산 트랜잭션 솔루션입니다.
AT 모드에 비해 TCC 모드는 비즈니스 코드에 상당한 영향을 미칠 수 있습니다.
이때 Try, Commit, Cancel 3단계는 다음과 같습니다:
- Try : 각 서비스가 트랜잭션에 필요한 자원을 사전 점유하는 단계입니다. @TwoPhasebusinessAction 어노테이션이 붙은 메서드가 실행됩니다.
- Confirm : 실제 비즈니스 로직을 수행하는 커밋 단계입니다.
- Cancel : 트랜잭션을 롤백하는 취소 단계입니다.
아래의 코드와 함께 더 자세히 설명하겠습니다.
public interface TccActionOne {
@TwoPhaseBusinessAction(name = "DubboTccActionOne", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "a") String a);
public boolean commit(BusinessActionContext actionContext);
public boolean rollback(BusinessActionContext actionContext);
}
위 코드는 Seata 공식 홈페이지에서 제공하는 예제코드입니다.
Try 단계에서는 @TwoPhaseBusinessAction이 붙은 메서드인 prepare 메서드가 실행됩니다.
그리고 Confirm, Cancel 단계는 @TwoPhaseBusinessAction의 commitMethod, rollbackMethod 속성 값에서 정의한 commit, rollback 메서드가 실행됩니다.
이후, @GlobalTransactional을 통해 글로벌 트랜잭션이 시작되면 자동으로 TCC 모드가 적용이 됩니다.
순서
- TM이 글로벌 트랜잭션 시작을 TC에게 요청해서 TC가 글로벌 트랜잭션을 시작합니다.
- RM은 Try 단계에서 자원을 예약하고 브랜치 트랜잭션을 TC에 등록합니다.
- TM은 모든 RM의 Try 단계가 성공한 것을 확인합니다.
- 모든 RM의 Try 단계가 성공했을 때 (커밋 시)
- TM은 TC에게 Commit 요청을 보냅니다.
- TC는 모든 RM에게 Commit 요청을 보냅니다.
- RM은 실제 비즈니스 로직을 실행합니다.
- RM의 Try 단계가 실패했을 때 (롤백 시)
- TM은 TC에게 Rollback 요청을 보냅니다.
- TC는 모든 RM에게 Rollback 요청을 보냅니다.
- RM은 예약된 자원을 해제하고, 원상복구 합니다.
TCC 모드의 장점은 데이터베이스에 종속되지 않는다는 점입니다. 개발자가 직접 Try, Confirm, Cancel 단계를 구현하기 때문에 비즈니스 리소스 잠금을 세밀하게 조정할 수 있으며, 그 수준 또한 유연하게 선택할 수 있습니다.
TC, TM, RM의 역할
- TC : Commit, Rollback 여부를 RM에게 전달, RM의 Try 단계 성공 여부 확인
- TM : RM에게 Commit, Rollback 요청 전달
- RM : Try 실행 후, TC에 브랜치 트랜잭션 등록, Commit / Rollback 수행
TCC는 데이터베이스에 의존하지 않고, 애플리케이션 레벨에서 리소스 잠금을 세밀하게 제어 가능합니다.
정확성이 필수인 시나리오에서 사용하면 좋습니다.
3. SAGA
Saga 패턴은 논문에서 유래된 장기 트랜잭션 솔루션입니다.
전체 분산 트랜잭션 프로세스를 여러 단계로 나누고, 각 단계는 하위 트랜잭션에 해당됩니다. 이때 각 하위 트랜잭션은 로컬 트랜잭션으로 실행되고 실행 후 커밋됩니다. 예외, 에러가 발생해 로컬 트랜잭션이 실패한 경우 이미 실행된 작업에 대해 보상 작업을 수행합니다.
아래의 그림은 SAGA 패턴을 표현한 그림입니다.
우선, Seata에서의 SAGA 패턴을 이해하기 위해선 State Machine Engine에 대해 알아야 합니다.
State Machine Engine은 총 3개의 계층으로 나눠져 있습니다.
- Eventing 레이어 : 이벤트 기반 아키텍처를 구현합니다. 이벤트를 Push 하고 소비자가 이벤트를 소비할 수 있게 합니다.
- Process Controller 레이어 : 상위 Eventing 레이어에 의해 실행되는 "empty"한 프로세스 엔진입니다. (상태의 구체적인 동작이나 라우팅 로직이 정의되지 않은 최소한의 엔진)
- StateMachineEngine 레이어 : State Machine Engine의 각 상태에 대한 행동과 라우팅 로직을 작성합니다. API를 제공하며, State Machine Engine 저장소를 제공합니다.
라우팅 로직이란 State Machine Engine이 각 상태를 실행한 뒤, 다음에 어떤 상태로 넘어갈지 결정하는 로직입니다.
위 그림은 State Machine Engine의 작동 원리입니다.
중요한 부분을 설명하자면, State Machine Engine의 내부 계층에서도 알 수 있듯이, State의 실행은 이벤트 주도 모델에 기반하여 실행됩니다. 즉, StateA가 실행된 뒤, 라우팅 메시지는 생성되고 이벤트 큐에 추가됩니다. 그리고 소비자는 이벤트 큐에서 라우팅 메시지를 가져온 뒤, StateB를 실행하는 구조입니다.
모든 상태 실행이 완료가 되면 State Machine Engine의 완료 이벤트가 로컬 데이터베이스에 기록되고 Seata 서버는 분산 트랜잭션에 대해 Commit, Rollback을 수행합니다.
이때 State Machine Engine에서 어떤 상태를 실행시킬지는 상태 다이어그램에 의해 생성된 JSON 상태 정의 파일에 의해 결정됩니다.
JSON의 세부 내용을 확인하고 싶으신 분은 아래의 접은 글을 확인해 주세요.
JSON 상태 정의 파일
{
"Name": "reduceInventoryAndBalance",
"Comment": "reduce inventory then reduce balance in a transaction",
"StartState": "ReduceInventory",
"Version": "0.0.1",
"States": {
"ReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryAction",
"ServiceMethod": "reduce",
"CompensateState": "CompensateReduceInventory",
"Next": "ChoiceState",
"Input": [
"$.[businessKey]",
"$.[count]"
],
"Output": {
"reduceInventoryResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
}
},
"ChoiceState":{
"Type": "Choice",
"Choices":[
{
"Expression":"[reduceInventoryResult] == true",
"Next":"ReduceBalance"
}
],
"Default":"Fail"
},
"ReduceBalance": {
"Type": "ServiceTask",
"ServiceName": "balanceAction",
"ServiceMethod": "reduce",
"CompensateState": "CompensateReduceBalance",
"Input": [
"$.[businessKey]",
"$.[amount]",
{
"throwException" : "$.[mockReduceBalanceFail]"
}
],
"Output": {
"compensateReduceBalanceResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
},
"Catch": [
{
"Exceptions": [
"java.lang.Throwable"
],
"Next": "CompensationTrigger"
}
],
"Next": "Succeed"
},
"CompensateReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
]
},
"CompensateReduceBalance": {
"Type": "ServiceTask",
"ServiceName": "balanceAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
]
},
"CompensationTrigger": {
"Type": "CompensationTrigger",
"Next": "Fail"
},
"Succeed": {
"Type":"Succeed"
},
"Fail": {
"Type":"Fail",
"ErrorCode": "PURCHASE_FAILED",
"Message": "purchase failed"
}
}
}
순서
- TM이 State Machine Engine을 호출하며 글로벌 트랜잭션 시작을 요청합니다.
- State Machine Engine이 TC에 XID을 요청합니다.
- State Machine Engine이 로컬 데이터베이스에 "State Machine Instance" 시작 이벤트를 기록합니다.
- State Machine Engine이 RM을 통해 JSON 상태 파일에 의해 첫 번째 상태를 실행합니다.
- State Machine Engine이 TC에 브랜치 트랜잭션을 등록해 브랜치 식별자를 발급 받습니다.
- State Machine Engine이 로컬 데이터베이스에 "State Machine Instance" 실행 이벤트를 기록합니다.
- 상태 실행 완료 후, 라우팅 메시지를 생성합니다.
- 소비자가 라우팅 메시지를 가져온 뒤, 다음 상태를 실행합니다.
- 상태 실행 중 예외 발생 (롤백 시)
- State Machine Engine이 실행된 트랜잭션에 대해 보상 노드를 역순으로 실행합니다.
- 모든 상태 실행 완료 (커밋 시)
- State Machine Engine이 TC에 트랜잭션 커밋 요청
- TC가 최종 트랜잭션 상태 확정
SAGA 모드는 보상 로직을 직접 작성할 수 있기에, 다단계로 구성된 복잡한 워크플로우에서 효율적으로 사용할 수 있습니다.
하지만, 글로벌 락이 존재하지 않아 Dirty write의 위험이 있습니다.
다음 글
다음 글에서는 Seata의 TC와 TM, RM이 어떻게 통신을 하는지 작성하려고 합니다.