project/wargame

CQRS 패턴을 이용한 데이터 쿼리 - 게임 전적과 통계 구현

downfa11 2025. 1. 3. 01:07

들어가기에 앞서

리그오브레전드의 전적 검색이나 통계 시스템을 생각해보자.

각 사용자의 전적에서는 사용한 챔피언별로 판수나 승률이 함께 조회된다. 심지어 각 챔피언별 판수나 승률, 아이템과 어떤 스펠을 사용했을때 가장 승률이 높은지도 알 수 있다.

op.gg

 

보이는거만 해도 각 챔피언당 몇 백만 판수씩 나오는데, 고작 플레이 수나, 승패에 따른 승률을 연산하기 위해 일일이 DB에서 꺼내와 더할건가?

예시가 너무 게임이라 다시 들자면 토스에서 제공하는 서비스중에서 서초구에 위치한 20대들의 평균 잔고가 얼마인지 제공하는게 있다.

 

 

모든 사용자들 중에서 서초구, 20대인 사람들을 쿼리문으로 조회해서 평균 잔고를 모두 더했겠는가? 그 사용자들이 100만 명이어도 평균을 계산하기 위해 모든 잔고를 더할건가? ...(1)

매번 잔고에 송금된 금액이 있을때마다 서초구, 20대라면 balance값에 값을 더하게 할건가?

그럼 뺄때마다 다시 DB에 접근해서 가감할거고? ...(2)

조회하는 필드가 서초구-20대, 서초구-30대, 서초구-40대 ... 로 늘어날게 뻔한데, 동시에 접근하는 테이블이 점점 많아진다고 느낀다면 맞다.

그럼 어쩔껀데?

MSA에서 이용하는 CQRS 패턴

앞서 예시로 든 두 가지 중에서 전자는 API Aggregation 패턴(1)에 가깝고, 후자인 (2)는 프로젝트에서 직접 구현한 기능과 유사하다.

그럼 후자의 문제점을 어떻게 해결했는가?

해답은 CQRS 패턴(Command and Query Responsibility Segregation)이다.

직역하자면 명령(Command)와 질의(Query)의 책임(Responsibility)을 분리(Segregation)하는 구조이다.

일반적인 경우, 쓰기 작업과 읽기 작업에 대해서 동일한 데이터 모델을 차용한다.

 

하지만 보통 쓰기 작업에 더 높은 정합성이 요구되기에, 시스템의 상태를 변경하는 Command와 상태를 조회하는 Query간의 데이터 모델을 분리한다는 말이다.

 

이벤트 소싱을 함께 도입하여 잔고를 바로 업데이트하는 대신, 이벤트 자체를 저장하여 계산할 수도 있다.

읽기 성능을 개선해 성능적인 이점을 가져오되, 이벤트 소싱 방식으로 확장할 수 있다.

이벤트를 발행하는 Command와 이벤트를 구독하는 Query로 하여금 메시지를 주고 받는다.

  • CommandHandler
  • EventHandler

CQRS 패턴을 사용하면 API Aggregation 패턴 쓸때보다 대부분의 경우에서 구현이 복잡하지만, 리스트가 훨씬 적고 뛰어난 퍼포먼스를 보일 수 있다.

 

 

Axon Framework

Java 진영에서 Event Sourcing과 CQRS를 구현하는 프레임워크

각 서비스끼리 메시지를 주고 받기 위한 Axon Server까지 사용하면 다양한 트랜잭션을 구현할 수 있다.

이벤트 소싱을 위해서는 CQRS가 필수지만, 반대로 CRQS를 위해 이벤트 소싱을 사용할 필요는 없다

굳이 Kafka를 운영하고 있는데 Axon-Server까지 쓸 필요가 없다고 판단했다. 이벤트 주도 아키텍처도 아닐뿐더러 CQRS 패턴에만 사용하기 위한 용도라..

근데 Reactor 프로젝트에 기반한 Axon과 Kafka를 통합하는데 있어서 ReactiveKafka를 지원하지 않는다.

직접 Axon의 이벤트를 kafka로 발행하고 수신하도록 처리해야한다.

result 서비스에서는 Axon Framework를 의존성 주입하지 않고, result-query에서만 kafka로 받은 메시지를 이벤트로 convert해서 이벤트로 처리하도록 했다.

이를 통해 명령(command)와 조회(query)를 분리해 CQRS 패턴을 구축한다.

DynamoDB를 이용해서 CQRS 패턴 구현하기

테이블 생성시, 기본 전략인 프로비저닝을 선택하면 요금이 나가기에 OnDemand로 설정하여 사용한 만큼만 설정되도록 한다.

글로벌 인덱스는 생성하고 나서도 추가할 수 있어서 내팽겨치고, 우선 테이블을 생성했다.

챔피언별 판수와 승률을 날짜별로 기록하는 목적으로, DynamoDB는 PK와 SK(Sorted Key)의 설계가 성능에 큰 영향을 미칠 정도로 중요하다.

100명 두고 테이블 설계 시켜놔도 다 다르게 적는다더니.. 진짜 애매하다 쩝

result 서비스에서 임시로 dummy 데이터를 담아서 게임 전적을 elasticsearch로 기록하는 메소드를 구현했다.

 

elasticsearch에 기록함과 동시에 Kafka를 통해서 게임 결과 등록 이벤트를 result-query 서비스로 전달한다.

Axon Framework의 @EventHandler로 이벤트를 수신해서 확인된 정보를 기준으로 DynamoDB에 데이터를 기록한다.

 

raw event Insert

PK: #1#Champname#시즌1#230728, SK: 승패 여부 (승리 수로 해도 1 아니면 0이다)

 

각 챔피언별 판수 정보를 업데이트

   PK: #1#Champname#시즌1#230728#summary, SK: -1 전체 판수: + 100, 승리: + 80, 패배: + 20

   PK: #1#Champname#시즌1, SK:-1 ,전체 판수: + 100, 승리: + 80, 패배: + 20

 

그럼, 사용자마다 전적 정보를 기록해보자 그리고 각 사용자마다 챔프별 판수와 승률을 기록하고자 한다.

 

 

raw event Insert

#membershipId#username#시즌1

 

PK: #membershipId#username#시즌1#230728#summary, SK: -1

   전체 판수: + 100, 승리: + 80, 패배: + 20,

      champName1 판수 : + 20, 승리: + 15, 패배: +5,

      champName2 판수 : + 20, 승리: + 15, 패배: +5,

      …

 

PK: #membershipId#username#시즌1, SK:-1

   전체 판수: + 100, 승리: + 80, 패배: + 20,

      champName1 판수 : + 20, 승리: + 15, 패배: +5,

      champName2 판수 : + 20, 승리: + 15, 패배: +5,

      …

DynamoDB OneTable Design

이때 SK로 timestamp를 쓰지만, -1로 고정하는 방법이 있다.

일별로 1개, 지역별로 1개만 생기기에 무조건 1개만 존재하도록 해서 좋다.

 

 

함께 비교하면 좋은 글을 모아봤음

챔프별로 시즌별로 통계를 CQRS 패턴의 쿼리 형태로 조회가 되는가?

result-query 서비스를 Spring MVC에서 Webflux로 마이그레이션하는 과정에서 생긴 CQRS 쿼리 오류

 

Result Event Received: ResultRequestEvent(spaceId=dummy-space-id, state=null, channel=0, room=0, winTeam=Blue, loseTeam=Red, blueTeams=[...
2024-10-02T21:34:44.199+09:00  INFO 1 --- [result-query] [onsumer-group-1] c.n.r.axon.GameResultEventHandler        : ResultRequestEvent publish to eventGateway Successfully!
2024-10-02T21:35:55.923+09:00 ERROR 1 --- [result-query] [or-http-epoll-7] a.w.r.e.AbstractErrorWebExceptionHandler : [ce8d9471-11]  500 Server Error for HTTP GET "/result/query/test-champ-1"

java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-epoll-7
        at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:86) ~[reactor-core-3.6.3.jar!/:3.6.3]
        Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
        *__checkpoint ⇢ Handler com.ns.resultquery.controller.ResultQueryController#getQueryToResultSumByChampName(String) [DispatcherHandler]
        *__checkpoint ⇢ HTTP GET "/result/query/test-champ-1" [ExceptionHandlingWebHandler]
Original Stack Trace:
​

흠.. @QueryHandler에서 Mono 형태로 전달받는게 안되는 오류였다.

기존 코드

public Mono<CountSumByChamp> queryToResultSumByChampName(String champName){
  // 챔프의 전체 판수와 승률을 쿼리

   System.out.println("queryToResultSumByChampName");

   return Mono.fromFuture(() ->
      queryGateway.query(new QueryResultSumByChampName(champName), CountSumByChamp.class));
}

여기서 보낸 queryGateway.query()가 Mono 형태가 아니라서 webflux가 작동중에 블로킹을 시도한건데, @QueryHandler인 query의 반환자도 Mono가 될 수 없도록 제거해서 해결했다.

 

 

새로 실행하면서 아무 전적이 없으니 빈 내용이라고 경고가 뜬거다. 휴

result 서비스에서 랜덤으로 전적을 생성해서 elasticsearch에 저장하자.

 

result-query 서비스는 게임이 끝나고, result 서비스로부터 Axon Framework를 통해 전적 기록 이벤트를 수신받는다.

그 결과는 아래와 같이 명령 형태(Command)로 통계 정보가 DynamoDB에 기록되고, 질의 형태(Query)로 조회할 수 있다.

 

전적 등록 이벤트를 수신받아 Command 형태로 데이터 상태를 변경하는건 해결했다.

이전 오류에서 해결했던 기능인 Query 형태의 메소드는 조회만을 목적으로 기능하며 잘 구현됐는지 확인해보자.

전적 1개 랜덤 생성시, test-champ-1 이라는 챔프의 통계는 한판 져서 다음과 같다.

전적 7번 랜덤 생성시, 어느 정도 승률이 나온걸 알 수 있다.

챔프 이름 ‘test-champ-1’를 검색해서 전체 판수와 승률을 통계낼 수 있다.

더 고도화한다면 티어별로도 나타낼 수 있겠지만 아직 거기까지 설계하진 않았다.

MSA 환경에서 Axon Framework를 이용해서 CQRS 패턴을 적용해 데이터 쿼리를 구현했다.

해당 프로세스는 게임 프로젝트에서 각 사용자들의 전적과 챔프별 통계에 사용된다.

이제 개선된 방식으로 각 챔피언별 승률과 각 사용자의 챔피언별 승률을 조회할거다.

각 사용자별로 챔프별, 시즌별 전체 판수와 승률을 조회

dynamoDB에 새로운 테이블을 생성하자. “wargaem-membership-query”

각 사용자들의 닉네임으로 전적을 검색해서, 챔프별로 판수와 승률, 전체 판수와 승률을 시즌별로 조회할 계획이다.

챔프 수는 업데이트할 수록 늘어날 계획이라, 설계 단계부터 확장성을 염두해뒀다.

 

raw event Insert (Insert, put)

"#" + membershipId + "_" + username + "season" + CURRENT_SEASON + "" + datetime

 

날짜별 판수 정보를 업데이트 (Query, Update) : pk + “#summary”

사용자별 정보 : username + "_season" + CURRENT_SEASON

resultCount, winCount, loseCount, 각 챔프별 판수

이미 한번 챔프별 전적을 구현하면서 삽질했기에 금방 진행될 줄 알았는데

 

DynamoDB에 Map<String, Attribute> 형태로 사용자의 챔프별 기록을 기록하면서 자료구조의 호환이 좀 어려웠다.

 

public class CountSumByMembership {
    private final String username;

    private final Long entireCount;
    private final Long winCount;
    private final Long loseCount;

    private final List<ChampStat> champStatList;
}

 

public class ChampStat {
    private final Long champIndex;
    private final String champName;

    private final Long resultCount;
    private final Long winCount;
    private final Long loseCount;
    private final String percent;
}

 

쿼리 형태로 조회할때는 Map<String, Attribute> 형태로 잘 읽어줘야한다. 형식이 어렵다.

사용자의 이름을 검색해도 전체 승률과 전적, 챔프별 승률과 통계까지 조회(Query)할 수 있다.

elasticSearch에서 사용자 이름으로 30개씩 전적을 검색하는 함수를 함께 호출하면 전적 검색 끝