개발자는 기록이 답이다

[구현] 최종적 일관성이란 무엇인가? 본문

카테고리 없음

[구현] 최종적 일관성이란 무엇인가?

slow-walker 2025. 2. 2. 16:22

계좌 이체와 도메인 서비스

에릭 에반스는 도메인 주도 설계(DDD)에서 도메인 서비스를 활용하는 사례로 계좌 이체를 소개했습니다.

 

도메인 서비스와 일관성

 

하지만 도메인 서비스를 사용하면 트랜잭션의 범위와 일관성이 도메인 서비스와 결합되어, 필요할 때 서비스를 독립적으로 분리하기 어려운 문제가 발생할 수 있습니다.

이를 해결하기 위해, 계좌 이체 프로세스를 개별 애그리게이트(Aggregate)로 분리하고, 나아가 별도의 마이크로서비스로 독립할 수 있도록 설계하는 방식을 고려할 수 있습니다.

계좌 이체 도메인 모델

계좌 이체와 트랜잭션/일관성 범위

 

  • 계좌 이체는 일관성의 범위로 한 개의 Transfer와 2 개의 Account 애그리게이트가 참여하고 애그리게이트마다 독립적인 데이터베이스 트랜잭션을 소유합니다.
  • Transfer 애그리게이트출금 계좌(from)와 입금 계좌(to)의 식별자를 저장하며, 계좌 간의 트랜잭션을 조정합니다.
  • 각 Account 애그리게이트는 독립적인 트랜잭션을 수행하며, 결과적 일관성을 유지하기 위해 이벤트를 활용할 수 있습니다.
  • 이를 통해 향후 계좌 이체 프로세스를 분리하여 Transfer 서비스와 Account 서비스를 독립적으로 운영할 수 있는 구조를 마련할 수 있습니다.

계좌 이체와 사가 패턴 적용

계좌 이체는 일반적으로 출금을 먼저 시도하고 성공하면 입금을 처리하는 방식이지만, 보상 트랜잭션(Compensating Transaction) 테스트를 위해 반대로 입금을 먼저 진행한 후, 출금이 실패할 경우 입금을 취소하는 방식을 적용할 수 있습니다.

 

단일 마이크로서비스 내에서 애그리게이트 간 일관성을 유지하기 위해 오케스트레이션(Orchestration)과 코레오그래피(Choreography) 방식을 활용하여 단계별로 계좌 이체를 구현할 수 있습니다.

이를 이해하면, Kafka 등의 메시지 브로커를 활용해 Transfer 서비스와 Account 서비스를 분리하여 이벤트 기반으로 동작하도록 개선할 수도 있습니다.

4. 오케스트레이션 방식의 계좌 이체

오케스트레이션 방식에서 계좌 이체 성공 시나리오

  1. 사용자가 transfer 서비스에 "TransferMoney"커맨드로 이체를 요청한다
  2. transfer 서비스는 Transfer 애그리게이트를 생성하고 [TransferCreated]이벤트를 발행한다
  3. TransferOrchestrator가 [TransferCreated]도메인 이벤트에 반응해 to 계좌에 Deposit커맨드르르 발행한다.
  4. Deposit 커맨드를 수신한 account서비스는 to 계좌에 입금 처리하고 [Deposited]이벤트를 발행한다.
  5. TransferOrchestrator는 [Deposited] 이벤트를 수신하고 transfer 서비스에 입금 완료로 처리하는 [CompleteDeposit]커맨드를 발행한다.
  6. TransferOrchestrator는 transfer 서비스가 입금 완료로 처리하면 from 계좌에 Withdraw 커맨드를 발행한다.
  7. Withdraw 커맨드를 수신한 account 서비스는 from 계좌에서 출금을 처리하고 [Withdrawed] 이벤트를 발행한다.
  8. TransferOrchestrator는 [Withdrawed] 이벤트에 반응해 transfer 출금 완료로 처리하는 [CompleteWithdraw] 커맨드를 발행한다.

 

transfer 서비스는 CompleteDeposit, CompleteWithdraw 커맨드를 처리하고 계좌 이체 완료를 검사해 입금/출금을 모두 완료했으면 계좌 이체 상태를 완료로 변경한다.


계좌 이체 애그리게이트

Transfer 애그리게이트는 계좌 이체 프로세스를 관리하는 핵심 도메인 객체입니다. 이 애그리게이트는 상관 관계 아이디로 Transfer 애그리게이트 식별자인 transferId를 사용합니다. 또한 출금할 계좌(fromAccount)와 입금할 계좌(toAccount)의 식별자를 저장하고 있으며, 계좌 이체가 완료될 때까지 상태를 유지합니다.

package com.healingpaper.connect.controllers.error;

public class Transfer {
    private String transferId;
    private String fromAccount;
    private boolean withdrawed;
    private String toAccount;
    private boolean deposited;
    private int amount;
    private boolean completed;

    public Transfer(TransferMoeny command){
        this.transferId = command.getTransferId;
        this.fromAccount = command.getFromAccount;
        this.toAccount = command.getToAccount;
        this.amount = command.getAmount;
    }

    public void complete(CompleteDeposit command) {
        this.deposited = true;
        this.complete();
    }

    public void complete(CompleteWithdraw command) {
        this.withdrawed = true;
        this.complete();
    }

    public void complete() {
        if(this.withdrawed && this.deposited){
            this.completed = true;
        }
    }

    public void cancel(CancelTransfer command) {
        this.completed = false;
    }
}

 

핵심 개념

  • 계좌 이체는 TransferMoney 커맨드로 시작되며, 입금과 출금이 완료되면 complete() 메서드를 호출하여 트랜잭션을 마무리합니다.
  • 입금 또는 출금이 실패할 경우, cancel() 메서드를 호출하여 계좌 이체를 취소할 수 있습니다.

TransferOrchestrator: 계좌 이체 오케스트레이션

계좌 이체 트랜잭션을 조정하는 TransferOrchestrator는 Transfer와 Account 애그리게이트가 발행하는 이벤트를 수신하고, 필요한 메시지(커맨드 이벤트)를 발행하여 비즈니스 프로세스를 조정합니다.

@Component
public class TransferOrchestrator {
    
    private final TransferService transferService;
    private final Gateway gateway;

    public TransferOrchestrator(TransferService transferService,
                                Gateway gateway) {
        
        this.transferService = transferService;
        this.gateway = gateway;
    }

    @EventListener
    public void on(TransferCreated event) {
        
        Deposit deposit = new Deposit(event.getToAccount(), event.getAmount(), Optional.of(event.getTransferId()));
        this.gateway.send(deposit);
    }

    @EventListener
    public void on(Deposited event) {
        
        if (event.getTransferId().isPresent()) {
            CompleteDeposit command = new CompleteDeposit(event.getTransferId().get());
            this.transferService.complete(command);

            QueryTransfer query = new QueryTransfer(event.getTransferId().get());
            Transfer transfer = this.transferService.query(query);

            Withdraw withdraw = new Withdraw(transfer.getFromAccount(), event.getAmount(), event.getTransferId());
            this.gateway.send(withdraw);
        }
    }

    @EventListener
    public void on(Withdrawed event) {
        
        if (event.getTransferId().isPresent()) {
            CompleteWithdraw command = new CompleteWithdraw(event.getTransferId().get());
            this.transferService.complete(command);
        }
    }

    @EventListener
    public void on(WithdrawFailed event) {
        
        if (event.getTransferId().isPresent()) {
            CancelTransfer command = new CancelTransfer(event.getTransferId().get());
            this.transferService.cancel(command);
        }
    }

    @EventListener
    public void on(TransferCanceled event) {
        
        CancelDeposit command = new CancelDeposit(event.getToAccount(), event.getAmount(), Optional.of(event.getTransferId()));
        this.gateway.send(command);
    }
}

 

 동작 원리

  • 계좌이체를 시작하면 transferId를 할당(1)해 입금 커맨드를 발행(2)한다.
  • 입금 완료 이벤트를 수신(3)하면 출금 커맨드를 발행(4)한다.
  • 출금 완료 이벤트를 수신(5)하면 TransferService가 제공하는 complete를 호출해 트랜잭션을 완료(6)한다.

Gateway: 커맨드를 보내고 이벤트를 발행하는 곳

TransferOrchestrator가 사용하는 Gateway는 스프링이 제공하는 메시지 전달 기능을 분리한 클래스이다. 

 

@Component
public class Gateway {
    
    private final ApplicationEventPublisher eventPublisher;

    public Gateway(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void send(Command command) {
        this.eventPublisher.publishEvent(command);
    }
    
    public void publish (Event event) {
        this.eventPublisher.publishEvent(event);
    }
}

 

DepositHandler/WithdrawHandler : 입금/출금 요청을 처리하는 핸들러

TransferOrchestrator가 Gateway.send 메소드를 호출해서 Accout에 입금과 출금 커맨드를 전달하면 스프링은 입금과 출금 이벤트 핸들러인 DepositHandler와 WithdrawHandler의 On 메소드를 호출합니다.

 

각 이벤트 핸들러는 AccountService에 입금(1)과 출금(2)를 위임합니다.

 

@Component
public class DepositHandler {
    
    private final AccountService accountService;

    public DepositHandler(AccountService accountService) {
        this.accountService = accountService;
    }

    @EventListener
    public void on(Deposit command) { (1)
        this.accountService.deposit(command);
    }
}


//

@Component
public class WithdrawHandler {

    private final AccountService accountService;

    public WithdrawHandler(AccountService accountService) {
        this.accountService = accountService;
    }

    @EventListener
    public void on(Withdraw command) { (2)
        this.accountService.withdraw(command);
    }
}

 

 

AccoutService : 입금 및 출금 처리 후 이벤트 발행

입금과 출금 요청을 받은 애플리케이션 서비스인 AccoutService는 입금과 출금을 처리하고 Deposited, Withdrawed 이벤트를 발행한다. 두 이벤트는 어떤 Transfer 애그리게이트와 연관돼 있는지 구별하기 위해 상관 관계 아이디로 transferId를 포함한다.

 

@Service
public class AccountService {
    
    private final AccountStore accountStore;
    private final Gateway gateway;

    public AccountService(AccountStore accountStore,
                          Gateway gateway) {
        this.accountStore = accountStore;
        this.gateway = gateway;
    }

    public void deposit(Deposit command) {
        Account account = this.accountStore.retrieve(command.getNo());
        account.deposit(command);
        this.accountStore.update(account);

        if (command.getTransferId().isPresent()) {
            this.gateway.publish(new Deposited(command.getNo(),
                    command.getFromAccountNo(),
                    command.getAmount(),
                    command.getTransferId()));
        }
    }


    public void withdraw(Withdraw command) {
        Account account = this.accountStore.retrieve(command.getNo());

        try {
            account.withdraw(command);
            this.accountStore.update(account);

            if (command.getTransferId().isPresent()) {
                this.gateway.publish(new Withdrawed(command.getNo(),
                        command.getAmount(),
                        command.getTransferId()));
            }
        } catch (NotEnoughBalanceException e) {
            this.gateway.publish(new WithdrawFailed(command.getTransferId()));
        }
    }
}

 

📌 참고 자료