마이크로서비스 아키텍처로 전환하면 가장 먼저 부딪히는 문제가 바로 데이터 정합성이다. 주문 서비스에서 결제를 처리하고, 재고 서비스에서 수량을 차감하고, 포인트 서비스에서 적립금을 계산하는 과정을 하나의 트랜잭션으로 묶어야 하는데, 각 서비스가 별도 데이터베이스를 사용하면 단일 @Transactional 어노테이션으로는 해결할 수 없다. 이번 포스팅에서는 Apache Seata와 Spring Boot Seata 연동을 통해 분산 트랜잭션 문제를 해결하는 방법에 대해서 정리하고자 한다.

@Transactional 하나로 안 되는 이유
단일 애플리케이션이라면 Spring의 @Transactional 하나로 충분하다. 문제는 마이크로서비스 환경이다. 서비스마다 독립된 데이터베이스를 사용하기 때문에 하나의 트랜잭션 매니저가 여러 데이터베이스를 동시에 제어할 수 없다. 주문 생성에 성공했는데 재고 차감이 실패하면? 주문 데이터만 덩그러니 남는다.
이 문제를 해결하는 대표적인 방법이 분산 트랜잭션이다. 전통적으로 Java EE의 JTA(Java Transaction API)를 사용하거나 2PC(Two-Phase Commit) 프로토콜을 직접 구현하는 방법이 있었지만, 성능 병목과 구현 복잡도가 높았다. 참고로 단일 서비스에서의 인증 처리는 Spring Security JWT 인증 가이드를 참고하면 된다. Apache Seata는 이 과정을 Spring Boot Seata starter 하나로 단순화한 오픈소스 프레임워크이다.

Apache Seata가 뭔데 스타가 26,000개나 될까
Apache Seata는 마이크로서비스 환경의 분산 트랜잭션 솔루션이다. 원래 Alibaba 내부 프로젝트였는데, 2019년 오픈소스로 공개된 이후 Apache 인큐베이팅 프로젝트로 옮겨졌다. GitHub 스타 26,000개, 포크 8,900개(2026년 3월 기준). 최신 버전은 2.6.0이다.
Seata 아키텍처는 세 가지 역할로 돌아간다:
- TC(Transaction Coordinator): 글로벌 트랜잭션의 시작, 커밋, 롤백을 조율하는 서버 측 컴포넌트이다. 독립된 서버로 배포한다.
- TM(Transaction Manager): 글로벌 트랜잭션의 범위를 정의하고 TC에게 시작과 종료를 요청하는 클라이언트 컴포넌트이다.
@GlobalTransactional어노테이션이 이 역할을 수행한다. - RM(Resource Manager): 각 서비스의 로컬 데이터베이스 리소스를 관리하며, TC와 통신하여 브랜치 트랜잭션의 커밋 또는 롤백을 수행한다.
Spring Boot Seata를 사용하면 TM과 RM이 각 마이크로서비스에 자동으로 내장되고, TC만 별도 서버로 배포하면 된다.

4가지 트랜잭션 모드, 어떤 걸 골라야 할까
Seata는 상황에 따라 선택할 수 있는 4가지 트랜잭션 모드를 제공한다.
AT 모드 — 가장 쉽고, 가장 많이 쓰인다
AT(Automatic Transaction) 모드는 Spring Boot Seata에서 가장 널리 사용되는 기본 모드이다. SQL을 가로채서 실행 전후 이미지(before image, after image)를 자동으로 기록하고, 롤백이 필요하면 이 이미지를 기반으로 보상 SQL을 생성한다. 개발자가 별도의 롤백 로직을 작성할 필요가 없다.
동작 원리는 2단계로 나뉜다:
- Phase 1: 비즈니스 SQL과 undo_log를 하나의 로컬 트랜잭션으로 커밋하고, 로컬 락을 해제한다.
- Phase 2(커밋): undo_log를 비동기로 삭제한다. 실질적으로 아무 작업도 하지 않으므로 속도가 빠르다.
- Phase 2(롤백): undo_log의 before image를 기반으로 보상 SQL을 생성하여 데이터를 원래 상태로 복원한다.
TCC 모드 — 직접 보상 로직을 작성한다
TCC(Try-Confirm-Cancel) 모드는 개발자가 Try(준비), Confirm(확정), Cancel(취소) 세 가지 메서드를 직접 구현해야 한다. 데이터베이스에 의존하지 않으므로 NoSQL이나 외부 API 호출에도 적용할 수 있다. 구현 복잡도가 높지만, 성능과 유연성이 뛰어나다.
SAGA 모드 — 긴 트랜잭션에 적합하다
SAGA 모드는 각 단계를 순차적으로 실행하고, 실패 시 이전 단계를 역순으로 보상하는 패턴이다. 레거시 시스템이나 외부 서비스와의 연동처럼 긴 비즈니스 프로세스에 적합하다.
XA 모드 — 데이터베이스 수준 2PC
XA 모드는 데이터베이스가 제공하는 XA 프로토콜을 그대로 활용한다. 강력한 일관성을 보장하지만, Phase 1에서 로컬 락을 유지하므로 동시성 성능이 떨어진다.
실무에서는 관계형 데이터베이스 기반 마이크로서비스라면 AT 모드로 시작하는 것을 권장한다. 공식 문서에서도 AT 모드를 기본 권장 모드로 안내하고 있다.

Spring Boot Seata 프로젝트 세팅 — 의존성부터 설정까지
Gradle 의존성 추가
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Seata Spring Boot Starter — 분산 트랜잭션 핵심 의존성
implementation 'org.apache.seata:seata-spring-boot-starter:2.6.0'
runtimeOnly 'com.mysql:mysql-connector-j'
}Groovyseata-spring-boot-starter 하나만 추가하면 된다. TM과 RM이 자동으로 구성되고, 데이터소스 프록시도 같이 붙는다. 별도의 @EnableAutoConfiguration 같은 설정은 필요 없다.
Maven을 사용하는 경우 아래와 같이 설정한다:
<!-- pom.xml -->
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.6.0</version>
</dependency>XMLMaven 의존성 역시 동일한 starter를 가리킨다. Maven Central에서 최신 버전을 확인할 수 있다.
application.yml 설정
# application.yml
seata:
enabled: true
application-id: ${spring.application.name}
# 트랜잭션 서비스 그룹 — TC 클러스터와 매핑되는 논리적 그룹명
tx-service-group: order-service-group
service:
vgroup-mapping:
# 트랜잭션 그룹을 실제 TC 클러스터에 매핑한다
order-service-group: default
grouplist:
# TC 서버 주소 — 개발 환경에서는 직접 지정한다
default: 127.0.0.1:8091
registry:
type: file # 운영 환경에서는 nacos, eureka 등 사용
config:
type: file # 운영 환경에서는 nacos, apollo 등 사용
# 데이터소스 자동 프록시 — AT 모드의 핵심이다
enable-auto-data-source-proxy: true
data-source-proxy-mode: ATYAML여기서 헷갈리기 쉬운 부분이 tx-service-group과 vgroup-mapping의 관계이다. tx-service-group은 현재 서비스가 속하는 트랜잭션 그룹의 이름이고, vgroup-mapping은 이 그룹이 어느 TC 클러스터에 연결되는지를 정의한다. 이 두 값의 매핑이 어긋나면 TC 연결에 실패하므로 이름을 정확히 맞춰야 한다. 개발 환경에서는 grouplist로 TC 서버 주소를 직접 지정하고, 운영 환경에서는 Nacos 같은 서비스 레지스트리를 쓴다.
undo_log 테이블 생성
AT 모드를 사용하려면 각 마이크로서비스의 데이터베이스에 undo_log 테이블을 생성해야 한다:
-- 각 비즈니스 데이터베이스에 생성해야 한다
CREATE TABLE IF NOT EXISTS `undo_log` (
`branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context, such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT NOT NULL COMMENT '0: normal status, 1: defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4
COMMENT = 'AT transaction mode undo table';SQLundo_log 테이블은 AT 모드가 Phase 1에서 실행한 SQL의 전후 이미지를 저장하는 곳이다. 롤백 시 이 테이블의 데이터를 참조하여 보상 SQL을 자동 생성한다. 주문 서비스, 재고 서비스, 결제 서비스 등 분산 트랜잭션에 참여하는 모든 데이터베이스에 이 테이블이 있어야 한다.

AT 모드로 주문-재고-결제 시스템 구현하기
실제 전자상거래 시나리오를 Spring Boot Seata AT 모드로 구현해 본다. 주문을 생성하면서 재고를 차감하고 잔액을 차감하는 과정을 하나의 글로벌 트랜잭션으로 묶는다.
Entity와 Repository 정의
// Order.java
package com.example.order.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private String productId;
private Integer quantity;
// 주문 금액 — BigDecimal을 사용하여 부동소수점 오차를 방지한다
private BigDecimal totalAmount;
private String status;
// getter, setter 생략
}Java주문 엔티티는 일반적인 JPA 엔티티와 동일하다. Spring Boot Seata AT 모드에서는 엔티티 클래스를 손댈 필요가 없다. Seata 관련 어노테이션도 없고, 기존 코드 그대로 분산 트랜잭션이 적용된다.
비즈니스 서비스 — @GlobalTransactional 적용
// OrderService.java
package com.example.order.service;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final RestTemplate restTemplate;
public OrderService(OrderRepository orderRepository, RestTemplate restTemplate) {
this.orderRepository = orderRepository;
this.restTemplate = restTemplate;
}
// @GlobalTransactional — 이 메서드가 글로벌 트랜잭션의 시작점이 된다
// 내부에서 호출하는 모든 서비스(재고, 결제)가 같은 XID로 묶인다
@GlobalTransactional(name = "create-order", timeoutMills = 60000,
rollbackFor = Exception.class)
public Order createOrder(String userId, String productId,
int quantity, BigDecimal unitPrice) {
BigDecimal totalAmount = unitPrice.multiply(BigDecimal.valueOf(quantity));
// 1. 재고 차감 — 재고 서비스 호출
restTemplate.postForObject(
"http://storage-service/api/storage/deduct",
new StorageDeductRequest(productId, quantity),
Void.class
);
// 2. 잔액 차감 — 결제 서비스 호출
restTemplate.postForObject(
"http://account-service/api/account/debit",
new AccountDebitRequest(userId, totalAmount),
Void.class
);
// 3. 주문 생성 — 로컬 데이터베이스에 저장
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setQuantity(quantity);
order.setTotalAmount(totalAmount);
order.setStatus("CREATED");
return orderRepository.save(order);
}
}Java@GlobalTransactional이 붙은 메서드가 실행되면 TM이 TC에게 글로벌 트랜잭션 시작을 요청하고, XID(글로벌 트랜잭션 ID)를 발급받는다. 이후 RestTemplate이나 Feign으로 다른 서비스를 호출하면, XID가 HTTP 헤더를 통해 자동으로 전파된다. timeoutMills = 60000은 타임아웃을 60초로 잡은 것이다. 이 시간 안에 모든 브랜치 트랜잭션이 끝나지 않으면 전체가 롤백된다.
재고 서비스 — 브랜치 트랜잭션 참여자
// StorageService.java
package com.example.storage.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StorageService {
private final StorageRepository storageRepository;
public StorageService(StorageRepository storageRepository) {
this.storageRepository = storageRepository;
}
// 로컬 @Transactional만 사용한다 — Seata RM이 자동으로 브랜치 등록을 처리한다
@Transactional
public void deductStock(String productId, int quantity) {
Storage storage = storageRepository.findByProductId(productId)
.orElseThrow(() -> new RuntimeException(
"상품을 찾을 수 없다: " + productId));
if (storage.getResidue() < quantity) {
throw new RuntimeException(
"재고 부족 — 현재 재고: " + storage.getResidue()
+ ", 요청 수량: " + quantity);
}
// 재고 차감 — 이 UPDATE 문의 전후 이미지가 undo_log에 자동 기록된다
storage.setUsed(storage.getUsed() + quantity);
storage.setResidue(storage.getResidue() - quantity);
storageRepository.save(storage);
}
}Java재고 서비스에서는 @GlobalTransactional을 쓰지 않는다. 일반 @Transactional만 있으면 된다. Seata의 데이터소스 프록시가 SQL 실행을 가로채서 자동으로 undo_log를 기록하고, RM이 TC에 브랜치 트랜잭션을 등록한다. 기존 로컬 트랜잭션 코드를 한 줄도 수정하지 않아도 된다.
결제 서비스 — 잔액 부족 시 전체 롤백
// AccountService.java
package com.example.account.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional
public void debitBalance(String userId, BigDecimal amount) {
Account account = accountRepository.findByUserId(userId)
.orElseThrow(() -> new RuntimeException(
"계정을 찾을 수 없다: " + userId));
// 잔액 부족 시 RuntimeException 발생 → 글로벌 트랜잭션 전체 롤백
if (account.getResidue().compareTo(amount) < 0) {
throw new RuntimeException(
"잔액 부족 — 현재 잔액: " + account.getResidue()
+ ", 차감 요청: " + amount);
}
account.setUsed(account.getUsed().add(amount));
account.setResidue(account.getResidue().subtract(amount));
accountRepository.save(account);
}
}Java결제 서비스에서 잔액 부족으로 예외가 터지면, XID로 묶인 모든 브랜치 트랜잭션이 롤백된다. 주문 데이터도, 재고 차감도 전부 원래 상태로 돌아간다. @GlobalTransactional 어노테이션 하나로 이 모든 과정이 자동 처리되는 것이다.

Seata Server 배포하기 — Docker로 5분 만에 시작
여기까지 Spring Boot Seata 클라이언트 쪽 설정을 마쳤다. 이제 TC(Transaction Coordinator) 서버를 띄워야 한다. Spring Boot 애플리케이션의 Docker 컨테이너화가 처음이라면 Spring Boot Docker 배포 가이드를 먼저 읽어보는 것을 추천한다.
Docker Compose로 Seata Server 실행
# docker-compose.yml
version: '3.8'
services:
seata-server:
image: apache/seata-server:2.6.0
ports:
- "8091:8091" # Seata 클라이언트 통신 포트
- "7091:7091" # 웹 콘솔 포트
environment:
# 스토리지 모드 — 개발 환경에서는 file, 운영에서는 db 또는 redis
- STORE_MODE=file
# JVM 힙 메모리 — 공식 권장 2GB 힙 + 1GB 오프힙
- JVM_XMS=512m
- JVM_XMX=512m
volumes:
- ./seata-data:/seata-server/sessionStoreYAML위 Docker Compose 파일 하나로 Seata Server를 실행할 수 있다. STORE_MODE=file은 독립 실행 모드로, 트랜잭션 로그를 로컬 파일에 저장한다. 개발과 테스트 용도로 적합하며, 운영 환경에서는 db 또는 redis 모드로 변경하여 고가용성을 확보해야 한다. 7091 포트로 접속하면 웹 콘솔에서 글로벌 트랜잭션 상태를 모니터링할 수 있다.
운영 환경 — DB 모드 + Nacos 레지스트리
# seata-server application.yml (운영 환경)
seata:
store:
mode: db
db:
datasource: druid
db-type: mysql
# Seata Server 전용 데이터베이스 — 비즈니스 DB와 분리한다
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://mysql-host:3306/seata_server?rewriteBatchedStatements=true
user: seata
password: ${SEATA_DB_PASSWORD}
registry:
type: nacos
nacos:
server-addr: nacos-host:8848
namespace: seata-production
group: SEATA_GROUP
cluster: defaultYAML운영 환경에서는 DB 모드를 사용하여 글로벌 트랜잭션 로그를 MySQL에 저장한다. rewriteBatchedStatements=true 옵션은 배치 처리 성능을 개선하는 MySQL 드라이버 설정이다. Nacos 레지스트리를 연동하면 TC 서버를 여러 대 띄워서 고가용성 클러스터를 구성할 수 있고, 클라이언트는 Nacos를 통해 TC 서버를 자동 발견한다.
Seata Server 전용 데이터베이스에는 global_table, branch_table, lock_table 3개 테이블을 생성해야 한다. DDL은 공식 GitHub 저장소의 script/server/db/ 디렉토리에서 확인할 수 있다.
실무에서 Spring Boot Seata 도입 시 반드시 확인할 것들
글로벌 락 경합 주의
AT 모드는 Phase 1에서 로컬 락을 해제하지만, 글로벌 커밋 전까지 글로벌 락을 유지한다. 같은 데이터를 동시에 변경하는 트랜잭션이 많으면 글로벌 락 경합이 발생한다. client.rm.lock.retryTimes(기본값 30)과 client.rm.lock.retryInterval을 비즈니스 특성에 맞게 조정해야 한다.
트랜잭션 타임아웃 설정
@GlobalTransactional의 timeoutMills 기본값은 60초이다. 외부 API 호출이 포함된 경우 네트워크 지연을 고려하여 충분히 설정해야 한다. 반대로 너무 길게 잡으면 글로벌 락 점유 시간이 늘어나 다른 트랜잭션에 영향을 준다.
XID 전파 확인
Spring Boot Seata는 RestTemplate과 Feign에 대해 XID를 HTTP 헤더(TX_XID)로 자동 전파한다. 하지만 WebClient나 커스텀 HTTP 클라이언트를 사용하는 경우에는 수동으로 XID를 전달해야 한다:
// WebClient 사용 시 XID를 수동으로 전파하는 예시
import org.apache.seata.core.context.RootContext;
WebClient.builder()
.defaultHeader(RootContext.KEY_XID, RootContext.getXID())
.build()
.post()
.uri("http://storage-service/api/storage/deduct")
.bodyValue(request)
.retrieve()
.bodyToMono(Void.class)
.block();JavaRootContext.getXID()가 현재 스레드의 글로벌 트랜잭션 ID를 반환한다. WebClient나 gRPC처럼 Seata가 자동 인터셉트하지 않는 클라이언트에서는 이 코드가 필수이다. 빠뜨리면 호출받는 서비스가 글로벌 트랜잭션 바깥에서 동작하게 되고, 롤백 대상에서 빠진다. 실제로 이 부분 때문에 디버깅에 시간을 쓰는 경우가 많다.
AT 모드와 JPA 함께 쓸 때 주의점
Spring Boot Seata AT 모드는 MyBatis, MyBatis-Plus, JPA, Hibernate를 모두 지원한다. 다만 JPA의 영속성 컨텍스트 플러시 타이밍과 Seata의 SQL 인터셉트 사이에 미묘한 차이가 있다. spring.jpa.properties.hibernate.jdbc.batch_size를 사용하는 경우, 배치 SQL이 한꺼번에 실행되면서 undo_log 기록이 예상과 다르게 동작할 수 있다. 문제가 발생하면 배치 사이즈를 줄이거나 flush() 호출 시점을 명시적으로 제어하는 것을 권장한다.

더 알아보기
다음 리소스에서 더 자세한 내용을 확인할 수 있다:
- Apache Seata 공식 문서 — 각 트랜잭션 모드의 상세 동작 원리와 설정 레퍼런스
- Seata 공식 샘플 저장소 — AT, TCC, SAGA, XA 모드별 Spring Boot 예제 프로젝트
- Baeldung — Distributed Transaction Management Using Apache Seata — Spring Boot와 Seata AT 모드 연동 튜토리얼
- Spring Boot 4.0 마이그레이션 가이드 — Spring Boot 최신 버전 업그레이드 시 참고

지금까지 Spring Boot Seata 분산 트랜잭션에 대해서 정리해 보았다. 실무에서 처음 MSA 환경에 분산 트랜잭션을 도입할 때 Seata의 AT 모드만으로도 대부분의 시나리오를 커버할 수 있었는데, 한 가지 경험에서 얻은 교훈은 TC 서버의 안정성이 곧 전체 시스템의 안정성이라는 점이었다. TC가 잠시라도 다운되면 진행 중인 글로벌 트랜잭션이 모두 타임아웃에 걸리므로, 운영 환경에서는 반드시 DB 모드 + Nacos 클러스터 조합으로 TC 고가용성을 확보한 뒤에 트래픽을 받는 것이 좋다.
