1. 서론

 

 

이번 시간에는 Query 기능 중 Point to Point, Subscription 기능을 구현합니다. 또한, Query 결과를 보기 위하여 Client 화면을 간략하게 만들겠습니다.

 

Client 화면은 크게 Point to Point Query와 Subsciprtion Query를 조회하는 화면 2개를 분할하였으며, 화면 호출 URL은 다음과 같습니다.

 

Point to Point : http://localhost:9090/p2p

Subscription : http://localhost:9090/subscription

 

 

 

Subscription 에서는 조회를 누르면 Server와의 Connection이 설정되므로 이를 해제하기 위한 종료 버튼을 추가하였습니다. 조회 버튼을 누르게되면, Server API를 호출합니다. 두 API 주소는 다음과 같습니다.

 

 

Point to Point : http://localhost:9090/account/info/{id}

Subscription : http://localhost:9090/account/info/subcription/{id}

 

 

이제 본격적으로 기능 구현을 진행하겠습니다.


2. Point to Point Query

Query를 처리하는 Handler가 하나만 존재하고, 한번만 질의만 하면되는 상황이라면 Point to Point Query가 적합합니다.

해당 기능 구현을 통해 사용방법을 알아보겠습니다.

 

1. Query 모듈 build.gradle 파일을 엽니다.

 

 


2. 화면 구현을 위하여 thymeleaf 의존성을 추가합니다.

 

build.gradle

dependencies{
    (...중략...)
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

 


3. 화면 호출을 위하여 Query 모듈 Controller 패키지에 WebController 클래스를 추가합니다.

 

 


4. 클래스 내용을 구현합니다.

 

WebController.java

@Controller
public class WebController {
    @GetMapping("/p2p")
    public void pointToPointQueryView(){}
}

 

Controller에서 http://localhost:9090/p2p URL 호출 시 p2p.html 파일을 전달하도록 지정합니다.


5. 화면 구현을 위해서 Query 모듈 resources 패키지 하위에 templates 패키지 및 p2p.html 파일을 생성합니다.

 


6. html 내용을을 구현합니다.

 

p2p.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>PointToPoint Query Example</title>
</head>
<script>
    window.addEventListener("DOMContentLoaded", function (){
        (function () {
            let appendDiv = document.getElementById("layout");
            let text = document.getElementById("holderInput");
            let pElem = document.createElement("p");
            document.getElementById("wrapper").addEventListener("click", append);

            function append(e) {
                let target = e.target;
                let callbackFunction = callback[target.getAttribute("data-cb")];
                callbackFunction();
            }

            let callback = {
                "search": (function () {
                    let holderId = text.value;
                    if (holderId === undefined || holderId === null || holderId ==="") {
                        alert("소유주를 입력하시오.");
                    } else {
                        let xhr = new XMLHttpRequest();
                        xhr.open('GET','http://localhost:9090/account/info/'+holderId, true);
                        xhr.send();
                        xhr.onload = function(){
                            if(xhr.status === 200){
                                let elem = pElem.cloneNode();
                                elem.innerText = xhr.responseText;
                                appendDiv.appendChild(elem);
                            }
                        }
                    }
                })
            }
        }());
    });
</script>
<body>
<div id="wrapper">
    <input type="button" data-cb="search" value="조회"/>
</div>
<input type="text" id="holderInput" placeholder="소유주 ID를 입력하시오.">
<div id="layout"/>
</body>
</html>

 

위 코드 내용 중 가장 핵심이 되는 로직은 callback 객체입니다. 구현 내용은 비동기로 Query를 수행하는 API에 소유주 정보를 인자로 요청하면, 해당 내용을 수신받아 화면에 표시합니다.

 


7. 화면이 정상적으로 출력되는지 확인하기 위하여, Query App을 기동합니다. 이후 웹브라우저(Chrome)을 열고 화면 호출 테스트를 수행합니다.(http://localhost:9090/p2p)

 

 


8. 테스트가 완료되었으면, Query를 수행할 API 내용을 구현하겠습니다. 먼저 Query 모듈 service 패키지내 QueryService 인터페이스를 엽니다.

 


9. Query 수행을 위한 메소드를 정의합니다.

 

QueryService.java

public interface QueryService {
    void reset();
    HolderAccountSummary getAccountInfo(String holderId);
}

10. Service 구현을 위하여 Query 모듈 service 패키지내 QueryServiceImpl 클래스를 엽니다.

 


11. Interface에 정의된 메소드를 구현합니다.

 

QueryServiceImpl.java

@RequiredArgsConstructor
@Slf4j
@Service
public class QueryServiceImpl implements QueryService {
	(...중략...)
    @Override
    public HolderAccountSummary getAccountInfo(String holderId) {
        AccountQuery accountQuery = new AccountQuery(holderId);
        log.debug("handling {}", accountQuery);
        return queryGateway.query(accountQuery, ResponseTypes.instanceOf(HolderAccountSummary.class)).join();
    }

}

12. API End Point 설정을 위해 controller 패키지내 위치한 HolderAccountController 클래스를 엽니다.

 

 


13. API End Point를 추가합니다.

 

HolderAccountController.java

@RestController
@RequiredArgsConstructor
public class HolderAccountController {
    private final QueryService queryService;

	(...중략...)
    @GetMapping("/account/info/{id}")
    public ResponseEntity<HolderAccountSummary> getAccountInfo(@PathVariable(value = "id") @NonNull @NotBlank String holderId){
        return ResponseEntity.ok()
                             .body(queryService.getAccountInfo(holderId));
    }

}

 

 


14. QueryGateway로 전달된 Query를 처리하는 Handler 작성을 위해 Query 모듈 projection 패키지 하위 HolderAccountProjection 클래스를 엽니다.

 

 


15. HolderAccountProjection 클래스에서 QueryHandler 메소드를 구현합니다.

 

HolderAccountProjection.java

@Component
@EnableRetry
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
    (...중략...)

    @QueryHandler
    public HolderAccountSummary on(AccountQuery query){
        log.debug("handling {}", query);
        return repository.findByHolderId(query.getHolderId()).orElse(null);
    }
}

16. Query App을 기동합니다. 이후 EventStore에 저장된 HolderID 중 하나를 선택하여 입력창에 기입합니다. 조회 버튼을 눌러 정상적으로 조회되는지 확인합니다.

 

테스트 결과, Read Model에 저장된 데이터가 정상적으로 출력되는 것을 확인할 수 있습니다.


3. Subscription Query

 

 

Subscription Query는 Client로부터 Connection을 연결하면, 이를 해제하지 않고 유지합니다. Query를 처리하는 Hanlder App에서는 초기 결과를 최초에 반환합니다. 이때 Flux 타입으로 반환하며, QueryUpdateEmitter를 통해서 Read Model의 변경이 있을 때마다 수신 받습니다.

 

데모 프로젝트에서는 SSE(Server Sent Event) 방식으로 구현하기 위해 Client 화면에서는 EventSource 객체를 사용하겠습니다.

 


1. Query 모듈 build.gradle 파일을 엽니다.

 

 


2. Flux 사용을 위하여 reactor-core 의존성을 추가합니다.

 

build.gradle

dependencies{
    (...중략...)
    implementation group: 'io.projectreactor', name: 'reactor-core'
}

3. 화면 호출을 위하여 Query 모듈 Controller 패키지에 WebController 클래스를 추가합니다.

 


4. 클래스 내용을 구현합니다.

 

WebController.java

@Controller
public class WebController {
   (...중략...)
    @GetMapping("/subscription")
    public void subscriptionQueryView(){}
}

 

Controller에서 http://localhost:9090/subscription URL 호출 시 subscription.html 파일을 전달하도록 지정합니다.


5. 화면 구현을 위해서 Query 모듈 resources 패키지 하위에 templates 패키지 및 subscription.html 파일을 생성합니다.

 


6. html 내용을을 구현합니다.

 

subscription.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Subscription Query Example</title>
</head>
<script>
    window.addEventListener("DOMContentLoaded", function (){
        (function () {
            let appendDiv = document.getElementById("layout");
            let text = document.getElementById("holderInput");
            let eventSource = undefined;
            let pElem = document.createElement("p");
            document.getElementById("wrapper").addEventListener("click", append);

            function append(e) {
                let target = e.target;
                let callbackFunction = callback[target.getAttribute("data-cb")];
                callbackFunction();
            }

            function closeEventSource() {
                eventSource.close();
                eventSource = undefined;
            }

            let callback = {
                "search": (function () {
                    let holderId = text.value;
                    if (eventSource !== undefined) {
                        closeEventSource();
                    }

                    if (holderId === undefined || holderId === null || holderId === "") {
                        alert("소유주를 입력하시오.");
                    } else {
                        eventSource = new EventSource('/account/info/subscription/' + holderId);
                        eventSource.onopen = function () {
                            console.log("connected");
                        };
                        eventSource.onmessage = function (event) {
                            let elem = pElem.cloneNode();
                            elem.innerText = event.data;
                            appendDiv.appendChild(elem);
                        };
                        eventSource.onerror = function () {
                            console.error("Connection error has occurred");
                            closeEventSource();
                        }
                    }
                }),
                "disconnect": (function () {
                    if (eventSource !== undefined) {
                        console.log("disconnected");
                        closeEventSource();
                    }
                })
            }
        }());
    });
</script>
<body>
<div id="wrapper">
    <input type="button" data-cb="search" value="조회"/>
    <input type="button" data-cb="disconnect" value="종료"/>
</div>
<input type="text" id="holderInput" placeholder="소유주 ID를 입력하시오.">
<div id="layout"/>
</body>
</html>

 

조회 버튼을 누르면, EventSource 객체를 생성하여 Server Sent Event를 수신받으며, 메시지가 전달되면 수신된 데이터를 화면에 출력하도록 구현하였습니다.


7. 화면이 정상적으로 출력되는지 확인하기 위하여, Query App을 기동합니다. 이후 웹브라우저(Chrome)을 열고 화면 호출 테스트를 수행합니다.(http://localhost:9090/usbscription)

 

 


8. 테스트가 완료되었으면, Query를 수행할 API 내용을 구현하겠습니다. 먼저 Query 모듈 service 패키지내 QueryService 인터페이스를 엽니다.

 


9. Query 수행을 위한 메소드를 정의합니다.

 

QueryService.java

public interface QueryService {
    (...중략...)
    Flux<HolderAccountSummary> getAccountInfoSubscription(String holderId);
}

10. Service 구현을 위하여 Query 모듈 service 패키지내 QueryServiceImpl 클래스를 엽니다.

 


11. Interface에 정의된 메소드를 구현합니다.

 

QueryServiceImpl.java

@RequiredArgsConstructor
@Slf4j
@Service
public class QueryServiceImpl implements QueryService {
    (...중략...)
    @Override
    public Flux<HolderAccountSummary> getAccountInfoSubscription(String holderId) {
        AccountQuery accountQuery = new AccountQuery(holderId);
        log.debug("handling {}", accountQuery);

        SubscriptionQueryResult<HolderAccountSummary, HolderAccountSummary> queryResult = queryGateway.subscriptionQuery(accountQuery,
                ResponseTypes.instanceOf(HolderAccountSummary.class),
                ResponseTypes.instanceOf(HolderAccountSummary.class)
        );

        return Flux.create(emitter -> {
            queryResult.initialResult().subscribe(emitter::next);
            queryResult.updates()
                    .doOnNext(holder -> {
                        log.debug("doOnNext : {}, isCanceled {}", holder, emitter.isCancelled());
                        if (emitter.isCancelled()) {
                            queryResult.close();
                        }
                    })
                    .doOnComplete(emitter::complete)
                    .subscribe(emitter::next);
        });
    }
}

 

위 코드 구현 내용은 최초에 initalResult 생성 후에, 지속적으로 updates 메소드를 통해 Stream 데이터를 전달받아 Client에게 전달합니다. 만약 중간에 Connection이 실패하게되면, 해당 Flux를 종료하도록 구현하였습니다.


12. API End Point 설정을 위해 controller 패키지내 위치한 HolderAccountController 클래스를 엽니다.

 

 


13. EndPoint를 추가합니다.

 

HolderAccountController.java

@RestController
@RequiredArgsConstructor
public class HolderAccountController {
    (...중략...)

    @GetMapping("account/info/subscription/{id}")
    public ResponseEntity<Flux<HolderAccountSummary>> getAccountInfoSubscription(@PathVariable(value = "id") @NonNull @NotBlank String holderId){
        return ResponseEntity.ok()
                             .body(queryService.getAccountInfoSubscription(holderId));
    }
}

14. Subscription에서는 Read Model에 변경이 발생되었을 때 이를 전파해야합니다. 따라서 이를 작성하기 위해  Query 모듈 projection 패키지 하위 HolderAccountProjection 클래스를 엽니다.

 

 


15. EventSourcingHandler를 통해 ReadModel의 변화가 발생하였을 때, QueryUpdateEmitter 클래스를 통해 이벤트 변경 내용을 전파하도록 클래스 내용을 수정합니다.

 

HolderAccountProjection.java

@Component
@EnableRetry
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
    private final AccountRepository repository;
    private final QueryUpdateEmitter queryUpdateEmitter;

    (...중략...)

    @EventHandler
    @AllowReplay
    protected void on(DepositMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() + event.getAmount());

        queryUpdateEmitter.emit(AccountQuery.class,
                query -> query.getHolderId().equals(event.getHolderID()),
                holderAccount);

        repository.save(holderAccount);
    }
    @EventHandler
    @AllowReplay
    protected void on(WithdrawMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() - event.getAmount());

        queryUpdateEmitter.emit(AccountQuery.class,
                query -> query.getHolderId().equals(event.getHolderID()),
                holderAccount);

        repository.save(holderAccount);
    }

	(...중략...)
}

 

각 Handler안에 queryUpdateEmitter를 통해 구독중인 Query와 동일한 ID의 Event가 들어오면, Query 결과에 전달되도록 처리하였습니다.


16. Query App을 기동합니다. 이후 EventStore에 저장된 HolderID 중 하나를 선택하여 입력창에 기입합니다. 조회 버튼을 눌러 정상적으로 조회되는지 확인합니다.

 

위 예제에서 holderId가 924eb0ab-c35f-4d3e-b753-a4ce35bd7c27인 계좌 전체의 잔고는 현재 290입니다. Update가 정상 수신되는지 확인하기 위하여, 해당 소유주가 보유한 계좌에서 5원을 인출하는 API를 호출합니다.

 

 


17. 5원 인출 후 Client 화면에서 변경된 데이터가 정상 수신되었는지 확인합니다.

 

확인 결과, 정상적으로 데이터 수신되었음을 확인할 수 있습니다.


18. 종료 버튼을 눌러 구독을 중지합니다. 이후 924eb0ab-c35f-4d3e-b753-a4ce35bd7c27 소유주가 보유한 계좌에서 추가로 5원 인출하였을 때, Query 결과가 화면에 표시되지 않음을 확인합니다. 화면의 변화가 없으면, 정상적으로 Connection이 종료된 것입니다.


4. 마치며

이번 시간에는 Point to Point, Subscription Query에 대해서 살펴보았습니다. Subscription Query는 반환 형태가 Flux 형태다보니 아무래도 Spring MVC에서는 사용하기 힘든 부분이 있을듯 합니다. 또한, 이를 지원하기 위해서는 Client에서도 SSE를 위한 구현이 필요하며, Server에서는 Client와 Connection 유지를 위해 Subscription Query가 증가할 수록 그에 상응하는 Thread 수가 증가합니다. 따라서 비즈니스 요건에 맞게 적절한 사용이 필요하며, Spring Webflux를 사용한다면 도입을 검토해볼 수 있을 것 같습니다.

 

1. 서론

 

이번 포스팅부터 생성된 Read Model에 대하여 Query하는 방법에 대하여 소개하겠습니다. AxonServer를 통해서 수많은 MicroService App들이 연결되어 있을 수 있습니다. 이러한 환경에서 Query 요청을 했을때, 단순 1:1 요청 응답을 요구할 수 도 있고 때로는 Query 요청에 따라 2개 이상의 다른 App에서 데이터를 수신받는 경우도 있습니다. 따라서 각기 다른 경우에 따라 처리해야하는 방법이 다릅니다.

 

Axon Framework는 총 3가지 타입의 Query 기능을 제공합니다. 이번 포스팅에서는 Axon 에서 제공하는 Query 종류와 동작 원리를 알아보겠습니다.


2. Axon Server 라우팅 기능(Query)

 

Command 명령을 요청할 때는 CommandBus를 이용하였고, Event 발생시에는 EventBus를 이용하였습니다. 마찬가지로 Query는 QueryGateway를 통해 요청을 전달하며, 전달된 Query는 QueryBus를 통해서 해당 Query를 처리하는 Handler로 연결됩니다. 이때 QueryHandler가 속한 App에 Query를 전달하는 역할을 Axon Server가 수행합니다. Query 관련 Axon Server의 라우팅 기능 동작 흐름을 살펴보겠습니다.

 

 

1. Point to Point Query

 

 

Command Handler와 마찬가지로 Application 기동시 AxonServer와 연결을 시도합니다. 연결이 완료되면, 해당 App은 자신이 처리가능한 Query Handler 정보를 Server에 등록합니다. Point to Point Query는 해당 Query를 처리하는 Handler가 단 하나의 Application에만 존재할 경우 해당 처리할 수 있는 App으로 Query를 전달하여 결과를 전달합니다.


2. Scatter & Gatter Query

 

 

 

두번째는 Scatter-Gather Query입니다. 이는 동일한 Query를 처리하는 Handler가 여러 App에 등록되어있을 때, 이를 처리하는 방법입니다. Application 기동시 Query Handler 정보를 Axon Server에 등록하면, Application 정보가 라우팅 테이블에 해당 App 정보를 기록합니다. 이때는 Client 측에서 각기 다른 App에서 수신되는 결과를 수집하여 처리 방법(한쪽 결과만 수집, 둘다 수집 등)을 정해야합니다. Axon Server는 Query 요청이 들어오면 등록된 Application에게 Query를 전달하며, 수신받은 App에서는 결과를 취합하여 전송합니다. 이후 Client 측에서 결과를 수신받아 데이터 결과를 종합합니다.


3. Subscription Query

 

 

Point to Point Query를 요청하였을 때, 만약 Query를 수행하는 Read Model에 대한 변경이 발생한다면, 화면에 출력되는 결과와 Read Model 사이 데이터 정합성 불일치 문제가 발생합니다. 따라서 이를 해결하기 위해서는 주기적으로 Query를 재요청하는 방법이 있습니다. 하지만 데이터 변경이 발생하지 않아도 계속 Query를 요청해야하는 문제점이 있으므로 효율적이지 못합니다.

 

 

 

Subscription Query는 Client측에서 Query를 요청할 때, Query 결과를 전달받고 Connection을 끊는 것이 아니라 계속 지속합니다. 이후 Query Handler가 위치한 App의 Read Model 변경이 발생할 경우 변경분에 대한 데이터를 전달받아 이를 최신화합니다.


3. Query Handler 동작 과정

 

 

AxonFramework 관련 Bean 생성시 사용자가 지정한 QueryBus가 없으면, Default로 AxonServerQueryBus가 생성됩니다. 이때 내부적으로 QueryProcessor가 만들어지고, 해당 생성자 안에서 ExecuterService를 통해 요청시 최대 10개의 Thread를 Default로 생성하도록 Handler에 등록합니다. 

 

ExecuterService에 의해서 만들어지는 Thread는 QueryProcessingTask이며, AxonServer로부터 Query를 전달받으면 해당 클래스의 run 메소드를 통해 QueryHandler 작업이 이어집니다.

 

 

 

Application 구동 이후, Query 요청이 발생되면, 내부적으로는 위와 같은 흐름을 거쳐 메시지가 전달됩니다.

 

  1. QueryGateway로 Query를 전달합니다. 이때 전달하는 Query가 Scatter-Gather, Subsciprtion, Point to Point 중 하나임을 메소드를 통해 AxonServer에게 전달합니다.
  2. 사용자가 전달한 Query를 GenericQueryMessage로 변환한다음 QueryBus로 전달합니다. Default QueryBus는 AxonServerQueryBus이므로 AxonServer에 전달됩니다.
  3. 전달된 Query는 QueryHandler가 존재하는 App으로 라우팅됩니다. 이때 AxonServer로부터 gRPC를 통해 onMessage 메소드가 호출되면, AxonServerQueryBus 내부에 할당된 Handler들의 onNext 메소드를 호출합니다.
  4. Bean 등록 당시 Handler 호출시 ExecutorService로부터 QueryProcessingTask 생성을 요청하였습니다. 따라서 Handler 호출과정에서 Thread 생성을 요청합니다.
  5. QueryProcessingTask 내부에 있는 run 메소드가 수행되면서 QueryProcessor에게 Query 수행을 위임합니다.
  6. QueryProcessor 내부 로직 수행중 Query 수행을 SimpleQueryBus에게 위임합니다.
  7. 내부적으로 UnitOfWork 과정을 거치면서, Reflection을 통해 QueryHandler 메소드를 찾아 수행후 결과를 돌려 받습니다.
  8. 최종 수행된 결과를 QueryProviderOutbound에게 전달합니다.
  9. AxonServer에게 결과를 전달합니다.
  10. Query를 요청한 Client에게 결과를 전달합니다.

4. 마치며

 

Query App 구현을 위해 기본적으로 알아야하는 내부 과정에 대해서 살펴봤습니다. 다음 포스팅에서는 코드 구현을 진행하겠습니다.

1. 서론

 

Software 개발 및 유지보수 단계에서 요구사항에 의하여 데이터 모델은 변하기 마련입니다. 그리고 바뀌는 데이터모델에 맞춰 Event 또한 형태가 변합니다. 이때, 이전 발행된 Event와 앞으로 적재되는 Event의 형태는 다르게 됩니다. 따라서 Replay 과정에서 변경된 Event를 적용하는데 있어 문제가 발생되지 않도록 코드를 통한 중재가 필요합니다. Axon 에서는 이를 위해 Event Upcasting 기능을 제공합니다. 이번 포스팅에서는 코드를 통하여 Event Upcasting을 적용하는 방법을 알아보겠습니다.


2. Versioning

 

 

Application 개발 후 요구사항 변경으로 계정 가입시에 회사명 정보가 추가되며, Read Model MView(Materialized View)에도 회사명 정보가 포함되고, 회사명이 기입되지 않으면 N/A로 표시된다고 가정하겠습니다.

 

요구사항 변경 내용을 코드로 구현하면 다음과 같습니다.


1. Command 수정을 위해서 API 변경이 필요합니다. Command 모듈내 dto 패키지에 위치한 HolderDTO 파일을 엽니다.

 


2. DTO 클래스에 company 정보를 추가합니다.

 

HolderDTO.java

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

3. Common 모듈 commands 패키지내 HolderCreationCommand 파일을 엽니다.

 


4. Command 클래스에 company 정보를 추가합니다.

 

HolderCreationCommand.java

@AllArgsConstructor
@ToString
@Getter
public class HolderCreationCommand {
    @TargetAggregateIdentifier
    private String holderID;
    private String holderName;
    private String tel;
    private String address;
    private String company;
}

5. Command 모듈 service 패키지에 위치한 TransactionServiceImpl 파일을 엽니다.

 


6. Service 클래스에서 계정 생성 로직에 Company 정보를 넘기도록 수정합니다.

 

TransactionServiceImpl.java

@Service
@RequiredArgsConstructor
public class TransactionServiceImpl implements TransactionService {
	(...중략...)
    @Override
    public CompletableFuture<String> createHolder(HolderDTO holderDTO) {
        return commandGateway.send(new HolderCreationCommand(UUID.randomUUID().toString()
                , holderDTO.getHolderName()
                , holderDTO.getTel()
                , holderDTO.getAddress()
                , holderDTO.getCompany())
                );
    }
    (...중략...)
}

 

7. Common 모듈의 build.gradle 파일을 엽니다.

 


8. build.gradle 파일에 axon-messaging 의존성을 추가합니다.

 

build.gradle

ext{
    axonVersion = "4.2.1"
}
bootJar { 
    enabled = false 
}
jar {
    enabled = true
}
dependencies{
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation group: 'org.axonframework', name: 'axon-messaging', version: "$axonVersion"
}

9. Common 모듈 events 패키지 내 HolderCreationEvent 파일을 엽니다.

 


10. Event에 변경된 사항을 추가합니다. 이때 Event에는 변경이 발생하므로, Event의 변경이 발생했음을 알리는 마커가 필요합니다. 이때 사용되는 어노테이션이 @Revision입니다. Revision 표시를 통해서 실제 Event가 발생되었을 때 EventStore에는 해당 Event의 버전이 저장되며, 추후 Event Upcasting 시에 해당 정보가 사용됩니다.

 

HolderCreationEvent.java

@AllArgsConstructor
@ToString
@Getter
@Revision("1.0")
public class HolderCreationEvent {
    private String holderID;
    private String holderName;
    private String tel;
    private String address;
    private String company;
}

11. Command 모듈 aggregate 패키지내에 위치한 HolderAggregate 파일을 엽니다.

 


12. Aggregate 클래스 Event 발행 로직에 company 정보를 전달할 수 있도록 변경합니다.

 

HolderAggregate.java

@AllArgsConstructor
@NoArgsConstructor
@Aggregate
@Slf4j
public class HolderAggregate {
	(...중략...)
    @CommandHandler
    public HolderAggregate(HolderCreationCommand command) {
        log.debug("handling {}", command);

        apply(new HolderCreationEvent(command.getHolderID(), command.getHolderName(), command.getTel(), command.getAddress(), command.getCompany()));
    }
    (...중략...)
}

13. Query 모듈에 version 패키지를 생성한 다음 HolderCreationEventV1 클래스를 만듭니다. 해당 클래스는 변경 이전HolderCreationEvent에 대해서 변경 후에 어떻게 추가된 정보를 처리를 지정 용도로 사용됩니다.

 


14. HolderCreationEventV1 클래스 내용을 구현합니다.

 

HolderCreationEventV1.java

public class HolderCreationEventV1 extends SingleEventUpcaster {
    private static SimpleSerializedType targetType = new SimpleSerializedType(HolderCreationEvent.class.getTypeName(), null);

    @Override
    protected boolean canUpcast(IntermediateEventRepresentation intermediateRepresentation) {
        return intermediateRepresentation.getType().equals(targetType);
    }

    @Override
    protected IntermediateEventRepresentation doUpcast(IntermediateEventRepresentation intermediateRepresentation) {
        return intermediateRepresentation.upcastPayload(
                new SimpleSerializedType(targetType.getName(), "1.0"),
                org.dom4j.Document.class,
                document -> {
                    document.getRootElement()
                            .addElement("company")
                            .setText("N/A");
                    return document;
                }
        );
    }

 

코드 구현내역은 다음과 같습니다. 먼저 targetType에는 대상 Event를 지정합니다. 데모 프로젝트에서는 HolderCreationEvent가 변경되었으므로 해당 Class 타입을 지정합니다. 두번째 인자에 위치한 null 값은 최초에는 @Revision 정보를 명시하지 않았으므로 존재하는 값이 없기 때문에 null로 지정하였습니다.

 

doUpcast 메소드는 실제 Event Version을 확인하고 이전 버전의 Event가 들어왔을 때 행동해야할 내용을 기술합니다.

EventStore에 저장된 Event 내용이 XML로 지정되므로, XML로 되어있는 Payload 에서 신규 추가된 company 정보와 값이 없을 경우 입력될 Default 값 N/A를 setText 메소드를 통해 지정합니다. 또한 해당 작업을 수행할 대상을 Revision 정보가 null인 targetType으로 한정합니다. 따라서 해당 메소드를 통해서 확인할 수 있는 사실은 HolderCreationEvent에 수많은 Revision 정보가 존재하더라도, HolderEventCreationEventV1 클래스에서는 Revision 번호가 1.0과 null인 두 Event간의 속성값에만 영향을 미칩니다.

 

 


15. Query 모듈내 config 패키지에 존재하는 AxonConfig 파일을 엽니다.

 

 


14. AxonConfig 파일에 Event Chain을 등록합니다.

 

AxonConfig.java

@Configuration
public class AxonConfig {
	(...중략...)

    @Bean
    public EventUpcasterChain eventUpcasterChain(){
        return new EventUpcasterChain(
                new HolderCreationEventV1()
        );
    }
}

 

만약 Event의 버전이 여러개 생성되었다면, HolderCreationEventV1이외 여러개의 버전간 핸들러 클래스를 생성한 다음 Event Chain 생성 로직에 핸들러 인스턴스를 등록합니다.


15. 새로운 계정 등록 테스트를 진행한다음 Query Application을 Replay 시킵니다.

 

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

{
	"holderName" : "Kane",
	"tel" : "02-2645-5678",
	"address" : "OO시 OO구",
    "company" : "Korea"
}

 

c.c.q.p.HolderAccountProjection          : projecting HolderCreationEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, holderName=kevin, tel=02-1234-5678, address=OO시 OO구, company=N/A) , timestamp : 2020-01-07T12:11:21.047Z
c.c.q.p.HolderAccountProjection          : projecting HolderCreationEvent(holderID=a5d267c6-fbd8-4f0d-b93b-03a8dbf09747, holderName=bruce, tel=02-5291-5678, address=OO시 OO구, company=N/A) , timestamp : 2020-01-07T12:12:14.238Z
c.c.q.p.HolderAccountProjection          : projecting HolderCreationEvent(holderID=1b77f5bc-73e6-4b7a-a1fd-d5453723b9c7, holderName=Kane, tel=02-2645-5678, address=OO시 OO구, company=Korea) , timestamp : 2020-01-11T13:00:10.487Z

 

위 내용은 Replay 이후 Application 로그 일부를 발췌한 내용입니다. 신규 생성한 Kane 사용자의 company는 입력값으로 정확히 등록이 되었으며, 기존에 발행된 Event는 N/A로 매핑된 것을 확인할 수 있습니다.


3. 마치며

 

이번 포스팅을 끝으로 Query Application에서 Event 처리와 관련된 내용을 마무리하겠습니다. 다음 포스팅에서는 Query Application에 저장된 Read Model을 기반으로 Query하는 방법에 대해서 다루도록 하겠습니다.

1. 서론

 

이전 포스팅에서 Replay에 대해서 학습했습니다. Replay는 신규 Read Model이 추가되거나 기존 모델의 변경이 있을 때, EventStore에서 기존 내역을 전달받아 재수행하는 작업입니다. 따라서 EventSourcing & CQRS 모델을 사용한다면, Replay 성능 고민이 반드시 필요합니다. 이번 포스팅에서는 AxonIQ 블로그를 기반으로 Replay 성능 개선 방법에 대해서 소개하겠습니다.

 

본문 설명에 앞서 AxonIQ 벤치마크 테스트 환경 Spec은 다음과 같습니다.

  • DBMS : Postgres(9.6), MongoDB(3.6)
  • CPU : vCPU 8코어 (GCP)
  • RAM : 30G
  • OS : Ubuntu 18.10
  • DISK : SSD 1T

 


2. Replay 문제점

 

만약 EventStore에 저장된 Event 수가 10억개이고, Read Model에서 Replay 수행 시 초당 1000개의 Event를 재생할 수 있다고 가정한다면, Replay 작업에만 약 11일이 걸립니다. 이는 대부분의 상황에서는 적용하기 힘듭니다. 따라서 Replay 작업  최적화가 반드시 필요합니다. 

 

앞선 포스팅에서 TrackingEventProcessor에 의하여 @EventHandler 메소드가 호출된다고 설명했습니다. 이때 별도 속성을 지정하지 않으면, TrackingEventProcessor는 Default 값으로 설정되며 기본값은 다음과 같습니다.

 

  • Thread 수 : 1개
  • Batch Size : 1
  • 최대 Thread 수 : Segment 개수
  • TokenClaim 주기 : 5000ms

 

이를 통해 알 수 있는 사실은 Axon에서 Event 처리는 Batch 단위로 이루어지는데, 기본 설정 값은 Event 1개씩 단일 Thread로 처리됨을 확인할 수 있습니다.

 

AxonIQ에서 TrackingEventProcessor에 대한 기본값 설정으로 Event 처리에 대한 벤치마크 수행 결과, 초당 260개의 Event를 처리하였습니다. 이는 100만개 Event 기준 10시간의 처리 능력을 보여주어 좋지 않은 처리 능력을 나타냈습니다. 지금부터 Event 처리 능력을 강화하는 방법에 대해서 하나씩 살펴보겠습니다.


3. Replay 개선 전략

 

3-1. Batch Size 조정

 

이전 설명에서 Axon Framework에서 Event를 Batch 단위로 처리한다고 했습니다. Batch 작업 시, 개별적인 Event를 다루는 것 외에 부가적인 기능은 다음과 같습니다.

 

  1. Tracking Token을 갱신하여 최신의 Event Stream 위치를 기억하도록 함.
  2. Transactional Store를 사용했을 때 DB의 Transaction Commit 작업 수행.

 

사용자의 별도 설정이 없다면, 기본 Batch Size는 1입니다. 이는 Replay 작업을 수행하기에는 너무 적은 수치입니다. 따라서 적절한 PoC를 통해 최적의 Size를 맞추어야 합니다. Axon에서는 TrackingEventProcessorConfiguration을 통해서 Size 조정이 가능합니다. 설정 방법은 다음과 같습니다.

 

1. Query 모듈내 config 패키지 생성 후 AxonConfig 파일을 생성합니다.

 


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

 

AxonConfig.java

@Configuration
public class AxonConfig {
    @Autowired
    public void configure(EventProcessingConfigurer configurer) {
        configurer.registerTrackingEventProcessor(
                "accounts",
                org.axonframework.config.Configuration::eventStore,
                c -> TrackingEventProcessorConfiguration.forSingleThreadedProcessing()
                        .andBatchSize(100)
        );
    }
}

 

코드 구현 내용은 accounts ProcessingGroup을 처리하는 TrackingEventProcessor의 Batch Size를 100으로 설정하였습니다.


출처 : https://axoniq.io/blog-overview/cqrs-replay-performance-tuning

 

위 그림은 Batch Size 조정에 따른 초당 Event 처리량을 그래프로 표시한 결과입니다. Size를 1부터 500까지 늘렸을 때 초당 260개의 이벤트 처리량에서 4000개로 15배의 성능 개선이 나타났습니다. 대략 500개 이상부터는 Size 증가에 따른 개선폭이 크지 않으므로 해당 예제에서는 500개가 적정선입니다. Batch Size 크기에 대한 적절한 가이드는 없으며, 이는 각 Application 환경에 맞게 테스트 이후 찾는 것이 좋습니다.

 

Batch Size를 늘려서 Transaction을 처리할 때 주의점이 있습니다. 이는 Read Model에 사용되는 DB가 Non-Transactional 하다면, 만약 Batch 중간에 실패했을 때 데이터가 자동 Rollback되지 않습니다. 따라서 이후 다시 Replay를 시도하게되면, 이미 처리된 Event가 다시 수행되므로 유의해야합니다.


3-2. 병렬 처리

 

출처 : https://axoniq.io/blog-overview/cqrs-replay-performance-tuning

 

TrackingEventProcessor의 Thread 수 기본 값은 1입니다. 즉 하나의 Thread로 모든 작업을 순차처리합니다. 따라서 Event Replay 성능을 높이기 위해서는 병렬도 증가가 필요합니다.

 

위 그림은 Batch Size는 500으로 설정한 상태에서 Thread 개수를 1에서 8개로 점차 늘렸을 때 초당 Event 처리량을 나타낸 것입니다. 병렬도를 8로 지정했을 때 초당 15000개의 Event를 처리할 수 있으므로 단일 Thread 대비 대략 4배정도의 개선이 이루어졌습니다.

 

 

하지만 Thread 개수를 늘릴 때는 고려해야할 사항이 많습니다. 그 이유는 병렬로 처리하게되면 처리되는 Event의 순서가 뒤바뀔 수 있기 때문입니다. 따라서 병렬 처리를 수행시, 순서가 보장될 수 있도록 처리해야합니다. Axon에서는 이러한 문제를 해결 하기 위해 Sequencing 정책을 제공합니다. Sequencing 정책이란 동일 Thread 내에서 Event는 반드시 처리 순서 보장에 대한 결정을 의미합니다.

 

기본적으로 Axon에서는  동일 Aggregate에 속한 Event는 동일 Thread에서 처리될 수 있도록 SequentialPerAggregatePolicy 클래스를 제공합니다. 이를 적용하여 AxonConfig 클래스 수정을 통해 병렬도를 변경하도록 하겠습니다.

 

AxonConfig.java

@Configuration
public class AxonConfig {
    @Autowired
    public void configure(EventProcessingConfigurer configurer) {
        configurer.registerTrackingEventProcessor(
                "accounts",
                org.axonframework.config.Configuration::eventStore,
                c -> TrackingEventProcessorConfiguration.forParallelProcessing(3)
                        .andBatchSize(100)
        );

        configurer.registerSequencingPolicy("accounts",
                configuration -> SequentialPerAggregatePolicy.instance());
    }
}

 

이전 대비 변경된 내용은 TrackingEventProcessor에 대해서 병렬도를 3으로 지정하였습니다. 또한 accounts ProcessingGroup을 대상으로 SequentialPerAggregatePolicy를 적용하여, 단일 Thread 내에서 동일 Aggregate Event가 순서대로 처리되도록 정책 설정하였습니다.


 

코드 변경 후 실제 Application을 구동한다음 Token Store에 TrackingEventProcessor 별로 Token이 생긴 것을 확인할 수 있습니다.


하지만 데모 프로젝트에서 단순히 위와같이 병렬도를 지정하고 Application을 수행하면, 소유주가 존재하지 않습니다 Error가 발생할 수도 있습니다. 이유는 아래와 같습니다.

 

데모 프로젝트 Aggregate 종류

 

 

Command App에서 구현된 Aggregate는 Holder와 Account 두개 입니다. 비즈니스 로직상 Account Aggregate는 반드시 Holder Aggregate가 존재해야지만 생성이 가능하며, Event Stream에도 순차적으로 생성되어 있습니다. 하지만 Read Model을 반영하는 과정에서 Thread를 분리시키면, 근본적으로 두 개의 Aggregate는 다릅니다. 따라서 SequentialPerAggregatePolicy 설정했어도 다른 Thread에 생성되어 처리될 가능성이 높습니다. 

 

HolderAccountProjection.java

@Component
@EnableRetry
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
	(...중략...)
	private HolderAccountSummary getHolderAccountSummary(String holderID) {
        log.debug("getHolder : {} ",holderID);
        return repository.findByHolderId(holderID)
                .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다." + holderID));
    }
}    

 

위 코드는 Account 생성 Event를 처리하기 위해 Repository 에서 Holder를 찾는 로직을 일부 발췌하였습니다. 이때 두 Aggregate가 다르므로 Account  생성 시점 Thread 1번에서 수행중인 Holder Aggregate가 DB에 반영되어있지 않을 수 있습니다. 그 결과 NoSuchElementException이 발생할 수 있습니다.

 

Replay를 수행할 때 의도적으로 @DisallowReplay 어노테이션을 추가하지 않는 이상 EventHandler에서 Event 누락이 발생되어서는 안됩니다. 따라서 문제점 해결을 위해 데모 프로젝트에서는 저장소 검색 과정에서 위와 같은 에러를 만나게 되었을 때 약간의 시차를 두고 다시 시도하게끔 지정하고자 합니다. 이를 위해 spring-retry 기능을 사용하도록 하겠습니다.


1. Query 모듈 build.gradle 파일을 열어 dependencies를 추가합니다.

 

build.gradle

dependencies{
 (...중략...)
 implementation group: 'org.springframework.retry', name: 'spring-retry'
}

2. projection 패키지 HolderAccountProjection 클래스 파일을 엽니다.

 


3. HolderAccountProjection 클래스 상단에 @EnableRetry 어노테이션을 추가합니다. 

 

HolderAccountProjection.java

@Component
@EnableRetry
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
	(...중략...)
}    

4. Account 생성 이벤트를 처리하는 Event Handler 메소드에 @Retryable 어노테이션을 추가합니다. 

 

HolderAccountProjection.java

@Component
@EnableRetry
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
	(...중략...)
    
    @EventHandler
    @Retryable(value = {NoSuchElementException.class}, maxAttempts = 5, backoff = @Backoff(delay = 1000))
    @AllowReplay
    protected void on(AccountCreationEvent event, @Timestamp Instant instant)  {
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setAccountCnt(holderAccount.getAccountCnt()+1);
        repository.save(holderAccount);
    }
    
    (...중략...)
}    

 

위 코드의 내용은 Repository로부터 NoSuchElementException이 발생하면 1초 대기후에 다시 시도하며, 최대 5번까지 재수행을 시도하도록 설정하였습니다.

 

위 4단계를 거친 다음 Query App을 기동하여 Reset을 수행하면, 발생하였던 문제가 정상적으로 수행됨을 확인할 수 있습니다.


3-3. Batch 최적화

 

Read Model 구현에 있어 JPA를 사용한다면 Batch Update를 수행할 때 자동으로 최적화를 수행합니다. 가령 동일한 Record에 대하여 Update가 연속 두번 발생하면, 실제로는 Entitiy 로딩하기 위한 1번의 DBMS Call과 JPA 내부 Persistence Context에서 2번의 수정 작업을 거쳐 최종 1번의 Update DBMS Call이 발생합니다.

 

또한 다량의 Insert 작업이 중간에 Update 작업 없이 발생한다면, DBMS에 Insert를 반영할 때 SQL 단건씩 DBMS Call을 발생시키는 것이 아니라 Bulk Insert를 수행하여 최적화를 달성합니다.

 

하지만 JPA를 통한 Model 이외에도 적용할 수 있는 최적화 방법은 여러가지가 있습니다. 그 중 2가지 방법을 소개하겠습니다.

 

  1. Entitiy에 대해서 Update를 수행하기 위해서는 기본적으로 데이터를 Persistence Context에 Load 이후에 변경을 실시하는데, Replay 과정에서는 굳이 데이터를 Load할 필요없이 바로 Update 구문을 수행함으로써 DBMS Call을 줄일 수 있습니다. 
  2. 동일 Aggregate에 대해서 발생하는 Insert, Update, delete Event 순서를 결과에 어긋나지 않게 재배치하여 Bulk 작업을 수행한다면, 획기적으로 DBMS Call을 줄일 수 있습니다. 

 

방금 소개시켜드린 2가지 방법은 단순히 Configuration 설정 변경으로는 적용할 수 없습니다. 따라서 이를 해결 하기위해서는 DBMS 최적화에 대한 기술적인 고민과 이를 적용하는 내용을 프로그래밍해야 합니다. Axon 에서는 이를 보조하기 위하여 다양한 API를 제공합니다. 또한 각각의 Batch 마다 하나씩 UnitOfWork가 존재합니다. 따라서 EventHandler 메소드 파라미터에 UnitOfWork 인자를 추가하면 UnitOfWork에서 처리되는 자원에 접근할 수 있습니다. 이를 통해 메시징 처리를 사용자가 효율적으로 Customizing 처리할 수 있도록 도움을 줍니다.

 

위 소개드린 2가지 기법들을 활용하여 Axon 에서 벤치마크 테스트한 결과, 기존 초당 15000개의 처리량에서 30000개로 2배의 성능 향상을 이룰 수 있었습니다. 이는 최초대비 115배의 성능 개선을 달성한 것입니다. 


4. Mongo DB 테스트 결과

 

Read Model을 Mongo DB를 사용햇을 때 이전 Replay 최적화 방식을 도입함에 있어 AxonIQ 벤치마크 테스트 결과는 다음과 같습니다.

 

  1. 최적화 없음 : 초당 12000개
  2. Batch 최적화 : 초당 30000개

 

AxonIQ 벤치마크 결과 Postgresql과 크게 다르지 않은 처리량을 볼 수 있습니다.


5. 마치며

 

이번 시간에는 Replay 성능 개선에 대하여 다루었습니다. 개인적으로 EventSourcing & CQRS를 도입하는데 있어 기술적으로 가장 고민을 많이해야하는 부분이 이번 포스팅 내용인 것 같습니다. Replay 수행에 있어 Application 개선 뿐만 아니라 DBMS 최적화 기법을 같이 고려한다면 더 큰 성능개선이 이루어질 수 있으므로 Read Model DB에 대한 지식은 큰 도움이 될 것입니다. 다음 시간에는 요구사항 변경에 따른 Event 모델 수정 및 Versioning에 대하여 다루겠습니다.

 

 

 

 

1. 서론

 

이번 시간에는 Query App의 EventHandler 로직을 구현하도록 하겠습니다. Query App의 Read Model은 이전 포스팅에서 도출한 구조를 사용하도록 하겠습니다. 


2. Projection 구현

 

Command에서 발생된 Event를 적용하는 과정을 Projection이라 합니다. 먼저 설계 단계에서 도출한 Entity 구현 이후 Projection을 구현하겠습니다. 

 

 

 

1. Query 모듈내 entity 패키지를 생성후 Entity 클래스를 생성합니다.

 


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

 

HolderAccountSummary.java

@Entity
@Table(name = "MV_ACCOUNT")
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter @Setter
public class HolderAccountSummary {
    @Id
    @Column(name = "holder_id", nullable = false)
    private String holderId;
    @Column(nullable = false)
    private String name;
    @Column(nullable = false)
    private String tel;
    @Column(nullable = false)
    private String address;
    @Column(name = "total_balance", nullable = false)
    private Long totalBalance;
    @Column(name = "account_cnt", nullable = false)
    private Long accountCnt;
}

3. repository 패키지를 생성 후 entitiy repository 인터페이스를 생성합니다.

 


4. repository 인터페이스 메소드를 정의합니다.

 

AccountRepository.java

public interface AccountRepository extends JpaRepository<HolderAccountSummary,String> {
    Optional<HolderAccountSummary> findByHolderId(String holderId);
}

 


5. projection 패키지 생성 후 projection 클래스를 생성합니다.

 


6. Projection 클래스를 구현합니다.

 

HolderAccountProjection.java

@Component
@AllArgsConstructor
@Slf4j
public class HolderAccountProjection {
    private final AccountRepository repository;

    @EventHandler
    protected void on(HolderCreationEvent event, @Timestamp Instant instant) {
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary accountSummary = HolderAccountSummary.builder()
                                                                        .holderId(event.getHolderID())
                                                                        .name(event.getHolderName())
                                                                        .address(event.getAddress())
                                                                        .tel(event.getTel())
                                                                        .totalBalance(0L)
                                                                        .accountCnt(0L)
                                                                    .build();
        repository.save(accountSummary);
    }
    
    @EventHandler
    protected void on(AccountCreationEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setAccountCnt(holderAccount.getAccountCnt()+1);
        repository.save(holderAccount);
    }
    
    @EventHandler
    protected void on(DepositMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() + event.getAmount());
        repository.save(holderAccount);
    }
    
    @EventHandler
    protected void on(WithdrawMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() - event.getAmount());
        repository.save(holderAccount);
    }

    private HolderAccountSummary getHolderAccountSummary(String holderID) {
        return repository.findByHolderId(holderID)
                .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
    }
}

 

위 코드는 Projection 로직을 담고 있습니다. EventHandler 메소드 파라미터에는 @Timestamp@SequenceNumber, ReplayStatus 등이 추가로 전달될 수 있으며, 자세한 내용은 Axon 공식 문서를 참고 바랍니다.


3. 테스트

 

API 테스트를 통해 발행된 Command가 Read Model에 제대로 반영되는지 테스트 하겠습니다.

 

1. reousrces 하위 application.yml 파일을 오픈 후에 로깅 정보를 입력합니다.

 


2. AxonServer 기동 후에 Query App을 수행합니다. 기동이 완료되면 DB에 테이블이 정상 생성되었는지 확인합니다.

 


3. Command App을 기동합니다. 이후 계정 생성, 계좌 생성, 입금, 출금 Command 명령을 수차례 반복 수행합니다.

 


4. 테스트 이후 Query App Read Model 갱신 여부를 확인합니다. 또한 Application Log를 통해서 Event 정상 처리를 확인합니다.

 

 

c.c.q.p.HolderAccountProjection          : projecting HolderCreationEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, holderName=kevin, tel=02-1234-5678, address=OO시 OO구) , timestamp : 2020-01-07T12:11:21.047Z
c.c.q.p.HolderAccountProjection          : projecting HolderCreationEvent(holderID=a5d267c6-fbd8-4f0d-b93b-03a8dbf09747, holderName=bruce, tel=02-5291-5678, address=OO시 OO구) , timestamp : 2020-01-07T12:12:14.238Z
c.c.q.p.HolderAccountProjection          : projecting AccountCreationEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=0e6d3546-4163-4083-bf2f-50f1289d8c25) , timestamp : 2020-01-07T12:12:27.633Z
c.c.q.p.HolderAccountProjection          : projecting AccountCreationEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=042937fb-e658-441d-b23a-fd56be237563) , timestamp : 2020-01-07T12:12:33.961Z
c.c.q.p.HolderAccountProjection          : projecting AccountCreationEvent(holderID=a5d267c6-fbd8-4f0d-b93b-03a8dbf09747, accountID=9eb1f188-c38f-401d-b212-3c26ea84acfa) , timestamp : 2020-01-07T12:12:45.193Z
c.c.q.p.HolderAccountProjection          : projecting AccountCreationEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=40d52cac-20d0-49c2-b973-049bd585108f) , timestamp : 2020-01-07T12:13:28.542Z
c.c.q.p.HolderAccountProjection          : projecting DepositMoneyEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=40d52cac-20d0-49c2-b973-049bd585108f, amount=300) , timestamp : 2020-01-07T12:15:02.059Z
c.c.q.p.HolderAccountProjection          : projecting WithdrawMoneyEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=40d52cac-20d0-49c2-b973-049bd585108f, amount=30) , timestamp : 2020-01-07T12:15:08.242Z
c.c.q.p.HolderAccountProjection          : projecting WithdrawMoneyEvent(holderID=0df539ed-2ee2-41be-af32-0f6724a75da3, accountID=40d52cac-20d0-49c2-b973-049bd585108f, amount=20) , timestamp : 2020-01-07T12:15:13.488Z
c.c.q.p.HolderAccountProjection          : projecting DepositMoneyEvent(holderID=a5d267c6-fbd8-4f0d-b93b-03a8dbf09747, accountID=9eb1f188-c38f-401d-b212-3c26ea84acfa, amount=300) , timestamp : 2020-01-07T12:15:29.451Z
c.c.q.p.HolderAccountProjection          : projecting WithdrawMoneyEvent(holderID=a5d267c6-fbd8-4f0d-b93b-03a8dbf09747, accountID=9eb1f188-c38f-401d-b212-3c26ea84acfa, amount=150) , timestamp : 2020-01-07T12:15:38.423Z

 

위 내역은 전체 로그중 일부만 발췌한 결과입니다. 확인 결과 Event가 정상적으로 반영된 것을 알 수 있습니다.


4. Replay

 

 

이전 포스팅에서 마지막 수신 Event Token 정보를 토대로 AxonSever으로부터 다음 목록을 수신받아 처리한다고 설명 했습니다. 하지만 때로는 Read Model 구조를 재구성하기 위해서 Event를 재생해야될 수 있습니다. Axon에서는 이를 위해 Replay 기능을 제공하며, 특정 시점부터 혹은 전체의 Event에 대한 Replay를 수행할 수 있습니다.

 

Replay 기능이 동작하면, 내부적으로는 Token 정보를 초기화하여, 특정 시점 혹은 처음부터 발행된 Event를 전달받아 재수행합니다. 

 

이번 시간에는 전체 Event를 재생하는 방법에 대해서 설명하며, 특정 시점부터 이벤트 Replay는 Axon 공식 문서를 참고 바랍니다.

 

 

1. Replay를 수행하기 위해 Projection 클래스를 변경합니다. 먼저 ProcessingGroup을 지정하여 TrackingEventProcessor로 하여금 어떤 Group을 대상으로 Replay를 수행할지 지정합니다.

 

 

HolderAccountProjection.java

@Component
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
(...중략...)
}

2. Replay 수행시 Read Model 초기화 작업이 이루어지지 않으면, 남아있는 데이터에 이벤트가 적용되므로 데이터 정합성이 맞지 않습니다. 따라서 Replay가 수행되기전, 대상 테이블 또한 초기화가 선행 되어야합니다. 이를 위해 @ResetHandler 어노테이션을 추가한 메소드를 정의하여 초기화 작업을 처리합니다.

 

HolderAccountProjection.java

@Component
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
(...중략...)
    @ResetHandler
    private void resetHolderAccountInfo(){
        log.debug("reset triggered");
        repository.deleteAll();
    }
}

3. Replay시 적용 대상 Handler 메소드에 @AllowReplay 어노테이션을 추가합니다.(Optional) 만약 Replay 대상에서 해당 Event는 처리하고 싶지 않을 경우에는 @DisallowReplay를 추가합니다. 예제에서는 전체 Replay 재생을 위해 @AllowReplay 어노테이션만 추가했습니다.

 

1~3번 과정을 모두 적용한 Projection 클래스 코드는 다음과 같습니다.

 

HolderAccountProjection.java

@Component
@AllArgsConstructor
@Slf4j
@ProcessingGroup("accounts")
public class HolderAccountProjection {
    private final AccountRepository repository;

    @EventHandler
    @AllowReplay
    protected void on(HolderCreationEvent event, @Timestamp Instant instant) {
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary accountSummary = HolderAccountSummary.builder()
                                                                        .holderId(event.getHolderID())
                                                                        .name(event.getHolderName())
                                                                        .address(event.getAddress())
                                                                        .tel(event.getTel())
                                                                        .totalBalance(0L)
                                                                        .accountCnt(0L)
                                                                    .build();
        repository.save(accountSummary);
    }
    @EventHandler
    @AllowReplay
    protected void on(AccountCreationEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setAccountCnt(holderAccount.getAccountCnt()+1);
        repository.save(holderAccount);
    }
    @EventHandler
    @AllowReplay
    protected void on(DepositMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() + event.getAmount());
        repository.save(holderAccount);
    }
    @EventHandler
    @AllowReplay
    protected void on(WithdrawMoneyEvent event, @Timestamp Instant instant){
        log.debug("projecting {} , timestamp : {}", event, instant.toString());
        HolderAccountSummary holderAccount = getHolderAccountSummary(event.getHolderID());
        holderAccount.setTotalBalance(holderAccount.getTotalBalance() - event.getAmount());
        repository.save(holderAccount);
    }

    private HolderAccountSummary getHolderAccountSummary(String holderID) {
        return repository.findByHolderId(holderID)
                .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
    }

    @ResetHandler
    private void resetHolderAccountInfo(){
        log.debug("reset triggered");
        repository.deleteAll();
    }
}

4. Reset 수행 EndPoint를 구현하기 위해 controller 패키지 및 클래스를 생성합니다.

 


5. controller 클래스를 구현합니다.

 

HolderAccountController.java

@RestController
@RequiredArgsConstructor
public class HolderAccountController {
    private final QueryService queryService;

    @PostMapping("/reset")
    public void reset() {
        queryService.reset();
    }
}

6. Service Package 및 Service 클래스를 생성합니다.

 


7. 서비스 클래스를 구현합니다.

 

QueryService.java

public interface QueryService {
    void reset();
}

 

QueryServiceImpl.java

@RequiredArgsConstructor
@Service
public class QueryServiceImpl implements QueryService{
    private final Configuration configuration;

    @Override
    public void reset() {
        configuration.eventProcessingConfiguration()
                .eventProcessorByProcessingGroup("accounts",
                        TrackingEventProcessor.class)
                .ifPresent(trackingEventProcessor -> {
                    trackingEventProcessor.shutDown();
                    trackingEventProcessor.resetTokens(); // (1)
                    trackingEventProcessor.start();
                });
    }
}    

 

 

실제 Token 초기화는 resetTokens 메소드를 통해서 이루어집니다. 해당 작업을 위해서는 EventProcessor의 재시작이 필요합니다.


8. Query App 재기동 후 API 테스트(POST : http://localhost:9090/reset)를 수행합니다. Application 로그 및 DB를 확인하면 정상적으로 Replay가 이루어졌음을 확인할 수 있습니다.


5. 마치며

 

EventHandler 메소드 적용에 따른 Read Model 구현을 완성했습니다. 다음 포스팅에서는 Replay 성능 개선 방법에 대하여 다루도록 하겠습니다.

1. 서론

 

 

이번 포스팅부터 Query App 구현을 다루겠습니다. Query App은 Event를 수신받아 Read Model에 반영하는 Projection 작업과 Read Model을 읽는 Query 2가지로 기능이 나뉩니다. 기능별로 실제 구현 코드량은 얼마되지 않지만 알아야 하는 개념이 많으므로 먼저 Event 처리 기능 관련하여 다루고 향후에 Query 기능을 다루겠습니다.

 

이번 내용은 EventHandler 구현 실습전 내부 처리 과정을 살펴보겠습니다.


2. Event 처리 과정

 

Token Store

 

 

이전 포스팅에서 확인 하였듯이 EventStore에는 EventStream의 내용을 순차적으로 적재합니다. 따라서 수신부에서 지금까지 수신된 Event는 어디까지이며, EvenStore에서 어디서부터 Event를 수신 받아야할지에 대한 정보를 가지고 있어야합니다. 해당 정보를 Token이라고 하며, Token은 Query App과 연관된 DB 내부에 저장하여 영구적으로 관리합니다.(Token Store) 

 

 

 

Token 내용

<org.axonframework.eventhandling.GlobalSequenceTrackingToken>
  <globalIndex>13</globalIndex>
</org.axonframework.eventhandling.GlobalSequenceTrackingToken>

 

위 예시는 TokenStore에 저장된 내용입니다.Token 컬럼에는 지금까지 Tracking된 Event의 Global Sequence 값이 들어있으며, 예시를 통해 13번이 마지막으로 수신된 Event임을 알 수 있습니다.

 


Tracking Event Processor

 

 

TokenStore를 통해서 마지막 수신 Event 정보를 알 수 있다면, 해당 정보를 토대로 Event 수신 요청 및 처리를 담당하는 중계 역할이 필요합니다. Axon에서 제공하는 Event 처리기는 Subscribing Event Processor(SEP), Tracking Event Processor(TEP) 2가지가 있습니다. 두 Event 처리기 차이점은 이벤트 발행 쓰레드에서 Event 처리여부입니다. SEP는 Event 발행 쓰레드에서 Event 또한 처리하며, TEP는 별도 쓰레드에서 처리합니다. TEP를 이해하는 것이 중요하므로 이를 중점적으로 다루겠습니다.


3. Tracking Event Processor

 

 

위 그림은 Query App이 기동될 때 EventProcessor 생성 및 처리 과정 흐름을 간략하게 표현했습니다. 

 

  1. Spring DefaultLifecycleProcessor가 Start 명령을 내립니다.
  2. Axon의 기본 설정을 담당하는 DefaultConfigurer 클래스 Start 메소드가 호출됩니다. 이후 등록된 Handler에게 수행 명령을 내립니다. 기본으로 등록된 Handler는 2개입니다.(EventProcessingModule, EventProcessorInfoConfiguration)
  3. EventProcessingInfoConfiguration의 Start 메소드가 호출됩니다.
  4. 내부 로직을 거쳐 ProcessorInfoSource와 EventProcessorControlService를 구동합니다. ProcessorInfoSource는 AxonServer에게 EventProcessor의 현재 상태를 주기적(Default 500ms)으로 보내는 역할을 담당합니다. EventProcessorContolService는 AxonServer에서 요청시 EventProcessor를 제어하는 서비스 역할을 담당합니다. EventPRocessorControlService의 실제 로직 수행은 AxonServerConnectionManager 및 EventProcessorController가 담당합니다.
  5. EventProcessingModule을 구동합니다. 이 과정에서 EventProcessor 생성을 요청합니다.
  6. TrackingEventProcessor를 생성합니다. 사용자가 별도 속성 정의를 하지 않았으면, DefaultEventProcessor가 생성됩니다. (※ Thread 수 : 1개, 배치 사이즈 : 1, 최대 Thread 수 : Segment 개수, tokenClaim 주기 : 5000ms)
  7. 생성된 TrackingEventProcessor에게 구동을 요청합니다. 내부적으로 AxonThreadFactory에게 WorkerLauncher 인스턴스에 대한 Thread 생성을 요청합니다. 
  8. TrackingEventProcessor가 구동중이면 내부로직이 반복 수행될 수도록 무한 루프로 구성되어 있습니다. 만약 EventStore Segment에 변경된 내역을 확인하여 처리해야한다면, AxonThreadFactory에게 TrackingSegmentWorker 인스턴스 생성을 요청합니다.
  9. Segment에 대한 Event 처리(ProcessingLoop)를 요청합니다.

 

9번 Segment Event 처리 과정을 자세히 확인하기 위해 순서도를 그리면 다음과 같습니다.

 

 

  1. EventStream을 오픈합니다.
  2. EventStream에서 최신 Event가 존재하는지 확인합니다. 존재하지 않는다면 Token 값을 갱신하고 작업을 종료합니다.
  3. 최신 Event를 수신받은 후 해당 App에서 처리가 가능한 Event인지 확인합니다. 만약 처리할 수 없다면 BlackList 등록이 가능한지 확인하고 이를 등록합니다. 이후 해당 Event는 MessageMonitor에 보고한 뒤 무시합니다.
  4. 처리가 가능한 Event라면 UnitOfWork를 수행합니다. 해당 과정에서 Token 값은 자동 갱신합니다. 이후 EventHandler를 찾아 메소드를 수행합니다.
  5. Event 처리가 완료되면 작업을 종료합니다.

 

내부구조는 복잡하지만 이를 한줄로 요약하자면 "주기적으로 TrackingEventProcessor에서 처리가 가능한 Event가 존재하는지 확인 및 처리(UnitOfWork)하고 Token 갱신하는 작업"으로 정의할 수 있습니다.


4. Axon Server 라우팅기능(Event)

이번에는 Client가 아닌 AxonServer 입장에서 Event 전달 흐름을 살펴보도록 하겠습니다.

 

 

Query Application을 구동하면 Token 정보를 읽어옵니다. 이후 EventStore에게 자신이 보유한 Token 정보를 알려준 다음 EventStream을 오픈합니다. 위 예시에는 요청당시 EventStore에는 추가로 유입된 Event가 없으므로 Application의 Token값 변경 후 작업을 종료합니다.

 

 

만약 Command App으로부터 Event가 추가된다면, 다음 ProcessingLoop 작업에서 해당 Event를 Query App으로 전달합니다. 수신받은 App에서 해당 Event 처리 가능 여부를 확인하는데, 만약 처리하지 못할 경우는 AxonServer에게 BlackList 추가를 요청합니다. 따라서 이후 신규 적재되는 Event에 대해서는 수신받지 않습니다.

 

 

신규 App이 추가로 기동되어 AxonServer에 등록요청한 상태에서 마지막 수신 Event가 2번이라면, AxonServer에서 3번부터 해당 Event를 App으로 전달합니다.


4. 마치며

 

이번 포스팅에서는 EventHandler 메소드를 수행하기위해서 알아야할 내부 구조를 알아봤습니다. 다음 포스팅에서는 EventHandler 메소드 구현에 대해 다루어보겠습니다.

1. 서론

 

Query App 관련 포스팅을 진행하기 앞서 Event가 저장되는 EventStore에 대하여 알아보고자 합니다. 이번 포스팅에서 다룰 내용은 EventStore를 위한 필요조건, DB 종류에 따른 EventStore 역할 장·단점 그리고 AxonServer EventStore 저장 구조입니다.


2. EventStore 필요 조건

 

Event를 읽고 쓰는데 있어 EventStore가 기본적으로 갖춰야할 조건을 알아보겠습니다.

 

1. 이벤트는 추가만 가능하고 입력,삭제,수정이 불가능하다.
2. 여러 이벤트가 하나의 트랜잭션 처리가 되어야 한다면, 트랜잭션 단위로 Commit 혹은 Rollback 되어야한다.
3. Commit된 이벤트는 유실되어서는 안된다
4. 발행된 모든 Event 중 Aggregate 별로 데이터를 읽을 수 있어야한다.
5. 모든 이벤트는 삽입된 순서대로 읽기가 가능해야한다.

 

위 조건은 대부분의 DBMS라면 충족되는 요건입니다. 그 밖에 EventStore를 구성하는데 있어 요구되는 사항은 무엇이 있을까요?

 

1. SnapShot 저장소 지원

 

 

Command App을 구현하면서 Snapshot 필요성을 인지하였습니다. EventStore에서는 특정 시점에 Aggregate별 Sequence 번호에 해당하는 Snapshot을 별도 공간에 적재하며, Event 로드시에 해당 스냅샷 상태와 스냅샷 이후의 Sequence 번호에 해당되는 Event만을 읽을 수 있도록 지원해야 합니다.

 


2. Event Notification 기능

 

 

EvenetStore가 신규 추가된 Event를 희망하는 App에게 전파하는 역할을 수행하지 못한다면, Subscriber에서 주기적으로 Polling하여 Event 유입이 있는지 확인하는 작업이 필요합니다. 이는 DB 관점에서 I/O 및 Network 트래픽이 증가하는 요인입니다. 일반적인 RDBMS에서는 이벤트 전파기능이 없기 때문에 위 그림과 같이 메시징 처리를 수행해야합니다.

 

 

이러한 문제점을 해결하고자 메시지 큐를 사용해서 EventStore에 적재함과 동시에 큐에도 이벤트를 적재하여 전송하는 방법을 생각할 수 있습니다. 하지만 이는 EventStore에 저장과 큐를 통한 Event 전송 시점에 대한 동기화를 보장할 수 없습니다.

 

또한, 기타 이유로 EventStore에 이벤트 삽입이 실패하는 경우 메시지 큐를 통해 이미 전달된 이벤트와의 일관성이 깨지게 됩니다.

 

따라서 EventStore의 가장 이상적인 구조는 EventStore 자체가 Message Bus 역할을 담당하는 것입니다.


3. EventStore 적합성 비교

 

지금부터 소개드리는 내용은 AxonIQ Webinar를 참고하여 작성하였습니다. 

 

1. RDBMS

 

RDBMS 사용의 장점으로는 Transaction에 대한 지원 및 기술적 성숙도가 높다는 점입니다. 또한 오랜시간동안 사용되었으므로 사용자들에게 친숙하며, 제공되는 Tool이 다양합니다.

 

하지만 가장 큰 문제점은 확장성입니다. 대량의 데이터 처리보다는 데이터 공간 효율화 및 관계를 통한 데이터 정합성 보장 등에 초점이 맞춰져 있습니다. AxonIQ에서 RDBMS를 EventStore로 사용했을 때의 벤치마크 테스트 결과는 다음과 같습니다.

 

출처 : https://youtu.be/zUSWsJteRfw

 

출처 : http://www.dbguide.net/db.db?cmd=view&boardUid=148209&boardConfigUid=9&boardIdx=136&boardStep=1

 

결과를 보면 데이터 양이 증가할 수록 처리량이 떨어지는 것을 확인할 수 있습니다. 다양한 요인이 있을 수 있겠지만, 대표적으로는 대용량 데이터를 기준으로 B-Tree 인덱스를 사용하면, 인덱스 Depth가 깊어지기 때문에 지속 발생하는 인덱스 Split과 더불어 수직적 탐색 비용이 증가합니다. 또한 데이터 특성상 인덱스 우측 Block에 Transaction이 집중적으로 몰리기 때문에 Oracle 기준 핫블록으로 인한 Latch 경합이 발생하여 동시성이 크게 저하될 수 있습니다.(Right Growing Index)

 

위 테스트 결과는 RDBMS 테이블 구조 변경 없이 일반적인 Heap 테이블과 B-Tree 인덱스를 기준으로 진행했습니다. 만약 DBMS가 Oracle이라면 Hash 파티셔닝, Reverse 인덱스, IOT(Index Organized Table) 등을 적절히 사용한다면 개선의 여지는 있습니다.

 

RDBMS는 Event Notification 기능이 없기 때문에 이를 고려해야합니다. 이전에 설명한 Polling 방식을 개선하기 위해서는 테이블 단위 Audit을 고려해볼 수 있습니다. 즉 Audit 결과를 File로 떨어트리고 해당 로그 tail 값을 AxonFramework에서 요구하는 포맷으로 변경한 다음 메시지 큐를 통해 보내는 방법이 있습니다. 혹은 Trigger를 이용하는 방법도 생각해볼 수 있으나 이는 추천하지 않습니다.


2. Mongo DB

 

 

Mongo DB는 대표적인 NoSQL로써 하나의 이벤트는 하나의 Document에 속하며, 대량의 데이터 처리에 적합합니다. 하지만 Transaction 지원 문제점이 있습니다. 최근에 4.2 버전이 Release되어 Multi Document에 대한 Transaction 기능이 추가되었지만, 단일 Node에서는 Transaction이 불가하는 등의 제약사항이 존재합니다.

 

참고자료

 

두 번째 문제점은 MongoDB 3.2 버전부터 Storage Engine으로 Wiredtiger를 기본적으로 사용하고 있습니다. Wiredtiger 저장 방식은 Btree, 컬럼스토어, LSM 방식이 있습니다. 이중 EventStore에 적합한 방식은 Write 작업에 최적화된 LSM 방식이나 MongoDB에는 LSM을 아직 지원하고 있지 않습니다.

 

세 번째 문제점은 RDBMS와 마찬가지로 Event Notification 기능이 없기 때문에, Commit 로그 결과 등을 AxonFramework에서 요구하는 포맷으로 변경한 다음 메시지 큐를 통해 보내는 방식을 고려해야 합니다.

 

마지막으로 모든 Document에 대하여 전역적인 Sequence 기능이 기본적으로 제공되지 않는다는 점입니다. 따라서 이를 해결하기 위해서는 직접 함수를 구현해야 합니다.

 

참고자료


3. Kafka

 

 

카프카는 대용량 환경에서 Message 전달 역할로 좋은선택입니다. 하지만 EventSourcing에 있어서 좋은 도구는 아닙니다. 그 이유는 위 그림과 같이 Event Stream에서 특정 Aggregate를 추출하기 위해서는 해당 Stream 전체를 읽으면서 그 중 내가 원하는 Aggregate만 필터링하는 작업이 수반되어야 하기 때문입니다.

 

 

물론 Aggregate 별로 Topic을 생성하는 방법등도 고려할 수 있으나 이는 Aggregate 별로 디스크에 적재되는 용량과 I/O 밸런스 등을 고려해야하는 등의 관리 포인트가 급격하게 상승합니다.

 

참고자료


4. Axon Server(Event Store)

 

AxonServer 내부에는 Event 저장을위한 별도 DB가 없으며, File을 직접 다룹니다. 외부와의 연결은 Rest API 혹은 gRPC 방법을 통해 가능합니다.

 

EventStore는 오직 데이터 추가만이 가능하도록 설계되었습니다. 따라서 수정, 삭제와 관련된 그 어떠한 API도 제공되지 않습니다.

 

 

 

AxonServer에서는 EventStream을 일정 크기별로 잘라서 Segment로 매핑합니다. 각 Segment는 하나의 파일이며, 내부에는 Event가 연속적으로 할당되어 있습니다. 생성된 파일은 데이터 Corruption 확인을 위해 CRC 체크하여 파일 손상을 확인합니다.

 

만약 Segment에 Event Entry가 가득차게되면, 새로운 파일을 생성하고 이를 가르키도록 Index 정보를 추가합니다.


 

 

EventStore는 Snapshot 저장소를 제공합니다. Snapshot 저장소 또한 Segment 단위로 저장되며, Snapshot Entry는 동일한 Aggregate의 번호가 매핑된 파일을 가르킵니다. 따라서 위 그림에서 A Aggregate를 읽는다고 가정한다면 Snapshot이 가르키는 1번 Segment 이후부터 데이터를 읽기 시작합니다.

 

하지만 이때 2번 Segment에는 A Aggregate Event가 존재하지 않습니다. 따라서 해당 Segment는 읽는 것이 의미가 없습니다. 따라서 스캔 과정에서 2번은 읽지 않고 Skip할 수 있다면 최소한의 I/O로 성능을 높일 수 있을 것입니다. AxonServer에서는 이를 위해 Bloom Filter를 도입하였습니다.


Bloom Filter

 

Bloom Filter는 찾고자 하는 데이터가 해당 집합에 포함되는지를 판단하는 확률적 자료구조입니다. 주로 DBMS에서 많이 사용하며, 디스크에서 찾고자하는 값이 존재할 가능성이 있는 경우에만 블록을 읽기 위해 사용됩니다.

 

예를 들어 설명하겠습니다. Bloom Filter는 N개의 bit 배열에 대해서 찾고자하는 데이터를 대상으로 H개의 해시 함수를 적용한 결과를 1로 표시한다음, 대상 집합에도 동일하게 H개의 해시 함수를 적용해 결과가 동일한지를 판단합니다. 만약 동일하다면 찾고자 하는 존재할 수도 있으므로 해당 집합을 탐색합니다.

 

 

예를들어 1개의 Segment에는 1개의 Event만 존재하고, 4개 bit 배열 및 1개의 해 시함수(mod 10)을 적용한다고 가정하겠습니다. 이때 찾고자하는 Aggregate 식별자는 14라면 위 해시 함수를 적용했을 때 결과는 4가 나옵니다. 또한 저장된 값 또한 14라면 Bloom Filter 및 찾고자 하는 값이 동일하므로 해당 집합에는 원하는 결과가 존재합니다.

 

 

하지만 만약 집합속에 있는 값이 24라면, 해시 함수 결과 똑같이 4라는 결과가 나옵니다. 이때는 값이 다른데도 불구하고, 값이 있을 수도 있다고 판정하여 해당 집합을 읽습니다. 따라서 이러한 경우는 비효율적인 I/O가 존재하며 이를 false positive라고 합니다. 따라서 Bloom Filter에서는 false positive를 줄이기 위해서 bit 배열의 수를 늘리는 것과 해시 함수 개수를 늘려서 동일한 값이 발생하지 않도록해야 불필요한 I/O를 유발하지 않습니다. 

 

참고자료


 

Bloom Filter를 통해서 읽어야할 Segment를 알았다면, 해당 Segment에서 시작점을 찾는 것은 Segment 내부에 저장되어있는 인덱스를 통해서 Aggregate의 Sequence 번호를 찾아갑니다. 결론적으로 Axon Server의 EventStore에서 Aggregate 데이터를 검색할 때, 해당 Aggregate의 식별자와 Sequence 번호를 기준으로 Bloom Filter인덱스를 활용하여 이를 찾습니다.


5. 마치며

 

이번 시간에는 EventStore 역할 비교 및 Axon Server 저장소 구조에 대해서 알아보았습니다. 다음 포스팅부터 Query Application 구현에 대하여 다루도록 하겠습니다.

1. 서론

이번 포스팅은 데모 프로젝트 진행에 있어 필수적으로 구현해야하는 코드는 없습니다. 따라서 Skip해도 괜찮습니다.  이번 시간에는 상태를 저장하는 State-Stored Aggregate에 대해서 알아보겠습니다.


2. Aggregate 종류

 

AxonFramework 에서 Aggregate의 종류는 크게 두 가지로 분류할 수 있습니다. 

 

  1. EventSourced Aggregate
  2. State-Stored Aggregate

 

EventSourced Aggregate는 기존 Command 어플리케이션을 제작하는 과정에서 구현한 모델 방식입니다. 즉 EventStore로부터 Event를 재생하면서 모델의 최신상태를 만듭니다.

 

출처 : https://altkomsoftware.pl/en/blog/cqrs-event-sourcing/

 

이와 반대로 State-Stored Aggregate는 위 그림과 같이 EventStore에 Event를 적재와 별개로 모델 자체에 최신 상태를 DB에 저장합니다. 데모 프로젝트 Aggregate 구조 변경을 통해 Command DB에 모델을 생성하는 방법에 대해서 알아봅시다.


3. State-Stored Aggregate

 

데모 프로젝트에는 Holder와 Account Entitiy가 존재합니다. Command 모델에서는 해당 Entitiy 관계를 분리하여 표현할 것이며 모델 구현은 JPA를 사용하겠습니다. 먼저 Command와 Query DB에 적재될 테이블 구조를 살펴보겠습니다.

 

 

ERD

 

Command 모델

 

Query 모델


3. Aggregate 구현

 

State-Stored Aggregate 구현을 위해 기존 코드를 단계적으로 변경하겠습니다.

 

1. HolderAggregate와 AccountAggregate 구조 변경을 통해 상태를 저장할 수 있도록 구현합니다.

 

HolderAggregate.java

@AllArgsConstructor
@NoArgsConstructor
@Aggregate
@Slf4j
@Entity(name = "holder")
@Table(name = "holder")
public class HolderAggregate {
    @AggregateIdentifier
    @Id
    @Column(name = "holder_id")
    @Getter
    private String holderID;
    @Column(name = "holder_name")
    private String holderName;
    private String tel;
    private String address;

    @OneToMany(mappedBy = "holder", orphanRemoval = true)
    private List<AccountAggregate> accounts = new ArrayList<>();

    public void registerAccount(AccountAggregate account){
        if(!this.accounts.contains(account))
            this.accounts.add(account);
    }
    public void unRegisterAccount(AccountAggregate account){
        this.accounts.remove(account);
    }

    @CommandHandler
    public HolderAggregate(HolderCreationCommand command) {
        log.debug("handling {}", command);

        this.holderID = command.getHolderID();
        this.holderName = command.getHolderName();
        this.tel = command.getTel();
        this.address = command.getAddress();

        apply(new HolderCreationEvent(command.getHolderID(), command.getHolderName(), command.getTel(), command.getAddress()));
    }
}

 

@Entity 어노테이션을 통해 대상 Aggregate가 JPA에서 관리되는 Entity임을 명시했습니다. 또한 Aggregate 식별자에 @Id 어노테이션을 추가하여 대상 속성이 PK임을 표시합니다.

 

HolderAggregate는 AccountAggregate와 1:N 관계를 맺고 있으므로 양방향 관계 설정 했으며, HolderAggregate가 삭제될 경우 AccountAggregate도 삭제되도록 orphanRemovel 옵션을 추가했습니다.

 

마지막으로 양방향 관계 설정 시 연관관계 편의 메소드 제공을 위해 registerAccount, unRegisterAccount 메소드를 추가했습니다. 

(참고 : 양방향 연관관계 편의 메소드)

 

혹시 위 Aggregate 코드에서 JPA 코드 추가 외에 혹시 이상한 점을 눈치채셨나요?

 

바로 EventSourcingHandler 메소드가 사라졌습니다. State-Stored Aggregate 모델은 모델 자체가 최신 상태를 유지하고 있으므로 EventStore로부터 Replay를 할 필요가 없습니다. 따라서 CommandHandler 메소드 내에서 Command 상태를 저장하는 로직을 포함시켜야 합니다.

 

이번에는 AccountAggreagte 클래스를 변경하겠습니다.

 

AccountAggregate.java

@NoArgsConstructor
@AllArgsConstructor
@Slf4j
@Aggregate
@EqualsAndHashCode
@Entity(name = "account")
@Table(name = "account")
public class AccountAggregate {
    @AggregateIdentifier
    @Id
    @Column(name = "account_id")
    private String accountID;

    @ManyToOne
    @JoinColumn(name = "holder_id", foreignKey = @ForeignKey(name = "FK_HOLDER"))
    private HolderAggregate holder;
    private Long balance;

    public void registerHolder(HolderAggregate holder){
        if(this.holder != null)
            this.holder.unRegisterAccount(this);
        this.holder = holder;
        this.holder.registerAccount(this);
    }

    @CommandHandler
    public AccountAggregate(AccountCreationCommand command) {
        log.debug("handling {}", command);
        this.accountID = command.getAccountID();
        HolderAggregate holder = command.getHolder();
        registerHolder(holder);
        this.balance = 0L;
        apply(new AccountCreationEvent(holder.getHolderID(),command.getAccountID()));
    }
    @CommandHandler
    protected void depositMoney(DepositMoneyCommand command){
        log.debug("handling {}", command);
        if(command.getAmount() <= 0) throw new IllegalStateException("amount >= 0");
        this.balance += command.getAmount();
        log.debug("balance {}", this.balance);
        apply(new DepositMoneyEvent(command.getHolderID(), command.getAccountID(), command.getAmount()));
    }
    @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");
        this.balance -= command.getAmount();
        log.debug("balance {}", this.balance);
        apply(new WithdrawMoneyEvent(command.getHolderID(), command.getAccountID(), command.getAmount()));
    }
}

 

HolderAggregate에서 설명한 부분은 제외하고 추가된 점은 Account 모델이 Holder 모델에 대하여 FK를 지니고 있으므로 관계상에서 FK를 명시하였습니다. 


2. HolderAggregate 클래스 생성을 위한 Repository 패키지 및 클래스를 생성합니다.

 


3. HolderRepository 클래스를 구현합니다.

@Repository
public interface HolderRepository extends JpaRepository<HolderAggregate,String> {
    Optional<HolderAggregate> findHolderAggregateByHolderID(String id);
}

4. 객체 대상으로 연관관계를 변경하였기 때문에 AccountCreateCommand 클래스를 수정합니다.

 


5. Service 클래스를 수정합니다.

 

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

...(중략)...

    @Override
    public CompletableFuture<String> createAccount(AccountDTO accountDTO) {
        HolderAggregate holder = holders.findHolderAggregateByHolderID(accountDTO.getHolderID())
                                       .orElseThrow( () -> new IllegalAccessError("계정 ID가 올바르지 않습니다."));
        return commandGateway.send(new AccountCreationCommand(UUID.randomUUID().toString(),holder));
    }

...(중략)...
}

6. Snapshot 설정을 위해 생성한 Configuration 속성을 적용하지 않도록 AxonConfig 클래스 @Configuration 어노테이션을 주석처리 합니다.

 

//@Configuration
//@AutoConfigureAfter(AxonAutoConfiguration.class)
public class AxonConfig {
...(중략)...
}

 

이로써 State-Stored Aggregate 구현에 대한 코드 변경은 끝났습니다.


4. 테스트

 

1. Application 실행 후 계정 생성 API 테스트를 진행합니다.

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

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

 

2. DB에서 계정 데이터 생성 여부를 확인합니다.

 


3. 계좌 생성 API를 호출합니다.

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

{
  "holderID" : "486832c2-b606-470d-949a-9f9d8613b112"
}

 

4. DB에서 계좌 데이터 생성 여부를 확인합니다.

 


5. 입금 API를 호출합니다.

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

{
  "accountID" : "9274bb43-ca87-4aa4-b1b4-0363382ad6fb",
  "holderID" : "486832c2-b606-470d-949a-9f9d8613b112",
  "amount" : 300
}

 

6. DB에서 해당 계정 잔고를 확인합니다.

 

 

정상적으로 입금된 것을 확인할 수 있습니다.


7. 출금 API를 4번 연속으로 호출합니다.

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

{
  "accountID" : "9274bb43-ca87-4aa4-b1b4-0363382ad6fb",
  "holderID" : "486832c2-b606-470d-949a-9f9d8613b112",
  "amount" : 1
}

8. DB에서 출금 내역을 확인합니다.

 

 

정상적으로 출금된 것을 확인할 수 있습니다. 

o.a.commandhandling.SimpleCommandBus     : Handling command [com.cqrs.command.commands.WithdrawMoneyCommand]
c.c.command.aggregate.AccountAggregate   : handling WithdrawMoneyCommand(accountID=9274bb43-ca87-4aa4-b1b4-0363382ad6fb, holderID=486832c2-b606-470d-949a-9f9d8613b112, amount=1)
c.c.command.aggregate.AccountAggregate   : balance 296
org.axonframework.messaging.Scope        : Clearing out ThreadLocal current Scope, as no Scopes are present

 

4번 연속 출금 후 Application의 로그를 일부 발췌하였습니다. 기존과 다른점은 EventSourced Aggregate의 경우에는 Replay를 위해 EventStore의 I/O 과정이 필요했지만 State-Stored Aggregate는 상태를 보관하므로 상태 복원 과정이 없습니다.


5. 마치며

State-Stored Aggreagte는 Command DB에 최신 상태를 보관합니다. 이로인해 매번 EventStore를 통해서 Replay를 하지 않아도 되는 점은 장점입니다.

 

하지만 만약에 테이블 데이터가 손실이 되어서 복구가 필요한 경우 Replay를 자동으로 수행되지 않으므로 별도로 EventSourcing 하는 작업을 구현해야 할 수 있습니다. 물론 DBMS 자체 복구 기능을 이용할 수도 있습니다. 하지만 Media Recovery가 불가피하다면 DB 서비스 중단이 발생합니다. 따라서 Aggregate별 사용 장단점을 인지한 다음 적절한 사용이 필요합니다.

 

이상으로 Command Application 구현 포스팅 마치도록 하겠습니다. 

+ Recent posts