시리즈 안내: 이 글은 Event Sourcing & CQRS 시리즈의 기본편입니다.
심화편에서는 분산 시스템에서의 이벤트 처리, 스냅샷, 프로젝션 최적화 등을 다룰 예정입니다.
Spring Boot로 웹 애플리케이션을 개발해본 경험이 있다면, 대부분 전통적인 CRUD 패턴에 익숙할 것입니다.
하지만 비즈니스가 복잡해지고 데이터의 변경 이력을 추적해야 하는 요구사항이 생기면서,
Event Sourcing과 CQRS 패턴이 주목받고 있습니다.
이 글에서는 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): 잔액 조회, 거래내역 조회 등 데이터를 읽는 작업
이 두 작업의 특성과 요구사항이 다르기 때문에 분리해서 처리하는 것이 효율적입니다.
🚀 왜 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());
}
}
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에서 데이터 확인
- 브라우저에서
http://localhost:8080/h2-console
접속 - JDBC URL:
jdbc:h2:mem:testdb
입력 - 다음 쿼리들로 데이터 확인:
-- 이벤트 저장소 확인 (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;
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을 처음 적용할 때는 작은 바운디드 컨텍스트부터 시작하여 점진적으로 확장해나가는 것을 추천합니다. 한 번에 전체 시스템을 바꾸려고 하지 마세요!
관련 자료:
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
Spring WebFlux 완벽 가이드: 리액티브 프로그래밍으로 대용량 트래픽 처리하기 (1) | 2025.06.10 |
---|---|
Event Sourcing과 CQRS 패턴 심화 구현 - Spring Boot로 고급 이벤트 드리븐 아키텍처 구축 (0) | 2025.06.07 |
Spring Boot 3.0 Native Image 완벽 가이드 - GraalVM으로 초고속 애플리케이션 만들기 (0) | 2025.06.04 |
Mutation Testing으로 테스트 커버리지 향상시키기: Spring Boot 프로젝트의 테스트 품질 혁신 (0) | 2025.05.25 |
Contract Testing으로 마이크로서비스 통합 테스트 효율화하기: Spring Boot 환경에서의 실무 가이드 (0) | 2025.05.25 |