Spring & Spring Boot 실무 가이드

Event Sourcing과 CQRS 패턴 입문 - Axon Framework로 시작하는 이벤트 드리븐 개발

devcomet 2025. 6. 6. 20:07
728x90
반응형

Event Sourcing과 CQRS 패턴 Axon Framework 튜토리얼
Event Sourcing과 CQRS 패턴 Axon Framework 튜토리얼

시리즈 안내: 이 글은 Event Sourcing & CQRS 시리즈의 기본편입니다.
심화편에서는 분산 시스템에서의 이벤트 처리, 스냅샷, 프로젝션 최적화 등을 다룰 예정입니다.

Spring Boot로 웹 애플리케이션을 개발해본 경험이 있다면, 대부분 전통적인 CRUD 패턴에 익숙할 것입니다.

하지만 비즈니스가 복잡해지고 데이터의 변경 이력을 추적해야 하는 요구사항이 생기면서,

Event SourcingCQRS 패턴이 주목받고 있습니다.

 

이 글에서는 Event Sourcing과 CQRS의 핵심 개념을 이해하고, Axon Framework를 활용해 실제로 구현해보는 과정을 단계별로 살펴보겠습니다.


📚 Event Sourcing과 CQRS란? - 은행 통장으로 이해하기

Event Sourcing: 모든 변화를 기록하는 방식

Event Sourcing을 이해하는 가장 좋은 예시는 은행 통장입니다.

전통적인 데이터베이스 방식은 계좌의 현재 잔액만 저장합니다:

계좌번호: 123-456-789
현재잔액: 1,500,000원

하지만 은행 통장을 보면 모든 거래 내역이 기록되어 있습니다:

2024-01-01  급여입금      +3,000,000원  잔액: 3,000,000원
2024-01-05  카드결제      -50,000원     잔액: 2,950,000원
2024-01-10  계좌이체      -1,000,000원  잔액: 1,950,000원
2024-01-15  ATM출금       -450,000원    잔액: 1,500,000원

Event Sourcing은 이처럼 모든 변경사항을 이벤트로 저장하는 방식입니다.

현재 상태는 모든 이벤트를 순서대로 적용(replay)해서 계산할 수 있습니다.

CQRS: 읽기와 쓰기의 분리

CQRS(Command Query Responsibility Segregation)는 명령(Command)과 조회(Query)의 책임을 분리하는 패턴입니다.

 

은행 시스템에서 생각해보면:

  • 명령(Command): 입금, 출금, 이체 등 계좌 상태를 변경하는 작업
  • 조회(Query): 잔액 조회, 거래내역 조회 등 데이터를 읽는 작업

이 두 작업의 특성과 요구사항이 다르기 때문에 분리해서 처리하는 것이 효율적입니다.

Event Sourcing과 CQRS 아키텍처 개념도
Event Sourcing과 CQRS의 전체적인 흐름을 보여주는 아키텍처 다이어그램


🚀 왜 Axon Framework를 사용해야 할까?

Axon Framework는 Java 기반의 Event Sourcing과 CQRS 구현을 위한 오픈소스 프레임워크입니다.

Axon Framework의 주요 장점

1. Spring Boot와의 완벽한 통합

  • Spring Boot Auto Configuration 지원
  • 기존 Spring 프로젝트에 쉽게 추가 가능

2. 개발자 친화적인 어노테이션

  • @Aggregate, @CommandHandler, @EventHandler 등 직관적인 어노테이션
  • 복잡한 Event Sourcing 로직을 간단하게 구현

3. 풍부한 기능 제공

  • 이벤트 저장소, 스냅샷, 분산 처리 등 엔터프라이즈급 기능
  • Axon Server를 통한 확장 가능한 아키텍처

4. 테스트하기 쉬운 구조

  • 단위 테스트를 위한 Test Fixtures 제공
  • Given-When-Then 패턴으로 테스트 작성 가능

🛠️ 프로젝트 환경 설정

1. Maven 의존성 추가

먼저 Spring Boot 프로젝트에 Axon Framework 의존성을 추가합니다:

<dependencies>
    <!-- Spring Boot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Axon Framework -->
    <dependency>
        <groupId>org.axonframework</groupId>
        <artifactId>axon-spring-boot-starter</artifactId>
        <version>4.9.1</version>
    </dependency>

    <!-- H2 Database -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- 테스트 의존성 -->
    <dependency>
        <groupId>org.axonframework</groupId>
        <artifactId>axon-test</artifactId>
        <version>4.9.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>

2. Application 설정 (application.yml)

server:
  port: 8080

spring:
  application:
    name: axon-bank-demo

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 

  h2:
    console:
      enabled: true
      path: /h2-console

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true

# Axon Framework 설정
axon:
  serializer:
    general: jackson
    messages: jackson
    events: jackson
  eventhandling:
    processors:
      # 이벤트 처리를 위한 프로세서 설정
      account-projection:
        mode: tracking
      transaction-history:
        mode: tracking

3. 메인 애플리케이션 클래스

@SpringBootApplication
@EnableJpaRepositories
public class AxonBankApplication {
    public static void main(String[] args) {
        SpringApplication.run(AxonBankApplication.class, args);
    }
}

💰 계좌 관리 시스템 구현하기

이제 본격적으로 Event Sourcing과 CQRS 패턴을 적용한 계좌 관리 시스템을 구현해보겠습니다.

1. Command 정의하기

Command는 시스템의 상태를 변경하려는 의도를 나타냅니다:

import lombok.AllArgsConstructor;
import lombok.Getter;

// 계좌 생성 명령
@Getter
@AllArgsConstructor
public class CreateAccountCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final String accountHolderName;
    private final BigDecimal initialBalance;
}

// 입금 명령
@Getter
@AllArgsConstructor
public class DepositMoneyCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final BigDecimal amount;
}

// 출금 명령
@Getter
@AllArgsConstructor
public class WithdrawMoneyCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final BigDecimal amount;
}

2. Event 정의하기

Event는 실제로 발생한 변경사항을 나타냅니다:

import lombok.AllArgsConstructor;
import lombok.Getter;

// 계좌 생성 이벤트
@Getter
@AllArgsConstructor
public class AccountCreatedEvent {
    private final String accountId;
    private final String accountHolderName;
    private final BigDecimal initialBalance;
}

// 입금 이벤트
@Getter
@AllArgsConstructor
public class MoneyDepositedEvent {
    private final String accountId;
    private final BigDecimal amount;
}

// 출금 이벤트
@Getter
@AllArgsConstructor
public class MoneyWithdrawnEvent {
    private final String accountId;
    private final BigDecimal amount;
}

3. Exception 정의하기

// 잔액 부족 예외
public class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

4. Aggregate 구현하기

Aggregate는 비즈니스 로직을 포함하고 상태를 관리하는 핵심 컴포넌트입니다:

@Aggregate
public class AccountAggregate {

    @AggregateIdentifier
    private String accountId;
    private String accountHolderName;
    private BigDecimal balance;

    // 기본 생성자 (Axon Framework에서 필요)
    protected AccountAggregate() {}

    @CommandHandler
    public AccountAggregate(CreateAccountCommand command) {
        // 비즈니스 룰 검증
        if (command.getInitialBalance().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("초기 잔액은 음수일 수 없습니다.");
        }

        // 이벤트 발행
        AggregateLifecycle.apply(new AccountCreatedEvent(
            command.getAccountId(),
            command.getAccountHolderName(),
            command.getInitialBalance()
        ));
    }

    @CommandHandler
    public void handle(DepositMoneyCommand command) {
        // 비즈니스 룰 검증
        if (command.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
        }

        // 이벤트 발행
        AggregateLifecycle.apply(new MoneyDepositedEvent(
            command.getAccountId(),
            command.getAmount()
        ));
    }

    @CommandHandler
    public void handle(WithdrawMoneyCommand command) {
        // 비즈니스 룰 검증
        if (command.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("출금액은 0보다 커야 합니다.");
        }

        if (this.balance.compareTo(command.getAmount()) < 0) {
            throw new InsufficientBalanceException("잔액이 부족합니다. 현재 잔액: " + this.balance);
        }

        // 이벤트 발행
        AggregateLifecycle.apply(new MoneyWithdrawnEvent(
            command.getAccountId(),
            command.getAmount()
        ));
    }

    @EventSourcingHandler
    public void on(AccountCreatedEvent event) {
        this.accountId = event.getAccountId();
        this.accountHolderName = event.getAccountHolderName();
        this.balance = event.getInitialBalance();
    }

    @EventSourcingHandler
    public void on(MoneyDepositedEvent event) {
        this.balance = this.balance.add(event.getAmount());
    }

    @EventSourcingHandler
    public void on(MoneyWithdrawnEvent event) {
        this.balance = this.balance.subtract(event.getAmount());
    }
}

 

 

Axon Framework 컴포넌트 구조도
Axon Framework에서 Command, Event, Aggregate의 상호작용을 보여주는 기술 다이어그램


5. Query Model과 Projection 구현하기

조회를 위한 별도의 모델을 구현합니다:

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;

// 계좌 조회 모델
@Entity
@Table(name = "account_view")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AccountView {
    @Id
    private String accountId;
    private String accountHolderName;
    private BigDecimal balance;
}

// 거래 내역 모델
@Entity
@Table(name = "transaction_history")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TransactionHistory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String accountId;
    private String transactionType;
    private BigDecimal amount;
    private BigDecimal balanceAfter;

    @CreationTimestamp
    private LocalDateTime timestamp;

    public TransactionHistory(String accountId, String transactionType, 
                            BigDecimal amount, BigDecimal balanceAfter) {
        this.accountId = accountId;
        this.transactionType = transactionType;
        this.amount = amount;
        this.balanceAfter = balanceAfter;
    }
}

// Repository
@Repository
public interface AccountViewRepository extends JpaRepository<AccountView, String> {
    List<AccountView> findByAccountHolderNameContaining(String name);
}

@Repository
public interface TransactionHistoryRepository extends JpaRepository<TransactionHistory, Long> {
    List<TransactionHistory> findByAccountIdOrderByTimestampDesc(String accountId);
}

// Event Handler (Projection)
@Component
@ProcessingGroup("account-projection")
@RequiredArgsConstructor
public class AccountProjection {

    private final AccountViewRepository repository;

    @EventHandler
    public void on(AccountCreatedEvent event) {
        AccountView accountView = new AccountView(
            event.getAccountId(),
            event.getAccountHolderName(),
            event.getInitialBalance()
        );
        repository.save(accountView);
    }

    @EventHandler
    public void on(MoneyDepositedEvent event) {
        repository.findById(event.getAccountId())
            .ifPresent(account -> {
                account.setBalance(account.getBalance().add(event.getAmount()));
                repository.save(account);
            });
    }

    @EventHandler
    public void on(MoneyWithdrawnEvent event) {
        repository.findById(event.getAccountId())
            .ifPresent(account -> {
                account.setBalance(account.getBalance().subtract(event.getAmount()));
                repository.save(account);
            });
    }
}

// 거래 내역 Projection
@Component
@ProcessingGroup("transaction-history")
@RequiredArgsConstructor
public class TransactionHistoryProjection {

    private final TransactionHistoryRepository repository;
    private final AccountViewRepository accountRepository;

    @EventHandler
    public void on(AccountCreatedEvent event) {
        TransactionHistory history = new TransactionHistory(
            event.getAccountId(),
            "ACCOUNT_CREATED",
            event.getInitialBalance(),
            event.getInitialBalance()
        );
        repository.save(history);
    }

    @EventHandler
    public void on(MoneyDepositedEvent event) {
        BigDecimal currentBalance = getCurrentBalance(event.getAccountId());

        TransactionHistory history = new TransactionHistory(
            event.getAccountId(),
            "DEPOSIT",
            event.getAmount(),
            currentBalance
        );
        repository.save(history);
    }

    @EventHandler
    public void on(MoneyWithdrawnEvent event) {
        BigDecimal currentBalance = getCurrentBalance(event.getAccountId());

        TransactionHistory history = new TransactionHistory(
            event.getAccountId(),
            "WITHDRAW",
            event.getAmount(),
            currentBalance
        );
        repository.save(history);
    }

    private BigDecimal getCurrentBalance(String accountId) {
        return accountRepository.findById(accountId)
            .map(AccountView::getBalance)
            .orElse(BigDecimal.ZERO);
    }
}

6. Query Handler 구현하기

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

// 계좌 조회 쿼리
@Getter
@AllArgsConstructor
public class FindAccountQuery {
    private final String accountId;
}

// 모든 계좌 조회 쿼리
public class FindAllAccountsQuery {
    // 모든 계좌 조회를 위한 빈 쿼리 클래스
}

// 거래 내역 조회 쿼리
@Getter
@AllArgsConstructor
public class FindTransactionHistoryQuery {
    private final String accountId;
}

// Query Handler
@Component
@RequiredArgsConstructor
public class AccountQueryHandler {

    private final AccountViewRepository repository;
    private final TransactionHistoryRepository transactionHistoryRepository;

    @QueryHandler
    public AccountView handle(FindAccountQuery query) {
        return repository.findById(query.getAccountId())
            .orElseThrow(() -> new RuntimeException("계좌를 찾을 수 없습니다: " + query.getAccountId()));
    }

    @QueryHandler
    public List<AccountView> handle(FindAllAccountsQuery query) {
        return repository.findAll();
    }

    @QueryHandler
    public List<TransactionHistory> handle(FindTransactionHistoryQuery query) {
        return transactionHistoryRepository.findByAccountIdOrderByTimestampDesc(query.getAccountId());
    }
}

🎮 REST API 컨트롤러 구현

이제 외부에서 시스템과 상호작용할 수 있는 REST API를 구현해보겠습니다:

import lombok.Data;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api/accounts")
@Validated
@RequiredArgsConstructor
public class AccountController {

    private final CommandGateway commandGateway;
    private final QueryGateway queryGateway;

    // 계좌 생성 API
    @PostMapping
    public ResponseEntity<Map<String, String>> createAccount(@RequestBody CreateAccountRequest request) {
        try {
            String accountId = UUID.randomUUID().toString();

            commandGateway.sendAndWait(new CreateAccountCommand(
                accountId,
                request.getAccountHolderName(),
                request.getInitialBalance()
            ));

            Map<String, String> response = Map.of("accountId", accountId, "message", "계좌가 성공적으로 생성되었습니다.");
            return ResponseEntity.ok(response);

        } catch (Exception e) {
            Map<String, String> errorResponse = Map.of("error", e.getMessage());
            return ResponseEntity.badRequest().body(errorResponse);
        }
    }

    // 입금 API
    @PostMapping("/{accountId}/deposit")
    public ResponseEntity<Map<String, String>> depositMoney(
            @PathVariable String accountId,
            @RequestBody DepositRequest request) {
        try {
            commandGateway.sendAndWait(new DepositMoneyCommand(accountId, request.getAmount()));

            Map<String, String> response = Map.of("message", "입금이 성공적으로 처리되었습니다.");
            return ResponseEntity.ok(response);

        } catch (Exception e) {
            Map<String, String> errorResponse = Map.of("error", e.getMessage());
            return ResponseEntity.badRequest().body(errorResponse);
        }
    }

    // 출금 API
    @PostMapping("/{accountId}/withdraw")
    public ResponseEntity<Map<String, String>> withdrawMoney(
            @PathVariable String accountId,
            @RequestBody WithdrawRequest request) {
        try {
            commandGateway.sendAndWait(new WithdrawMoneyCommand(accountId, request.getAmount()));

            Map<String, String> response = Map.of("message", "출금이 성공적으로 처리되었습니다.");
            return ResponseEntity.ok(response);

        } catch (InsufficientBalanceException e) {
            Map<String, String> errorResponse = Map.of("error", e.getMessage());
            return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
        } catch (Exception e) {
            Map<String, String> errorResponse = Map.of("error", e.getMessage());
            return ResponseEntity.badRequest().body(errorResponse);
        }
    }

    // 계좌 조회 API
    @GetMapping("/{accountId}")
    public ResponseEntity<AccountView> getAccount(@PathVariable String accountId) {
        try {
            AccountView account = queryGateway.query(
                new FindAccountQuery(accountId),
                AccountView.class
            ).join();

            return ResponseEntity.ok(account);

        } catch (Exception e) {
            return ResponseEntity.notFound().build();
        }
    }

    // 모든 계좌 조회 API
    @GetMapping
    public ResponseEntity<List<AccountView>> getAllAccounts() {
        List<AccountView> accounts = queryGateway.query(
            new FindAllAccountsQuery(),
            ResponseTypes.multipleInstancesOf(AccountView.class)
        ).join();

        return ResponseEntity.ok(accounts);
    }

    // 거래 내역 조회 API
    @GetMapping("/{accountId}/transactions")
    public ResponseEntity<List<TransactionHistory>> getTransactionHistory(@PathVariable String accountId) {
        List<TransactionHistory> history = queryGateway.query(
            new FindTransactionHistoryQuery(accountId),
            ResponseTypes.multipleInstancesOf(TransactionHistory.class)
        ).join();

        return ResponseEntity.ok(history);
    }
}

// Request DTO 클래스들
@Data
class CreateAccountRequest {
    @NotBlank(message = "계좌 소유자 이름은 필수입니다.")
    private String accountHolderName;

    @NotNull(message = "초기 잔액은 필수입니다.")
    @DecimalMin(value = "0.0", message = "초기 잔액은 0 이상이어야 합니다.")
    private BigDecimal initialBalance;
}

@Data
class DepositRequest {
    @NotNull(message = "입금액은 필수입니다.")
    @DecimalMin(value = "0.01", message = "입금액은 0보다 커야 합니다.")
    private BigDecimal amount;
}

@Data
class WithdrawRequest {
    @NotNull(message = "출금액은 필수입니다.")
    @DecimalMin(value = "0.01", message = "출금액은 0보다 커야 합니다.")
    private BigDecimal amount;
}

🔍 H2 데이터베이스로 동작 확인하기

1. 애플리케이션 실행 및 테스트

애플리케이션을 실행한 후 다음 단계로 테스트할 수 있습니다:

# 애플리케이션 실행
./mvnw spring-boot:run

# 또는 IDE에서 AxonBankApplication.main() 실행

2. REST API 테스트

계좌 생성 요청:

curl -X POST http://localhost:8080/api/accounts \
  -H "Content-Type: application/json" \
  -d '{
    "accountHolderName": "홍길동",
    "initialBalance": 100000
  }'

 

입금 요청:

curl -X POST http://localhost:8080/api/accounts/{accountId}/deposit \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50000
  }'

 

출금 요청:

curl -X POST http://localhost:8080/api/accounts/{accountId}/withdraw \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 30000
  }'

 

계좌 조회:

curl http://localhost:8080/api/accounts/{accountId}

 

거래 내역 조회:

curl http://localhost:8080/api/accounts/{accountId}/transactions

3. H2 Console에서 데이터 확인

  1. 브라우저에서 http://localhost:8080/h2-console 접속
  2. JDBC URL: jdbc:h2:mem:testdb 입력
  3. 다음 쿼리들로 데이터 확인:
-- 이벤트 저장소 확인 (Axon Framework가 자동 생성)
SELECT * FROM DOMAIN_EVENT_ENTRY ORDER BY TIME_STAMP;

-- 계좌 뷰 테이블 확인
SELECT * FROM ACCOUNT_VIEW;

-- 거래 내역 테이블 확인
SELECT * FROM TRANSACTION_HISTORY ORDER BY TIMESTAMP DESC;

-- 스냅샷 테이블 (있다면)
SELECT * FROM SNAPSHOT_EVENT_ENTRY;

H2 Console에서 Event Sourcing 이벤트 확인
H2 데이터베이스 콘솔에서 저장된 이벤트들을 확인하는 실제 화면

4. 이벤트 재생(Event Replay) 확인

Event Sourcing의 핵심 기능인 이벤트 재생을 확인해보겠습니다:

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class EventReplayService {

    private final EventStore eventStore;

    public List<Object> getEventsForAccount(String accountId) {
        return eventStore.readEvents(accountId)
            .asStream()
            .map(DomainEventMessage::getPayload)
            .collect(Collectors.toList());
    }

    public BigDecimal calculateBalanceFromEvents(String accountId) {
        List<Object> events = getEventsForAccount(accountId);
        BigDecimal balance = BigDecimal.ZERO;

        for (Object event : events) {
            if (event instanceof AccountCreatedEvent) {
                balance = ((AccountCreatedEvent) event).getInitialBalance();
            } else if (event instanceof MoneyDepositedEvent) {
                balance = balance.add(((MoneyDepositedEvent) event).getAmount());
            } else if (event instanceof MoneyWithdrawnEvent) {
                balance = balance.subtract(((MoneyWithdrawnEvent) event).getAmount());
            }
        }

        return balance;
    }
}

🧪 테스트 코드 작성

Axon Framework는 테스트하기 쉬운 구조를 제공합니다. Test Fixtures를 활용한 단위 테스트를 작성해보겠습니다:

 

Aggregate 테스트:

import org.axonframework.test.aggregate.AggregateTestFixture;
import org.axonframework.test.aggregate.FixtureConfiguration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class AccountAggregateTest {

    private FixtureConfiguration<AccountAggregate> fixture;

    @BeforeEach
    void setUp() {
        fixture = new AggregateTestFixture<>(AccountAggregate.class);
    }

    @Test
    void testCreateAccount() {
        String accountId = "test-account";
        String accountHolderName = "홍길동";
        BigDecimal initialBalance = new BigDecimal("100000");

        fixture.givenNoPriorActivity()
               .when(new CreateAccountCommand(accountId, accountHolderName, initialBalance))
               .expectEvents(new AccountCreatedEvent(accountId, accountHolderName, initialBalance));
    }

    @Test
    void testDepositMoney() {
        String accountId = "test-account";
        BigDecimal depositAmount = new BigDecimal("50000");

        fixture.given(new AccountCreatedEvent(accountId, "홍길동", new BigDecimal("100000")))
               .when(new DepositMoneyCommand(accountId, depositAmount))
               .expectEvents(new MoneyDepositedEvent(accountId, depositAmount));
    }

    @Test
    void testWithdrawMoney() {
        String accountId = "test-account";
        BigDecimal withdrawAmount = new BigDecimal("30000");

        fixture.given(new AccountCreatedEvent(accountId, "홍길동", new BigDecimal("100000")))
               .when(new WithdrawMoneyCommand(accountId, withdrawAmount))
               .expectEvents(new MoneyWithdrawnEvent(accountId, withdrawAmount));
    }

    @Test
    void testWithdrawMoreThanBalance() {
        String accountId = "test-account";
        BigDecimal withdrawAmount = new BigDecimal("150000");

        fixture.given(new AccountCreatedEvent(accountId, "홍길동", new BigDecimal("100000")))
               .when(new WithdrawMoneyCommand(accountId, withdrawAmount))
               .expectException(InsufficientBalanceException.class);
    }

    @Test
    void testInvalidDepositAmount() {
        String accountId = "test-account";
        BigDecimal invalidAmount = new BigDecimal("-1000");

        fixture.given(new AccountCreatedEvent(accountId, "홍길동", new BigDecimal("100000")))
               .when(new DepositMoneyCommand(accountId, invalidAmount))
               .expectException(IllegalArgumentException.class);
    }
}

 

통합 테스트:

@SpringBootTest
@AutoConfigureTestDatabase
@TestMethodOrder(OrderAnnotation.class)
class AccountIntegrationTest {

    @Autowired
    private CommandGateway commandGateway;

    @Autowired
    private QueryGateway queryGateway;

    private String testAccountId;

    @Test
    @Order(1)
    void testCreateAccount() throws Exception {
        testAccountId = UUID.randomUUID().toString();

        commandGateway.sendAndWait(new CreateAccountCommand(
            testAccountId,
            "테스트 사용자",
            new BigDecimal("100000")
        ));

        // 잠깐 기다려서 프로젝션이 업데이트되도록 함
        Thread.sleep(100);

        AccountView account = queryGateway.query(
            new FindAccountQuery(testAccountId),
            AccountView.class
        ).get();

        assertThat(account.getAccountId()).isEqualTo(testAccountId);
        assertThat(account.getAccountHolderName()).isEqualTo("테스트 사용자");
        assertThat(account.getBalance()).isEqualTo(new BigDecimal("100000"));
    }

    @Test
    @Order(2)
    void testDepositMoney() throws Exception {
        commandGateway.sendAndWait(new DepositMoneyCommand(
            testAccountId,
            new BigDecimal("50000")
        ));

        Thread.sleep(100);

        AccountView account = queryGateway.query(
            new FindAccountQuery(testAccountId),
            AccountView.class
        ).get();

        assertThat(account.getBalance()).isEqualTo(new BigDecimal("150000"));
    }

    @Test
    @Order(3)
    void testWithdrawMoney() throws Exception {
        commandGateway.sendAndWait(new WithdrawMoneyCommand(
            testAccountId,
            new BigDecimal("30000")
        ));

        Thread.sleep(100);

        AccountView account = queryGateway.query(
            new FindAccountQuery(testAccountId),
            AccountView.class
        ).get();

        assertThat(account.getBalance()).isEqualTo(new BigDecimal("120000"));
    }
}

🔧 운영 환경 최적화

프로덕션 설정

application-prod.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/axon_bank
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver

  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: validate
    show-sql: false

axon:
  eventhandling:
    processors:
      account-projection:
        mode: tracking
        batch-size: 100
        initial-segment-count: 4
      transaction-history:
        mode: tracking
        batch-size: 50

  serializer:
    general: jackson
    messages: jackson
    events: jackson

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always

logging:
  level:
    org.axonframework: INFO
    com.example.axonbank: INFO

성능 최적화

스냅샷 설정:

@Configuration
public class AxonConfig {

    @Bean
    public SnapshotTriggerDefinition accountSnapshotTrigger() {
        return new EventCountSnapshotTriggerDefinition(
            Snapshotter.builder().build(),
            100 // 100개 이벤트마다 스냅샷 생성
        );
    }
}

 

프로젝션 배치 처리:

@EventHandler
@ProcessingGroup("account-projection")
public void on(List<Object> events) {
    // 배치 처리로 성능 향상
    Map<String, AccountView> accountsToUpdate = new HashMap<>();

    for (Object event : events) {
        if (event instanceof MoneyDepositedEvent) {
            MoneyDepositedEvent depositEvent = (MoneyDepositedEvent) event;
            AccountView account = accountsToUpdate.computeIfAbsent(
                depositEvent.getAccountId(),
                id -> repository.findById(id).orElse(null)
            );

            if (account != null) {
                account.setBalance(account.getBalance().add(depositEvent.getAmount()));
            }
        }
        // 다른 이벤트 처리...
    }

    repository.saveAll(accountsToUpdate.values());
}

⚖️ Axon Framework 장단점 및 실무 가이드

장점

1. 완벽한 감사 추적 (Audit Trail)

  • 모든 변경사항이 이벤트로 기록되어 추적 가능
  • 규제 준수가 중요한 금융, 의료 분야에서 특히 유용

2. 시간 여행 (Time Travel)

  • 과거 특정 시점의 상태로 되돌아갈 수 있음
  • 디버깅과 데이터 분석에 강력한 도구

3. 높은 확장성

  • Command와 Query의 분리로 각각 독립적으로 확장 가능
  • Read 모델을 여러 개 만들어 다양한 조회 요구사항 대응

4. 이벤트 기반 통합

  • 마이크로서비스 간 느슨한 결합
  • 이벤트를 통한 시스템 간 통신

단점 및 주의사항

1. 복잡성 증가

  • 전통적인 CRUD 방식보다 학습 곡선이 가파름
  • 이벤트 버전 관리, 스키마 진화 등 고려사항 많음

2. 이벤트 저장소 관리

  • 이벤트가 계속 쌓이므로 저장소 용량 관리 필요
  • 스냅샷 기능을 활용한 성능 최적화 필요

3. 최종 일관성 (Eventual Consistency)

  • Command와 Query 모델 간 동기화 지연 가능
  • 실시간 일관성이 중요한 시스템에서는 주의 필요

실무 적용 가이드라인

언제 사용해야 할까?

 

적합한 경우:

  • 복잡한 비즈니스 로직이 있는 도메인
  • 감사 추적이 중요한 시스템
  • 이벤트 기반 아키텍처 구축 시
  • 높은 읽기 성능이 필요한 시스템

부적합한 경우:

  • 단순한 CRUD 애플리케이션
  • 프로토타입이나 작은 규모의 프로젝트
  • 팀의 Event Sourcing 경험이 부족한 경우

성능 최적화 팁:

// 1. 스냅샷 활용
@Aggregate(snapshotTriggerDefinition = "accountSnapshotTrigger")
public class AccountAggregate {
    // 100개 이벤트마다 스냅샷 생성
}

// 2. 이벤트 업캐스팅으로 스키마 진화 관리
@Component
public class AccountEventUpcaster implements SingleEventUpcaster {
    // 이전 버전 이벤트를 새 버전으로 변환
}

// 3. 프로젝션 최적화
@EventHandler
@DisallowReplay // 재생 시 실행하지 않음
public void on(MoneyDepositedEvent event) {
    // 외부 시스템 호출 등 부작용이 있는 처리
}

🔮 다음 단계: 심화편 예고

이번 기본편에서는 Event Sourcing과 CQRS의 핵심 개념을 학습하고 완전한 계좌 관리 시스템을 구현해보았습니다.

 

심화편에서 다룰 내용:

  • 사가(Saga) 패턴을 활용한 분산 트랜잭션 처리
  • 이벤트 버전 관리와 업캐스팅 전략
  • Axon Server를 활용한 분산 시스템 구축
  • 이벤트 암호화와 GDPR 준수 방안
  • Spring Cloud와의 통합
  • 대용량 데이터 처리를 위한 고급 최적화

마무리

Event Sourcing과 CQRS는 복잡한 비즈니스 요구사항을 해결하는 강력한 패턴입니다.

Axon Framework를 활용하면 이러한 패턴을 Spring Boot 환경에서 효과적으로 구현할 수 있습니다.

 

이 글에서 구현한 계좌 관리 시스템은 다음과 같은 기능을 포함합니다:

  • 계좌 생성, 입금, 출금 - 핵심 비즈니스 로직
  • 거래 내역 추적 - 완전한 감사 추적
  • 이벤트 재생 - Event Sourcing의 핵심 기능
  • 테스트 코드 - 품질 보장
  • 운영 환경 설정 - 프로덕션 준비

처음에는 복잡해 보일 수 있지만, 단계별로 접근하고 충분한 실습을 통해 익숙해지면 전통적인 방식으로는 해결하기 어려운 문제들을 우아하게 해결할 수 있습니다.

무엇보다 중요한 것은 언제 사용해야 하는지를 판단하는 것입니다. 모든 프로젝트에 Event Sourcing이 필요한 것은 아니므로,

비즈니스 요구사항과 팀의 역량을 고려해서 신중하게 선택하시기 바랍니다.


💡 개발자 팁: Event Sourcing을 처음 적용할 때는 작은 바운디드 컨텍스트부터 시작하여 점진적으로 확장해나가는 것을 추천합니다. 한 번에 전체 시스템을 바꾸려고 하지 마세요!

관련 자료:

728x90
반응형