1. 서론

지난시간에 이어 Aggregate에 명령을 요청하는 API 구현 및 테스트를 진행하고자 합니다. 이번 포스팅에서는 API 레벨 테스트를 진행할 것이며, Axon에서 제공하는 테스트 관련 클래스 소개는 차후에 진행하겠습니다.

 


2. DTO 구현

EventSourcing & CQRS 예제 프로젝트 개요에서 API 엔드포인트를 도출했습니다. 이를 바탕으로 API 호출시 매핑되는 DTO 클래스 먼저 구현하겠습니다.

 

 

1. dto 패키지를 만듭니다. 그리고 dto 클래스 5개를 만듭니다.

 


2. dto 클래스를 구현합니다.

 

HolderDTO.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class HolderDTO {
    private String holderName;
    private String tel;
    private String address;
}

AccountDTO.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class AccountDTO {
    private String holderID;
}

TransactionDTO.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TransactionDTO {
    private String accountID;
    private String holderID;
    private Long amount;
}

DepositDTO.java

public class DepositDTO extends TransactionDTO {}

WithdrawalDTO.java

public class WithdrawalDTO extends TransactionDTO {}

 

입금과 출금형식이 동일하므로 TransactionDTO에 공통 속성 구현한다음 상속하였습니다.


3. Service 구현

CommandGateway와의 연결을 위한 Service 클래스를 구현합니다.

 

1. service 패키지 생성 후 service 인터페이스 및 구현 클래스를 생성합니다.

 


2. 인터페이스 정의 및 클래스를 구현합니다.

 

TransactionService.java

public interface TransactionService {
    CompletableFuture<String> createHolder(HolderDTO holderDTO);
    CompletableFuture<String> createAccount(AccountDTO accountDTO);
    CompletableFuture<String> depositMoney(DepositDTO transactionDTO);
    CompletableFuture<String> withdrawMoney(WithdrawalDTO transactionDTO);
}

TransactionServiceImpl.java

@Service
@RequiredArgsConstructor
public class TransactionServiceImpl implements TransactionService {
    private final CommandGateway commandGateway;

    @Override
    public CompletableFuture<String> createHolder(HolderDTO holderDTO) {
        return commandGateway.send(new HolderCreationCommand(UUID.randomUUID().toString()
                , holderDTO.getHolderName()
                , holderDTO.getTel()
                , holderDTO.getAddress()));
    }

    @Override
    public CompletableFuture<String> createAccount(AccountDTO accountDTO) {
        return commandGateway.send(new AccountCreationCommand(accountDTO.getHolderID(),UUID.randomUUID().toString()));
    }

    @Override
    public CompletableFuture<String> depositMoney(DepositDTO transactionDTO) {
        return commandGateway.send(new DepositMoneyCommand(transactionDTO.getAccountID(), transactionDTO.getHolderID(), transactionDTO.getAmount()));
    }

    @Override
    public CompletableFuture<String> withdrawMoney(WithdrawalDTO transactionDTO) {
        return commandGateway.send(new WithdrawMoneyCommand(transactionDTO.getAccountID(), transactionDTO.getHolderID(), transactionDTO.getAmount()));
    }
}

 

 

Service 구현 코드를 보면 직관적으로 이해할 수 있듯이 CommandGateway를 통해 Command를 생성 합니다. 이는 지난 포스팅에서 다룬 Command 수행 내부 흐름 첫번째 단계에 해당합니다.

 

참고로 CommandGateway에서 제공되는 API는 크게 두 가지로 첫째는 위 소스코드에서 사용한 send 메소드이고 나머지는 sendAndWait 메소드입니다. send 메소드는 비동기 방식이며, sendAndWait은 동기 방식의 메소드입니다. 동기 방식의 메소드는 default가 응답이 올때까지 대기하며 이는 호출 후 hang 상태가 지속되면 스레드 고갈이 일어날 수 있습니다. 따라서 메소드 파라미터에 timeout을 지정하여 실패 처리할 수 있습니다. 자세한 내용은 Axon 공식 문서를 참고 바랍니다.


4. Controller 구현

Controller를 통해 API 매핑작업을 수행합니다.

 

1. controller 패키지 만든 후 controller 클래스를 생성합니다.

 


2. Controller 클래스를 구현합니다.

@RestController
@RequiredArgsConstructor
public class TransactionController {
    private final TransactionService transactionService;

    @PostMapping("/holder")
    public CompletableFuture<String> createHolder(@RequestBody HolderDTO holderDTO){
        return transactionService.createHolder(holderDTO);
    }

    @PostMapping("/account")
    public CompletableFuture<String> createAccount(@RequestBody AccountDTO accountDTO){
        return transactionService.createAccount(accountDTO);
    }

    @PostMapping("/deposit")
    public CompletableFuture<String> deposit(@RequestBody DepositDTO transactionDTO){
        return transactionService.depositMoney(transactionDTO);
    }

    @PostMapping("/withdrawal")
    public CompletableFuture<String> withdraw(@RequestBody WithdrawalDTO transactionDTO){
        return transactionService.withdrawMoney(transactionDTO);
    }
}

 

Controller 클래스는 단순히 전달받은 DTO를 Service에 전달하는 역할만 수행하므로 자세한 설명은 생략합니다. 이로써 Command Application 기본 코드 작성은 끝났습니다.


5. Log 설정

 

Command, EventSourcing Handler 메소드가 수행되는 상황을 분석하기 위해서 Logging 설정을 진행합니다.

 

1. resource 폴더 및 application.yml 파일을 연다음 logging 설정을 추가합니다.

 


2. Aggregate 코드에 @Slf4j 어노테이션 추가 및 log 정보를 기록합니다.

 

HolderAggregate.java

@RequiredArgsConstructor
@Aggregate
@Slf4j
public class HolderAggregate {
    @AggregateIdentifier
    private String holderID;
    private String holderName;
    private String tel;
    private String address;

    @CommandHandler
    public HolderAggregate(HolderCreationCommand command) {
        log.debug("handling {}", command);
        apply(new HolderCreationEvent(command.getHolderID(), command.getHolderName(), command.getTel(), command.getAddress()));
    }

    @EventSourcingHandler
    protected void createHolder(HolderCreationEvent event){
        log.debug("applying {}", event);
        this.holderID = event.getHolderID();
        this.holderName = event.getHolderName();
        this.tel = event.getTel();
        this.address = event.getAddress();
    }
}

 

AccountAggregate.java

@RequiredArgsConstructor
@Aggregate
@Slf4j
public class AccountAggregate {
    @AggregateIdentifier
    private String accountID;
    private String holderID;
    private Long balance;

    @CommandHandler
    public AccountAggregate(AccountCreationCommand command) {
        log.debug("handling {}", command);
        apply(new AccountCreationEvent(command.getHolderID(),command.getAccountID()));
    }
    @EventSourcingHandler
    protected void createAccount(AccountCreationEvent event){
        log.debug("applying {}", event);
        this.accountID = event.getAccountID();
        this.holderID = event.getHolderID();
        this.balance = 0L;
    }
    @CommandHandler
    protected void depositMoney(DepositMoneyCommand command){
        log.debug("handling {}", command);
        if(command.getAmount() <= 0) throw new IllegalStateException("amount >= 0");
        apply(new DepositMoneyEvent(command.getHolderID(), command.getAccountID(), command.getAmount()));
    }
    @EventSourcingHandler
    protected void depositMoney(DepositMoneyEvent event){
        log.debug("applying {}", event);
        this.balance += event.getAmount();
        log.debug("balance {}", this.balance);
    }
    @CommandHandler
    protected void withdrawMoney(WithdrawMoneyCommand command){
        log.debug("handling {}", command);
        if(this.balance - command.getAmount() < 0) throw new IllegalStateException("잔고가 부족합니다.");
        else if(command.getAmount() <= 0 ) throw new IllegalStateException("amount >= 0");
        apply(new WithdrawMoneyEvent(command.getHolderID(), command.getAccountID(), command.getAmount()));
    }
    @EventSourcingHandler
    protected void withdrawMoney(WithdrawMoneyEvent event){
        log.debug("applying {}", event);
        this.balance -= event.getAmount();
        log.debug("balance {}", this.balance);
    }
}

6. API 테스트 코드 작성

Command Application API 테스트를 수행하기 위한 코드를 작성합니다. API 테스트를 위해 Postman을 비롯하여 여러 툴이 있지만, IntelliJhttp 기능을 사용해서 테스트를 진행하도록 하겠습니다.

 

1. Command 모듈 적절한 위치에 http 확장자로 끝나는 파일을 생성합니다.

 


2. http 코드를 작성합니다.

 

POST http://localhost:8080/holder
Content-Type: application/json

{
	"holderName" : "kevin",
	"tel" : "02-1234-5678",
	"address" : "OO시 OO구"
}

###

POST http://localhost:8080/account
Content-Type: application/json

{
  "holderID" : "계정 생성 후 반환되는 UUID"
}

###

POST http://localhost:8080/deposit
Content-Type: application/json

{
  "accountID" : "계좌 생성 후 반환되는 UUID",
  "holderID" : "계정 생성 후 반환되는 UUID",
  "amount" : 300
}

###

POST http://localhost:8080/withdrawal
Content-Type: application/json

{
  "accountID" : "계좌 생성 후 반환되는 UUID",
  "holderID" : "계정 생성 후 반환되는 UUID",
  "amount" : 10
}

###

7. API 테스트 

 

1. AxonServer가 기동된 상태에서 Command App을 수행합니다. 혹시 Axon Server 기동 방법이 궁금하신 분은 AxonServer 설치 및 실행 포스팅을 참고 바랍니다.

 


2. http 파일에서 계정 생성 url에 커서를 위치 시킨다음 [Alt + Enter] 키를 누릅니다.

 


3. Run Localhost:8080 버튼을 눌러 API를 실행합니다.

 

 

4. 정상 실행결과 및 반환된 계정 식별자 값을 확인합니다.

 

또한 위 그림과 같이 Application 로그에도 정상적으로 CommandHanlder 및 EventSourcingHandler 메소드가 수행된 것을 확인할 수 있습니다.


8. 성능 개선

 

데모 프로젝트에서는 Command 명령 생성과 이를 처리하는 Command Handler를 하나의 App에 모두 구현하였음에도 불구하고 위 Application에서는 Command 발행 시 Axon Server와의 통신을 수행합니다. 이는 AxonServer와 연결시 기본적으로 CommandBus로써 AxonServerCommandBus를 사용하기 때문입니다. 

 

이를 개선하기 위해서는 Command 처리시 AxonServer 연결없이 명령을 처리하도록 변경이 필요합니다. AxonFramework에서는 SimpleCommandBus 클래스를 제공하며, 설정을 통해 CommandBus 인터페이스 교체가 가능합니다.

 

설정 변경을 통해 Command Bus를 변경하도록 하겠습니다.

 

1. Command 모듈에 config 패키지 생성 후 AxonConfig 클래스를 생성합니다.

 


2. AxonConfig 클래스를 구현합니다.

 

AxonConfig.java

@Configuration
@AutoConfigureAfter(AxonAutoConfiguration.class)
public class AxonConfig {
    @Bean
    SimpleCommandBus commandBus(TransactionManager transactionManager){
        return  SimpleCommandBus.builder().transactionManager(transactionManager).build();
    }
 }

 

AxonFramework는 기본적으로 AxonAutoConfiguration 클래스를 통해 Default 속성을 정의합니다. Custom 속성을 추가하기 위해 Axon 기본 설정이 완료된 후 수행될 수 있도록 @AutoConfigureAfter 어노테이션을 추가했습니다.


3. Application 수행 후 테스트하면 CommandBus가 SimpleCommandBus로 교체된 것을 확인할 수 있습니다.

 o.a.commandhandling.SimpleCommandBus     : Handling command [com.cqrs.command.commands.HolderCreationCommand]

9. 마치며

이로써 Command Application의 전반적인 코드 구현을 완성하였습니다. 하지만 위 프로그램은 구조적인 문제점을 갖고 있습니다. 다음 포스팅에서는 해당 프로그램이 가진 문제점과 이를 해결하는 방법에 대해서 다루도록 하겠습니다.

 

 

 

 

+ Recent posts