7.1 API 조합 패턴 응용 쿼리
7.1.1 findOrder() 쿼리
findOrder()
: 기본 키로 주문 정보를 조회하는 메서드.
- 위의 그림은 findOrder 시 호출되는 서비스들이다. 모놀리틱 애플리케이션은 전체 데이터가 하나의 DB에 있기 때문에 알기 쉽게 SELECT 문으로 여러 테이블을 조인해서 주문 내역을 조회한다. 반면 마이크로서비스 아키텍처로 전환하면 데이터가 여러 서비스에 뿔뿔이 흩어지게 된다.
- 주문 서비스: 주문 기본 정보
- 주방 서비스: 음식점 관점의 주문상태, 픽업 준비까지 예상 소요 시간
- 배달 서비스: 주문 배달 상태, 배달 예상 정보, 현재 배달원 위치
- 회계 서비스: 주문 지불 상태
- 클라이언트가 주문 내역을 조회하려면 이런 모든 서비스에 요청을 해야한다.
7.1.2 API 조합 패턴 개요
- API 조합 패턴은 데이터를 가진 서비스를 호출한 후 그 반환 결과를 조합해서 가져온다. 이 과정에서 두 종류의 참여자가 개입한다.
- API 조합기: 프로바이더 서비스를 쿼리하여 데이터를 조회한다.
- 프로바이더 서비스: 최종 결과로 반환한 데이터의 일부를 갖고 있는 서비스
- API 조합기는 A,B,C 세 프로바이더 서비스에서 데이터를 조회한 후 그 결과를 조합한다. API 조합기는 웹 애플리케이션처럼 웹 페이지에 데이터를 렌더링하는 클라이언트일 수 있고, 쿼리 작업을 API 엔드포인트로 표출한 API 게이트웨이나 프론트엔드를 위한 백엔드 패턴의 변형일 수도 있다. (BFF 말하는 것 같음)
7.1.3 API를 조합 패턴으로 findOrder() 쿼리 구현
- 여기서 API 조합기는 쿼리를 REST 끝점으로 표출한 서비스다. HTTP 대신 gRPC 같은 다른 IPC 프로토콜을 사용하는 서비스 역시 개념은 같다. REST 끝점
GET / order/{orderId}
가 구현된 주문 검색 조합기는 orderId로 네 서비스를 호출한 후 수신한 응답을 조인한다.
7.1.4 API 조합 설계 이슈
- API 조합 패턴에는 두 가지 설계 이슈가 존재한다.
- 어느 컴포넌트를 쿼리 작업의 API 조합기로 선정할 것인가?
- 어떻게 해야 효율적으로 취합 로직을 작성할 것인가?
누가 API 조합기 역할을 맡을 것인가?
- 서비스 클라이언트를 API 조합기로 만든다.
- 주문 상태 뷰를 구현한 웹 애플리케이션 같은 클라이언트가 동일한 LAN에서 실행 중이라면 가장 효율적으로 주문 내역을 조회할 수 있다. 하지만 클라이언트가 방화벽 외부에 있고 서비스가 위치한 네트워크가 느리다면 그리 실용적이지 않다. (8장)
- 애플리케이션의 외부 API 게이트웨이를 API 조합기로 만든다.
- 쿼리 작업이 애플리케이션의 외부 API 중 일부라면 이 방법이 타당하다. 다른 서비스로 요청을 보내는 대신 API 게이트웨이에 API 조합 로직을 구현하는 것이다. 이 방법은 모바일 기기 등 방화벽 외부에서 접근하는 클라이언트가 API 호출 한 번으로 여러 서비스의 데이터를 조회할 수 있기 때문에 효율적이다.
- API 조합기를 StandAlone 서비스로 구현한다.
- 내부적으로 여러 서비스가 사용하는 쿼리 작업이면 이 방법이 좋음.
API 조합기는 리액티브 프로그래밍 모델을 사용해야 한다.
- 쿼리 작업의 반응 시간을 줄이기 위해 API 조합기가 프로바이더 서비스를 병렬 호출해야 한다.
- 주문 검색 애그리거트는 호출 대상인 네 서비스가 서로 의존 관계가 없기 때문에 동시 호출하는 것이 맞다. 하지만 어떤 프로바이더 서비스를 호출하기 위해 다른 서비스의 결과를 먼저 가져와야 하는 경우도 있다. 이럴 때에는 프로바이더 서비스를 순차 호출해야 한다.
- 자바의 CompletableFuture, RxJava의 Observable, 리액터 등
API 조합 패턴의 장단점
- 장점: 마이크로 서비스 아키텍처에서 쉽고 단순하게 쿼리 작업 구현 가능
- 단점: 오버헤드 증가, 가용성 저하 우려, 데이터 일관성 결여
오버헤드가 증가한다
- API 조합 패턴은 여러 번 요청하고 여러 DB 쿼리를 실행해야 한다.
가용성이 저하될 우려가 있다.
- 어떤 작업의 가용성은 더 많은 서비스가 개입할수록 감소. (3장) 하나의 쿼리 작업에 세 서비스 (API 조합기 + 둘 이상의 프로바이더 서비스)가 반드시 개입되는 구조라서 하나의 서비스로 처리하는 것에 비해 가용성은 현저히 낮다.
- 아래 두 가지 방법으로 가용성을 높일 수 있다.
- 프로바이더 서비스가 불능인 경우 API 조합기가 이전에 캐시한 데이터를 반환하게 한다.
- API 조합기가 미완성된 데이터를 반환하는 것. 가령 주방 서비스가 일시 불능 상태면 주문 검색 조합기가 이 서비스의 데이터만 제외한 나머지 데이터를 반환한다.
데이터 일관성이 결여된다.
- 여러 DB를 대상으로 여러 쿼리를 실행하기 때문에 일관되지 않은 데이터가 반환도리 수 있다.
- 예를 들어 주문 서비스가 조회한 주문 상태는 CANCELLED지만, 주방 서비스가 조회한 이 주문의 티켓은 아직 취소되지 않았을 수도 있다. API 조합기는 이 문제점을 해결해야 하는데, 코드가 점점 복잡해진다.
이러한 단점에도 API 조합 패턴은 쿼리 기능을 쉽게 구현할 수 있는 수단으로 매우 유용하다. 효율적으로 구현하기 어려운 (거대한 데이터를 인 메모리 조인하는) 쿼리 작업은 CQRS 패턴으로 구현하는 것이 현명하다.
7.2 CQRS 패턴
CQRS (Command and Query Responsibility Segregation) 패턴: 여러 서비스에 있는 데이터를 가져오는 쿼리는 이벤트를 이용해서 해당 서비스의 데이터를 복제한 읽기 전용 뷰를 유지 한다.
7.2.1 CQRS의 필요성
findOrderHistory() 쿼리 구현
findOrderHistory()
: 아래의 매개변수를 받아 소비자의 주문 이력을 조회하는 쿼리 작업 (다건)
- consumerId: 소비자 PK
- OrderHistoryFilter: 필터 조건. 기간(필수), 주문 상태(옵션), 음식점명 및 메뉴 항목을 검색할 키워드 (옵션)
findOrder()
와 비슷하지만 모든 프로바이더 서비스가 필터/정렬의 속성을 보관하지 않아 기존 방식으로 해결하기 어렵다.
findOrderHistory()
의 OrderHistoryFilter
라는 메뉴 항목과 매치할 keywords 속성이 있는데, 메뉴 항목을 저장하는 서비스는 주문 서비스, 주방 서비스 2개 뿐이고, 나머지 배달 서비스, 회계 서비스는 메뉴 항목을 저장하지 않기 때문에 keywords 항목을 지정할 수 없다. 마찬가지로 주방 서비스, 배달 서비스 등 둘다 orderCreationDate 속성으로 정렬할 수 없다.
- API 조합기는 이 문제를 두 가지 방법으로 해결할 수 있다.
- 첫째, API 조합기로 데이터를 인 메모리 조인한다. 그러나 거대한 데이터 뭉치를 이런 식으로 API 조합기에서 조인하면 급격히 효율이 떨어진다.
- 둘째, API 조합기로 주문 서비스, 주방 서비스에서 데이터를 조회하고, 주문 ID를 이용하여 다른 서비스에 있는 데이터를 요청한다. 하지만 이는 해당 서비스가 대량 조회 API를 제공할 경우에만 가능한 방법이다. 그렇다고 주문 데이터를 하나하나 요청하는 것은 과도한 네트워크 트래픽이 유발되므로 비효율적이다.
어려운 단일 서비스 쿼리: findAvailableRestaurants()
- 하나의 서비스에 국한된 쿼리도 구현하기 어려운 경우가 있다. 이유는 두 가지이다.
- 데이터를 가진 서비스에 쿼리를 구현하는 것이 부적절한 경우가 있다.
- 서비스 DB가 효율적인 쿼리를 지원하지 않기 때문이다.
- 예를 들어
findAvailableRestaurants()
쿼리 작업은 주어진 시점에, 주어진 위치로 배달 가능한 음식점을 검색한다. 이 쿼리의 핵심은 배달 주소의 특정 거리 내에 있는 음식점을 위치 기준으로 찾는 기능이다. 이 기능은 음식점을 표시하는 UI 모듈에 의해 호출되어, 주문 프로세스에 있어서 매우 중요한 부분이다.
findAvailableRestaurants()
쿼리에서 가장 어려운 부분은 효율적으로 지리 공간 쿼리를 수행하는 작업이다 이 쿼리를 어떻게 구현할지는 음식점 데이터가 저장된 DB의 능력에 좌우된다.
- 가령 MongoDB, Postgress, MySQL의 Geospatial Extensions를 이용하면 아주 쉽게 구현이 가능하나, 사용 중인 DB가 해당 기능을 지원하지 않을 경우 이 쿼리를 구현하기 훨씬 까다롭다. 음식점 데이터를 지리 공간 쿼리에 맞게 설계된 형태로 유지할 수 밖에 없다.
관심사를 분리할 필요성
- 데이터를 가진 서비스에 쿼리를 구현하면 안 될 때가 있다.
findAvailableRestaurants()
는 음식점 서비스에 있는 데이터를 조회하는 쿼리 작업이나 음식점명, 주소, 요리, 메뉴, 오픈 시간 등 다양한 속성을 저장한다.
- 관심사를 어떻게 분리하면 좋을지, 어느 한 서비스에 너무 많은 책임을 부과하지 않으면 어떻게 해야 할까 하는 문제도 고민해야 한다. 가령 음식점 서비스 개발 팀의 주 임무는 음식점 주인이 자기가 운영하는 음식점을 잘 관리할 수 있게 해주는 서비스를 개발하는 일이지, 성능이 매우 중요한 대용량 데이터를 조회하는 쿼리를 구현하는 일은 아니다. 그리고 이 팀의 개발자가
findAvailableRestaurants()
의 개발까지 담당할 경우, 나중에 자신이 변경한 코드를 배포하면 만에 하나 소비자가 주문을 못 하게 되지 않을까 걱정하며 불안에 시달리게 될 것이다.
- 그러므로
findAvailableRestaurants()
쿼리는 다른 팀 (주문 서비스 개발팀)이 구현하고 음식점 서비스는 검색할 음식점 데이터만 제공하는 구조가 낫다.
7.2.2 CQRS 개요
- 마이크로서비스에서는 쿼리를 구현할 때 흔히 다음 세가지의 난관에 봉착한다.
- API를 조합하여 여러 서비스에 흩어진 데이터를 조회하려면 값비싸고 비효울적인 인-메모리 조인을 해야한다.
- 데이터를 가진 서비스는 필요한 쿼리를 효율적으로 지원하지 않는 DB에 또는 그런 형태로 데이터를 저장한다.
- 관심사를 분리할 필요가 있다는 것은 데이터를 가진 서비스가 쿼리 작업을 구현할 장소로 적합하지 않다.
- 이 세가지 문제를 CQRS 패턴으로 해결할 수 있다.
CQRS는 커맨드와 쿼리를 서로 분리한다.
- CQRS는 이름에서 볼 수 있듯이 관심사의 분리/구분에 관한 패턴이다.
이 패턴은 영속적 데이터 모델과 그것을 사용하는 모듈을 커맨드와 쿼리, 두 개로 가른다.
- 생성/수정/삭제 (CUD, HTTP POST, PUT, DELETE) 기능은 커맨드 쪽 모듈 및 데이터 모델에 구현
- 조회 (R, HTTP GET) 기능은 쿼리 쪽 모듈 및 데이터 모델에 구현
- CQRS 서비스에서 커맨드 쪽 도메인 모델은 CRUD 작업을 처리하고 자체 DB에 매핑된다. 커맨드 쪽은 데이터가 바뀔 때 마다 (이벤트 소싱 등의 프레임워크를 이용하여) 도메인 이밴트를 발행한다.
- 별도로 나눠진 쿼리 모델은 복잡한 쿼리를 처리한다.
- 쿼리 쪽은 반드시 지원해야 하는 쿼리에 대해서는 모든 종류의 DB를 지원해야 한다.
- 또 도메인 이벤트를 구독하고 DB를 업데이트하는 이벤트 핸들러가 존재한다.
- 쿼리 종류마다 쿼리 모델을 하나씩 가진 다중 쿼리 모델도 존재한다.
CQRS와 쿼리 전용 서비스
- CQRS는 서비스 내부에 적용할 수 있을 뿐만 아니라, 쿼리 서비스만 단독으로 정의하는 것도 가능하다.
- 쿼리 서비스에는 커맨드 작업이 전혀 없는 오직 쿼리 작업만으로 구성된 API가 있고, 하나 이상의 다른 서비스가 발행한 이벤트를 구독하여 항상 최신 상태로 유지되는 DB를 쿼리하는 로직이 구현되어 있다.
- 주문 이력 서비스는 여러 서비스가 발행한 이벤트를 구독하고 주문 이력 DB를 업데이트 하는 이벤트 핸들러를 갖고 있다.
- 또한 쿼리 서비스는 한 서비스가 가진 데이터를 복제한 뷰를 구현하는 수단으로 유용하다. 가령 좀 전에 나왔던
findAvailableRestaurants()
쿼리 작업을 가용 음식점 서비스라는 별도의 쿼리 서비스로 구현할 수 있다.
- 여러 면에서 CQRS는 RDBMS를 기록 시스템으로 활용하면서 텍스트 검색 엔진 (Elsatic Search)를 이용하여 텍스트 검색 쿼리를 처리하는 대중적인 접근 방식을 이벤트를 기반으로 일반화한 것이라고 볼 수 있다. 다만 CQRS는 텍스트 검색 엔진 뿐 아니라 훨씬 다양한 종류의 DB를 활용할 수 있다는 차이점이 있고, CQRS 쿼리 쪽 뷰는 이벤트를 구독해서 거의 실시간으로 업데이트가 된다.
7.2.3 CQRS의 장점
마이크로서비스 아키텍처에서 효율적인 쿼리가 가능하다
- API 조합 패턴으로 쿼리하면 거대한 데이터를 인-메모리 조인하는데, 이 작업을 효율적으로 적용 가능
다양한 쿼리를 효율적으로 구현할 수 있다.
- 다양한 쿼리를 애플리케이션/서비스에 효율적으로 구현할 수 있다. CQRS 패턴을 이용하면 각 쿼리가 효울적으로 구현된 하나 이상의 뷰를 정의하여 단일 데이터 저장소의 한계를 극복할 수 있다.
이벤트 소싱 애플리케이션에서 쿼리가 가능하다.
- CQRS는 이벤트 소싱의 한계 (이벤트 저장소는 기본키 쿼리만 지원)을 극복하게 해준다.
- CQRS 패턴은 하나 이상의 애그리거트 뷰를 정의하고 이벤트 소싱 기반의 애그리거트가 발행한 이벤트 스트림을 구독해서 항상 최신 상태를 유지한다. 그래서 이벤트 소싱 애그리거트는 거의 예외없이 CQRS를 사용한다.
관심사가 더 분리된다.
- CQRS 패턴은 서비스의 커맨드 쪽, 쿼리 쪽에 각각 알맞은 코드 모듈과 DB 스키마ㅡㄹ 별도로 정의한다. 이렇게 관심사를 분리하면 커맨드/쿼리 양쪽 모두 관리하기 간편해지는 이점이 있다.
7.2.3 CQRS의 단점
아키텍처가 복잡하다
- 개발자는 뷰를 조회/수정하는 쿼리를 작성하고, 별도의 데이터 저장소를 관리해야하는 운영 복잡도도 증가된다.
복제 시차를 신경써야 한다
- 커맨드/쿼리 양쪽 뷰사이의 랙(lag)을 처리해야 한다.
- 커맨드 쪽이 이벤트를 발행하는 시점과 쿼리 쪽이 이벤트를 받아 뷰를 업데이트하는 시점 사이에 당연히 랙이 발생한다. 즉, 클라이언트 애플리케이션이 애그리거트를 업데이트한 즉시 뷰를 쿼리하면 이전 버전의 애그리거트를 바라볼 수도 있다.
- 랙을 해결할 수 있는 한 가지 방법은 커맨드/쿼리 양쪽 API가 클라이언트에 버전 정보를 전달해서 해당 데이터가 업데이트가 된 애그리거트에서 가져온 건지 분간하게 만드는 것이다. 이 경우 클라이언트는 최신 데이터를 받을 때 까지 쿼리 쪽 뷰를 계속 폴링한다.
- 네이티브 모바일 앱이나 SPA (Single Page Application) 같은 UI 애플리케이션은 쿼리를 하지 않고 커맨드가 성공하면 자신의 로컬 모델을 업데이트 하는 방법으로 랙을 해소할 수 있다.
- 커맨드가 반환한 데이터로 자체 모델을 업데이트 한다. 별다른 문제가 없다면 사용자 액션으로 쿼리가 트리거될 때 뷰는 최신 상태가 된다.
- 하지만 모델을 업데이트하려면 UI 코드가 서버 쪽 코드를 알고 있어야 하는 단점이 존재한다.
위와 같은 이유로 웬만하면 API를 조합해서 쓰고 요구사항이 복잡해지는 경우에 CQRS를 사용하자.
7.3. CQRS 뷰 설계
- 뷰 모듈을 개발할 때에는 몇 가지 중요한 설계 결정을 해야 한다
- DB를 선정하고 스키마를 설계해야 한다.
- 데이터 접근 모듈을 설계할 때 멱등한/동시 업데이트 등 다양한 문제를 고려해야 한다
- 기존 애플리케이션에 새 뷰를 구현하거나 기존 스키마를 바꿀 경우, 뷰를 효율적으로 (재)빌드할 수 있는 수단을 강구해야 한다.
- 뷰 클라이언트에서 복제 시차를 어떻게 처리할지 결정해야 한다.
7.3.1 뷰 DB 선택
SQL 대 NoSQL DB
- NoSQL은 대부분 트랜잭션 기능이 제한적이고 범용적인 쿼리 능력은 없지만, 유연한 데이터 모델, 우수한 성능/확장성 등 SQL 기반 DB보다 나은 경우가 있다.
- NoSQL DB는 CQRS 뷰와 잘 맞는다.
- NoSQL DB의 데이터 모델과 우수한 성능 역시 CQRS 뷰에 유리하다.
- CQRS 뷰는 단순 트랜잭션만 사용하고 고정된 쿼리만 실행하므로 NoSQL DB의 제약 사항에도 영향을 받지 않는다.
- 물론 SQL DB를 사용해서 CQRS 뷰를 구현하는 것이 좋은 경우도 있다.
- 최신 하드웨어에서 실행되는 최신 RDBMS는 예전보다 성능이 뛰어나고, 아무래도 대부분의 IT 종사자들은 SQL DB가 더 익숙하다.
- SQL DB는 확장판을 설치해서 비관계형 기능 (ex: 지리 공간 데이터형 쿼리)를 추가할 수 있다.
업데이트 작업 지원
- 뷰 데이터 모델에서는 쿼리뿐만 아니라 이벤트 핸들러가 실행할 업데이트 작업 역시 효율적으로 구현되어야 한다.
- 이벤트 핸들러는 대게 뷰 DB에 있는 레코드를 기본키로 찾아 수정/삭제한다. 예를 들어
findOrderHistory()
쿼리의 CQRS 뷰를 설계한다고 할 때, 이 뷰는 서비스에서 이벤트를 수신 받아 그대로 해당 레코드에 업데이트 한다
- 하지만 외래키를 이용해서 레코드를 수정/삭제하는 경우도 존재한다. 만약 Delivery와 Order가 1:1 관계라면 Delivery.id와 Order.id는 같다.
- 일부 DB 자료형은 외래키 기반의 업데이트 작업을 지원하나, NoSQL DB에서는 non PK 기반으로 데이터를 업데이트하기가 쉽지 않다. 애플리케이션이 업데이트할 레코드를 결정하려면 외래키에서 기본키로 매핑 가능한 데이터를 DB에 갖고 있어야 한다.
- 예를 들어 PK 기반의 수정/삭제만 지원되는 DynamoDB를 사용한다면, 먼저 DynamoDB 보조 인덱스를 쿼리해서 수정/삭제할 항목의 기본키를 결정해야 한다.
7.3.2 데이터 접근 모듈 설계
- 이벤트 핸들러와 쿼리 API 모듈은 DB에 직접 접근하는 대신 DAO 및 헬퍼 클래스로 구성된 데이터 접근 모듈을 사용한다.
- DAO는 이벤트 핸들러가 호출한 업데이트 작업과 쿼리 모듈이 호출한 쿼리 작업을 실제로 수행하고, 고수준 코드에 쓰이는 자료형과 DB API간 매핑, 동시 업데이트 처리 및 업데이트 멱등성 보장 등 다양한 작업을 한다.
동시성 처리
- 뷰가 한 종류의 애그리거트가 발행한 이벤트를 구독한다면 동시성 이슈는 없다. 하지만 뷰가 여러 종류의 애그리거트를 발행할 이벤트를 구독할 경우, 여러 이벤트 핸들러가 동일한 레코드에 달려들어 업데이트 할 수 있다.
- 예를 들어 동일한 주문을 대상으로 Order 이벤트 핸들러와 Delivery 이벤트 핸들러가 동일한 시간에 호출되어 해당 주문의 DB를 레코드하는 업데이트하는 DAO가 동시애 호출된다고 가정해보자. DAO는 동시 업데이트로 서로가 서로의 데이터를 덮어쓰지 않도록 작성되어야 한다. 만약 DAO 레코드를 읽고 업데이트된 레코드를 쓴다면 낙관적 잠금, 비관적 잠금이든 둘중 하나를 적용해야 한다.
멱등한 이벤트 핸들러
- 이벤트 핸들러는 같은 이벤트를 한 번 이상 넘겨받고 호출될 수 있다. 쿼리 쪽 이벤트 핸들러가 멱등한 경우에는 문제될 일은 아니나 그렇지 않은 경우 뷰 데이터 저장소는 일시적으로 동기화가 안 될 것이다.
- 예를 들어 주문 이력 뷰를 유지하는 이벤트 핸들러가 PICKED_UP, DELIVERED, PICKED_UP, DELIVERED 순서로 이벤트를 이벤트를 받아 호출한다고 해보자. 메시지 브로커가 최초로 PICKED_UP, DELIVERED 이벤트를 전달한 후, 네트워크 오류 등 어떤문제가 생겨 이전 시점의 이벤트 전달을 재개하면 PICKED_UP, DELIVERED 이벤트를 다시 전달한다. 두 번째 PICKED_UP 이벤트 핸들러가 처리한 이후, 주문 이력 뷰는 DELIVERED 이벤트가 처리되기 전 까지는 일시적으로 과거 주문 상태를 바라보게 된다.
- 중복 이벤트 때문에 부정확한 결과가 나온다면 멱등한 이벤트 핸들러가 아니다. 가령 은행 잔고를 증가시키는 이벤트는 당연히 멱등하지 않다. 비멱등적 이벤트 핸들러는 자신이 뷰 데이터 저장소에서 처리한 이벤트 ID를 기록해 두었다가 중복 이벤트가 들어오면 솎아내야 한다.
- 이벤트 핸들러는 반드시 이벤트 ID를 기록하고 데이터 저장소를 원자적으로 업데이트 해야 한다. 뷰 데이터 저장소가
SQL DB
일 경우, 이벤트 핸들러가 처리완료한 이벤트를 뷰 업데이트 트랜잭션 일부로 PROCESSED_EVENTS
테이블에 삽입할 수 있다. 그러나 NoSQL DB
인 경우, 이벤트 핸들러는 자신이 업데이트하는 데이터 저장소 '레코드' (MongoDB의 도큐먼트, DynamoDB의 테이블 아이템)에 이벤트를 저장해야 한다.
- 이벤트 핸들러가 모든 이벤트ID를 기록할 필요는 없다.
이벤트 ID가 그냥 하나씩 증가하는 구조
라면 주어진 애그리거트 인스턴스에서 전달받은 max(eventID)를 각 레코드에 저장하면 된다.** 레코드가 단일 애그리거트 인스턴스에 해당
된다면 이벤트 핸들러는 max(eventId)만 기록하면 된다. 여러 애그리거트의 이벤트가 조합된 결과를 나타내는 레코드
는 [애그리거트 타입, 애그리거트 ID] -> max(eventId) 맵을 담고 있어야 한다.
{...
"Order3945484543123-48546546542": "0000815e0c6f18f-0242ac1110e0ee02e"
"Delivery3945484543123-48546546542": "0000815e0c6f18f-0242ac1110e0ee02e"
}
클라이언트 애플리케이션이 최종 일관된 뷰를 사용할 수 있다.
- 커맨드와 쿼리 모듈 API를 이용하면 클라이언트가 비일관성을 감지하게 만들 수 있다. 커맨드 쪽 작업이 클라이언트에 발행된 이벤트의 ID가 포함된 토큰을 반환하고, 클라이언트는 이 토큰을 쿼리 작업에 전달하면 해당 이벤트에 의해 뷰가 업데이트되지 않았을 경우 에러가 반환된다.
7.3.3 CQRS 뷰 추가 업데이트
- 뷰를 추가/수정하는 작업은 개념만 보면 간단하다.
- 새 뷰를 생성하려면 쿼리 쪽 모듈을 개발하고, 데이터 저장소를 세팅하고, 서비스를 배포한다.
- 기존 뷰를 수정하는 작업도 이벤트 핸들러를 변경한 후 뷰를 재생성한다.
- 그러나 이 방법은 실제로 잘 통하지 않는다.
아카이빙된 이벤트를 이용하여 CQRS 뷰 구축
- 우선 메시지 브로커는 메시지를 무기한 보관할 수 없다. 그러므로 필요한 이벤트를 메시지 브로커에서 전부 읽기만 해서는 뷰를 구축할 수 없다. 이를테면 AWS S3 같은 곳에 아카이빙된, 더 오래된 이벤트도 같이 가져와야 한다. 아파치 스파크처럼 확장 가능한 빅데이터 기술을 응용하면 가능하다
CQRS 뷰를 단계적으로 구축
- 전체 이벤트를 처리하는 시간/리소스가 점점 증가하는 것도 뷰 생성의 다른 문제점이다. 결국 뷰는 너무 느려지고 비용도 많이 들 것이다. 해결 방법은 2단계 증분 알고리즘 (two-step incremental algorithm)을 적용하는 것이다.
- 1단계는 주기적으로 각 애그리거트의 인스턴스의 스냅샷을 그 이전의 스냅샷과 이 스냅샷이 생성된 이후 죽 발생한 이벤트를 바탕으로 계산한다.
- 2단계는 이렇게 계산된 스냅샷과 그 이후 발생한 이벤트를 이용하여 뷰를 생성한다.
7.4 CQRS 뷰 구현: AWS DynamoDB 응용
- DynamoDB는 아마존 클라우드에서 서비스로 사용 가능한 확장성이 우수한 NoSQL DB. DynamoDB는 완전 관리형 (fully Managed) DB라서 테이블의 처리 능력을 동적으로 가감할 수 있다. 데이터 모델은 JSON 객체처럼 계층적인 이름-값 쌍이 포함된 테이블로 구성되어 있다.
findOrderHistory()
의 CQRS 뷰는 여러 서비스의 이벤트를 소비하기 때문에 스탠드얼론 주문 뷰 서비스로 구현된다. 주문 이력 서비스는 여러 가지 모듈이 있지만, 개발/테스트를 단순화하기 위해 모듈마다 책임을 나누어 구현한다.
OrderHistoryEventHandler
: 여러 서비스가 발행한 이벤트를 구독하여 OrderHistoryDAO
를 호출
OrderHistoryQuery API 모듈
: 앞서 설명한 REST Endpoint
OrderHistoryDataAccess
: DynamoDB 테이블 및 관련 헬퍼 클래스를 조회/수정하는 메서드가 정의된 OrderHistoryDAO
를 포함
ftgo-order-history
: 주문이 저장된 DynamoDB 테이블
7.4.1 OrderHistoryEventHandlers 모듈
OrderHistoryEventHandlers
는 이벤트를 소비해서 DynamoDB 테이블을 업데이트하는 이벤트 핸들러로 구성된 모듈이다
public class OrderHistoryEventHandlers {
private OrderHistoryDao orderHistoryDao;
public OrderHistoryEventHandlers(OrderHistoryDao orderHistoryDao) {
this.orderHistoryDao = orderHistoryDao;
}
public void handleOrderCreated(DomainEventEnvelope<OrderCreated> dee) {
boolean result = orderHistoryDao.addOrder(makeOrder(dee.getAggregateId(), dee.getEvent()), makeSourceEvent(dee));
}
private Order makeOrder(String orderId, OrderCreatedEvent event) {
return new Order(orderId,
Long.toString(event.getOrderDetails().getConsumerId()),
OrderState.APPROVAL_PENDING,
event.getOrderDetails().getLineItems(),
event.getOrderDetails().getOrderTotal(),
event.getOrderDetails().getRestaurantId(),
event.getRestaurantName());
}
public void handleDeliveryPickedUp(DomainEventEnvelope<DeliveryPickedUp> dee) {
orderHistoryDao.notePickedUp(dee.getEvent().getOrderId(),
makeSourceEvent(dee));
}
}
- 이벤트 핸들러는 하나의 DomainEventEnvelope형 매개변수를 받는다. 이벤트와 이벤트에 관한 메타데이터가 이 매개변수에 담겨 있다.
- 가령 OrderCreate 이벤트가 발생하면 handleOrderCreated() 이벤트가 호출되고, 이 메서드는 다시 orderHistoryDao.addOrder()를 호출해서 DB에 Order를 생성한다.
- 마찬가지로 DeliveryPickedUp 이벤트가 발생하면 handleDeliveryPickedUp(), orderHistoryDao.notePickedUp()이 연달아 호출되어 DB에 있는 Order 상태를 업데이트 한다.
- handleOrderCreated(), handleDeliveryPickedUp() 두 메서드가 호출하는 헬퍼메서드 makeSourceEvent()는 이벤트를 발생시킨 애그리거트 타입과 ID, 그리고 이벤트 ID가 포함된 SourceEvent를 생헝한다.
7.4.2 DynamoDB 데이터 모델링 및 쿼리 설계
FTGO-order-hitsory 테이블 설계
OrderHistoryDataAccess
모듈은 각 Order를 DynamoDB 테이블 ftgo-order-history의 아이템 하나로 저장할 수 있다. orderCreationTime, status 같은 단순 필드는 단일 값 아이템 속성에 매핑되고, lineItems 필드는 타임 라인당 맵 하나씩, 맵 리스트 형태의 속성에 매핑된다. (객체의 JSON 배열)
findOrderHistory 쿼리 전용 인덱스
- 최근 순서로 정렬된 주문 검색 결과를 반환하는
findOrderHistory()
를 구현하기 위해 위의 그림과 같이 보조 인덱스를 선언해준다
- 이 보조 인덱스 (consumerId, orderCreationTime)은 non-unique, non-key (비식별) 속성을 가지고 있다. 비식별 속성은 애플리케이션에서 테이블에서 데이터를 가져올 필요 없이 바로 반환되는 값이라 성능이 향상된다.
findOrderHistory 쿼리 구현
- 검색 기준 filter (예: 주문 조회 시작/종료)
- DynamoDB query() 작업은 정렬 키에 범위 제약을 걸 수 있는 조건 표현식을 지원한다.
- 그 밖에 비식별 속성에 해당되는 검색기준은 Boolean 표현식인 Filter Expression을 이용하여 구현한다.
- 예를 들어
OrderHistoryDaoDynamoDb
에서 CANCELLED 상태인 주문은 orderStatus = :orderStatus 필터 표현식으로 검색한다.
- 음식점명, 메뉴 항목 주문 검색 키워드는 조금 까다롭다.
OrderHistoryDaoDynamoDb
는 음식점명과 메뉴 항목을 토큰화한 후 키워드들을 keywords라는 세트 값 속성에 저장하는 방식으로 키워드 검색을 한다.
contains(keywords, :keyword1) OR contains(keywords, :keyword2)
처럼 contains() 함수를 쓴 필터 표현식으로 키워드가 포함된 주문 정보를 찾는다.
쿼리 결과 페이지네이션
- DynamoDB는 반환할 아이템의 최대 개수를 pageSize 매개변수로 지정한다. 이 수치보다 더 많은 아이템이 검색되면 쿼리 결과에 LastEvaluatedKey라는 NOT NULL 속성이 포함된다. 그러면 DAO는 다음 페이지를 조회할 때 exclusiveStartKey 매개변수를 LastEvaluatedKey로 세팅하여 호출한다.
주문 업데이트
PutItem()
: 기본 로 찾은 아이템을 생성 또는 대체하는 작업. 이 작업을 이용하여 OrderHistoryDaoDynamoDB가 주문을 삽입/수정할 수는 있지만, 동일한 아이템을 동시 업데이트할 경우 정확히 처리된다는 보장이 없다.
- 가령 두 개의 이벤트 핸들러가 동일한 아이템을 동시에 처리한다고 했을 때, DynamoDB에서 아이템을 가져와서 메모리상에서 변경한 후 PutItem() 메서드로 업데이트 한다. 즉 아이템의 변경분을 덮어쓸 확률이 존재한다.
UpdateItem()
: 개별 아이템 속성을 업데이트하고, 필요 시 아이템을 생성하는 작업이다. 상이한 이벤트 핸들러가 상이한 Order 아이템 속성을 업데이트하므로 이 메서드를 사용하는 맞고, 주문을 테이블에서 미리 가져올 필요가 없기 때문에 더 효율적이다.
중복 이벤트 감지
- 이벤트에 반응하여 DB를 업데이트하는 작업은 중복 이벤트를 솎아내야 한다. 중복 이벤트를 접수한 이벤트 핸들러가 아이템 속성을 과거 값으로 세팅할 가능성이 있기 때문이다.
- OrderHistoryDaoDynamoDb는 아이템마다 업데이트를 일으킨 이벤트를 일일이 기록해서 중복 이벤트를 감지한다. UpdadteItem() 작업의 조건부 업데이트 메커니즘을 활용하면 중복 이벤트가 아닐 때에만 아이템을 업데이트할 수 있다.
OrderHistoryDaoDynamoDb
DAO는 (수신한 이벤트 ID의 최댓값과 동일한) <애그리거트 타입><애그리거트 ID> 속성을 이용하여 각 애그리거트 인스턴스에서 전달받은 이벤트를 추적할 수 있다. 즉, 이 속성이 존재하고 그 값이 자신의 ID보다 같거나 작은 이벤트면 중복 이벤트다.
attribute_not_exists(<애그리거트 타입><애그리거트 ID>)
또는 <애그리거트 타입><애그리거트 ID> <: 이벤트 ID
- 조건부 표현식은 속성이 존재하지 않거나, eventID가 가장 마지막에 처리된 이벤트 ID보다 클 경우에만 업데이트한다.
attribute_not_exists(Delivery3949384394-039434903)
또는 Delivery3949384394-039434903 <: 이벤트 ID
addOrder() 메서드
@Override
public boolean addOrder(Order order, Optional<SourceEvent> eventSource) {
UpdateItemSpec spec = new UpdateItemSpec()
.withPrimaryKey("orderId", order.getOrderId())
.withUpdateExpression("SET orderStatus = :orderStatus, " +
"creationDate = :creationDate, consumerId = :consumerId, lineItems =" +
" :lineItems, keywords = :keywords, restaurantId = :restaurantId, " +
" restaurantName = :restaurantName"
)
.withValueMap(new Maps()
.add(":orderStatus", order.getStatus().toString())
.add(":consumerId", order.getConsumerId())
.add(":creationDate", order.getCreationDate().getMillis())
.add(":lineItems", mapLineItems(order.getLineItems()))
.add(":keywords", mapKeywords(order))
.add(":restaurantId", order.getRestaurantId())
.add(":restaurantName", order.getRestaurantName())
.map())
.withReturnValues(ReturnValue.NONE);
return idempotentUpdate(spec, eventSource);
}
- addOrder()는 order, eventSource 두 매개변수를 받아 ftgo-order-history 테이블에 Order를 추가하는 메서드.
- addOrder()는 AWS SDK의 일부로서 업데이트 작업이 기술된
UpdateItemSpec
를 생성하고, 중복 업데이트를 방지하는 조건부 표현식을 추가한 후 업데이트를 수행하는 헬퍼 메서드 idempotentUpdate()를 호출한다.
idempotentUpdate() 메서드
private boolean idempotentUpdate(UpdateItemSpec spec, Optional<SourceEvent> eventSource) {
try {
table.updateItem(eventSource.map(es -> es.addDuplicateDetection(spec))
.orElse(spec));
return true;
} catch (ConditionalCheckFailedException e) {
logger.error("not updated {}", eventSource);
return false;
}
}
...
public UpdateItemSpec addDuplicateDetection(UpdateItemSpec spec) {
HashMap<String, String> nameMap = spec.getNameMap() == null ? new HashMap<>() : new HashMap<>(spec.getNameMap());
nameMap.put("#duplicateDetection", "events." + aggregateType + aggregateId);
HashMap<String, Object> valueMap = new HashMap<>(spec.getValueMap());
valueMap.put(":eventId", eventId);
return spec.withUpdateExpression(String.format("%s , #duplicateDetection = :eventId", spec.getUpdateExpression()))
.withNameMap(nameMap)
.withValueMap(valueMap)
.withConditionExpression(Expressions.and(spec.getConditionExpression(), "attribute_not_exists(#duplicateDetection) OR #duplicateDetection < :eventId"));
}
- sourceEvent를 받은 idempotentUpdate()는 SourceEvent.addDuplicateDetection()를 호출해서 방금 전 설명한 조건부 표현식을 UpdateItemSpec에 추가한다. 그리고 중복 이벤트일 경우 updateItem()이 던진 ConditionalCheckFailedException 예외를 붙잡아 아무 동작도하지 않는다.
findOrderHistory() 메서드
@Override
public OrderHistory findOrderHistory(String consumerId, OrderHistoryFilter
filter) {
QuerySpec spec = new QuerySpec()
.withScanIndexForward(false)
.withHashKey("consumerId", consumerId)
.withRangeKeyCondition(new RangeKeyCondition("creationDate").gt
(filter.getSince().getMillis()));
filter.getStartKeyToken().ifPresent(token -> spec.withExclusiveStartKey
(toStartingPrimaryKey(token)));
Map<String, Object> valuesMap = new HashMap<>();
String filterExpression = Expressions.and(
keywordFilterExpression(valuesMap, filter.getKeywords()),
statusFilterExpression(valuesMap, filter.getStatus()));
if (!valuesMap.isEmpty())
spec.withValueMap(valuesMap);
if (StringUtils.isNotBlank(filterExpression)) {
spec.withFilterExpression(filterExpression);
}
System.out.print("filterExpression.toString()=" + filterExpression);
filter.getPageSize().ifPresent(spec::withMaxResultSize);
ItemCollection<QueryOutcome> result = index.query(spec);
return new OrderHistory(StreamSupport.stream(result.spliterator(), false)
.map(this::toOrder).collect(toList()),
Optional.ofNullable(result.getLastLowLevelResult().getQueryResult
().getLastEvaluatedKey()).map(this::toStartKeyToken));
}
7.5 마치며
- 각 서비스 데이터는 프라이빗하기 때문에 여러 서비스의 데이터를 가져오는 쿼리는 구현하기 쉽지 않다.
- 여러 서비스에서 데이터를 조회하는 쿼리는 크게 API 조합 패턴과 CQRS로 구현된다.
- 여러 서비스에서 데이터를 취합하는 API 조합 패턴은 쿼리를 구현하기 가장 간편한 방법이므로 가능하다면 많이 사용하는 것이 좋다.
- API 조합 패턴은 쿼리가 조금만 복잡해져도 대량 데이터를 인메모리 조인해야 하므로 효율이 낮다.
- CQRS 패턴은 뷰 전용 DB를 이용하여 쿼리한다. 기능이 좋은만큼 구현이 어렵다.
- CQRS 뷰 모듈은 중복 이벤트 솎아내기, 동시 업데이트 처리 기능을 갖추어야 한다.
- CQRS를 사용하면 한 서비스가 다른 서비스가 소유한 데이터를 반환하는 쿼리 구현도 가능하므로 관심사 분리 관점에서 유리하다.
- 클라이언트는 CQRS 뷰의 최종 일관성을 처리해야 한다.