이미지 로딩 중...

분산 트랜잭션과 2PC 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 7. · 3 Views

분산 트랜잭션과 2PC 완벽 가이드

마이크로서비스 아키텍처에서 여러 데이터베이스에 걸친 트랜잭션을 안전하게 처리하는 방법을 배웁니다. 2단계 커밋 프로토콜(2PC)의 원리부터 실제 구현, 그리고 분산 환경에서의 데이터 일관성 보장 전략까지 실무에 필요한 모든 것을 다룹니다.


목차

  1. 분산_트랜잭션의_필요성
  2. 2단계_커밋_프로토콜_2PC
  3. 트랜잭션_코디네이터_구현
  4. Prepare_단계_구현
  5. Commit_단계_구현
  6. 타임아웃_처리와_복구
  7. XA_트랜잭션_표준
  8. Saga_패턴과의_비교

1. 분산_트랜잭션의_필요성

시작하며

여러분이 전자상거래 시스템을 마이크로서비스로 구축했다고 가정해봅시다. 주문을 처리할 때 주문 서비스는 주문 DB에 기록하고, 결제 서비스는 결제 DB에서 금액을 차감하고, 재고 서비스는 재고 DB에서 상품 수량을 줄여야 합니다.

이 세 가지 작업이 모두 성공하거나 모두 실패해야 하는데, 만약 주문은 성공했지만 결제가 실패한다면? 또는 결제는 됐는데 재고 차감이 실패한다면?

이런 문제는 마이크로서비스 아키텍처에서 가장 까다로운 도전 과제 중 하나입니다. 단일 데이터베이스라면 ACID 트랜잭션으로 간단히 해결되지만, 여러 독립적인 데이터베이스에 걸쳐있을 때는 전혀 다른 접근이 필요합니다.

데이터 불일치가 발생하면 비즈니스 손실은 물론 고객 신뢰까지 잃게 됩니다. 바로 이럴 때 필요한 것이 분산 트랜잭션입니다.

분산 트랜잭션은 여러 독립적인 시스템에서 실행되는 작업들을 하나의 논리적 단위로 묶어 원자성(Atomicity)을 보장합니다.

개요

간단히 말해서, 분산 트랜잭션은 네트워크로 연결된 여러 독립적인 데이터베이스나 서비스에 걸쳐 ACID 속성을 보장하는 트랜잭션입니다. 마이크로서비스가 대세가 된 현대 아키텍처에서는 각 서비스가 자신의 데이터베이스를 독립적으로 관리합니다(Database per Service 패턴).

하지만 비즈니스 로직은 종종 여러 서비스에 걸쳐 있습니다. 예를 들어, 송금 시스템에서 A 계좌에서 출금하고 B 계좌에 입금하는 작업이 서로 다른 은행의 시스템에 있다면, 이 두 작업은 원자적으로 처리되어야 합니다.

전통적인 단일 DB 트랜잭션에서는 BEGIN, COMMIT, ROLLBACK으로 간단히 처리했다면, 이제는 네트워크 지연, 부분 실패, 타임아웃 등을 모두 고려해야 합니다. 분산 트랜잭션의 핵심은 크게 세 가지입니다: 첫째, 모든 참여자가 커밋할 준비가 되었는지 확인하는 단계.

둘째, 모든 참여자에게 최종 결정(커밋 또는 롤백)을 전달하는 단계. 셋째, 장애 발생 시 복구 메커니즘.

이러한 특징들이 분산 환경에서 데이터 일관성을 유지하는 핵심입니다.

코드 예제

// 분산 트랜잭션의 기본 개념을 보여주는 예제
public class DistributedTransactionExample {
    // 여러 데이터베이스에 걸친 작업을 조율
    public void transferMoney(String fromAccount, String toAccount, double amount) {
        TransactionCoordinator coordinator = new TransactionCoordinator();

        try {
            // 1단계: 모든 참여자에게 작업 시작 알림
            coordinator.begin();
            coordinator.addParticipant(bankADatabase);
            coordinator.addParticipant(bankBDatabase);

            // 각 DB에서 작업 실행
            bankADatabase.withdraw(fromAccount, amount);  // 출금
            bankBDatabase.deposit(toAccount, amount);     // 입금

            // 2단계: 모든 참여자가 준비되었는지 확인 후 커밋
            if (coordinator.prepareAll()) {
                coordinator.commitAll();  // 모두 성공
            } else {
                coordinator.rollbackAll(); // 하나라도 실패시 전체 롤백
            }
        } catch (Exception e) {
            coordinator.rollbackAll();    // 예외 발생시 롤백
            throw new TransactionFailedException("Transfer failed", e);
        }
    }
}

설명

이것이 하는 일: 이 코드는 두 개의 다른 은행 데이터베이스에 걸친 송금 작업을 하나의 트랜잭션으로 처리합니다. TransactionCoordinator가 전체 프로세스를 관리하며, 모든 작업이 성공할 때만 최종 커밋합니다.

첫 번째로, coordinator.begin()과 addParticipant() 메서드로 트랜잭션에 참여할 모든 데이터베이스를 등록합니다. 이 단계에서 각 참여자는 트랜잭션 ID를 받고 로그를 준비합니다.

왜 이렇게 하냐면, 나중에 장애가 발생했을 때 어떤 트랜잭션의 상태인지 추적할 수 있어야 하기 때문입니다. 그 다음으로, 실제 비즈니스 로직이 실행됩니다.

withdraw()와 deposit()이 호출되지만, 아직 실제 DB에 커밋되지는 않습니다. 각 데이터베이스는 변경사항을 임시 영역에 보관하고 있습니다.

이 시점에서 다른 트랜잭션들은 이 데이터를 볼 수 없습니다(isolation). 마지막으로, prepareAll()이 모든 참여자에게 "커밋할 준비가 되었나?"고 묻습니다.

모든 참여자가 "예"라고 답하면 commitAll()을 호출하여 최종 커밋합니다. 하나라도 "아니오"라고 답하거나 타임아웃이 발생하면 rollbackAll()로 모든 변경사항을 취소합니다.

여러분이 이 코드를 사용하면 마이크로서비스 환경에서도 데이터 일관성을 보장할 수 있습니다. 네트워크 장애가 발생하거나 일부 서비스가 다운되어도 부분적인 커밋이 발생하지 않습니다.

또한 명시적인 롤백 메커니즘으로 언제든 안전하게 원상태로 복구할 수 있고, 트랜잭션 로그를 통해 장애 후 복구도 가능합니다.

실전 팁

💡 분산 트랜잭션은 성능 오버헤드가 크므로, 정말 필요한 경우에만 사용하세요. 가능하다면 보상 트랜잭션(Saga)이나 이벤트 소싱 같은 대안도 고려해보세요.

💡 네트워크 타임아웃을 너무 짧게 설정하면 불필요한 롤백이 발생하고, 너무 길게 설정하면 리소스가 오래 잠깁니다. 실제 네트워크 환경을 측정하여 적절한 값을 찾으세요.

💡 트랜잭션 로그는 반드시 영구 저장소에 기록하세요. 코디네이터가 다운되었다가 복구될 때 진행 중이던 트랜잭션의 상태를 알 수 있어야 합니다.

💡 데드락 감지 메커니즘을 구현하세요. 분산 환경에서는 여러 트랜잭션이 서로를 기다리면서 교착상태에 빠질 수 있습니다.


2. 2단계_커밋_프로토콜_2PC

시작하며

여러분이 5명의 친구와 함께 여행을 계획한다고 상상해보세요. 모두가 참석 가능한지 확인하지 않고 바로 호텔을 예약하면 어떻게 될까요?

한 명이 갑자기 못 간다고 하면 예약 취소 수수료를 물어야 합니다. 그래서 우리는 먼저 모두에게 "갈 수 있어?"라고 물어보고, 모두가 "예"라고 답하면 그때 호텔을 예약합니다.

2단계 커밋 프로토콜(Two-Phase Commit, 2PC)은 바로 이런 방식입니다. 먼저 모든 참여자에게 준비되었는지 물어보고(1단계: Prepare), 모두가 준비되었다고 하면 실제 커밋을 진행합니다(2단계: Commit).

이 간단하지만 강력한 메커니즘이 1970년대부터 분산 시스템의 표준으로 자리잡았습니다. 하지만 실제 구현은 생각보다 복잡합니다.

네트워크가 끊어지면? 참여자 중 하나가 다운되면?

코디네이터가 죽으면? 이 모든 경우를 고려해야 합니다.

개요

간단히 말해서, 2PC는 분산 트랜잭션을 두 단계로 나누어 처리하는 프로토콜입니다. 1단계에서 투표(Vote)를 하고, 2단계에서 최종 결정(Decision)을 실행합니다.

이 프로토콜은 중앙 코디네이터(Transaction Manager)와 여러 참여자(Resource Managers)로 구성됩니다. 코디네이터는 전체 프로세스를 조율하며, 각 참여자는 자신의 로컬 트랜잭션을 관리합니다.

예를 들어, 주문 처리 시스템에서 코디네이터는 주문 서비스가 되고, 참여자는 재고 DB, 결제 DB, 배송 DB가 될 수 있습니다. 기존의 로컬 트랜잭션에서는 하나의 DB가 직접 커밋/롤백을 결정했다면, 이제는 코디네이터가 모든 참여자의 의견을 수렴하여 결정합니다.

2PC의 핵심 특징은 다음과 같습니다: 첫째, 차단 프로토콜(Blocking Protocol)입니다. 참여자들은 2단계가 완료될 때까지 리소스를 잠그고 대기해야 합니다.

둘째, 원자적 커밋 프로토콜(Atomic Commit Protocol)로서 모든 참여자가 같은 결정에 도달하도록 보장합니다. 셋째, 영구 로그를 통해 장애 복구가 가능합니다.

이러한 특징들이 분산 환경에서 일관성을 보장하지만, 동시에 성능 저하의 원인이기도 합니다.

코드 예제

// 2단계 커밋 프로토콜의 기본 구조
public class TwoPhaseCommitProtocol {
    private List<Participant> participants;
    private TransactionLog log;

    public boolean executeTransaction(DistributedTransaction tx) throws Exception {
        // PHASE 1: PREPARE (투표 단계)
        log.write("PREPARE", tx.getId());
        List<Vote> votes = new ArrayList<>();

        for (Participant p : participants) {
            // 각 참여자에게 "커밋할 준비가 되었나?" 질문
            Vote vote = p.prepare(tx);
            votes.add(vote);
            log.write("VOTE " + p.getId(), vote);
        }

        // 모든 투표 확인: 하나라도 NO면 중단
        boolean canCommit = votes.stream().allMatch(v -> v == Vote.YES);

        // PHASE 2: COMMIT or ABORT (결정 단계)
        if (canCommit) {
            log.write("COMMIT", tx.getId());
            for (Participant p : participants) {
                p.commit(tx);  // 모든 참여자에게 커밋 명령
            }
            return true;
        } else {
            log.write("ABORT", tx.getId());
            for (Participant p : participants) {
                p.rollback(tx); // 모든 참여자에게 롤백 명령
            }
            return false;
        }
    }
}

설명

이것이 하는 일: 이 코드는 2PC 프로토콜의 핵심 로직을 구현합니다. 코디네이터가 모든 참여자로부터 투표를 받고, 만장일치일 때만 커밋하며, 각 단계마다 로그를 기록하여 장애 복구를 가능하게 합니다.

첫 번째로, PHASE 1에서는 log.write("PREPARE")로 시작을 기록한 후, 모든 참여자에게 prepare() 메시지를 보냅니다. 각 참여자는 이 시점에서 필요한 모든 검증을 수행합니다.

예를 들어, 재고가 충분한지, 계좌 잔액이 충분한지, 제약조건이 위반되지 않는지 등을 확인합니다. 검증이 성공하면 undo/redo 로그를 디스크에 기록하고 Vote.YES를 반환합니다.

이 로그가 중요한 이유는, 이후 장애가 발생해도 커밋하거나 롤백할 수 있어야 하기 때문입니다. 그 다음으로, 코디네이터는 모든 투표를 수집하여 canCommit 변수에 최종 결정을 저장합니다.

votes.stream().allMatch()는 모든 투표가 YES인지 확인합니다. 단 하나라도 NO가 있거나, 타임아웃이 발생하면 false가 됩니다.

이 결정은 반드시 로그에 기록되어야 합니다. 코디네이터가 결정을 내린 순간부터는 절대 번복할 수 없기 때문입니다.

마지막으로, PHASE 2에서는 결정 내용을 모든 참여자에게 전파합니다. canCommit이 true면 commit() 메시지를, false면 rollback() 메시지를 보냅니다.

참여자들은 이 메시지를 받으면 즉시 실행해야 하며, 실패해서는 안 됩니다(왜냐하면 이미 prepare 단계에서 성공을 약속했기 때문). 만약 네트워크 문제로 메시지가 전달되지 않으면, 코디네이터는 재시도하거나 타임아웃 후 복구 절차를 시작합니다.

여러분이 이 프로토콜을 사용하면 분산 환경에서도 원자성을 보장할 수 있습니다. 모든 참여자가 같은 결정(커밋 또는 롤백)에 도달하게 되며, 부분 커밋 같은 불일치 상태가 발생하지 않습니다.

또한 각 단계마다 로그를 기록하므로 시스템 장애 후에도 트랜잭션을 완료하거나 롤백할 수 있고, 명확한 프로토콜 구조로 디버깅과 모니터링이 용이합니다.

실전 팁

💡 Prepare 단계에서 참여자는 반드시 영구 저장소에 로그를 기록해야 합니다. 메모리만 사용하면 크래시 후 복구가 불가능합니다.

💡 코디네이터의 결정(COMMIT/ABORT)을 로그에 기록한 직후에는 절대 롤백할 수 없습니다. 이 시점이 "commitment point"이며, 이후로는 무조건 commit을 완료해야 합니다.

💡 타임아웃 값은 네트워크 왕복 시간(RTT)의 최소 3배 이상으로 설정하세요. 너무 짧으면 정상 동작 중에도 타임아웃이 발생합니다.

💡 참여자가 prepare 단계에서 YES를 반환한 후에는 절대 일방적으로 롤백해서는 안 됩니다. 코디네이터의 명령을 기다려야 합니다.

💡 2PC는 동기식 프로토콜이므로 성능이 중요한 경우 비동기 메시징이나 Saga 패턴을 고려하세요.


3. 트랜잭션_코디네이터_구현

시작하며

여러분이 오케스트라 지휘자라고 생각해보세요. 각 연주자(참여자)가 제시간에 연주를 시작하고 끝낼 수 있도록 조율하는 것이 지휘자의 역할입니다.

만약 바이올린 섹션이 준비가 안 되었다면? 연주를 시작할 수 없습니다.

한 파트라도 빠지면 조화로운 음악이 나올 수 없죠. 분산 트랜잭션에서 코디네이터는 바로 이런 지휘자 역할을 합니다.

모든 참여자의 상태를 추적하고, 적절한 타이밍에 명령을 내리며, 문제가 발생하면 전체를 중단시킵니다. 코디네이터 없이는 각 참여자가 제멋대로 동작하여 데이터 불일치가 발생합니다.

실제 프로덕션 환경에서 코디네이터는 고가용성(High Availability)을 갖춰야 합니다. 코디네이터가 다운되면 모든 진행 중인 트랜잭션이 멈추기 때문입니다.

개요

간단히 말해서, 트랜잭션 코디네이터는 2PC 프로토콜을 실행하는 중앙 관리자로서, 참여자 등록, 상태 추적, 명령 전파, 타임아웃 관리, 장애 복구를 담당합니다. 실제 시스템에서는 코디네이터가 수천 개의 동시 트랜잭션을 처리해야 할 수 있습니다.

각 트랜잭션마다 참여자 목록, 현재 상태, 타임아웃 정보, 로그 위치 등을 관리해야 합니다. 예를 들어, 대규모 전자상거래 사이트에서는 초당 수백 건의 주문이 발생하며, 각 주문이 분산 트랜잭션을 시작합니다.

코디네이터는 이 모든 것을 동시에 처리하면서도 정확성을 보장해야 합니다. 전통적인 단일 서비스에서는 하나의 프로세스가 모든 것을 처리했다면, 이제는 독립적인 마이크로서비스로서 오직 트랜잭션 조율만 전담합니다.

코디네이터의 핵심 책임은 다음과 같습니다: 첫째, 트랜잭션 ID 생성 및 관리. 각 트랜잭션을 고유하게 식별해야 합니다.

둘째, 참여자 목록 유지 및 상태 추적. 누가 참여하고 있고, 현재 어느 단계인지 알아야 합니다.

셋째, 타임아웃 모니터링 및 장애 감지. 응답이 없는 참여자를 적시에 발견해야 합니다.

넷째, 트랜잭션 로그 관리. 크래시 후 복구를 위한 모든 정보를 기록해야 합니다.

이러한 책임들이 분산 시스템의 안정성을 결정합니다.

코드 예제

// 트랜잭션 코디네이터의 핵심 구현
public class TransactionCoordinator {
    private Map<String, Transaction> activeTransactions = new ConcurrentHashMap<>();
    private TransactionLog log;
    private ScheduledExecutorService timeoutMonitor;

    public String beginTransaction() {
        // 고유한 트랜잭션 ID 생성
        String txId = UUID.randomUUID().toString();
        Transaction tx = new Transaction(txId);
        tx.setState(TxState.ACTIVE);

        activeTransactions.put(txId, tx);
        log.write("BEGIN " + txId);

        // 타임아웃 모니터링 시작 (30초 후 자동 체크)
        scheduleTimeout(tx, 30000);

        return txId;
    }

    public void addParticipant(String txId, Participant participant) {
        Transaction tx = activeTransactions.get(txId);
        if (tx == null) throw new IllegalStateException("Unknown transaction");

        tx.addParticipant(participant);
        log.write("PARTICIPANT " + txId + " " + participant.getId());
    }

    public boolean commit(String txId) throws Exception {
        Transaction tx = activeTransactions.get(txId);
        tx.setState(TxState.PREPARING);

        // PHASE 1: Prepare
        boolean allReady = preparePhase(tx);

        // PHASE 2: Commit or Abort
        if (allReady) {
            return commitPhase(tx);
        } else {
            return abortPhase(tx);
        }
    }

    private void scheduleTimeout(Transaction tx, long millis) {
        timeoutMonitor.schedule(() -> handleTimeout(tx), millis, TimeUnit.MILLISECONDS);
    }
}

설명

이것이 하는 일: 이 코드는 실제 운영 환경에서 사용할 수 있는 트랜잭션 코디네이터의 골격을 제공합니다. ConcurrentHashMap으로 여러 트랜잭션을 동시에 관리하고, 각 트랜잭션의 상태를 추적하며, 타임아웃을 자동으로 감지합니다.

첫 번째로, beginTransaction() 메서드는 새로운 트랜잭션을 시작합니다. UUID로 전역적으로 고유한 ID를 생성하고(분산 환경에서 충돌 방지), Transaction 객체를 생성하여 activeTransactions 맵에 저장합니다.

이 맵은 ConcurrentHashMap이므로 여러 스레드에서 동시에 접근해도 안전합니다. log.write("BEGIN")으로 시작을 기록하는 것은 나중에 크래시 복구 시 "이 트랜잭션이 시작되었는가?"를 판단하기 위함입니다.

그 다음으로, addParticipant()로 트랜잭션에 참여할 서비스를 등록합니다. 각 참여자는 네트워크 주소, 연결 정보, 백업 엔드포인트 등의 메타데이터를 포함합니다.

이 정보도 로그에 기록되어야 하는데, 코디네이터가 재시작될 때 어떤 참여자들에게 명령을 보내야 하는지 알아야 하기 때문입니다. 참여자 목록은 prepare 단계 이전에 확정되어야 합니다.

마지막으로, commit() 메서드가 실제 2PC를 실행합니다. 먼저 상태를 PREPARING으로 변경하여 더 이상 참여자를 추가할 수 없게 만듭니다.

preparePhase()에서 모든 참여자에게 prepare 메시지를 보내고 응답을 기다립니다. 모두가 YES라면 commitPhase()로, 하나라도 NO거나 타임아웃이면 abortPhase()로 진행합니다.

scheduleTimeout()은 백그라운드에서 시간을 모니터링하다가 지정된 시간이 지나면 자동으로 트랜잭션을 중단시킵니다. 여러분이 이 코디네이터를 사용하면 복잡한 분산 트랜잭션을 체계적으로 관리할 수 있습니다.

수백 개의 동시 트랜잭션을 안전하게 처리할 수 있고, 각 트랜잭션의 상태를 실시간으로 추적하여 모니터링 대시보드에 표시할 수 있습니다. 타임아웃 메커니즘으로 무한정 대기하는 상황을 방지하며, 영구 로그를 통해 시스템 장애 후에도 진행 중이던 트랜잭션을 완료하거나 롤백할 수 있습니다.

실전 팁

💡 activeTransactions 맵의 크기를 주기적으로 모니터링하세요. 계속 증가한다면 트랜잭션이 제대로 정리되지 않는 메모리 누수가 발생한 것입니다.

💡 코디네이터를 이중화(HA)할 때는 트랜잭션 로그를 공유 스토리지나 분산 DB에 저장하세요. 로컬 디스크에만 쓰면 장애 조치(failover)가 불가능합니다.

💡 트랜잭션 ID는 단순 증가 숫자보다 UUID를 사용하는 것이 좋습니다. 여러 코디네이터 인스턴스가 동시에 실행될 때 ID 충돌을 방지합니다.

💡 참여자와의 통신은 비동기 I/O를 사용하세요. 동기식으로 구현하면 하나의 느린 참여자가 전체 코디네이터를 블로킹할 수 있습니다.

💡 트랜잭션 완료 후 일정 시간 동안 메타데이터를 보관하세요. 참여자가 늦게 재시도할 때 "이미 커밋된 트랜잭션"임을 알려줄 수 있습니다.


4. Prepare_단계_구현

시작하며

여러분이 결혼식 준비를 한다고 가정해봅시다. 예식장, 스튜디오, 메이크업샵, 식당 등 여러 업체를 동시에 예약해야 합니다.

하지만 한 곳이라도 예약이 안 되면 전체 일정이 무산됩니다. 그래서 먼저 모든 업체에 "이 날짜에 예약 가능한가요?"라고 물어보고, 모두 "예"라고 답하면 그때 정식 계약을 진행합니다.

2PC의 Prepare 단계는 바로 이 "예약 가능 여부 확인" 과정입니다. 각 참여자에게 "커밋할 수 있나요?"라고 물어보고, 참여자는 필요한 모든 검증을 수행한 후 답변합니다.

이 단계가 중요한 이유는, 여기서 "예"라고 답하면 반드시 커밋할 수 있어야 하기 때문입니다. 나중에 마음을 바꿀 수 없습니다.

실제 구현에서는 각 참여자가 undo/redo 로그를 디스크에 쓰고, 필요한 락을 획득하고, 제약조건을 검증해야 합니다. 이 모든 작업이 성공해야만 "예"라고 답할 수 있습니다.

개요

간단히 말해서, Prepare 단계는 코디네이터가 모든 참여자에게 투표를 요청하고, 각 참여자가 커밋 가능 여부를 판단하여 YES 또는 NO로 응답하는 과정입니다. 이 단계의 핵심은 "durability"입니다.

참여자가 YES를 반환하기 전에 반드시 영구 저장소에 변경사항을 기록해야 합니다. 왜냐하면 그 직후에 크래시가 발생해도 재시작 후 커밋을 완료할 수 있어야 하기 때문입니다.

예를 들어, 은행 시스템에서 계좌 이체를 준비할 때, 출금 계좌에 충분한 잔액이 있는지 확인하고, 트랜잭션 로그를 디스크에 쓰고, 해당 행에 락을 걸어야 합니다. 이 모든 것이 성공해야 YES를 반환합니다.

기존의 단일 DB 트랜잭션에서는 커밋 시점에 한 번만 검증했다면, 2PC에서는 Prepare 단계에서 미리 검증하고 리소스를 확보해 둡니다. Prepare 단계의 핵심 작업은 다음과 같습니다: 첫째, 모든 비즈니스 로직 검증.

잔액 확인, 재고 확인, 권한 확인 등 실제 커밋 시 실패할 수 있는 모든 조건을 미리 체크합니다. 둘째, undo/redo 로그를 영구 저장소에 기록.

크래시 후 복구를 위한 모든 정보를 디스크에 씁니다. 셋째, 필요한 락 획득 및 리소스 예약.

다른 트랜잭션이 같은 데이터를 변경하지 못하도록 잠급니다. 넷째, 타임아웃 내에 응답.

너무 오래 걸리면 코디네이터가 중단시킵니다. 이러한 작업들이 모두 성공해야만 YES를 반환할 수 있습니다.

코드 예제

// Prepare 단계의 참여자 측 구현
public class ParticipantImpl implements Participant {
    private Database db;
    private TransactionLog log;
    private Map<String, LockSet> heldLocks = new ConcurrentHashMap<>();

    @Override
    public Vote prepare(String txId, Operation operation) {
        try {
            // 1. 비즈니스 로직 검증
            if (!validateOperation(operation)) {
                return Vote.NO;  // 검증 실패시 즉시 NO
            }

            // 2. 필요한 락 획득 시도 (타임아웃: 5초)
            LockSet locks = acquireLocks(operation.getAffectedRows(), 5000);
            if (locks == null) {
                return Vote.NO;  // 락 획득 실패
            }
            heldLocks.put(txId, locks);

            // 3. Undo/Redo 로그를 디스크에 기록
            log.writeUndoLog(txId, operation.getBeforeImage());
            log.writeRedoLog(txId, operation.getAfterImage());
            log.force();  // 디스크 동기화 (매우 중요!)

            // 4. 메모리에서 변경 적용 (아직 커밋은 아님)
            db.applyInMemory(operation);

            // 5. "커밋 준비 완료" 상태를 로그에 기록
            log.write("PREPARED " + txId);
            log.force();

            return Vote.YES;  // 모든 준비 완료!

        } catch (Exception e) {
            // 어떤 단계에서든 실패하면 NO
            rollbackPreparation(txId);
            return Vote.NO;
        }
    }
}

설명

이것이 하는 일: 이 코드는 참여자가 Prepare 요청을 받았을 때 수행해야 하는 모든 단계를 구현합니다. 검증부터 로그 기록까지 순차적으로 진행하며, 어느 단계에서든 실패하면 즉시 NO를 반환하여 트랜잭션을 중단시킵니다.

첫 번째로, validateOperation()으로 비즈니스 규칙을 검증합니다. 예를 들어, 계좌 이체라면 출금 계좌의 잔액이 충분한지, 계좌가 동결 상태가 아닌지, 일일 이체 한도를 초과하지 않는지 등을 체크합니다.

상품 주문이라면 재고가 충분한지, 배송 가능 지역인지 등을 확인합니다. 이 검증이 중요한 이유는, YES를 반환한 후에는 이런 이유로 커밋을 거부할 수 없기 때문입니다.

그 다음으로, acquireLocks()로 필요한 모든 데이터베이스 행에 락을 겁니다. 타임아웃을 5초로 설정하여 데드락을 방지합니다.

락을 획득하면 heldLocks 맵에 저장해두고, 나중에 커밋이나 롤백 시 해제합니다. 이 락은 2단계가 완료될 때까지 유지되므로, 다른 트랜잭션은 이 데이터를 읽거나 쓸 수 없습니다.

이것이 2PC가 "차단 프로토콜"이라고 불리는 이유입니다. 세 번째로, 가장 중요한 로그 기록 단계입니다.

writeUndoLog()는 현재 값(before image)을 기록하여 롤백 시 사용하고, writeRedoLog()는 새 값(after image)을 기록하여 커밋 시 사용합니다. log.force()는 버퍼를 디스크에 강제로 쓰는 함수로, 이것을 호출하지 않으면 크래시 시 로그가 손실됩니다.

많은 초보자들이 이 단계를 빼먹어서 데이터 손실을 경험합니다. 마지막으로, applyInMemory()로 메모리 상에서 변경을 적용합니다.

하지만 아직 디스크에는 쓰지 않습니다. 다른 트랜잭션은 이 변경을 볼 수 없습니다(isolation).

"PREPARED" 상태를 로그에 기록하고 나면, 이제 코디네이터의 커밋 명령을 기다리는 상태가 됩니다. 이 시점부터는 참여자가 일방적으로 롤백할 수 없으며, 반드시 코디네이터의 결정을 따라야 합니다.

여러분이 이 코드를 사용하면 안전하게 분산 트랜잭션을 준비할 수 있습니다. 모든 검증을 미리 수행하므로 커밋 단계에서는 실패할 일이 없으며, 영구 로그 덕분에 크래시 후에도 트랜잭션을 완료할 수 있습니다.

명시적인 락 관리로 동시성 문제를 방지하고, 타임아웃 메커니즘으로 무한 대기를 막을 수 있습니다.

실전 팁

💡 log.force()는 성능에 큰 영향을 미칩니다. 그룹 커밋(group commit) 기법을 사용하여 여러 트랜잭션의 로그를 한 번에 디스크에 쓰면 성능이 크게 향상됩니다.

💡 락 획득 순서를 정렬하여 데드락을 방지하세요. 예를 들어, 항상 계좌 번호 순서대로 락을 획득하면 순환 대기가 발생하지 않습니다.

💡 Prepare 단계에서 NO를 반환하는 것은 정상적인 동작입니다. 재고 부족, 잔액 부족 등은 비즈니스 예외이지 시스템 오류가 아닙니다.

💡 타임아웃은 네트워크 지연과 디스크 I/O를 고려하여 설정하세요. 일반적으로 5-10초가 적절하지만, SSD와 HDD는 크게 다릅니다.

💡 PREPARED 상태에서는 절대 자동으로 롤백하지 마세요. 코디네이터가 이미 COMMIT 결정을 내렸을 수도 있습니다. 반드시 코디네이터에게 문의해야 합니다.


5. Commit_단계_구현

시작하며

여러분이 군대에서 행진 연습을 한다고 상상해보세요. 교관이 "준비!"라고 외치면 모두가 자세를 잡고, 모두가 준비되었음을 확인한 후 "앞으로, 가!"라고 명령합니다.

일단 "가!" 명령이 떨어지면, 그 명령은 취소할 수 없습니다. 모두가 동시에 같은 방향으로 움직여야 합니다.

2PC의 Commit 단계는 바로 이 "가!" 명령입니다. Prepare 단계에서 모든 참여자가 YES라고 투표했다면, 코디네이터는 COMMIT 결정을 내리고 모든 참여자에게 전파합니다.

이 결정은 절대 번복할 수 없으며, 참여자들은 무조건 이 명령을 따라야 합니다. 실제 구현에서 중요한 점은, 코디네이터가 COMMIT 결정을 로그에 기록한 순간부터는 어떤 일이 있어도 커밋을 완료해야 한다는 것입니다.

네트워크가 끊어져도, 참여자가 다운되어도, 재시도를 통해 반드시 완료해야 합니다.

개요

간단히 말해서, Commit 단계는 코디네이터가 모든 참여자에게 COMMIT 명령을 보내고, 참여자들이 실제로 변경사항을 디스크에 영구 저장하는 과정입니다. 이 단계의 핵심은 "단순성"과 "멱등성"입니다.

참여자는 이미 Prepare 단계에서 모든 검증과 준비를 마쳤으므로, Commit 단계에서는 단순히 redo 로그를 실제 데이터에 적용하고 락을 해제하면 됩니다. 실패할 이유가 없습니다.

예를 들어, 데이터베이스 커밋 시 redo 로그의 내용을 테이블에 쓰고, 트랜잭션 로그에 "COMMITTED"를 기록하고, 다른 트랜잭션에게 변경사항을 보이게 만듭니다. 기존의 단일 DB 트랜잭션에서는 커밋이 즉시 완료되었다면, 분산 환경에서는 모든 참여자가 커밋을 완료할 때까지 기다려야 합니다.

Commit 단계의 핵심 작업은 다음과 같습니다: 첫째, 코디네이터가 COMMIT 결정을 영구 로그에 기록. 이것이 "point of no return"입니다.

둘째, 모든 참여자에게 COMMIT 메시지 전파. 네트워크 실패 시 재시도합니다.

셋째, 참여자가 redo 로그를 실제 데이터베이스에 적용. 메모리 변경을 디스크에 씁니다.

넷째, 참여자가 락을 해제하고 "COMMITTED" 상태 기록. 다른 트랜잭션이 이제 이 데이터에 접근할 수 있습니다.

다섯째, 코디네이터가 모든 참여자의 완료를 확인하고 트랜잭션 종료. 이러한 작업들이 모두 완료되어야 트랜잭션이 완전히 끝납니다.

코드 예제

// Commit 단계의 코디네이터 및 참여자 구현
public class CommitPhaseHandler {
    private TransactionLog log;

    // 코디네이터 측: Commit 결정 및 전파
    public boolean commitPhase(Transaction tx) throws Exception {
        // 1. COMMIT 결정을 로그에 기록 (Point of No Return!)
        log.write("COMMIT " + tx.getId());
        log.force();  // 반드시 디스크에 동기화

        // 2. 모든 참여자에게 COMMIT 명령 전송
        List<Future<Boolean>> futures = new ArrayList<>();
        for (Participant p : tx.getParticipants()) {
            // 비동기로 전송하여 성능 향상
            Future<Boolean> future = executor.submit(() -> {
                return sendCommitWithRetry(p, tx.getId(), 3);
            });
            futures.add(future);
        }

        // 3. 모든 참여자의 완료를 기다림
        for (Future<Boolean> future : futures) {
            future.get(30, TimeUnit.SECONDS);  // 타임아웃 30초
        }

        // 4. 트랜잭션 완료 로그 기록
        log.write("COMPLETED " + tx.getId());
        return true;
    }

    // 참여자 측: Commit 실행
    public void commit(String txId) throws Exception {
        // 1. Redo 로그를 실제 데이터베이스에 적용
        RedoLog redoLog = log.getRedoLog(txId);
        db.applyToDatabase(redoLog);
        db.flush();  // 디스크에 쓰기

        // 2. "COMMITTED" 상태를 로그에 기록
        log.write("COMMITTED " + txId);
        log.force();

        // 3. 모든 락 해제 (다른 트랜잭션이 이제 접근 가능)
        LockSet locks = heldLocks.remove(txId);
        locks.releaseAll();

        // 4. 트랜잭션 메타데이터 정리
        cleanupTransaction(txId);
    }

    // 재시도 로직
    private boolean sendCommitWithRetry(Participant p, String txId, int maxRetries) {
        for (int i = 0; i < maxRetries; i++) {
            try {
                p.commit(txId);
                return true;
            } catch (Exception e) {
                if (i == maxRetries - 1) throw e;
                Thread.sleep(1000 * (i + 1));  // 지수 백오프
            }
        }
        return false;
    }
}

설명

이것이 하는 일: 이 코드는 2PC의 두 번째 단계를 구현합니다. 코디네이터는 번복할 수 없는 COMMIT 결정을 내리고, 모든 참여자가 이를 완료할 때까지 추적합니다.

재시도 로직으로 일시적 네트워크 장애를 극복합니다. 첫 번째로, 코디네이터가 log.write("COMMIT")으로 결정을 기록합니다.

이것이 가장 중요한 순간입니다. 이 로그가 디스크에 쓰인 후에는 절대 롤백할 수 없습니다.

왜냐하면 일부 참여자는 이미 이 메시지를 받아서 커밋을 시작했을 수도 있기 때문입니다. log.force()를 호출하여 버퍼의 내용이 실제로 디스크에 쓰였음을 보장합니다.

만약 이 시점에 코디네이터가 크래시되면, 재시작 후 로그를 읽어서 커밋을 계속 진행해야 합니다. 그 다음으로, sendCommitWithRetry()를 통해 각 참여자에게 COMMIT 메시지를 보냅니다.

executor.submit()을 사용하여 모든 참여자에게 병렬로 보내므로 성능이 향상됩니다. 만약 한 참여자가 일시적으로 다운되었다면, 재시도 로직이 작동하여 최대 3번까지 다시 시도합니다.

지수 백오프(exponential backoff)로 재시도 간격을 늘려서 네트워크 혼잡을 피합니다. 이 단계는 멱등성(idempotent)이므로 같은 메시지를 여러 번 보내도 문제없습니다.

세 번째로, 참여자 측의 commit() 메서드에서 실제 커밋을 수행합니다. getRedoLog()로 Prepare 단계에서 기록한 redo 로그를 읽어옵니다.

이 로그에는 "이 행의 이 컬럼을 이 값으로 변경"하는 정보가 들어있습니다. applyToDatabase()가 이 변경사항을 실제 테이블에 적용하고, db.flush()로 디스크에 씁니다.

이제 트랜잭션의 결과가 영구적으로 저장되었습니다. 마지막으로, locks.releaseAll()로 Prepare 단계에서 획득했던 모든 락을 해제합니다.

이제 다른 트랜잭션들이 이 데이터를 읽거나 수정할 수 있습니다. 대기 중이던 트랜잭션들이 깨어나서 실행을 재개합니다.

cleanupTransaction()으로 트랜잭션 메타데이터를 메모리에서 제거하지만, 로그는 일정 기간 보관합니다(복구나 감사를 위해). 여러분이 이 코드를 사용하면 신뢰성 있게 분산 커밋을 완료할 수 있습니다.

코디네이터 크래시 후에도 로그를 통해 커밋을 완료할 수 있고, 네트워크 장애 시 자동 재시도로 최종적으로 성공합니다. 병렬 전송으로 성능을 최적화하며, 명확한 상태 전이로 디버깅이 쉽습니다.

실전 팁

💡 COMMIT 로그를 쓴 후에는 어떤 경우에도 ABORT로 바꿀 수 없습니다. 코드 리뷰 시 이 불변성이 지켜지는지 반드시 확인하세요.

💡 참여자가 COMMIT 메시지를 중복으로 받을 수 있습니다. 따라서 commit() 메서드는 멱등성을 가져야 하며, 이미 커밋된 트랜잭션에 대해서는 조용히 성공을 반환하세요.

💡 모든 참여자의 완료를 기다리는 동안 코디네이터가 크래시되면, 재시작 후 로그를 읽어서 미완료 참여자들에게 다시 COMMIT을 보내야 합니다.

💡 참여자가 영구히 응답하지 않는 경우를 대비해 수동 개입 절차를 마련하세요. 운영자가 강제로 커밋을 완료시키거나 해당 참여자를 제거할 수 있어야 합니다.

💡 락 해제 순서는 중요하지 않지만, 가능한 한 빨리 해제하세요. 락을 오래 잡고 있으면 다른 트랜잭션의 대기 시간이 길어집니다.


6. 타임아웃_처리와_복구

시작하며

여러분이 온라인 회의를 진행하는데 한 참가자가 10분째 응답이 없다면 어떻게 해야 할까요? 무한정 기다릴 수는 없습니다.

일정 시간 후에는 "응답 없음"으로 간주하고 회의를 진행해야 합니다. 하지만 그 사람이 실제로는 접속되어 있는데 네트워크만 느린 거라면?

섣불리 판단하면 문제가 됩니다. 분산 트랜잭션에서 타임아웃 처리는 이보다 훨씬 복잡합니다.

참여자가 응답하지 않는 이유가 다양하기 때문입니다: 네트워크 지연, 프로세스 크래시, 디스크 I/O 지연, 데드락 등. 각 상황마다 다른 대응이 필요하며, 잘못 판단하면 데이터 불일치가 발생합니다.

더 어려운 문제는 코디네이터 자체가 크래시되는 경우입니다. 참여자들은 PREPARED 상태에서 락을 잡고 코디네이터의 결정을 기다리는데, 코디네이터가 다운되면 어떻게 해야 할까요?

이런 상황을 "in-doubt period"라고 부르며, 2PC의 가장 큰 약점입니다.

개요

간단히 말해서, 타임아웃 처리는 응답이 없는 참여자나 코디네이터를 감지하고 적절한 복구 절차를 수행하는 메커니즘입니다. 복구는 트랜잭션 로그를 기반으로 미완료 트랜잭션을 완료하거나 롤백합니다.

실제 시스템에서는 다양한 레벨의 타임아웃을 설정해야 합니다. 네트워크 왕복 시간(RTT) 기반 타임아웃, 디스크 I/O 타임아웃, 전체 트랜잭션 타임아웃 등.

예를 들어, 클라우드 환경에서는 지역 간 네트워크 지연이 수백 밀리초일 수 있으므로, Prepare 단계 타임아웃을 최소 5초 이상으로 설정해야 합니다. 반면 로컬 네트워크에서는 1초도 충분합니다.

전통적인 단일 시스템에서는 프로세스가 죽으면 OS가 자동으로 정리했다면, 분산 환경에서는 명시적인 복구 프로토콜이 필요합니다. 타임아웃 및 복구의 핵심 시나리오는 다음과 같습니다: 첫째, Prepare 단계에서 참여자 타임아웃 - 코디네이터가 ABORT 결정.

둘째, Commit 단계에서 참여자 타임아웃 - 코디네이터가 재시도하거나 수동 개입. 셋째, 코디네이터 크래시 - 백업 코디네이터가 로그를 읽고 복구.

넷째, 참여자 크래시 후 재시작 - 로컬 로그를 읽고 미완료 트랜잭션 처리. 다섯째, 네트워크 파티션 - 양쪽 모두 상대방이 죽었다고 판단하는 "split-brain" 방지.

이러한 시나리오들을 모두 처리해야 프로덕션에서 안전하게 운영할 수 있습니다.

코드 예제

// 타임아웃 처리 및 복구 로직
public class TimeoutAndRecovery {
    private TransactionLog log;
    private ScheduledExecutorService scheduler;

    // Prepare 단계 타임아웃 처리
    public boolean preparePhaseWithTimeout(Transaction tx) {
        List<Future<Vote>> futures = new ArrayList<>();

        for (Participant p : tx.getParticipants()) {
            Future<Vote> future = executor.submit(() -> p.prepare(tx));
            futures.add(future);
        }

        // 모든 참여자로부터 응답 수집 (타임아웃: 10초)
        for (int i = 0; i < futures.size(); i++) {
            try {
                Vote vote = futures.get(i).get(10, TimeUnit.SECONDS);
                if (vote == Vote.NO) {
                    return false;  // 하나라도 NO면 즉시 중단
                }
            } catch (TimeoutException e) {
                // 타임아웃 발생: 해당 참여자를 NO로 간주
                log.write("TIMEOUT participant " + i + " in tx " + tx.getId());
                return false;
            }
        }
        return true;
    }

    // 코디네이터 복구 (재시작 후 실행)
    public void recoverCoordinator() {
        // 1. 트랜잭션 로그를 읽어서 미완료 트랜잭션 찾기
        List<Transaction> inDoubtTxs = log.scanForInDoubtTransactions();

        for (Transaction tx : inDoubtTxs) {
            String lastState = log.getLastState(tx.getId());

            if (lastState.equals("COMMIT")) {
                // COMMIT 결정이 로그에 있음 -> 커밋 완료
                resumeCommit(tx);
            } else if (lastState.equals("ABORT")) {
                // ABORT 결정이 로그에 있음 -> 롤백 완료
                resumeAbort(tx);
            } else if (lastState.equals("PREPARING")) {
                // Prepare 중에 크래시 -> 안전하게 ABORT
                log.write("ABORT " + tx.getId());
                resumeAbort(tx);
            }
        }
    }

    // 참여자 복구 (재시작 후 실행)
    public void recoverParticipant() {
        List<Transaction> preparedTxs = log.scanForPreparedTransactions();

        for (Transaction tx : preparedTxs) {
            // 코디네이터에게 문의: 이 트랜잭션의 최종 결정은?
            Decision decision = askCoordinatorDecision(tx.getId());

            if (decision == Decision.COMMIT) {
                commit(tx.getId());
            } else if (decision == Decision.ABORT) {
                rollback(tx.getId());
            } else {
                // 코디네이터도 모름 -> 정기적으로 재시도
                scheduleRetry(tx.getId(), 5000);
            }
        }
    }
}

설명

이것이 하는 일: 이 코드는 분산 시스템의 장애 시나리오를 처리합니다. 타임아웃으로 무한 대기를 방지하고, 로그 기반 복구로 크래시 후에도 일관성을 유지하며, 명시적인 복구 절차로 자동 복구를 가능하게 합니다.

첫 번째로, preparePhaseWithTimeout()은 각 참여자의 응답을 Future로 받고 10초 타임아웃을 설정합니다. get(10, TimeUnit.SECONDS)는 지정된 시간 내에 결과를 받지 못하면 TimeoutException을 던집니다.

이 예외를 catch하여 해당 참여자를 Vote.NO로 간주하고 전체 트랜잭션을 중단합니다. 타임아웃을 너무 짧게 설정하면 정상 동작 중에도 중단되고, 너무 길게 설정하면 리소스가 오래 잠깁니다.

네트워크 환경과 디스크 성능을 측정하여 적절한 값을 찾아야 합니다. 그 다음으로, recoverCoordinator()는 코디네이터가 재시작될 때 자동으로 호출됩니다.

scanForInDoubtTransactions()는 로그를 스캔하여 "BEGIN"은 있지만 "COMPLETED"가 없는 트랜잭션을 찾습니다. 마지막 상태를 확인하여 어떤 단계에서 크래시되었는지 판단합니다.

"COMMIT" 로그가 있다면 이미 번복할 수 없는 결정을 내렸으므로 resumeCommit()으로 완료해야 합니다. "PREPARING" 상태라면 아직 결정을 내리지 않았으므로 안전하게 ABORT할 수 있습니다.

세 번째로, recoverParticipant()는 참여자가 재시작될 때 실행됩니다. scanForPreparedTransactions()는 "PREPARED" 상태로 로그에 기록되어 있지만 "COMMITTED"나 "ABORTED"가 없는 트랜잭션을 찾습니다.

이런 트랜잭션은 코디네이터의 결정을 기다리던 중 크래시된 것입니다. askCoordinatorDecision()으로 코디네이터에게 "이 트랜잭션은 커밋되었나요?"라고 물어봅니다.

코디네이터가 응답하면 그에 따라 처리합니다. 마지막으로, 가장 어려운 경우는 코디네이터도 참여자도 결정을 모르는 상황입니다.

코디네이터가 COMMIT 로그를 쓰기 직전에 크래시되었다면, 재시작 후에는 이 트랜잭션에 대해 알지 못합니다. 참여자는 PREPARED 상태로 락을 잡고 있지만, 코디네이터에게 물어봐도 "모르는 트랜잭션"이라는 답만 받습니다.

이 경우 scheduleRetry()로 주기적으로 재시도하거나, 일정 시간 후 수동 개입을 요청해야 합니다. 여러분이 이 복구 메커니즘을 구현하면 시스템이 장애에 강해집니다.

일시적 장애 후 자동으로 복구되므로 운영자의 수동 개입이 줄어들고, 로그 기반 복구로 데이터 손실을 방지하며, 명시적인 타임아웃으로 무한 대기나 데드락을 피할 수 있습니다. 또한 모니터링 시스템과 연동하여 복구 중인 트랜잭션을 추적하고, 복구 실패 시 알림을 보낼 수 있습니다.

실전 팁

💡 타임아웃 값은 환경 변수나 설정 파일로 관리하세요. 네트워크 환경에 따라 다르므로 하드코딩하면 안 됩니다.

💡 복구 절차는 멱등성을 가져야 합니다. 복구 중에 또 크래시될 수 있으므로, 여러 번 실행해도 같은 결과가 나와야 합니다.

💡 PREPARED 상태의 트랜잭션이 너무 오래 남아있다면 경고를 발생시키세요. 이는 코디네이터 장애나 네트워크 파티션을 의미할 수 있습니다.

💡 코디네이터를 이중화할 때는 "분산 합의(distributed consensus)"를 사용하세요. 두 개의 코디네이터가 동시에 다른 결정을 내리면 재앙입니다.

💡 수동 개입 도구를 미리 준비하세요. CLI나 웹 UI를 통해 운영자가 강제로 트랜잭션을 커밋하거나 롤백할 수 있어야 합니다.


7. XA_트랜잭션_표준

시작하며

여러분이 국제 표준 규격에 맞춰 제품을 만든다고 생각해보세요. USB 포트가 어떤 회사 제품이든 같은 모양이듯이, 분산 트랜잭션에도 표준이 있습니다.

그래야 Oracle, MySQL, PostgreSQL, MQ 등 다양한 벤더의 제품들이 서로 협력할 수 있습니다. XA는 1991년 Open Group이 제정한 분산 트랜잭션 처리 표준입니다.

2PC 프로토콜을 구체적인 API로 정의하여, 이를 구현한 제품들은 서로 다른 벤더라도 함께 트랜잭션에 참여할 수 있습니다. 예를 들어, Java 애플리케이션에서 Oracle DB와 IBM MQ를 하나의 트랜잭션으로 묶을 수 있습니다.

하지만 XA는 복잡하고 성능 오버헤드가 크며, 모든 데이터베이스가 완벽하게 지원하는 것은 아닙니다. 특히 클라우드 네이티브 환경에서는 대안적인 접근법들이 더 인기를 얻고 있습니다.

개요

간단히 말해서, XA는 분산 트랜잭션의 표준 인터페이스로서, 트랜잭션 매니저(TM)와 리소스 매니저(RM) 사이의 통신 프로토콜을 정의합니다. Java에서는 JTA/JTS로 구현됩니다.

XA 아키텍처는 세 가지 컴포넌트로 구성됩니다: 첫째, 애플리케이션(AP) - 비즈니스 로직을 실행하고 트랜잭션을 시작합니다. 둘째, 트랜잭션 매니저(TM) - 2PC 프로토콜을 조율하는 코디네이터입니다.

셋째, 리소스 매니저(RM) - 데이터베이스나 메시지 큐 같은 실제 리소스입니다. 예를 들어, Spring Framework에서는 JtaTransactionManager가 TM 역할을 하고, Oracle JDBC 드라이버가 RM 역할을 합니다.

기존의 로컬 트랜잭션 API(JDBC의 commit/rollback)는 단일 DB만 다룰 수 있었다면, XA API는 여러 이종 시스템을 하나의 트랜잭션으로 묶을 수 있습니다. XA의 핵심 API는 다음과 같습니다: 첫째, xa_start() - 트랜잭션 브랜치 시작.

각 RM은 글로벌 트랜잭션의 로컬 브랜치를 생성합니다. 둘째, xa_end() - 트랜잭션 브랜치 종료.

더 이상 작업하지 않음을 알립니다. 셋째, xa_prepare() - 2PC의 Prepare 단계.

RM이 커밋 준비를 완료합니다. 넷째, xa_commit() / xa_rollback() - 2PC의 Commit 단계.

최종 결정을 실행합니다. 다섯째, xa_recover() - 장애 복구.

미완료 트랜잭션 목록을 조회합니다. 이러한 API들이 표준 인터페이스를 제공하여 벤더 독립성을 실현합니다.

코드 예제

// Java JTA를 사용한 XA 트랜잭션 예제
import javax.transaction.*;
import javax.sql.XADataSource;
import javax.sql.XAConnection;
import javax.jms.XAConnectionFactory;

public class XATransactionExample {
    private UserTransaction userTx;  // JTA 트랜잭션 인터페이스
    private XADataSource xaDataSource1;  // Oracle DB
    private XADataSource xaDataSource2;  // MySQL DB
    private XAConnectionFactory xaJmsFactory;  // MQ

    public void executeDistributedTransaction() throws Exception {
        // 1. 글로벌 트랜잭션 시작
        userTx.begin();

        try {
            // 2. 여러 XA 리소스에서 작업 수행
            // Oracle DB 작업
            XAConnection xaConn1 = xaDataSource1.getXAConnection();
            Connection conn1 = xaConn1.getConnection();
            Statement stmt1 = conn1.createStatement();
            stmt1.executeUpdate("UPDATE accounts SET balance = balance - 100 WHERE id = 1");

            // MySQL DB 작업
            XAConnection xaConn2 = xaDataSource2.getXAConnection();
            Connection conn2 = xaConn2.getConnection();
            Statement stmt2 = conn2.createStatement();
            stmt2.executeUpdate("INSERT INTO transactions (amount) VALUES (100)");

            // MQ 메시지 전송
            XASession jmsSession = xaJmsFactory.createXAConnection().createXASession();
            MessageProducer producer = jmsSession.createProducer(queue);
            producer.send(jmsSession.createTextMessage("Transfer completed"));

            // 3. 모든 작업 성공시 커밋
            // JTA가 내부적으로 2PC를 수행: prepare → commit
            userTx.commit();

        } catch (Exception e) {
            // 4. 예외 발생시 모든 리소스 롤백
            userTx.rollback();
            throw e;
        }
    }

    // XA 리소스 복구 (애플리케이션 서버 재시작 후)
    public void recoverXATransactions() throws Exception {
        TransactionManager tm = getTransactionManager();

        // 모든 RM으로부터 미완료 트랜잭션 조회
        Xid[] inDoubtXids = xaDataSource1.getXAResource().recover(XAResource.TMSTARTRSCAN);

        for (Xid xid : inDoubtXids) {
            // TM 로그와 대조하여 최종 결정 확인
            if (tm.shouldCommit(xid)) {
                xaDataSource1.getXAResource().commit(xid, false);
            } else {
                xaDataSource1.getXAResource().rollback(xid);
            }
        }
    }
}

설명

이것이 하는 일: 이 코드는 JTA(Java Transaction API)를 사용하여 Oracle DB, MySQL DB, 메시지 큐를 하나의 원자적 트랜잭션으로 처리합니다. 개발자는 단순히 begin/commit만 호출하면, 내부적으로 JTA가 2PC를 수행합니다.

첫 번째로, userTx.begin()으로 글로벌 트랜잭션을 시작합니다. 이 시점에 트랜잭션 매니저는 고유한 글로벌 트랜잭션 ID(XID)를 생성합니다.

XID는 세 부분으로 구성됩니다: format ID, global transaction ID, branch qualifier. 이후 이 트랜잭션 컨텍스트에서 실행되는 모든 XA 리소스 작업은 자동으로 같은 글로벌 트랜잭션에 포함됩니다.

ThreadLocal을 사용하여 현재 스레드의 트랜잭션 컨텍스트를 추적합니다. 그 다음으로, 각 XA 리소스에서 작업을 수행합니다.

xaDataSource.getXAConnection()을 호출하면, 드라이버가 내부적으로 XAResource 객체를 생성하고 트랜잭션 매니저에 등록합니다. 이제 이 커넥션에서 실행되는 모든 SQL은 글로벌 트랜잭션의 일부가 됩니다.

executeUpdate()를 호출해도 즉시 커밋되지 않고, 트랜잭션 매니저의 결정을 기다립니다. 마찬가지로 JMS 메시지도 즉시 전송되지 않고 버퍼에 보관됩니다.

세 번째로, userTx.commit()을 호출하면 JTA가 2PC를 자동으로 수행합니다. 먼저 모든 등록된 XAResource에 대해 prepare()를 호출합니다.

Oracle JDBC 드라이버는 내부적으로 "XA PREPARE" 명령을 DB에 보냅니다. 모든 RM이 XA_OK를 반환하면, TM은 commit() 메서드를 호출합니다.

각 RM은 "XA COMMIT" 명령을 실행하여 실제로 데이터를 디스크에 씁니다. 개발자는 이 복잡한 과정을 신경 쓰지 않고 간단한 API만 사용하면 됩니다.

마지막으로, recoverXATransactions()는 애플리케이션 서버가 재시작될 때 자동으로 호출됩니다. recover() 메서드는 RM에게 "PREPARED 상태의 트랜잭션이 있나?"라고 묻습니다.

RM은 자신의 트랜잭션 로그를 스캔하여 미완료 XID 목록을 반환합니다. TM은 자신의 로그와 대조하여 각 트랜잭션의 최종 결정을 확인한 후, commit() 또는 rollback()을 호출하여 복구를 완료합니다.

여러분이 XA를 사용하면 표준 기반의 이식성 있는 코드를 작성할 수 있습니다. Oracle에서 PostgreSQL로 DB를 변경해도 애플리케이션 코드는 수정할 필요가 없고, 애플리케이션 서버(WildFly, WebLogic 등)가 트랜잭션 관리를 대신해주므로 개발이 단순해집니다.

자동 복구 메커니즘으로 장애 후에도 데이터 일관성이 유지되며, 수십 년간 검증된 안정성을 제공합니다.

실전 팁

💡 XA 트랜잭션은 성능 오버헤드가 큽니다. 벤치마크 결과, 로컬 트랜잭션보다 2-10배 느립니다. 정말 필요한 경우에만 사용하세요.

💡 모든 데이터베이스가 XA를 완벽하게 지원하는 것은 아닙니다. MySQL의 경우 InnoDB만 XA를 지원하며, MyISAM은 불가능합니다.

💡 XA 트랜잭션 중에는 커넥션 풀을 사용할 수 없습니다. 각 XA 커넥션은 트랜잭션이 완료될 때까지 독점적으로 사용됩니다.

💡 클라우드 환경에서는 XA 대신 Saga 패턴이나 이벤트 소싱을 고려하세요. 많은 클라우드 DB(DynamoDB, CosmosDB 등)는 XA를 지원하지 않습니다.

💡 XA 트랜잭션 로그는 주기적으로 정리하세요. 오래된 완료 트랜잭션 로그가 쌓이면 디스크 공간과 복구 시간에 영향을 줍니다.


8. Saga_패턴과의_비교

시작하며

여러분이 해외여행을 예약한다고 가정해봅시다. 2PC 방식은 항공편, 호텔, 렌터카를 동시에 모두 예약하고, 하나라도 실패하면 전체를 취소합니다.

반면 Saga 방식은 먼저 항공편을 예약하고, 그 다음 호텔을 예약하고, 마지막으로 렌터카를 예약합니다. 만약 렌터카 예약이 실패하면, 이미 예약된 호텔과 항공편을 각각 취소합니다(보상 트랜잭션).

2PC는 동기식 잠금 방식으로 강한 일관성을 보장하지만, 성능과 가용성이 낮습니다. Saga는 비동기 메시징 방식으로 높은 성능과 가용성을 제공하지만, 최종 일관성(eventual consistency)만 보장합니다.

어느 것이 더 나은가요? 상황에 따라 다릅니다.

금융 거래처럼 즉시 일관성이 필요하고 트랜잭션이 짧은 경우는 2PC가 적합합니다. 전자상거래처럼 긴 비즈니스 프로세스를 다루고 부분 실패를 허용할 수 있는 경우는 Saga가 적합합니다.

개요

간단히 말해서, Saga는 긴 비즈니스 트랜잭션을 일련의 로컬 트랜잭션으로 나누고, 실패 시 보상 트랜잭션(compensating transaction)으로 롤백하는 패턴입니다. 2PC와 달리 락을 오래 잡지 않습니다.

Saga의 핵심 아이디어는 "각 단계를 즉시 커밋하되, 실패하면 역순으로 취소"입니다. 예를 들어, 주문 프로세스가 "주문 생성 → 결제 → 재고 차감 → 배송"이라면, 각 단계를 독립적인 트랜잭션으로 실행하고 즉시 커밋합니다.

재고 차감이 실패하면 "결제 취소 → 주문 취소"를 역순으로 실행합니다. 각 취소 작업을 보상 트랜잭션이라고 부릅니다.

전통적인 2PC에서는 모든 단계가 완료될 때까지 리소스를 잠갔다면, Saga에서는 각 단계마다 즉시 락을 해제하므로 동시성이 높습니다. 2PC와 Saga의 비교는 다음과 같습니다: 일관성: 2PC는 강한 일관성(ACID), Saga는 최종 일관성(BASE).

2PC는 트랜잭션 도중 중간 상태가 외부에 노출되지 않지만, Saga는 각 단계가 커밋되므로 중간 상태가 보일 수 있습니다. 성능: 2PC는 모든 참여자가 Prepare를 완료할 때까지 대기하므로 느립니다.

Saga는 각 단계를 비동기로 처리하므로 빠릅니다. 가용성: 2PC는 한 참여자가 다운되면 전체가 블로킹됩니다.

Saga는 한 서비스가 다운되어도 다른 서비스는 계속 동작합니다. 복잡성: 2PC는 표준 프로토콜이 있어 구현이 상대적으로 단순합니다.

Saga는 각 비즈니스 로직마다 보상 트랜잭션을 설계해야 하므로 복잡합니다. 적용 범위: 2PC는 짧고 빠른 트랜잭션에 적합합니다(몇 초 이내).

Saga는 긴 비즈니스 프로세스에 적합합니다(수 분~수 시간).

코드 예제

// 2PC와 Saga 패턴 비교 예제
public class TransactionPatternComparison {

    // 2PC 방식: 동기식, 강한 일관성
    public void orderWith2PC(Order order) throws Exception {
        TransactionCoordinator coordinator = new TransactionCoordinator();

        try {
            coordinator.begin();

            // 모든 작업을 하나의 트랜잭션으로
            orderService.createOrder(order);
            paymentService.chargeCard(order.getAmount());
            inventoryService.reserveStock(order.getItems());
            shippingService.arrangeDelivery(order);

            // 모두 성공하면 한 번에 커밋
            coordinator.commit();  // 내부적으로 2PC 수행

        } catch (Exception e) {
            coordinator.rollback();  // 자동 롤백
            throw e;
        }
    }

    // Saga 방식: 비동기식, 최종 일관성
    public void orderWithSaga(Order order) {
        SagaOrchestrator saga = new SagaOrchestrator();

        // 각 단계를 순차적으로 실행, 각각 즉시 커밋
        saga.addStep(
            // Forward action
            () -> orderService.createOrder(order),
            // Compensating action (보상 트랜잭션)
            (orderId) -> orderService.cancelOrder(orderId)
        );

        saga.addStep(
            () -> paymentService.chargeCard(order.getAmount()),
            (paymentId) -> paymentService.refund(paymentId)
        );

        saga.addStep(
            () -> inventoryService.reserveStock(order.getItems()),
            (reservationId) -> inventoryService.releaseStock(reservationId)
        );

        saga.addStep(
            () -> shippingService.arrangeDelivery(order),
            (deliveryId) -> shippingService.cancelDelivery(deliveryId)
        );

        // 비동기로 실행, 실패시 보상 트랜잭션 자동 실행
        saga.executeAsync();
    }

    // Saga 오케스트레이터 (간단한 구현)
    class SagaOrchestrator {
        private List<SagaStep> steps = new ArrayList<>();
        private List<Object> completedResults = new ArrayList<>();

        void addStep(Supplier<Object> action, Consumer<Object> compensation) {
            steps.add(new SagaStep(action, compensation));
        }

        void executeAsync() {
            CompletableFuture.runAsync(() -> {
                try {
                    // Forward 실행
                    for (SagaStep step : steps) {
                        Object result = step.action.get();
                        completedResults.add(result);
                        log.info("Step completed: " + result);
                    }
                } catch (Exception e) {
                    // 실패시 보상 트랜잭션 역순 실행
                    log.error("Saga failed, compensating...");
                    for (int i = completedResults.size() - 1; i >= 0; i--) {
                        steps.get(i).compensation.accept(completedResults.get(i));
                    }
                }
            });
        }
    }
}

설명

이것이 하는 일: 이 코드는 같은 주문 프로세스를 2PC와 Saga 두 가지 방식으로 구현하여 차이점을 보여줍니다. 2PC는 모든 작업을 하나의 트랜잭션으로 묶고, Saga는 각 작업을 독립 트랜잭션으로 실행하며 보상 트랜잭션을 준비합니다.

첫 번째로, orderWith2PC() 메서드는 전통적인 2PC 방식입니다. coordinator.begin()으로 글로벌 트랜잭션을 시작하고, 4개의 서비스 호출이 모두 같은 트랜잭션 컨텍스트에서 실행됩니다.

이 시점에 각 서비스는 필요한 데이터베이스 행에 락을 걸어서 다른 트랜잭션이 접근하지 못하게 합니다. 모든 작업이 성공해야만 coordinator.commit()이 호출되어 2PC 프로토콜을 시작합니다.

하나라도 실패하면 rollback()이 자동으로 모든 변경을 취소합니다. 이 방식의 장점은 간단하고 일관성이 보장된다는 것이지만, 락 대기 시간이 길어서 동시 처리량이 낮습니다.

그 다음으로, orderWithSaga() 메서드는 Saga 패턴을 사용합니다. addStep()으로 각 단계를 등록할 때, forward action과 compensating action을 쌍으로 제공합니다.

예를 들어, "결제(chargeCard)"의 보상 트랜잭션은 "환불(refund)"입니다. executeAsync()는 비동기로 각 단계를 순차적으로 실행하며, 각 단계는 완료되면 즉시 커밋됩니다.

따라서 주문이 생성된 직후에는 이미 데이터베이스에 커밋되어 다른 트랜잭션이 볼 수 있습니다. 세 번째로, SagaOrchestrator의 execute 로직을 보면, for 루프로 각 단계를 순차적으로 실행하고 결과를 completedResults에 저장합니다.

예를 들어, createOrder()가 반환한 orderId를 저장해두었다가, 나중에 실패하면 cancelOrder(orderId)를 호출하는 데 사용합니다. catch 블록에서는 역순으로 보상 트랜잭션을 실행합니다.

마지막에 실행된 단계부터 차례로 취소하여, 최종적으로는 아무 일도 없었던 것처럼 만듭니다. 마지막으로, 중요한 차이점은 중간 상태의 가시성입니다.

2PC에서는 커밋 전까지 어떤 변경도 외부에 보이지 않습니다(isolation). 하지만 Saga에서는 주문이 생성된 후 결제가 진행되는 동안, 이 주문을 조회하면 "결제 중" 상태로 보입니다.

만약 결제가 실패하면 "주문 취소됨" 상태로 변경됩니다. 이런 중간 상태를 다른 트랜잭션이 볼 수 있으므로, 비즈니스 로직에서 이를 고려해야 합니다.

여러분이 이 두 패턴의 차이를 이해하면 올바른 선택을 할 수 있습니다. 은행 계좌 이체처럼 즉시 일관성이 필요하고 트랜잭션이 밀리초 단위로 끝나는 경우는 2PC를 사용하세요.

전자상거래 주문처럼 여러 시스템에 걸쳐있고 수 초~수 분이 걸리는 경우는 Saga를 사용하세요. 또한 2PC는 모든 참여자가 XA를 지원해야 하지만, Saga는 일반 REST API만으로도 구현할 수 있어 클라우드 환경에 더 적합합니다.

실전 팁

💡 금융 거래는 2PC, 긴 비즈니스 프로세스는 Saga를 기본으로 고려하세요. 하지만 절대적인 규칙은 아니며, 요구사항에 따라 선택하세요.

💡 Saga의 보상 트랜잭션은 비즈니스 로직입니다. 단순한 기술적 롤백이 아니라, "환불", "재고 반환" 같은 의미 있는 비즈니스 작업입니다.

💡 Saga는 "dirty read"가 발생할 수 있습니다. 다른 트랜잭션이 나중에 롤백될 데이터를 읽을 수 있으므로, UI에서 "처리 중" 상태를 명확히 표시하세요.

💡 2PC는 참여자 수가 증가할수록 성능이 급격히 저하됩니다. 3개 이상의 서비스가 참여한다면 Saga나 이벤트 소싱을 고려하세요.

💡 하이브리드 접근도 가능합니다. 중요한 부분은 2PC로 강한 일관성을 보장하고, 나머지는 Saga로 비동기 처리하는 방식입니다.


#Java#DistributedTransaction#2PC#Microservices#DataConsistency

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.