1. CQRS의 기본 구조
CQRS 패턴의 핵심은 읽기 모델과 쓰기 모델을 분리하는 것입니다.
1.1 명령 모델 (Write Model)
public class AccountCommandModel {
private String id;
private BigDecimal balance;
public void deposit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
this.balance = this.balance.add(amount);
}
public void withdraw(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds");
}
this.balance = this.balance.subtract(amount);
}
// Getters and setters 여기에 추가
}
1.2 쿼리 모델 (Read Model)
public class AccountQueryModel {
private String id;
private BigDecimal balance;
private List recentTransactions;
// Getters 만 처리한다.
}
public class Transaction {
private LocalDateTime timestamp;
private BigDecimal amount;
private String type; // "DEPOSIT" 또는 "WITHDRAWAL"
// Constructor and getters 처리
}
팁: 쿼리 모델은 읽기 전용으로 설계하여 성능을 최적화하세요. 필요한 경우 비정규화된 데이터를 포함할 수 있습니다.
2. 명령 핸들러 구현
명령 핸들러는 시스템의 상태를 변경하는 작업을 처리합니다.
public class AccountCommandHandler {
private final AccountRepository repository;
private final EventPublisher eventPublisher;
public AccountCommandHandler(AccountRepository repository, EventPublisher eventPublisher) {
this.repository = repository;
this.eventPublisher = eventPublisher;
}
public void handle(DepositCommand command) {
AccountCommandModel account = repository.findById(command.getAccountId());
account.deposit(command.getAmount());
repository.save(account);
eventPublisher.publish(new DepositedEvent(command.getAccountId(), command.getAmount()));
}
public void handle(WithdrawCommand command) {
AccountCommandModel account = repository.findById(command.getAccountId());
account.withdraw(command.getAmount());
repository.save(account);
eventPublisher.publish(new WithdrawnEvent(command.getAccountId(), command.getAmount()));
}
}
팁: 명령 핸들러에서 도메인 로직을 실행하고, 결과로 발생한 이벤트를 발행하세요. 이는 이벤트 소싱과 CQRS를 결합할 때 특히 유용합니다.
3. 쿼리 핸들러 구현
쿼리 핸들러는 시스템의 현재 상태에 대한 정보를 제공합니다.
public class AccountQueryHandler {
private final AccountQueryRepository repository;
public AccountQueryHandler(AccountQueryRepository repository) {
this.repository = repository;
}
public AccountQueryModel getAccount(String accountId) {
return repository.findById(accountId);
}
public List getAccountsWithBalanceAbove(BigDecimal amount) {
return repository.findByBalanceGreaterThan(amount);
}
}
팁: 쿼리 모델은 필요에 따라 여러 개의 특화된 모델로 분리할 수 있습니다. 이를 통해 각 쿼리의 성능을 최적화할 수 있습니다.
4. 이벤트 핸들링
명령 측에서 발생한 이벤트를 처리하여 쿼리 모델을 업데이트합니다.
public class AccountEventHandler {
private final AccountQueryRepository repository;
public AccountEventHandler(AccountQueryRepository repository) {
this.repository = repository;
}
@EventHandler
public void on(DepositedEvent event) {
AccountQueryModel account = repository.findById(event.getAccountId());
account.setBalance(account.getBalance().add(event.getAmount()));
account.getRecentTransactions().add(new Transaction(LocalDateTime.now(), event.getAmount(), "DEPOSIT"));
repository.save(account);
}
@EventHandler
public void on(WithdrawnEvent event) {
AccountQueryModel account = repository.findById(event.getAccountId());
account.setBalance(account.getBalance().subtract(event.getAmount()));
account.getRecentTransactions().add(new Transaction(LocalDateTime.now(), event.getAmount(), "WITHDRAWAL"));
repository.save(account);
}
}
팁: 이벤트 핸들러는 비동기적으로 실행될 수 있으므로, 일시적인 불일치를 허용하는 설계가 필요합니다. 최종적 일관성(Eventual Consistency)을 고려하세요.
5. 인프라스트럭처 구성
CQRS 패턴을 지원하기 위한 인프라 구성 예시입니다.
@Configuration
public class CqrsConfig {
@Bean
public CommandBus commandBus() {
return new SimpleCommandBus();
}
@Bean
public QueryBus queryBus() {
return new SimpleQueryBus();
}
@Bean
public EventBus eventBus() {
return new SimpleEventBus();
}
@Bean
public AccountCommandHandler accountCommandHandler(AccountRepository repository, EventBus eventBus) {
return new AccountCommandHandler(repository, eventBus);
}
@Bean
public AccountQueryHandler accountQueryHandler(AccountQueryRepository repository) {
return new AccountQueryHandler(repository);
}
@Bean
public AccountEventHandler accountEventHandler(AccountQueryRepository repository) {
return new AccountEventHandler(repository);
}
}
팁: 명령 버스, 쿼리 버스, 이벤트 버스를 분리하여 각각의 처리 로직과 성능 요구사항을 독립적으로 관리할 수 있습니다.
6. CQRS 시스템 테스팅
CQRS 패턴을 적용한 시스템의 테스트 방법입니다.
@SpringBootTest
public class AccountCqrsTest {
@Autowired
private CommandBus commandBus;
@Autowired
private QueryBus queryBus;
@Test
public void testDepositAndQueryBalance() {
String accountId = "acc123";
BigDecimal depositAmount = new BigDecimal("100.00");
commandBus.dispatch(new DepositCommand(accountId, depositAmount));
AccountQueryModel account = queryBus.dispatch(new GetAccountQuery(accountId));
assertEquals(depositAmount, account.getBalance());
assertEquals(1, account.getRecentTransactions().size());
}
}
팁: CQRS 시스템을 테스트할 때는 명령 실행 후 쿼리 모델이 적절히 업데이트되었는지 확인하는 것이 중요합니다. 비동기 이벤트 처리로 인한 지연을 고려하여 테스트를 설계하세요.
결론
CQRS 패턴은 복잡한 도메인 모델을 가진 시스템에서 큰 이점을 제공하는 패턴으로 읽기와 쓰기 모델을 분리함으로써 각각의 요구사항에 맞게 최적화할 수 있으며, 시스템의 확장성과 성능을 향상시킬 수 있습니다. 그러나 CQRS 적용시 구조가 갑자기 복잡해지기 때문에 적용 여부를 신중히 결정해야 합니다. 결국 시간 대비 성능 면에서 어느부분을 더 우선시하느냐가 관건이지 않나 싶습니다. 이런 부분은 사실 경험에 의존하는게 크다 보니 많은 시행착오를 통해서 느껴야 될 부분이 큽니다.