분산 트랜잭션?

트랜잭션

트랜잭션은 데이터의 상태를 변화시키기 위한 여러 명령어들을 하나의 논리적인 작업으로 수행한다. 이에 대한 결과로 원자성(전부 성공하거나 전부 실패), 일관성(수행 후 데이터 간 모순이 없는 일관적인 상태), 독립성(트랜잭션은 서로 영향을 주지 않음), 지속성(트랜잭션의 결과는 영구 반영)을 보장해준다.

분산 트랜잭션

분산 트랜잭션은 서로 다른 시스템이나 데이터베이스에서 트랜잭션이 수행하되, 이 트랜잭션들을 논리적으로 맞추는 동작이다. 주로 MSA와 같은 환경에서 서로 다른 애플리케이션이 각자의 트랜잭션을 수행하는 경우에 필요하다.

이 분산 트랜잭션을 이용하여 시스템은 도메인 컨텍스트를 분리하고, 독립적인 애플리케이션으로 분리를 함과 동시에 각 서비스에 맞는 데이터베이스를 채택할 수 있는 장점이 있다. 도메인 간의 응집도를 높이고, 다른 데이터베이스 시스템임에도 불구하고 논리적으로 ACID를 가져갈 수 있다는 점은 큰 장점이다.

하지만 논리적으로 서로 다른 시스템의 트랜잭션을 맞추는 것이므로 분산 환경에서는 특히 원자성, 일관성을 목표로 하는 것은 어렵다. 가령 동기적으로 여러 서비스에 쓰기 요청을 보냈을 경우 하나라도 트랜잭션이 실패한다면? 혹은 메일은 보내졌으나, 알림톡이 실패한다면? 등 트랜잭션이 분산된다면 여러 문제 상황이 발생할 수 있다.

어떻게 분산 트랜잭션을 해결하는가

2PC

코디네이터 (트랜잭션 매니저)를 별도로 두어 N개의 데이터베이스 트랜잭션을 관리하는 방식이다.

sequenceDiagram
    autonumber
    participant App as 애플리케이션
    participant Coord as 코디네이터
    participant OrderDB as "참여자 A"
    participant StockDB as "참여자 B"

    App->>Coord: 글로벌 트랜잭션 시작 요청
    Coord->>OrderDB: prepare
    Coord->>StockDB: prepare
    OrderDB-->>Coord: 커밋 가능(ready)
    StockDB-->>Coord: 커밋 가능(ready)
    alt 모두 준비 완료
        Coord->>OrderDB: commit
        Coord->>StockDB: commit
        OrderDB-->>Coord: 커밋 완료
        StockDB-->>Coord: 커밋 완료
    else 하나라도 실패 또는 타임아웃
        Coord->>OrderDB: rollback
        Coord->>StockDB: rollback
    end
    Coord-->>App: 글로벌 트랜잭션 결과
  1. 애플리케이션이 분산 트랜잭션을 시작하기 위해 코디네이터에게 트랜잭션 ID를 요청한다.
  2. N개의 애플리케이션은 글로벌 트랜잭션에 참여하게 되고, 하나라도 실패한다면 코디네이터든 애플리케이션이 트랜잭션을 종료시킬 수 있다.
  3. 애플리케이션이 트랜잭션 커밋을 할 준비가 되면, 코디네이터는 모든 참여자에게 prepare 요청을 보낸다. 마찬가지로 어느 하나라도 실패하면 트랜잭션을 종료시킨다.
  4. 모든 참여자가 정상적으로 WAL 커밋(redis나 로컬 캐시 같은 임시 저장소, 롤백 상황에서 쉽게 대처하기 위해)을 하면 코디네이터에게 성공했음 응답을 보내고, 코디네이터는 글로벌 트랜잭션을 커밋으로 업데이트할 수 있다.
  5. 코디네이터는 다시 한번 모든 참여자들에게 커밋되었음을 알리고 참여자들은 디스크에 데이터를 쓸 수 있다.

장점

  • 분산 트랜잭션 환경에서 원자적으로 데이터를 기록할 수 있다.
  • WAL 커밋 단계를 둠으로써 롤백 상황에서 안전하게 대처할 수 있다.

단점

  • 하나의 글로벌 트랜잭션을 수행하기 위해 N개의 애플리케이션과 2번의 통신이 필요하고, 애플리케이션들과 동기적으로 통신하기 때문에 하나라도 네트워크 지연이 생기면 그만큼 느려진다.
  • 코디네이터가 장애가 생기면, 전체 트랜잭션에서 문제가 생길 수 있다.
  • 앞의 지연 상황이 생긴다면, 다른 스레드에서 잠긴 데이터에 대해 접근이 불가하다.

JTA

JTA는 Java Transaction API 약자로 자바에서 분산 트랜잭션을 처리하기 위한 API이다. 트랜잭션 관리자, 자원 관리자 인터페이스를 제공하여 분산 트랜잭션을 관리할 수 있다.

// jakarta.transaction.UserTransaction 으로 두 XA 리소스를 하나의 전역 트랜잭션으로 묶는다
// UserTransaction, TransactionManager 구현체는 Atomikos 가 제공한다(com.atomikos.icatch.jta.*)
UserTransactionManager transactionManager = new UserTransactionManager();
transactionManager.init();
UserTransaction userTransaction = new UserTransactionImp();
 
AtomikosDataSourceBean orderDataSource = new AtomikosDataSourceBean();
orderDataSource.setXaDataSource(orderXaDataSource); // javax.sql.XADataSource 구현체(예: MysqlXADataSource)
orderDataSource.setUniqueResourceName("orderDataSource");
 
AtomikosDataSourceBean stockDataSource = new AtomikosDataSourceBean();
stockDataSource.setXaDataSource(stockXaDataSource);
stockDataSource.setUniqueResourceName("stockDataSource");
 
userTransaction.begin();
try {
    try (Connection orderConn = orderDataSource.getConnection()) {
        orderConn.createStatement().executeUpdate("UPDATE orders SET status = 'PAID' WHERE id = 1");
    }
    try (Connection stockConn = stockDataSource.getConnection()) {
        stockConn.createStatement().executeUpdate("UPDATE stock SET quantity = quantity - 1 WHERE product_id = 100");
    }
    userTransaction.commit(); // 두 XAResource 에 대해 2PC 를 수행한다
} catch (Exception e) {
    userTransaction.rollback();
    throw e;
}
// Spring: JtaTransactionManager 를 등록하면 @Transactional 이 여러 XA DataSource 를 2PC 로 묶는다
// Spring Boot 3 는 spring-boot-starter-jta-atomikos 자동 설정을 제공하지 않으므로
// Atomikos 가 배포하는 com.atomikos:transactions-spring-boot3-starter 를 직접 추가해야 한다
@Configuration
class JtaConfig {
    @Bean(initMethod = "init", destroyMethod = "close")
    fun orderDataSource(): AtomikosDataSourceBean = AtomikosDataSourceBean().apply {
        setXaDataSource(orderXaDataSource)
        uniqueResourceName = "orderDataSource"
    }
 
    @Bean(initMethod = "init", destroyMethod = "close")
    fun stockDataSource(): AtomikosDataSourceBean = AtomikosDataSourceBean().apply {
        setXaDataSource(stockXaDataSource)
        uniqueResourceName = "stockDataSource"
    }
 
    @Bean
    fun transactionManager(
        userTransaction: UserTransactionImp,
        transactionManager: UserTransactionManager,
    ): JtaTransactionManager = JtaTransactionManager(userTransaction, transactionManager)
}
 
@Service
class OrderPaymentService(
    private val orderJdbcTemplate: JdbcTemplate,
    private val stockJdbcTemplate: JdbcTemplate,
) {
    @Transactional
    fun pay(orderId: Long, productId: Long) {
        orderJdbcTemplate.update("UPDATE orders SET status = 'PAID' WHERE id = ?", orderId)
        stockJdbcTemplate.update("UPDATE stock SET quantity = quantity - 1 WHERE product_id = ?", productId)
        // 반환 시점에 JtaTransactionManager 가 두 DataSource 를 함께 커밋하거나 함께 롤백한다
    }
}

코레오그래피 사가

코레오그래피 사가는 중앙에서 트랜잭션을 제어해주는 관리자 없이 분산 애플리케이션끼리 메시지를 전달하며, 글로벌 트랜잭션을 수행하는 방식이다.

어떻게 보면 이벤트 드리븐 패턴과 비슷해보일 수 있지만, 결국엔 모든 서비스들의 로컬 트랜잭션이 커밋되어야 한다는 점에서 큰 차이를 보인다. 때문에 하나의 로컬 트랜잭션이 실패 시 이전 애플리케이션에게 보상 트랜잭션을 위한 메시지를 전달하고, 트랜잭션을 롤백해야하며 이 애플리케이션은 또 다른 보상 트랜잭션 메시지를 발행해야한다. 즉, 실질적으로는 로컬 트랜잭션 간의 의존성이 강하게 엮여있어 언제든지 롤백을 해야한다.

sequenceDiagram
    autonumber
    participant A as "애플리케이션 A"
    participant B as "애플리케이션 B"
    participant C as "애플리케이션 C"

    A->>B: 이벤트 발행(정상)
    B->>B: 로컬 트랜잭션 커밋
    B->>C: 이벤트 발행(정상)
    alt 정상 처리
        C->>C: 로컬 트랜잭션 커밋
    else C 실패
        C-->>B: 보상 이벤트
        B->>B: 보상 트랜잭션 실행
        B-->>A: 보상 이벤트
        A->>A: 보상 트랜잭션 실행
    end

장점

  • 별도의 트랜잭션 중앙 관리자가 필요없다.

단점

  • 로컬 트랜잭션 간 결합도가 높고, 트랜잭션 간 순서가 필요하다.
  • 글로벌 트랜잭션에 참여하는 로컬 트랜잭션이 많아질수록 흐름 파악이 어려워진다.
  • 트랜잭션, 보상 트랜잭션 발행에 대한 이중관리와 이벤트가 꼬일 위험이 있다.

오케스트레이션 사가

오케스트레이션 사가는 중앙에서 트랜잭션을 제어해주는 관리자가 존재하는 방식이다. 중앙 관리자가 분산 애플리케이션에게 동기적으로 쓰기 요청을 하고, 하나라도 실패할 경우 보상 트랜잭션을 요청한다.

중앙에서 글로벌 트랜잭션을 관리한다는 면에서 로컬 트랜잭션 흐름이 명확해보이지만, 결국엔 각 애플리케이션들은 보상 트랜잭션까지 2벌로 관리해야한다. 또한 중앙 관리자 역할을 하는 애플리케이션이 죽는다면, 유저는 요청 자체를 할 수 없는 상황에 이른다.

장점

  • 로컬 트랜잭션들(보상 트랜잭션 포함)을 한 눈에 관리하기 용이하다.

단점

  • 여러 애플리케이션들과 강한 의존성을 가진다.
  • 트랜잭션 관리자가 단일 실패 지점이 되어 장애 시 서비스가 마비될 수 있다.

최종 일관성

최종 일관성은 비동기로 애플리케이션 간 통신을 하며, 로컬 트랜잭션들을 커밋하며 최종적으로 모든 데이터의 상태를 맞추는 방식이다.

2PC, 사가 패턴 등은 결국엔 모든 로컬 트랜잭션이 커밋되어야 하지만 최종 일관성은 로컬 트랜잭션이 독립적으로 커밋하면서 잠깐 데이터 간 상태가 안맞을지라도 결국 상태가 일치하면 된다를 목표로 한다.

이벤트 드리븐

이벤트 드리븐 아키텍쳐는 도메인 서비스에서 로컬 트랜잭션 커밋 후 도메인 이벤트를 발행하고 이 관심사를 구독하는 도메인 서비스들은 각자의 로컬 트랜잭션을 수행하는 패턴이다.

도메인 이벤트라는 비동기 메시지 그리고 낮은 결합도를 가진 패턴이기에 도메인 서비스는 이벤트만 발행할 뿐 이 외에 어떻게 처리되는 관심을 가지지 않아도 된다. 즉, 트랜잭션 간의 의존성도 없다. 다만 비동기로 도메인 서비스들이 로컬 트랜잭션을 수행하기 때문에 에러 상황에 대한 대처가 필요하다.

장점

  • 로컬 트랜잭션, 도메인 서비스 간 결합도가 없다.
  • 비동기로 메시지만 발행하면 되기에 동기적으로 호출되는 구간에서는 클라이언트에게 빠른 응답을 한다.
  • 직렬화되어 메시지들이 전파되는 것이 아니고, 병렬적으로 도메인 서비스들이 관심사를 구독하기 때문에 최종적 일관성이 빠르게 맞춰진다.

단점

  • 로컬 트랜잭션을 처리중인 서비스가 장애 발생 시 어떻게 알림을 받고, 어떻게 처리할 것인가 등에 대한 고민이 필요하다.
  • 비동기 처리이기 때문에 잠깐이지만 데이터 간 일관성이 맞지 않을 수 있다.

아웃박스 패턴

이벤트 드리븐 패턴에서 이벤트는 메시지 브로커에게 전송하고, 애플리케이션들이 구독하여 풀어나가는 방식이다. 만약 메시지 브로커가 장애가 발생하거나 모종의 이유로 메시지가 유실이 된다면 어떻게 될까? 발행처에선 커밋이 되었지만 다른 애플리케이션들은 해당 이벤트에 대한 처리가 안되는 상황이 발생한다.

아웃박스 패턴은 이 상황을 해결하기 위해 메시지 브로커에게 메시지를 전송하기 전에 로컬 트랜잭션에서 메시지를 데이터베이스에 영속화시키고, 이를 재조회하거나 스케줄러가 polling하여 메시지 브로커에게 이벤트를 넘기는 방식이다. 메시지가 영속화되었기 때문에 앞서 메시지 유실, 브로커 장애 상황이 발생하더라도 자동 혹은 수동으로 메시지를 재발행할 수 있다.

장점

  • 메시지가 유실되거나 브로커 장애 상황이 발생하더라도 재발행할 수 있다.
  • 메시지가 로컬 트랜잭션에 있기 때문에 일관성을 보장할 수 있다.

단점

  • 이벤트 발행과 동일한 양의 데이터를 영속화하므로 스토리지 비용이 증가할 수 있다.
  • 스케줄러를 이용한 방식이라면, 즉시 이벤트 발행이 되지 않을 수 있다.

동기화 배치

스케줄러가 주기적으로 데이터의 상태를 확인하고, 그에 맞추어 다른 데이터들을 동기화시켜주는 방법이다.

타겟 데이터 소스를 다루는 서비스는 다른 서비스와의 의존성이 없다. 또한 다른 서비스와의 의존성이 없다는 것은 클라이언트에 대한 요청을 빠르게 응답할 수 있다. 하지만 배치 애플리케이션이 여러 트랜잭션 성공 유무를 확인해야하며, 스케줄러의 주기만큼 다른 데이터의 동기화가 느려진다.

장점

  • 타겟 서비스는 다른 서비스와의 의존성이 없다.
  • 빠르게 클라이언트에게 응답할 수 있다.

단점

  • 동기화 대상 서비스와 배치 애플리케이션 간의 결합도가 상당히 높고, 트랜잭션도 함께 엮여 있다.
  • 스케줄러의 주기만큼 데이터의 동기화가 느려진다.